Compare commits
30 Commits
976a416081
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d609118de | |||
| d11bf28625 | |||
| 9bbe1dd214 | |||
| e3ef695069 | |||
| 5bdfd6835d | |||
| 27716c5ec0 | |||
| dea8c45939 | |||
| 22367f45f2 | |||
| e5853268de | |||
| b37386b9e8 | |||
| bd8b0fecf1 | |||
| c15db2847d | |||
| 0a95aca872 | |||
| 91bea767d0 | |||
| ed6aa4b9c9 | |||
| d11f8510b4 | |||
| 221e15d7ac | |||
| e403be8090 | |||
| 58698a1087 | |||
| f1be2dd735 | |||
| d9406f98d1 | |||
| 98703f2c8b | |||
| 9b15daaf6d | |||
| 7d95e48941 | |||
| f8aa1e29a2 | |||
| 83bd8e0e5e | |||
| 9898af1bf7 | |||
| ed9eba8dc1 | |||
| 3740224f79 | |||
| 2e83efcf12 |
844
Cargo.lock
generated
844
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ publish = false
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
camino = { version = "1.1", optional = true }
|
||||
chrono = { version = "0.4.39", features = ["now", "serde"] }
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
@@ -23,18 +23,17 @@ derive_more = { version = "2", features = [
|
||||
] }
|
||||
http = "1"
|
||||
image = { version = "0.25", optional = true }
|
||||
leptos = { version = "0.7.7", features = ["tracing"] }
|
||||
leptos_axum = { version = "0.7", optional = true }
|
||||
leptos_meta = { version = "0.7" }
|
||||
leptos_router = { version = "0.7.0" }
|
||||
leptos = { version = "0.8", features = ["tracing"] }
|
||||
leptos_axum = { version = "0.8", optional = true }
|
||||
leptos_meta = { version = "0.8" }
|
||||
leptos_router = { version = "0.8" }
|
||||
moonboard-parser = { workspace = true, optional = true }
|
||||
rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
|
||||
ron = { version = "0.8" }
|
||||
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"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
tracing = { version = "0.1" }
|
||||
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"] }
|
||||
redb = { version = "2.4", optional = true }
|
||||
bincode = { version = "1.3", optional = true }
|
||||
serde_json = { version = "1" }
|
||||
codee = { version = "0.3" }
|
||||
error_reporter = { version = "1" }
|
||||
getrandom = { version = "0.3.1" }
|
||||
|
||||
[dev-dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[dev-dependencies.test-try]
|
||||
version = "0.1"
|
||||
[dev-dependencies]
|
||||
test-try = "0.1"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"]
|
||||
@@ -65,7 +60,6 @@ ssr = [
|
||||
"dep:image",
|
||||
"dep:bincode",
|
||||
"dep:tokio",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:leptos_axum",
|
||||
"dep:confik",
|
||||
@@ -77,6 +71,11 @@ 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]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "ascend"
|
||||
|
||||
@@ -42,9 +42,8 @@ pub fn App() -> impl IntoView {
|
||||
<Router>
|
||||
<Routes fallback=|| "Not found">
|
||||
<Route path=path!("/") view=Home />
|
||||
<Route path=path!("/wall/:wall_uid") view=pages::wall::Wall />
|
||||
<Route path=path!("/wall/:wall_uid/edit") view=pages::edit_wall::EditWall />
|
||||
<Route path=path!("/wall/:wall_uid/routes") view=pages::routes::Routes />
|
||||
<Route path=path!("/wall/:wall_uid") view=pages::wall::Page />
|
||||
<Route path=path!("/wall/:wall_uid/holds") view=pages::holds::Page />
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
|
||||
@@ -3,19 +3,22 @@ pub mod ron {
|
||||
|
||||
use codee::Decoder;
|
||||
use codee::Encoder;
|
||||
use leptos::prelude::FromServerFnError;
|
||||
use leptos::prelude::ServerFnErrorErr;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use server_fn::ServerFnError;
|
||||
use server_fn::ContentType;
|
||||
use server_fn::codec::Encoding;
|
||||
use server_fn::codec::FromReq;
|
||||
use server_fn::codec::FromRes;
|
||||
use server_fn::codec::IntoReq;
|
||||
use server_fn::codec::IntoRes;
|
||||
use server_fn::error::IntoAppError;
|
||||
use server_fn::request::ClientReq;
|
||||
use server_fn::request::Req;
|
||||
use server_fn::response::ClientRes;
|
||||
use server_fn::response::Res;
|
||||
use server_fn::response::TryRes;
|
||||
|
||||
pub struct Ron;
|
||||
|
||||
@@ -44,10 +47,13 @@ pub mod ron {
|
||||
}
|
||||
|
||||
impl Encoding for Ron {
|
||||
const CONTENT_TYPE: &'static str = "application/ron";
|
||||
const METHOD: http::Method = http::Method::POST;
|
||||
}
|
||||
|
||||
impl ContentType for Ron {
|
||||
const CONTENT_TYPE: &'static str = "application/ron";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RonEncoded<T>(pub T);
|
||||
|
||||
@@ -74,9 +80,10 @@ pub mod ron {
|
||||
where
|
||||
Request: ClientReq<Err>,
|
||||
T: Serialize,
|
||||
Err: FromServerFnError,
|
||||
{
|
||||
fn into_req(self, path: &str, accepts: &str) -> Result<Request, ServerFnError<Err>> {
|
||||
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -86,21 +93,25 @@ pub mod ron {
|
||||
where
|
||||
Request: Req<Err> + Send,
|
||||
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?;
|
||||
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
|
||||
impl<CustErr, T, Response> IntoRes<Ron, Response, CustErr> for RonEncoded<T>
|
||||
impl<Err, T, Response> IntoRes<Ron, Response, Err> for RonEncoded<T>
|
||||
where
|
||||
Response: Res<CustErr>,
|
||||
Response: TryRes<Err>,
|
||||
T: Serialize + Send,
|
||||
Err: FromServerFnError,
|
||||
{
|
||||
async fn into_res(self) -> Result<Response, ServerFnError<CustErr>> {
|
||||
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
async fn into_res(self) -> Result<Response, Err> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -110,12 +121,13 @@ pub mod ron {
|
||||
where
|
||||
Response: ClientRes<Err> + Send,
|
||||
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?;
|
||||
Ron::decode(&data)
|
||||
.map(RonEncoded)
|
||||
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
.map_err(|e| ServerFnErrorErr::Deserialization(e.to_string()).into_app_error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
crates/ascend/src/components.rs
Normal file
27
crates/ascend/src/components.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,58 @@
|
||||
use crate::components::icons;
|
||||
use crate::models;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Attempt(#[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView {
|
||||
tracing::trace!("Enter");
|
||||
pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let s = time_ago(date.get());
|
||||
|
||||
let text = move || match attempt.get() {
|
||||
Some(models::Attempt::Attempt) => "Learning experience",
|
||||
Some(models::Attempt::Send) => "Send",
|
||||
Some(models::Attempt::Flash) => "Flash",
|
||||
Some(models::Attempt::Send) => "Send",
|
||||
Some(models::Attempt::Attempt) => "Learning experience",
|
||||
None => "No attempt",
|
||||
};
|
||||
|
||||
let text_color = match attempt.get() {
|
||||
Some(attempt) => attempt.gradient().class_text(),
|
||||
None => "",
|
||||
};
|
||||
|
||||
let icon = move || match attempt.get() {
|
||||
Some(models::Attempt::Attempt) => view! { <icons::BoltSlashSolid /> }.into_any(),
|
||||
Some(models::Attempt::Send) => view! { <icons::PaperAirplaneSolid /> }.into_any(),
|
||||
Some(models::Attempt::Flash) => view! { <icons::BoltSolid /> }.into_any(),
|
||||
Some(models::Attempt::Send) => view! { <icons::Trophy /> }.into_any(),
|
||||
Some(models::Attempt::Attempt) => view! { <icons::ArrowTrendingUp /> }.into_any(),
|
||||
None => view! { <icons::NoSymbol /> }.into_any(),
|
||||
};
|
||||
|
||||
let classes = format!("flex flex-row gap-3 {}", text_color);
|
||||
|
||||
view! {
|
||||
<div class="flex gap-2 justify-center items-center">
|
||||
<span>{icon}</span>
|
||||
<span>{text}</span>
|
||||
<div class="flex flex-row justify-between my-2">
|
||||
<div>{s}</div>
|
||||
|
||||
<div class=classes>
|
||||
<span>{text}</span>
|
||||
<span>{icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn time_ago(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let duration = now.signed_duration_since(dt);
|
||||
|
||||
if duration.num_days() == 0 {
|
||||
"Today".to_string()
|
||||
} else if duration.num_days() == 1 {
|
||||
"1 day ago".to_string()
|
||||
} else {
|
||||
format!("{} days ago", duration.num_days())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,78 @@ use super::icons::Icon;
|
||||
use crate::components::outlined_box::OutlinedBox;
|
||||
use crate::gradient::Gradient;
|
||||
use leptos::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[component]
|
||||
pub fn Button(
|
||||
#[prop(into, optional)] icon: MaybeProp<Icon>,
|
||||
|
||||
#[prop(into)] text: Signal<String>,
|
||||
#[prop(into, optional)] text: MaybeProp<String>,
|
||||
|
||||
#[prop(optional)] color: Gradient,
|
||||
|
||||
#[prop(into, optional)] highlight: MaybeProp<bool>,
|
||||
|
||||
onclick: impl FnMut(MouseEvent) + 'static,
|
||||
#[prop(into, optional)] disabled: MaybeProp<bool>,
|
||||
|
||||
#[prop(into, optional)] on_click: MaybeProp<Callback<()>>,
|
||||
) -> impl IntoView {
|
||||
let icon_view = icon.get().map(|i| {
|
||||
let icon_view = i.into_view();
|
||||
let mut classes = "self-center mx-5 my-2.5 rounded-sm".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_text());
|
||||
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
|
||||
|
||||
view! { <div class=classes>{icon_view}</div> }
|
||||
});
|
||||
let icon_view = move || {
|
||||
icon.get().map(|i| {
|
||||
let icon_view = i.into_view();
|
||||
let mut classes = "self-center".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_text());
|
||||
|
||||
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>{icon_view}</div> }
|
||||
})
|
||||
};
|
||||
|
||||
view! { <div class=classes /> }
|
||||
});
|
||||
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());
|
||||
|
||||
let text_view = view! { <div class="self-center mx-5 my-2.5 uppercase w-full text-lg font-thin">{text.get()}</div> };
|
||||
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! {
|
||||
<button
|
||||
on:click=onclick
|
||||
type="button"
|
||||
class="mb-2 me-2 hover:brightness-125 active:brightness-90"
|
||||
>
|
||||
<button type="button" class=class prop:disabled=prop_disabled on:click=on_click>
|
||||
<OutlinedBox color highlight>
|
||||
<div class="flex items-stretch">{icon_view} {separator} {text_view}</div>
|
||||
</OutlinedBox>
|
||||
@@ -59,7 +90,7 @@ mod tests {
|
||||
let text = "foo";
|
||||
let onclick = |_| {};
|
||||
|
||||
view! { <Button text onclick /> };
|
||||
view! { <Button text on:click=onclick /> };
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -68,6 +99,6 @@ mod tests {
|
||||
let text = "foo";
|
||||
let onclick = |_| {};
|
||||
|
||||
view! { <Button icon text onclick /> };
|
||||
view! { <Button icon text on:click=onclick /> };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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())>
|
||||
<icons::Check />
|
||||
</span>
|
||||
@@ -16,7 +16,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
|
||||
};
|
||||
|
||||
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_str(color.class_from());
|
||||
classes.push(' ');
|
||||
@@ -25,7 +25,7 @@ pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[p
|
||||
};
|
||||
|
||||
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()}
|
||||
</div>
|
||||
};
|
||||
|
||||
13
crates/ascend/src/components/header_v2.rs
Normal file
13
crates/ascend/src/components/header_v2.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ pub enum Icon {
|
||||
WrenchSolid,
|
||||
ForwardSolid,
|
||||
Check,
|
||||
Heart,
|
||||
HeartOutline,
|
||||
ArrowPath,
|
||||
PaperAirplaneSolid,
|
||||
NoSymbol,
|
||||
Trophy,
|
||||
ArrowTrendingUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CodeBracketSquare,
|
||||
}
|
||||
impl Icon {
|
||||
// TODO: Actually impl IntoView for Icon instead
|
||||
@@ -23,12 +27,16 @@ impl Icon {
|
||||
Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(),
|
||||
Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(),
|
||||
Icon::Check => view! { <Check /> }.into_any(),
|
||||
Icon::Heart => view! { <Heart /> }.into_any(),
|
||||
Icon::HeartOutline => view! { <HeartOutline /> }.into_any(),
|
||||
Icon::ArrowPath => view! { <ArrowPath /> }.into_any(),
|
||||
Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(),
|
||||
Icon::NoSymbol => view! { <NoSymbol /> }.into_any(),
|
||||
Icon::Trophy => view! { <Trophy /> }.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]
|
||||
pub fn HeartOutline() -> impl IntoView {
|
||||
view! {
|
||||
@@ -226,3 +248,55 @@ pub fn ArrowTrendingUp() -> impl IntoView {
|
||||
</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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
|
||||
let highlight = move || highlight.get().unwrap_or(false);
|
||||
|
||||
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_str(color.class_from());
|
||||
c.push(' ');
|
||||
@@ -22,9 +22,14 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
|
||||
let mut c = "py-1.5 rounded-md".to_string();
|
||||
if highlight() {
|
||||
let bg = match color {
|
||||
Gradient::PinkOrange => "bg-pink-900",
|
||||
Gradient::CyanBlue => "bg-cyan-900",
|
||||
Gradient::TealLime => "bg-teal-900",
|
||||
Gradient::PinkOrange => "bg-rose-900",
|
||||
Gradient::CyanBlue => "bg-cyan-800",
|
||||
Gradient::TealLime => "bg-emerald-700",
|
||||
Gradient::PurplePink => "bg-fuchsia-950",
|
||||
Gradient::PurpleBlue => "bg-purple-900",
|
||||
Gradient::Orange => "bg-orange-900",
|
||||
Gradient::Pink => "bg-pink-900",
|
||||
Gradient::PinkRed => "bg-fuchsia-950",
|
||||
};
|
||||
|
||||
c.push(' ');
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn Problem(
|
||||
for row in 0..dim.get().rows {
|
||||
for col in 0..dim.get().cols {
|
||||
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 hold = view! { <Hold role /> };
|
||||
holds.push(hold);
|
||||
@@ -28,11 +28,17 @@ pub fn Problem(
|
||||
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! {
|
||||
<div class="grid gap-8 grid-cols-[auto,1fr]">
|
||||
<div class=move || { grid_classes }>{holds}</div>
|
||||
<div class="grid gap-8 grid-cols-[auto_1fr]">
|
||||
<div style=style class="grid gap-3">
|
||||
{holds}
|
||||
</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"),
|
||||
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 {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
|
||||
@@ -3,27 +3,27 @@ use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
|
||||
pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
|
||||
tracing::trace!("Enter problem info");
|
||||
|
||||
let name = problem.name;
|
||||
let set_by = problem.set_by;
|
||||
let method = problem.method;
|
||||
let 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! {
|
||||
<div class="grid grid-rows-none gap-0.5 grid-cols-[auto,1fr]">
|
||||
<NameValue name="Name:" value=name />
|
||||
<NameValue name="Method:" value=method.to_string() />
|
||||
<NameValue name="Set By:" value=set_by />
|
||||
<div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
|
||||
<NameValue name="Method:" value=method />
|
||||
// <NameValue name="Name:" value=name />
|
||||
// <NameValue name="Set By:" value=set_by />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn NameValue(#[prop(into)] name: String, #[prop(into)] value: String) -> impl IntoView {
|
||||
fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<p class="mr-4 text-right text-orange-300">{name}</p>
|
||||
<p class="font-semibold text-white">{value}</p>
|
||||
<p class="mr-4 font-light text-right text-orange-300">{name.get()}</p>
|
||||
<p class="text-white">{value.get()}</p>
|
||||
}
|
||||
}
|
||||
|
||||
9
crates/ascend/src/css.rs
Normal file
9
crates/ascend/src/css.rs
Normal 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));")
|
||||
}
|
||||
@@ -1,32 +1,52 @@
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub enum Gradient {
|
||||
#[default]
|
||||
PurpleBlue,
|
||||
PinkOrange,
|
||||
CyanBlue,
|
||||
TealLime,
|
||||
PurplePink,
|
||||
#[default]
|
||||
Orange,
|
||||
Pink,
|
||||
PinkRed,
|
||||
}
|
||||
impl Gradient {
|
||||
pub fn class_from(&self) -> &str {
|
||||
pub fn class_from(&self) -> &'static str {
|
||||
match self {
|
||||
Gradient::PinkOrange => "from-pink-500",
|
||||
Gradient::CyanBlue => "from-cyan-500",
|
||||
Gradient::TealLime => "from-teal-300",
|
||||
Gradient::PurplePink => "from-purple-500",
|
||||
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 {
|
||||
Gradient::PinkOrange => "to-orange-400",
|
||||
Gradient::CyanBlue => "to-blue-500",
|
||||
Gradient::TealLime => "to-lime-300",
|
||||
Gradient::PurplePink => "to-pink-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 {
|
||||
Gradient::PinkOrange => "text-pink-500",
|
||||
Gradient::PinkOrange => "text-rose-400",
|
||||
Gradient::CyanBlue => "text-cyan-500",
|
||||
Gradient::TealLime => "text-teal-300",
|
||||
Gradient::TealLime => "text-emerald-300",
|
||||
Gradient::PurplePink => "text-fuchsia-500",
|
||||
Gradient::PurpleBlue => "text-purple-600",
|
||||
Gradient::Orange => "text-orange-400",
|
||||
Gradient::Pink => "text-pink-400",
|
||||
Gradient::PinkRed => "text-pink-400",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
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;
|
||||
}
|
||||
|
||||
pub mod gradient;
|
||||
|
||||
pub mod resources;
|
||||
|
||||
pub mod codec;
|
||||
pub mod components;
|
||||
pub mod css;
|
||||
pub mod gradient;
|
||||
pub mod models;
|
||||
pub mod pages;
|
||||
pub mod resources;
|
||||
pub mod server_functions;
|
||||
pub mod tracing;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
@@ -43,12 +22,12 @@ pub fn hydrate() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
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(),
|
||||
)
|
||||
.with_writer(
|
||||
// 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.
|
||||
.without_time()
|
||||
|
||||
@@ -8,17 +8,79 @@ pub use v2::ImageFilename;
|
||||
pub use v2::ImageResolution;
|
||||
pub use v2::ImageUid;
|
||||
pub use v2::Method;
|
||||
pub use v2::Problem;
|
||||
pub use v2::ProblemUid;
|
||||
pub use v2::Root;
|
||||
pub use v2::Wall;
|
||||
pub use v2::WallDimensions;
|
||||
pub use v2::WallUid;
|
||||
pub use v3::Attempt;
|
||||
pub use v3::UserInteraction;
|
||||
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;
|
||||
|
||||
pub mod v4 {
|
||||
use super::v1;
|
||||
use super::v2;
|
||||
use super::v3;
|
||||
use chrono::DateTime;
|
||||
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)]
|
||||
pub struct DatedAttempt {
|
||||
pub date_time: DateTime<Utc>,
|
||||
pub attempt: v3::Attempt,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod v3 {
|
||||
use super::v2;
|
||||
use derive_more::Display;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -38,26 +100,8 @@ pub mod v3 {
|
||||
/// Added to personal challenges
|
||||
pub is_saved: bool,
|
||||
}
|
||||
impl UserInteraction {
|
||||
pub fn new(wall_uid: v2::WallUid, problem_uid: v2::ProblemUid) -> Self {
|
||||
Self {
|
||||
wall_uid,
|
||||
problem_uid,
|
||||
is_favorite: false,
|
||||
attempted_on: BTreeMap::new(),
|
||||
is_saved: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn best_attempt(&self) -> Option<(chrono::DateTime<chrono::Utc>, Attempt)> {
|
||||
self.attempted_on
|
||||
.iter()
|
||||
.max_by_key(|(_date, attempt)| *attempt)
|
||||
.map(|(date, attempt)| (*date, *attempt))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy, Display)]
|
||||
pub enum Attempt {
|
||||
/// Tried to climb problem, but was not able to.
|
||||
Attempt,
|
||||
@@ -87,22 +131,14 @@ pub mod v2 {
|
||||
pub struct Wall {
|
||||
pub uid: WallUid,
|
||||
|
||||
// TODO: Replace by walldimensions
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
|
||||
pub holds: BTreeMap<v1::HoldPosition, Hold>,
|
||||
pub problems: BTreeSet<ProblemUid>,
|
||||
}
|
||||
impl Wall {
|
||||
pub fn random_problem(&self) -> Option<ProblemUid> {
|
||||
use rand::seq::IteratorRandom;
|
||||
let mut rng = rand::rng();
|
||||
self.problems.iter().choose(&mut rng).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WallDimensions {
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
@@ -110,11 +146,6 @@ pub mod v2 {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct WallUid(pub uuid::Uuid);
|
||||
impl WallUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Problem {
|
||||
@@ -128,22 +159,17 @@ pub mod v2 {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct ProblemUid(pub uuid::Uuid);
|
||||
impl ProblemUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
#[display("Feet follow hands")]
|
||||
FeetFollowHands,
|
||||
|
||||
#[display("Footless")]
|
||||
Footless,
|
||||
|
||||
#[display("Footless plus kickboard")]
|
||||
FootlessPlusKickboard,
|
||||
|
||||
#[display("Footless")]
|
||||
Footless,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -157,15 +183,6 @@ pub mod v2 {
|
||||
pub uid: ImageUid,
|
||||
pub resolutions: BTreeMap<ImageResolution, ImageFilename>,
|
||||
}
|
||||
impl Image {
|
||||
pub(crate) fn srcset(&self) -> String {
|
||||
self.resolutions
|
||||
.iter()
|
||||
.map(|(res, filename)| format!("/files/holds/{} {}w", filename.filename, res.width))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ImageResolution {
|
||||
@@ -180,11 +197,6 @@ pub mod v2 {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct ImageUid(pub uuid::Uuid);
|
||||
impl ImageUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod v1 {
|
||||
|
||||
295
crates/ascend/src/models/semantics.rs
Normal file
295
crates/ascend/src/models/semantics.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
use super::*;
|
||||
use crate::gradient::Gradient;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
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 {
|
||||
pub(crate) fn new(wall_uid: WallUid, problem: Problem) -> Self {
|
||||
Self {
|
||||
wall_uid,
|
||||
problem,
|
||||
is_favorite: false,
|
||||
attempted_on: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn latest_attempt(&self) -> Option<DatedAttempt> {
|
||||
self.attempted_on.last_key_value().map(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn todays_attempt(&self) -> Option<Attempt> {
|
||||
self.latest_attempt()
|
||||
.filter(|latest_attempt| {
|
||||
let today_local_naive = chrono::Local::now().date_naive();
|
||||
let datetime_local_naive = latest_attempt.date_time.with_timezone(&chrono::Local).date_naive();
|
||||
datetime_local_naive == today_local_naive
|
||||
})
|
||||
.map(|dated_attempt| dated_attempt.attempt)
|
||||
}
|
||||
|
||||
pub(crate) fn best_attempt(&self) -> Option<DatedAttempt> {
|
||||
self.attempted_on.iter().max_by_key(|(_date_time, attempt)| *attempt).map(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn attempted_on(&self) -> impl IntoIterator<Item = DatedAttempt> {
|
||||
self.attempted_on.iter().rev().map(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(DateTime<Utc>, Attempt)> for DatedAttempt {
|
||||
fn from(value: (DateTime<Utc>, Attempt)) -> Self {
|
||||
let (date_time, attempt) = value;
|
||||
DatedAttempt { date_time, attempt }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&(DateTime<Utc>, Attempt)> for DatedAttempt {
|
||||
fn from(value: &(DateTime<Utc>, Attempt)) -> Self {
|
||||
let &(date_time, attempt) = value;
|
||||
DatedAttempt { date_time, attempt }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&DateTime<Utc>, &Attempt)> for DatedAttempt {
|
||||
fn from(value: (&DateTime<Utc>, &Attempt)) -> Self {
|
||||
let (&date_time, &attempt) = value;
|
||||
DatedAttempt { date_time, attempt }
|
||||
}
|
||||
}
|
||||
|
||||
impl WallUid {
|
||||
#[expect(dead_code)]
|
||||
pub(crate) fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
#[expect(dead_code)]
|
||||
pub(crate) fn min() -> Self {
|
||||
Self(uuid::Uuid::nil())
|
||||
}
|
||||
#[expect(dead_code)]
|
||||
pub(crate) fn max() -> Self {
|
||||
Self(uuid::Uuid::max())
|
||||
}
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub(crate) fn srcset(&self) -> String {
|
||||
self.resolutions
|
||||
.iter()
|
||||
.map(|(res, filename)| format!("/files/holds/{} {}w", filename.filename, res.width))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageUid {
|
||||
pub(crate) fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Attempt {
|
||||
pub(crate) fn icon(&self) -> crate::components::icons::Icon {
|
||||
use crate::components::icons::Icon;
|
||||
match self {
|
||||
Attempt::Attempt => Icon::ArrowTrendingUp,
|
||||
Attempt::Send => Icon::Trophy,
|
||||
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);
|
||||
}
|
||||
}
|
||||
3
crates/ascend/src/pages.rs
Normal file
3
crates/ascend/src/pages.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod holds;
|
||||
pub mod settings;
|
||||
pub mod wall;
|
||||
@@ -24,7 +24,7 @@ struct RouteParams {
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn EditWall() -> impl IntoView {
|
||||
pub fn Page() -> impl IntoView {
|
||||
let params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
let wall_uid = Signal::derive(move || {
|
||||
params
|
||||
@@ -84,12 +84,18 @@ fn Ready(wall: models::Wall) -> impl IntoView {
|
||||
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! {
|
||||
<div>
|
||||
<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>
|
||||
}
|
||||
}
|
||||
@@ -105,7 +111,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
let upload = Action::from(ServerAction::<SetImage>::new());
|
||||
let upload = ServerAction::<SetImage>::new();
|
||||
|
||||
let hold = Signal::derive(move || {
|
||||
let refreshed = upload.value().get().map(Result::unwrap);
|
||||
@@ -155,7 +161,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<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>
|
||||
|
||||
<input
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -29,22 +29,20 @@ pub fn Settings() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Import(wall_uid: WallUid) -> impl IntoView {
|
||||
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
|
||||
// #[component]
|
||||
// #[tracing::instrument(skip_all)]
|
||||
// fn Import(wall_uid: WallUid) -> impl IntoView {
|
||||
// let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new();
|
||||
// let onclick = move |_mouse_event| {
|
||||
// import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
|
||||
// };
|
||||
// view! {
|
||||
// <p>"Import problems from"</p>
|
||||
// <button on:click=onclick>"Mini Moonboard"</button>
|
||||
// }
|
||||
// }
|
||||
|
||||
let onclick = move |_mouse_event| {
|
||||
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
|
||||
};
|
||||
|
||||
view! {
|
||||
<p>"Import problems from"</p>
|
||||
<button on:click=onclick>"Mini Moonboard"</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[server(name = ImportFromMiniMoonboard)]
|
||||
#[server]
|
||||
#[tracing::instrument]
|
||||
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
|
||||
use crate::server::config::Config;
|
||||
|
||||
@@ -1,49 +1,21 @@
|
||||
// +--------------- Filter ----------- ↓ -+
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// +--------------------------------------+
|
||||
|
||||
// +---------------------------+
|
||||
// | Next Problem |
|
||||
// +---------------------------+
|
||||
|
||||
// +--------------- Problem --------------+
|
||||
// | Name: ... |
|
||||
// | Method: ... |
|
||||
// | Set by: ... |
|
||||
// | |
|
||||
// | | Flash | Top | Attempt | |
|
||||
// | |
|
||||
// +--------------------------------------+
|
||||
|
||||
// +---------+ +---------+ +---------+
|
||||
// | Flash | | Send | | Attempt |
|
||||
// +---------+ +---------+ +---------+
|
||||
|
||||
// +---------- <Latest attempt> ----------+
|
||||
// | Today: <Attempt> |
|
||||
// | 14 days ago: <Attempt> |
|
||||
// +--------------------------------------+
|
||||
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::components::OnHoverRed;
|
||||
use crate::components::ProblemInfo;
|
||||
use crate::components::attempt::Attempt;
|
||||
use crate::components::button::Button;
|
||||
use crate::components::checkbox::Checkbox;
|
||||
use crate::components::header::HeaderItem;
|
||||
use crate::components::header::HeaderItems;
|
||||
use crate::components::header::StyledHeader;
|
||||
use crate::components::icons::Icon;
|
||||
use crate::gradient::Gradient;
|
||||
use crate::models;
|
||||
use crate::models::HoldRole;
|
||||
use crate::server_functions;
|
||||
use crate::server_functions::SetIsFavorite;
|
||||
use leptos::Params;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::params::Params;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Deref;
|
||||
|
||||
#[derive(Params, PartialEq, Clone)]
|
||||
struct RouteParams {
|
||||
@@ -52,13 +24,11 @@ struct RouteParams {
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Wall() -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
pub fn Page() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let route_params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
|
||||
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
|
||||
|
||||
let wall_uid = Signal::derive(move || {
|
||||
route_params
|
||||
.get()
|
||||
@@ -68,257 +38,694 @@ pub fn Wall() -> impl IntoView {
|
||||
});
|
||||
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
let problem = crate::resources::problem_by_uid(wall_uid, problem_uid.into());
|
||||
let user_interaction = crate::resources::user_interaction(wall_uid, problem_uid.into());
|
||||
|
||||
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
|
||||
let problem_sig2 = Signal::derive(move || problem.get().transpose().map(Option::flatten));
|
||||
|
||||
let fn_next_problem = move |wall: &models::Wall| {
|
||||
set_problem_uid.set(wall.random_problem());
|
||||
};
|
||||
|
||||
// Set a problem when wall is set (loaded)
|
||||
Effect::new(move |_prev_value| match &*wall.read() {
|
||||
Some(Ok(wall)) => {
|
||||
if problem_uid.get().is_none() {
|
||||
tracing::debug!("Setting next problem");
|
||||
fn_next_problem(wall);
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
tracing::error!("Error getting wall: {err}");
|
||||
}
|
||||
None => {}
|
||||
});
|
||||
|
||||
let ui_is_flash = RwSignal::new(false);
|
||||
let ui_is_climbed = RwSignal::new(false);
|
||||
let ui_is_attempt = RwSignal::new(false);
|
||||
let ui_is_favorite = RwSignal::new(false);
|
||||
|
||||
// On reception of user interaction state, set UI signals
|
||||
Effect::new(move |_prev_value| {
|
||||
if let Some(user_interaction) = user_interaction.get() {
|
||||
let user_interaction = user_interaction.ok().flatten();
|
||||
|
||||
if let Some(user_interaction) = user_interaction {
|
||||
ui_is_favorite.set(user_interaction.is_favorite);
|
||||
} else {
|
||||
ui_is_favorite.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let header_items = move || HeaderItems {
|
||||
left: vec![],
|
||||
middle: vec![HeaderItem {
|
||||
text: "ASCEND".to_string(),
|
||||
link: None,
|
||||
}],
|
||||
right: vec![
|
||||
HeaderItem {
|
||||
text: "Routes".to_string(),
|
||||
link: Some(format!("/wall/{}/routes", wall_uid.get())),
|
||||
},
|
||||
HeaderItem {
|
||||
text: "Holds".to_string(),
|
||||
link: Some(format!("/wall/{}/edit", wall_uid.get())),
|
||||
},
|
||||
],
|
||||
};
|
||||
let user_interactions = crate::resources::user_interactions_for_wall(wall_uid);
|
||||
|
||||
leptos::view! {
|
||||
<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">
|
||||
<Transition fallback=|| ()>
|
||||
{move || Suspend::new(async move {
|
||||
tracing::info!("executing main suspend");
|
||||
let wall = wall.await?;
|
||||
let grid = {
|
||||
let wall = wall.clone();
|
||||
view! {
|
||||
<Transition fallback=|| ()>
|
||||
{
|
||||
let wall = wall.clone();
|
||||
move || {
|
||||
let wall = wall.clone();
|
||||
Suspend::new(async move {
|
||||
let wall = wall.clone();
|
||||
tracing::info!("executing grid suspend");
|
||||
let view = view! {
|
||||
<Grid wall=wall.clone() problem=problem_sig2 />
|
||||
};
|
||||
Ok::<_, ServerFnError>(view)
|
||||
})
|
||||
}
|
||||
}
|
||||
</Transition>
|
||||
}
|
||||
};
|
||||
let v = view! {
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
|
||||
<div>{grid}</div>
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Context {
|
||||
wall: Signal<models::Wall>,
|
||||
user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
|
||||
problem: Signal<Option<models::Problem>>,
|
||||
filtered_problem_transformations: Signal<Vec<HashSet<models::Problem>>>,
|
||||
user_interaction: Signal<Option<models::UserInteraction>>,
|
||||
todays_attempt: Signal<Option<models::Attempt>>,
|
||||
latest_attempt: Signal<Option<models::DatedAttempt>>,
|
||||
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
|
||||
cb_click_hold: Callback<models::HoldPosition>,
|
||||
cb_remove_hold_from_filter: Callback<models::HoldPosition>,
|
||||
cb_next_problem: Callback<()>,
|
||||
cb_set_problem: Callback<models::Problem>,
|
||||
cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>,
|
||||
cb_set_is_favorite: Callback<bool>,
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="flex">
|
||||
<Button
|
||||
onclick=move |_| {
|
||||
ui_is_flash
|
||||
.update(|x| {
|
||||
*x = !*x;
|
||||
});
|
||||
}
|
||||
text="Flash"
|
||||
icon=Icon::BoltSolid
|
||||
color=Gradient::CyanBlue
|
||||
highlight=Signal::derive(move || { ui_is_flash.get() })
|
||||
/>
|
||||
#[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!();
|
||||
|
||||
<Button
|
||||
onclick=move |_| {
|
||||
ui_is_climbed
|
||||
.update(|x| {
|
||||
*x = !*x;
|
||||
});
|
||||
}
|
||||
text="Send"
|
||||
icon=Icon::Trophy
|
||||
color=Gradient::TealLime
|
||||
highlight=Signal::derive(move || { ui_is_climbed.get() })
|
||||
/>
|
||||
// Extract data from URL
|
||||
let (problem, set_problem) = leptos_router::hooks::query_signal::<models::Problem>("problem");
|
||||
|
||||
<Button
|
||||
onclick=move |_| {
|
||||
ui_is_attempt
|
||||
.update(|x| {
|
||||
*x = !*x;
|
||||
});
|
||||
}
|
||||
text="Attempt"
|
||||
icon=Icon::ArrowTrendingUp
|
||||
color=Gradient::PinkOrange
|
||||
highlight=Signal::derive(move || { ui_is_attempt.get() })
|
||||
/>
|
||||
</div>
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
<Button
|
||||
icon=Icon::ArrowPath
|
||||
text="Next problem"
|
||||
onclick=move |_| fn_next_problem(&wall)
|
||||
/>
|
||||
// Derive signals
|
||||
let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
|
||||
let problem_transformations = signals::problem_transformations(wall);
|
||||
|
||||
<div class="m-4" />
|
||||
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);
|
||||
|
||||
<Transition fallback=|| ()>
|
||||
{move || Suspend::new(async move {
|
||||
tracing::info!("executing probleminfo suspend");
|
||||
let problem = problem.await?;
|
||||
let problem_view = problem
|
||||
.map(|problem| view! { <ProblemInfo problem /> });
|
||||
let view = view! { {problem_view} };
|
||||
Ok::<_, ServerFnError>(view)
|
||||
})}
|
||||
</Transition>
|
||||
// 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));
|
||||
});
|
||||
|
||||
<div class="m-4" />
|
||||
// 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,
|
||||
}));
|
||||
});
|
||||
|
||||
<Suspense fallback=move || {
|
||||
view! {}
|
||||
}>
|
||||
{move || {
|
||||
let x = 10;
|
||||
let attempt_suspend = Suspend::new(async move {
|
||||
let user_interaction = user_interaction.await;
|
||||
let user_interaction = user_interaction.ok().flatten();
|
||||
let best_attempt = user_interaction
|
||||
.and_then(|x| x.best_attempt());
|
||||
let best_attempt_date = move || {
|
||||
best_attempt.map(|pair| pair.0)
|
||||
};
|
||||
let best_attempt_attempt = move || {
|
||||
best_attempt.map(|pair| pair.1)
|
||||
};
|
||||
view! {
|
||||
<Attempt attempt=Signal::derive(best_attempt_attempt) />
|
||||
}
|
||||
});
|
||||
attempt_suspend
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
Ok::<_, ServerFnError>(v)
|
||||
})}
|
||||
</Transition>
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
|
||||
// #[component]
|
||||
// #[tracing::instrument(skip_all)]
|
||||
// fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
|
||||
// tracing::trace!("Enter");
|
||||
// }
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Transformations() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let left = Signal::derive(move || {
|
||||
let mut problem = ctx.problem.get()?;
|
||||
let new_pattern = problem.pattern.shift_left(1)?;
|
||||
problem.pattern = new_pattern;
|
||||
Some(problem)
|
||||
});
|
||||
|
||||
let right = Signal::derive(move || {
|
||||
let mut problem = ctx.problem.get()?;
|
||||
let wall_dimensions = ctx.wall.read().wall_dimensions;
|
||||
let new_pattern = problem.pattern.shift_right(wall_dimensions, 1)?;
|
||||
problem.pattern = new_pattern;
|
||||
Some(problem)
|
||||
});
|
||||
|
||||
let on_click_left = Callback::new(move |()| {
|
||||
tracing::debug!("left");
|
||||
if let Some(problem) = left.get() {
|
||||
ctx.cb_set_problem.run(problem);
|
||||
}
|
||||
});
|
||||
let on_click_mirror = Callback::new(move |()| {
|
||||
tracing::debug!("mirror");
|
||||
if let Some(mut problem) = ctx.problem.get() {
|
||||
problem.pattern = problem.pattern.mirror();
|
||||
ctx.cb_set_problem.run(problem);
|
||||
}
|
||||
});
|
||||
let on_click_right = Callback::new(move |()| {
|
||||
tracing::debug!("right");
|
||||
if let Some(problem) = right.get() {
|
||||
ctx.cb_set_problem.run(problem);
|
||||
}
|
||||
});
|
||||
|
||||
let left_disabled = Signal::derive(move || left.read().is_none());
|
||||
let right_disabled = Signal::derive(move || right.read().is_none());
|
||||
|
||||
view! {
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<Button icon=Icon::ChevronLeft disabled=left_disabled on_click=on_click_left />
|
||||
<Button icon=Icon::CodeBracketSquare on_click=on_click_mirror />
|
||||
<Button icon=Icon::ChevronRight disabled=right_disabled on_click=on_click_right />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Grid(wall: models::Wall, #[prop(into)] problem: Signal<Result<Option<models::Problem>, ServerFnError>>) -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
fn LikedButton() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let mut cells = vec![];
|
||||
for (&hold_position, hold) in &wall.holds {
|
||||
let role = move || problem.get().map(|o| o.and_then(|p| p.holds.get(&hold_position).copied()));
|
||||
let role = Signal::derive(role);
|
||||
let cell = view! { <Hold role hold=hold.clone() /> };
|
||||
cells.push(cell);
|
||||
}
|
||||
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-1", wall.rows, wall.cols,);
|
||||
let _ctx = use_context::<Context>().unwrap();
|
||||
|
||||
view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn HoldsButton() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let link = move || format!("/wall/{}/edit", ctx.wall.read().uid);
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-[auto,1fr]">
|
||||
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>
|
||||
<a href=link>
|
||||
<Button text="Holds" icon=Icon::WrenchSolid />
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn FavoriteButton() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let ui_toggle = Signal::derive(move || {
|
||||
let guard = ctx.user_interaction.read();
|
||||
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
|
||||
});
|
||||
let on_click = Callback::new(move |_| {
|
||||
ctx.cb_set_is_favorite.run(!ui_toggle.get());
|
||||
});
|
||||
let icon = Signal::derive(move || if ui_toggle.get() { Icon::Heart } else { Icon::HeartOutline });
|
||||
let text = Signal::derive(move || if ui_toggle.get() { "Saved" } else { "Save" }.to_string());
|
||||
view! { <Button text icon on_click color=Gradient::PinkRed /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn NextProblemButton() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let on_click = Callback::new(move |_| ctx.cb_next_problem.run(()));
|
||||
view! { <Button icon=Icon::ArrowPath text="Randomize" on_click color=Gradient::PurpleBlue /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Filter() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
move || {
|
||||
let mut cells = vec![];
|
||||
for hold_pos in ctx.filter_holds.get() {
|
||||
let w = &*ctx.wall.read();
|
||||
if let Some(hold) = w.holds.get(&hold_pos).cloned() {
|
||||
let onclick = move |_| {
|
||||
ctx.cb_remove_hold_from_filter.run(hold_pos);
|
||||
};
|
||||
|
||||
let v = view! {
|
||||
<button on:click=onclick class="cursor-pointer">
|
||||
<OnHoverRed>
|
||||
<Hold hold />
|
||||
</OnHoverRed>
|
||||
</button>
|
||||
};
|
||||
cells.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
let problems_count = ctx.filtered_problem_transformations.read().iter().map(|set| set.len()).sum::<usize>();
|
||||
|
||||
let problems_counter = {
|
||||
let name = view! { <p class="mr-4 font-light text-right text-orange-300">{"Problems:"}</p> };
|
||||
let value = view! { <p class="text-white">{problems_count}</p> };
|
||||
view! {
|
||||
<div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
|
||||
{name} {value}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
let sep = (!cells.is_empty()).then_some(view! { <Separator /> });
|
||||
|
||||
#[derive(Default)]
|
||||
struct InteractionCounters {
|
||||
flash: u64,
|
||||
send: u64,
|
||||
attempt: u64,
|
||||
}
|
||||
let mut interaction_counters = InteractionCounters::default();
|
||||
let interaction_counters_view = {
|
||||
let user_ints = ctx.user_interactions.read();
|
||||
for problem_set in ctx.filtered_problem_transformations.read().iter() {
|
||||
for problem in problem_set {
|
||||
if let Some(user_int) = user_ints.get(problem) {
|
||||
match user_int.best_attempt().map(|da| da.attempt) {
|
||||
Some(models::Attempt::Flash) => interaction_counters.flash += 1,
|
||||
Some(models::Attempt::Send) => interaction_counters.send += 1,
|
||||
Some(models::Attempt::Attempt) => interaction_counters.attempt += 1,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let flash = (interaction_counters.flash > 0).then(|| {
|
||||
let class = Gradient::CyanBlue.class_text();
|
||||
view! {
|
||||
<span class="mx-1">
|
||||
<span class=class>{interaction_counters.flash}</span>
|
||||
</span>
|
||||
}
|
||||
});
|
||||
let send = (interaction_counters.send > 0).then(|| {
|
||||
let class = Gradient::TealLime.class_text();
|
||||
view! {
|
||||
<span class="mx-1">
|
||||
<span class=class>{interaction_counters.send}</span>
|
||||
</span>
|
||||
}
|
||||
});
|
||||
let attempt = (interaction_counters.attempt > 0).then(|| {
|
||||
let class = Gradient::PinkOrange.class_text();
|
||||
view! {
|
||||
<span class="mx-1">
|
||||
<span class=class>{interaction_counters.attempt}</span>
|
||||
</span>
|
||||
}
|
||||
});
|
||||
|
||||
if flash.is_some() || send.is_some() || attempt.is_some() {
|
||||
view! {
|
||||
<span>{"("}</span>
|
||||
{flash}
|
||||
{send}
|
||||
{attempt}
|
||||
<span>{")"}</span>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-5 gap-1">{cells}</div>
|
||||
{sep}
|
||||
{problems_counter}
|
||||
{interaction_counters_view}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn AttemptRadioGroup() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let problem = ctx.problem;
|
||||
|
||||
let mut attempt_radio_buttons = vec![];
|
||||
for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] {
|
||||
let ui_toggle = Signal::derive(move || ctx.todays_attempt.get() == Some(variant));
|
||||
|
||||
let onclick = move |_| {
|
||||
let attempt = if ui_toggle.get() { None } else { Some(variant) };
|
||||
|
||||
if let Some(problem) = problem.get() {
|
||||
ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt {
|
||||
wall_uid: ctx.wall.read().uid,
|
||||
problem,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
};
|
||||
attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> });
|
||||
}
|
||||
|
||||
view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let text = variant.to_string();
|
||||
let icon = variant.icon();
|
||||
let color = match variant {
|
||||
models::Attempt::Attempt => Gradient::PinkOrange,
|
||||
models::Attempt::Send => Gradient::TealLime,
|
||||
models::Attempt::Flash => Gradient::CyanBlue,
|
||||
};
|
||||
view! { <Button text icon color highlight=selected /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn History() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let attempts = move || {
|
||||
ctx.user_interaction
|
||||
.read()
|
||||
.as_ref()
|
||||
.iter()
|
||||
.flat_map(|x| x.attempted_on())
|
||||
.map(|dated_attempt| {
|
||||
let date = dated_attempt.date_time;
|
||||
let attempt = dated_attempt.attempt;
|
||||
view! { <Attempt date attempt /> }
|
||||
})
|
||||
.collect_view()
|
||||
};
|
||||
|
||||
let placeholder = move || {
|
||||
ctx.latest_attempt.read().is_none().then(|| {
|
||||
let today = chrono::Utc::now();
|
||||
view! { <Attempt date=today attempt=None /> }
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
{placeholder}
|
||||
{attempts}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Wall() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
move || {
|
||||
let wall = ctx.wall.read();
|
||||
|
||||
let mut cells = vec![];
|
||||
for (&hold_position, hold) in &wall.holds {
|
||||
let hold_role = signals::hold_role(ctx.problem, 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(" "))
|
||||
};
|
||||
|
||||
view! {
|
||||
<div style=style class="grid gap-1 p-1">
|
||||
{cells}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: refactor this to use the Problem component
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Hold(hold: models::Hold, role: Signal<Result<Option<HoldRole>, ServerFnError>>) -> impl IntoView {
|
||||
tracing::trace!("Enter");
|
||||
fn Hold(
|
||||
hold: models::Hold,
|
||||
|
||||
#[prop(optional)]
|
||||
#[prop(into)]
|
||||
role: Option<Signal<Option<HoldRole>>>,
|
||||
) -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
move || {
|
||||
let role = role.get()?;
|
||||
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 {
|
||||
let role = role.get();
|
||||
|
||||
let class = {
|
||||
let role_classes = match role {
|
||||
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
|
||||
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
|
||||
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
|
||||
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
|
||||
Some(HoldRole::Start) => Some("outline outline-3 outline-green-500"),
|
||||
Some(HoldRole::Normal) => Some("outline outline-3 outline-blue-500"),
|
||||
Some(HoldRole::Zone) => Some("outline outline-3 outline-amber-500"),
|
||||
Some(HoldRole::End) => Some("outline outline-3 outline-red-500"),
|
||||
None => Some("brightness-50"),
|
||||
};
|
||||
let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string();
|
||||
if let Some(c) = role_classes {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
class.push(' ');
|
||||
class.push_str(c);
|
||||
}
|
||||
s
|
||||
};
|
||||
}
|
||||
|
||||
let img = hold.image.as_ref().map(|img| {
|
||||
let srcset = img.srcset();
|
||||
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
|
||||
});
|
||||
|
||||
let view = view! { <div class=class>{img}</div> };
|
||||
Ok::<_, ServerFnError>(view)
|
||||
view! { <div class=class>{img}</div> }
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Separator() -> impl IntoView {
|
||||
view! { <div class="m-2 h-4 sm:m-3 md:m-4" /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView {
|
||||
view! {
|
||||
<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">
|
||||
{move || title.get()}
|
||||
</div>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
mod signals {
|
||||
use crate::models;
|
||||
use leptos::prelude::*;
|
||||
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>> {
|
||||
Signal::derive(move || user_interaction.read().as_ref().and_then(models::UserInteraction::latest_attempt))
|
||||
}
|
||||
|
||||
pub fn todays_attempt(latest_attempt: Signal<Option<models::UserInteraction>>) -> Signal<Option<models::Attempt>> {
|
||||
Signal::derive(move || latest_attempt.read().as_ref().and_then(models::UserInteraction::todays_attempt))
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn wall_uid(wall: Signal<models::Wall>) -> Signal<models::WallUid> {
|
||||
Signal::derive(move || wall.read().uid)
|
||||
}
|
||||
|
||||
pub fn user_interaction(
|
||||
user_interactions: Signal<BTreeMap<models::Problem, models::UserInteraction>>,
|
||||
problem: Signal<Option<models::Problem>>,
|
||||
) -> Signal<Option<models::UserInteraction>> {
|
||||
Signal::derive(move || {
|
||||
let problem = problem.get()?;
|
||||
let user_interactions = user_interactions.read();
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use leptos::prelude::Get;
|
||||
use leptos::prelude::Signal;
|
||||
use leptos::server::Resource;
|
||||
use server_fn::ServerFnError;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
type RonResource<T> = Resource<Result<T, ServerFnError>, Ron>;
|
||||
|
||||
@@ -16,41 +17,28 @@ pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wal
|
||||
)
|
||||
}
|
||||
|
||||
pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<Option<models::ProblemUid>>) -> RonResource<Option<models::Problem>> {
|
||||
/// Returns user interaction for a single problem
|
||||
pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Option<models::Problem>>) -> RonResource<Option<models::UserInteraction>> {
|
||||
Resource::new_with_options(
|
||||
move || (wall_uid.get(), problem_uid.get()),
|
||||
move |(wall_uid, problem_uid)| async move {
|
||||
let Some(problem_uid) = problem_uid else {
|
||||
move || (wall_uid.get(), problem.get()),
|
||||
move |(wall_uid, problem)| async move {
|
||||
let Some(problem) = problem else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::server_functions::get_problem_by_uid(wall_uid, problem_uid)
|
||||
crate::server_functions::get_user_interaction(wall_uid, problem)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
.map(Some)
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn problems_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<Vec<models::Problem>> {
|
||||
/// Returns all user interactions for a wall
|
||||
pub fn user_interactions_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<BTreeMap<models::Problem, models::UserInteraction>> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn user_interaction(
|
||||
wall_uid: Signal<models::WallUid>,
|
||||
problem_uid: Signal<Option<models::ProblemUid>>,
|
||||
) -> RonResource<Option<models::UserInteraction>> {
|
||||
Resource::new_with_options(
|
||||
move || (wall_uid.get(), problem_uid.get()),
|
||||
move |(wall_uid, problem_uid)| async move {
|
||||
let Some(problem_uid) = problem_uid else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::server_functions::get_user_interaction(wall_uid, problem_uid)
|
||||
move |wall_uid| async move {
|
||||
crate::server_functions::get_user_interactions_for_wall(wall_uid)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
},
|
||||
|
||||
@@ -121,13 +121,6 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat
|
||||
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
|
||||
{
|
||||
// Opening the table creates the table
|
||||
@@ -147,11 +140,23 @@ use crate::models;
|
||||
pub mod current {
|
||||
use super::v2;
|
||||
use super::v3;
|
||||
pub use v2::TABLE_PROBLEMS;
|
||||
use super::v4;
|
||||
pub use v2::TABLE_ROOT;
|
||||
pub use v2::TABLE_WALLS;
|
||||
pub use v3::TABLE_USER;
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,118 @@
|
||||
use super::db::Database;
|
||||
use super::db::DatabaseOperationError;
|
||||
use super::db::{self};
|
||||
use crate::models;
|
||||
use redb::ReadableTable;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if is_at_version(db, 2).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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
|
||||
let set_by = "mini-mb-2020-parser";
|
||||
|
||||
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
|
||||
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 {
|
||||
let row = mv.description.row();
|
||||
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, 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 {
|
||||
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
|
||||
mini_moonboard::Method::Footless => models::Method::Footless,
|
||||
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
|
||||
};
|
||||
|
||||
let problem_id = models::ProblemUid::create();
|
||||
|
||||
let problem = models::Problem {
|
||||
uid: problem_id,
|
||||
name,
|
||||
set_by: set_by.to_owned(),
|
||||
holds,
|
||||
method,
|
||||
date_added: chrono::Utc::now(),
|
||||
};
|
||||
let pattern = models::Pattern { pattern }.canonicalize();
|
||||
let problem = models::Problem { pattern, method };
|
||||
problems.push(problem);
|
||||
}
|
||||
|
||||
db.write(|txn| {
|
||||
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();
|
||||
wall.problems.extend(problems.iter().map(|p| p.uid));
|
||||
wall.problems.extend(problems);
|
||||
walls_table.insert(wall_uid, wall)?;
|
||||
|
||||
for problem in problems {
|
||||
let key = (wall_uid, problem.uid);
|
||||
problems_table.insert(key, problem)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -2,8 +2,13 @@ use crate::codec::ron::Ron;
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::models;
|
||||
use crate::models::UserInteraction;
|
||||
use derive_more::Display;
|
||||
use derive_more::Error;
|
||||
use derive_more::From;
|
||||
use leptos::prelude::*;
|
||||
use leptos::server;
|
||||
use server_fn::ServerFnError;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
@@ -15,7 +20,7 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
|
||||
use crate::server::db::Database;
|
||||
use leptos::prelude::expect_context;
|
||||
use redb::ReadableTable;
|
||||
tracing::trace!("Enter");
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
@@ -40,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::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::trace!("Enter");
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
enum Error {
|
||||
@@ -65,63 +70,7 @@ pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEnco
|
||||
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
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
@@ -130,12 +79,12 @@ pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<R
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn get_user_interaction(
|
||||
wall_uid: models::WallUid,
|
||||
problem_uid: models::ProblemUid,
|
||||
problem: models::Problem,
|
||||
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::trace!("Enter");
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
enum Error {
|
||||
@@ -145,13 +94,13 @@ pub(crate) async fn get_user_interaction(
|
||||
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 user_interaction = db
|
||||
.read(|txn| {
|
||||
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)
|
||||
})
|
||||
.await?;
|
||||
@@ -159,46 +108,198 @@ pub(crate) async fn get_user_interaction(
|
||||
Ok(user_interaction)
|
||||
}
|
||||
|
||||
let user_interaction = inner(wall_uid, problem_uid)
|
||||
let user_interaction = inner(wall_uid, problem)
|
||||
.await
|
||||
.map_err(error_reporter::Report::new)
|
||||
.map_err(ServerFnError::new)?;
|
||||
Ok(RonEncoded::new(user_interaction))
|
||||
}
|
||||
|
||||
/// Returns all user interactions for a wall
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub(crate) async fn get_problem_by_uid(
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn get_user_interactions_for_wall(
|
||||
wall_uid: models::WallUid,
|
||||
problem_uid: models::ProblemUid,
|
||||
) -> Result<RonEncoded<models::Problem>, ServerFnError> {
|
||||
) -> Result<RonEncoded<BTreeMap<models::Problem, models::UserInteraction>>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::trace!("Enter");
|
||||
use redb::ReadableTable;
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
enum Error {
|
||||
#[display("Problem not found: {_0:?}")]
|
||||
NotFound(#[error(not(source))] models::ProblemUid),
|
||||
DatabaseOperation(DatabaseOperationError),
|
||||
}
|
||||
|
||||
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?;
|
||||
async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::Problem, models::UserInteraction>, Error> {
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
Ok(RonEncoded::new(problem))
|
||||
let user_interactions = db
|
||||
.read(|txn| {
|
||||
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
|
||||
let user_interactions = user_table
|
||||
.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| {
|
||||
guard.map(|(_key, val)| {
|
||||
let val = val.value();
|
||||
(val.problem.clone(), val)
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(user_interactions)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(user_interactions)
|
||||
}
|
||||
|
||||
let user_interaction = inner(wall_uid).await.map_err(error_reporter::Report::new).map_err(ServerFnError::new)?;
|
||||
Ok(RonEncoded::new(user_interaction))
|
||||
}
|
||||
|
||||
/// Inserts or updates today's attempt.
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn upsert_todays_attempt(
|
||||
wall_uid: models::WallUid,
|
||||
problem: models::Problem,
|
||||
attempt: Option<models::Attempt>,
|
||||
) -> 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, attempt: Option<models::Attempt>) -> Result<UserInteraction, Error> {
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let user_interaction = db
|
||||
.write(|txn| {
|
||||
let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
|
||||
|
||||
let key = (wall_uid, problem.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));
|
||||
|
||||
// If the last entry is from today, remove it
|
||||
if let Some(entry) = user_interaction.attempted_on.last_entry() {
|
||||
let today_local_naive = chrono::Local::now().date_naive();
|
||||
|
||||
let entry_date = entry.key();
|
||||
let entry_date_local_naive = entry_date.with_timezone(&chrono::Local).date_naive();
|
||||
|
||||
if entry_date_local_naive == today_local_naive {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(attempt) = attempt {
|
||||
user_interaction.attempted_on.insert(chrono::Utc::now(), attempt);
|
||||
}
|
||||
|
||||
user_table.insert(key, user_interaction.clone())?;
|
||||
|
||||
Ok(user_interaction)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(user_interaction)
|
||||
}
|
||||
|
||||
inner(wall_uid, problem, 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
|
||||
.map_err(error_reporter::Report::new)
|
||||
.map_err(ServerFnError::new)
|
||||
.map(RonEncoded::new)
|
||||
}
|
||||
|
||||
22
crates/ascend/src/tracing.rs
Normal file
22
crates/ascend/src/tracing.rs
Normal 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;
|
||||
@@ -1,3 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
relative: true,
|
||||
files: ["*.html", "./src/**/*.rs"],
|
||||
},
|
||||
// https://tailwindcss.com/docs/content-configuration#using-regular-expressions
|
||||
safelist: [
|
||||
{
|
||||
pattern: /bg-transparent/,
|
||||
},
|
||||
{
|
||||
pattern: /grid-cols-.+/,
|
||||
},
|
||||
{
|
||||
pattern: /grid-rows-.+/,
|
||||
},
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
|
||||
BIN
docs/ascend.jpg
BIN
docs/ascend.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 392 KiB |
18
flake.lock
generated
18
flake.lock
generated
@@ -10,11 +10,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1740523129,
|
||||
"narHash": "sha256-q/k/T9Hf+aCo8/xQnqyw+E7dYx8Nq1u7KQ2ylORcP+M=",
|
||||
"lastModified": 1746696290,
|
||||
"narHash": "sha256-YokYinNgGIu80OErVMuFoIELhetzb45aWKTiKYNXvWA=",
|
||||
"owner": "plul",
|
||||
"repo": "basecamp",
|
||||
"rev": "0882906c106ab0bf193b3417c845c5accbec2419",
|
||||
"rev": "108ef2874fd8f934602cda5bfdc0e58a541c6b4a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -25,11 +25,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1740547748,
|
||||
"narHash": "sha256-Ly2fBL1LscV+KyCqPRufUBuiw+zmWrlJzpWOWbahplg=",
|
||||
"lastModified": 1746576598,
|
||||
"narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3a05eebede89661660945da1f151959900903b6a",
|
||||
"rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -53,11 +53,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1740623427,
|
||||
"narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=",
|
||||
"lastModified": 1746671794,
|
||||
"narHash": "sha256-V+mpk2frYIEm85iYf+KPDmCGG3zBRAEhbv0E3lHdG2U=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab",
|
||||
"rev": "ceec434b8741c66bb8df5db70d7e629a9d9c598f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
nativeBuildInputs = [
|
||||
pkgs.cargo-leptos
|
||||
pkgs.dart-sass
|
||||
pkgs.tailwindcss
|
||||
pkgs.tailwindcss_4
|
||||
|
||||
# For optimizing wasm release builds
|
||||
pkgs.binaryen
|
||||
@@ -130,13 +130,14 @@
|
||||
basecamp.mkShell pkgs {
|
||||
rust.enable = true;
|
||||
rust.toolchain.targets = [ "wasm32-unknown-unknown" ];
|
||||
rust.toolchain.components.rust-analyzer.nightly = true;
|
||||
|
||||
packages = [
|
||||
pkgs.bacon
|
||||
pkgs.cargo-leptos
|
||||
pkgs.leptosfmt
|
||||
pkgs.dart-sass
|
||||
pkgs.tailwindcss
|
||||
pkgs.tailwindcss_4
|
||||
pkgs.tailwindcss-language-server
|
||||
|
||||
# For optimizing wasm release builds
|
||||
@@ -145,6 +146,7 @@
|
||||
|
||||
env.RUST_LOG = "info,ascend=debug";
|
||||
env.MOONBOARD_PROBLEMS = "moonboard-problems";
|
||||
env.LEPTOS_TAILWIND_VERSION = "v4.0.8";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
3
justfile
3
justfile
@@ -8,6 +8,9 @@ fmt:
|
||||
fd --extension=rs --exec-batch leptosfmt
|
||||
bc-fmt
|
||||
|
||||
fix:
|
||||
bc-fix
|
||||
|
||||
serve:
|
||||
RUST_BACKTRACE=1 cargo leptos watch -- serve
|
||||
|
||||
|
||||
16
notes.txt
Normal file
16
notes.txt
Normal 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user