Compare commits

..

40 Commits

Author SHA1 Message Date
4d609118de chore: add cargo shear exception for getrandom 2025-05-08 20:25:46 +02:00
d11bf28625 docs: update readme image 2025-05-08 20:24:38 +02:00
9bbe1dd214 chore: maintenance updates & leptos 0.8 2025-05-08 20:24:14 +02:00
e3ef695069 chore: remove canonicalize migration 2025-04-19 20:54:23 +02:00
5bdfd6835d feat: use on_enter 2025-04-19 20:53:01 +02:00
27716c5ec0 save/saved text 2025-04-06 23:27:11 +02:00
dea8c45939 feat: canonicalize patterns on import 2025-04-06 22:50:07 +02:00
22367f45f2 feat: like button 2025-04-06 22:44:47 +02:00
e5853268de feat: migration canonicalize problems 2025-04-06 21:11:09 +02:00
b37386b9e8 feat: sample from transformations 2025-04-03 23:12:42 +02:00
bd8b0fecf1 feat: transformation buttons 2025-04-01 21:00:31 +02:00
c15db2847d wip 2025-04-01 17:48:19 +02:00
0a95aca872 wip 2025-04-01 15:02:49 +02:00
91bea767d0 wip 2025-04-01 13:46:28 +02:00
ed6aa4b9c9 wip 2025-03-31 22:43:19 +02:00
d11f8510b4 wip 2025-03-31 16:25:20 +02:00
221e15d7ac transformation buttons 2025-03-31 13:20:47 +02:00
e403be8090 feat: refactor to controller component and redesign 2025-03-28 15:20:37 +01:00
58698a1087 better 2025-03-24 23:08:15 +01:00
f1be2dd735 refactor 2025-03-24 13:46:00 +01:00
d9406f98d1 chore: migrate to tailwindcss v4 2025-03-23 20:56:15 +01:00
98703f2c8b refactor: user interaction signal 2025-03-21 13:39:19 +01:00
9b15daaf6d refactor: radio button group 2025-03-20 22:53:51 +01:00
7d95e48941 refactor 2025-03-20 16:44:30 +01:00
f8aa1e29a2 show counter for filter 2025-03-17 23:14:26 +01:00
83bd8e0e5e feat: problem filtering 2025-03-17 16:51:04 +01:00
9898af1bf7 feat: filter UI part 2 2025-03-17 15:08:50 +01:00
ed9eba8dc1 feat: filter UI 2025-03-17 12:25:34 +01:00
3740224f79 feat: remove attempt 2025-03-11 21:24:46 +01:00
2e83efcf12 feat: store attempts 2025-03-11 18:16:21 +01:00
7118b66104 feat: responsive /wall layout 2025-02-26 15:39:34 +01:00
f1d0fc68c5 feat: highlight hold on hover 2025-02-26 13:41:30 +01:00
1d7374d387 feat: just prod-deploy with upload of locally build artifact 2025-02-26 13:35:28 +01:00
91430f8985 problem UID in query params 2025-02-25 23:38:01 +01:00
fc96105091 rust edition 2024 2025-02-25 21:42:03 +01:00
a6942156e4 fix routes page 2025-02-20 22:28:55 +01:00
aebd30c1c2 show method 2025-02-19 21:49:34 +01:00
bf8e79b88c feat: remove old migrations 2025-02-19 20:32:15 +01:00
b28546d2de refactor: use test-try 2025-02-19 15:15:34 +01:00
a164b0628d feat: use redb 2025-02-18 21:23:37 +01:00
45 changed files with 4373 additions and 1094 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.wasm32-unknown-unknown]
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]

View File

@@ -5,6 +5,15 @@ language-servers = ["rust-analyzer", "tailwindcss-ls"]
[language-server.rust-analyzer.config] [language-server.rust-analyzer.config]
# procMacro = { ignored = { leptos_macro = ["server"] } } # procMacro = { ignored = { leptos_macro = ["server"] } }
cargo = { features = ["ssr", "hydrate"] } cargo = { features = ["ssr", "hydrate"] }
check = { command = "check" }
rustfmt = { overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"] }
# rustfmt = { overrideCommand = [
# "sh",
# "-c",
# "set -euo pipefail; rustfmt --emit stdout --edition 2024 | leptosfmt --stdin",
# ] }
[language-server.tailwindcss-ls] [language-server.tailwindcss-ls]
config = { userLanguages = { rust = "html", "*.rs" = "html" } } config = { userLanguages = { rust = "html", "*.rs" = "html" } }

1505
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
[workspace] [workspace]
resolver = "2" resolver = "3"
members = ["crates/ascend", "crates/moonboard-parser"] members = ["crates/ascend", "crates/moonboard-parser"]
[workspace.package] [workspace.package]
edition = "2021" edition = "2024"
authors = ["Asger Juul Brunshøj <asgerbrunshoj@gmail.com>"] authors = ["Asger Juul Brunshøj <asgerbrunshoj@gmail.com>"]
[workspace.dependencies.moonboard-parser] [workspace.dependencies.moonboard-parser]

View File

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

View File

@@ -1,3 +1,4 @@
use crate::codec::ron::Ron;
use crate::pages; use crate::pages;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_router::components::*; use leptos_router::components::*;
@@ -16,7 +17,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
<HydrationScripts options /> <HydrationScripts options />
<MetaTags /> <MetaTags />
</head> </head>
<body class="bg-slate-950 text-white"> <body class="text-white bg-slate-950">
<App /> <App />
</body> </body>
</html> </html>
@@ -24,7 +25,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
} }
#[component] #[component]
pub fn App() -> impl leptos::IntoView { pub fn App() -> impl IntoView {
use leptos_meta::Stylesheet; use leptos_meta::Stylesheet;
use leptos_meta::Title; use leptos_meta::Title;
@@ -39,13 +40,47 @@ pub fn App() -> impl leptos::IntoView {
<Title text="Ascend" /> <Title text="Ascend" />
<Router> <Router>
<main> <Routes fallback=|| "Not found">
<Routes fallback=|| "Not found"> <Route path=path!("/") view=Home />
<Route path=path!("/") view=pages::wall::Wall /> <Route path=path!("/wall/:wall_uid") view=pages::wall::Page />
<Route path=path!("/wall/edit") view=pages::edit_wall::EditWall /> <Route path=path!("/wall/:wall_uid/holds") view=pages::holds::Page />
<Route path=path!("/wall/routes") view=pages::routes::Routes /> </Routes>
</Routes>
</main>
</Router> </Router>
} }
} }
#[component]
pub fn Home() -> impl IntoView {
// TODO: show cards with walls, and a "new wall" button
tracing::debug!("Rendering home component");
// dbg!(leptos::prelude::Owner::current().map(|o| o.ancestry()));
let wall_uid = OnceResource::<_, Ron>::new_with_options(
async move {
// dbg!(leptos::prelude::Owner::current().map(|o| o.ancestry()));
let walls = crate::server_functions::get_walls()
.await
.inspect_err(|e| {
dbg!(e);
})
.expect("failed to get walls")
.into_inner();
walls.first().map(|wall| wall.uid)
},
false,
);
Effect::new(move || {
tracing::debug!("running effect");
if let Some(wall_uid) = wall_uid.get().flatten() {
tracing::debug!("navigating");
let navigate = leptos_router::hooks::use_navigate();
let url = format!("/wall/{}", wall_uid);
navigate(&url, Default::default());
tracing::debug!("navigated");
}
});
}

View File

@@ -1,59 +1,141 @@
pub mod ron { pub mod ron {
//! Wrap T in RonCodec<T> that when serialized, always serializes to a [ron] string. //! Wrap T in RonCodec<T> that when serialized, always serializes to a [ron] string.
#[derive(Debug, Clone)] use codee::Decoder;
pub struct RonCodec<T> { use codee::Encoder;
t: T, use leptos::prelude::FromServerFnError;
use leptos::prelude::ServerFnErrorErr;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
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::TryRes;
pub struct Ron;
impl<T> Encoder<T> for Ron
where
T: Serialize,
{
type Encoded = String;
type Error = ron::Error;
fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
ron::to_string(val)
}
} }
impl<T> RonCodec<T> { impl<T> Decoder<T> for Ron
where
for<'de> T: Deserialize<'de>,
{
type Encoded = str;
type Error = ron::error::SpannedError;
fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
ron::from_str(val)
}
}
impl Encoding for 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);
impl<T> RonEncoded<T> {
pub fn into_inner(self) -> T { pub fn into_inner(self) -> T {
self.t self.0
} }
pub fn new(t: T) -> Self { pub fn new(t: T) -> Self {
Self { t } Self(t)
} }
} }
impl<T> std::ops::Deref for RonCodec<T> { impl<T> std::ops::Deref for RonEncoded<T> {
type Target = T; type Target = T;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.t &self.0
} }
} }
impl<T> serde::Serialize for RonCodec<T> // IntoReq
impl<T, Request, Err> IntoReq<Ron, Request, Err> for RonEncoded<T>
where where
T: serde::Serialize, Request: ClientReq<Err>,
T: Serialize,
Err: FromServerFnError,
{ {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn into_req(self, path: &str, accepts: &str) -> Result<Request, Err> {
where let data = Ron::encode(&self.0).map_err(|e| ServerFnErrorErr::Serialization(e.to_string()).into_app_error())?;
S: serde::Serializer, Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data)
{
let serialized = ron::to_string(&self.t).map_err(serde::ser::Error::custom)?;
serializer.serialize_str(&serialized)
} }
} }
impl<'de, T> serde::Deserialize<'de> for RonCodec<T> // FromReq
impl<T, Request, Err> FromReq<Ron, Request, Err> for RonEncoded<T>
where where
T: serde::de::DeserializeOwned + 'static, Request: Req<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{ {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> async fn from_req(req: Request) -> Result<Self, Err> {
where let data = req.try_into_string().await?;
D: serde::Deserializer<'de>, Ron::decode(&data)
{ .map(RonEncoded)
let s = String::deserialize(deserializer)?; .map_err(|e| ServerFnErrorErr::Args(e.to_string()).into_app_error())
let t: T = ron::from_str(&s).map_err(serde::de::Error::custom)?; }
Ok(Self { t }) }
// IntoRes
impl<Err, T, Response> IntoRes<Ron, Response, Err> for RonEncoded<T>
where
Response: TryRes<Err>,
T: Serialize + Send,
Err: FromServerFnError,
{
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)
}
}
// FromRes
impl<T, Response, Err> FromRes<Ron, Response, Err> for RonEncoded<T>
where
Response: ClientRes<Err> + Send,
T: DeserializeOwned,
Err: FromServerFnError,
{
async fn from_res(res: Response) -> Result<Self, Err> {
let data = res.try_into_string().await?;
Ron::decode(&data)
.map(RonEncoded)
.map_err(|e| ServerFnErrorErr::Deserialization(e.to_string()).into_app_error())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::RonCodec; use super::Ron;
use codee::Decoder;
use codee::Encoder;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -63,25 +145,15 @@ pub mod ron {
value: i32, value: i32,
} }
#[test] #[test_try::test_try]
fn test_ron_codec() { fn test_ron_codec() {
let original = TestStruct { let original = TestStruct {
name: "Test".to_string(), name: "Test".to_string(),
value: 42, value: 42,
}; };
let enc = Ron::encode(&original)?;
// Wrap in RonCodec let dec: TestStruct = Ron::decode(&enc)?;
let wrapped = RonCodec::new(original.clone()); assert_eq!(dec, original);
// Serialize
let serialized = serde_json::to_string(&wrapped).expect("Serialization failed");
println!("Serialized: {}", serialized);
// Deserialize
let deserialized: RonCodec<TestStruct> = serde_json::from_str(&serialized).expect("Deserialization failed");
// Compare
assert_eq!(deserialized.into_inner(), original);
} }
} }
} }

View File

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

View File

@@ -0,0 +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)] 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::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::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 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())
}
}

View File

@@ -1,9 +1,104 @@
use super::icons::Icon;
use crate::components::outlined_box::OutlinedBox;
use crate::gradient::Gradient;
use leptos::prelude::*; use leptos::prelude::*;
use web_sys::MouseEvent;
#[component] #[component]
pub fn Button(#[prop(into)] text: String, onclick: impl Fn(MouseEvent) -> () + 'static) -> impl IntoView { pub fn Button(
#[prop(into, optional)] icon: MaybeProp<Icon>,
#[prop(into, optional)] text: MaybeProp<String>,
#[prop(optional)] color: Gradient,
#[prop(into, optional)] highlight: MaybeProp<bool>,
#[prop(into, optional)] disabled: MaybeProp<bool>,
#[prop(into, optional)] on_click: MaybeProp<Callback<()>>,
) -> impl IntoView {
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
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());
view! { <div class=classes>{icon_view}</div> }
})
};
let separator = move || {
(icon.read().is_some() && text.read().is_some()).then(|| {
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
classes.push(' ');
classes.push_str(color.class_from());
classes.push(' ');
classes.push_str(color.class_to());
view! { <div class=classes /> }
})
};
let text_view = move || {
text.get().map(|text| {
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
classes.push(' ');
classes.push_str(margin);
view! { <div class=classes>{text}</div> }
})
};
let class = move || {
let mut classes = vec![];
if disabled.get().unwrap_or_default() {
classes.extend(["brightness-50"]);
} else {
classes.extend(["cursor-pointer", "hover:brightness-125", "active:brightness-90"]);
}
classes.join(" ")
};
let on_click = move |_| {
if let Some(cb) = on_click.get() {
cb.run(());
}
};
let prop_disabled = move || disabled.get();
view! { view! {
<button on:click=onclick type="button" class="text-black bg-orange-300 hover:bg-orange-400 focus:ring-4 focus:ring-orange-500 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none">{ text }</button> <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>
</button>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn baseline() {
let text = "foo";
let onclick = |_| {};
view! { <Button text on:click=onclick /> };
}
#[test]
fn simple() {
let icon = Icon::ForwardSolid;
let text = "foo";
let onclick = |_| {};
view! { <Button icon text on:click=onclick /> };
} }
} }

View File

@@ -0,0 +1,50 @@
use crate::components::icons;
use crate::components::outlined_box::OutlinedBox;
use crate::gradient::Gradient;
use leptos::prelude::*;
#[component]
pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[prop(optional)] color: Gradient) -> impl IntoView {
let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4()));
let checkbox_view = view! {
<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>
</div>
};
let separator = {
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
classes.push(' ');
classes.push_str(color.class_from());
classes.push(' ');
classes.push_str(color.class_to());
view! { <div class=classes /> }
};
let text_view = view! {
<div class="self-center my-2.5 mx-5 w-full text-lg font-thin uppercase">
{move || text.get()}
</div>
};
view! {
<div class="inline-block mb-2 me-2 hover:brightness-125 active:brightness-90">
<input
type="checkbox"
id=unique_id.clone()
value=""
class="hidden peer"
required=""
bind:checked=checked
/>
<label for=unique_id class="cursor-pointer">
<OutlinedBox color>
<div class="flex">{checkbox_view} {separator} {text_view}</div>
</OutlinedBox>
</label>
</div>
}
}

View File

@@ -1,11 +1,13 @@
use leptos::prelude::*; use leptos::prelude::*;
#[derive(Debug, Clone)]
pub struct HeaderItems { pub struct HeaderItems {
pub left: Vec<HeaderItem>, pub left: Vec<HeaderItem>,
pub middle: Vec<HeaderItem>, pub middle: Vec<HeaderItem>,
pub right: Vec<HeaderItem>, pub right: Vec<HeaderItem>,
} }
#[derive(Debug, Clone)]
pub struct HeaderItem { pub struct HeaderItem {
pub text: String, pub text: String,
pub link: Option<String>, pub link: Option<String>,
@@ -13,44 +15,46 @@ pub struct HeaderItem {
/// Header with background color etc. /// Header with background color etc.
#[component] #[component]
pub fn StyledHeader(items: HeaderItems) -> impl IntoView { pub fn StyledHeader(#[prop(into)] items: Signal<HeaderItems>) -> impl IntoView {
view! { view! {
<div class="bg-orange-300 text-black border-b-2 border-b-orange-400"> <div class="text-black bg-orange-300 border-b-2 border-b-orange-400">
// <div class="container mx-auto" > // <div class="container mx-auto" >
<Header items /> <Header items />
// </div> // </div>
</div> </div>
} }
} }
/// Function header without styling /// Function header without styling
#[component] #[component]
pub fn Header(items: HeaderItems) -> impl IntoView { pub fn Header(#[prop(into)] items: Signal<HeaderItems>) -> impl IntoView {
let HeaderItems { left, middle, right } = items; let left = move || items.read().left.clone();
let middle = move || items.read().middle.clone();
let right = move || items.read().right.clone();
view! { view! {
<div class="grid grid-cols-[1fr_3fr_1fr] text-xl font-semibold p-4"> <div class="grid p-4 text-xl font-semibold grid-cols-[1fr_3fr_1fr]">
// Left side of header // Left side of header
<div class="justify-self-start"> <div class="justify-self-start">
<Items items=left /> <Items items=Signal::derive(left) />
</div> </div>
// Expanding space in the middle // Expanding space in the middle
<div class="justify-self-center font-semibold"> <div class="justify-self-center font-semibold">
<Items items=middle /> <Items items=Signal::derive(middle) />
</div> </div>
// Right side of header // Right side of header
<div class="justify-self-end"> <div class="justify-self-end">
<Items items=right /> <Items items=Signal::derive(right) />
</div> </div>
</div> </div>
} }
} }
#[component] #[component]
fn Items(items: Vec<HeaderItem>) -> impl IntoView { fn Items(#[prop(into)] items: Signal<Vec<HeaderItem>>) -> impl IntoView {
let items = items.into_iter().map(|item| view! { <Item item /> }).collect_view(); let items = move || items.get().into_iter().map(|item| view! { <Item item /> }).collect_view();
view! { <div class="flex gap-4">{items}</div> } view! { <div class="flex gap-4">{items}</div> }
} }

View File

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

View File

@@ -0,0 +1,302 @@
use leptos::prelude::*;
#[derive(Copy, Debug, Clone)]
pub enum Icon {
BoltSolid,
BoltSlashSolid,
WrenchSolid,
ForwardSolid,
Check,
Heart,
HeartOutline,
ArrowPath,
PaperAirplaneSolid,
NoSymbol,
Trophy,
ArrowTrendingUp,
ChevronLeft,
ChevronRight,
CodeBracketSquare,
}
impl Icon {
// TODO: Actually impl IntoView for Icon instead
pub fn into_view(self) -> impl IntoView {
match self {
Icon::BoltSolid => view! { <BoltSolid /> }.into_any(),
Icon::BoltSlashSolid => view! { <BoltSlashSolid /> }.into_any(),
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(),
}
}
}
#[component]
pub fn BoltSolid() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z"
clip-rule="evenodd"
/>
</svg>
}
}
#[component]
pub fn BoltSlashSolid() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path d="m20.798 11.012-3.188 3.416L9.462 6.28l4.24-4.542a.75.75 0 0 1 1.272.71L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262ZM3.202 12.988 6.39 9.572l8.148 8.148-4.24 4.542a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262ZM3.53 2.47a.75.75 0 0 0-1.06 1.06l18 18a.75.75 0 1 0 1.06-1.06l-18-18Z" />
</svg>
}
}
#[component]
pub fn WrenchSolid() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
clip-rule="evenodd"
/>
</svg>
}
}
#[component]
pub fn ForwardSolid() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path d="M5.055 7.06C3.805 6.347 2.25 7.25 2.25 8.69v8.122c0 1.44 1.555 2.343 2.805 1.628L12 14.471v2.34c0 1.44 1.555 2.343 2.805 1.628l7.108-4.061c1.26-.72 1.26-2.536 0-3.256l-7.108-4.061C13.555 6.346 12 7.249 12 8.689v2.34L5.055 7.061Z" />
</svg>
}
}
#[component]
pub fn Check() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
stroke="currentColor"
stroke-width="2"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
}
}
#[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! {
<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="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
/>
</svg>
}
}
#[component]
pub fn ArrowPath() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="M4.755 10.059a7.5 7.5 0 0 1 12.548-3.364l1.903 1.903h-3.183a.75.75 0 1 0 0 1.5h4.992a.75.75 0 0 0 .75-.75V4.356a.75.75 0 0 0-1.5 0v3.18l-1.9-1.9A9 9 0 0 0 3.306 9.67a.75.75 0 1 0 1.45.388Zm15.408 3.352a.75.75 0 0 0-.919.53 7.5 7.5 0 0 1-12.548 3.364l-1.902-1.903h3.183a.75.75 0 0 0 0-1.5H2.984a.75.75 0 0 0-.75.75v4.992a.75.75 0 0 0 1.5 0v-3.18l1.9 1.9a9 9 0 0 0 15.059-4.035.75.75 0 0 0-.53-.918Z"
clip-rule="evenodd"
/>
</svg>
}
}
#[component]
pub fn PaperAirplaneSolid() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path d="M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z" />
</svg>
}
}
#[component]
pub fn NoSymbol() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-6"
>
<path
fill-rule="evenodd"
d="m6.72 5.66 11.62 11.62A8.25 8.25 0 0 0 6.72 5.66Zm10.56 12.68L5.66 6.72a8.25 8.25 0 0 0 11.62 11.62ZM5.105 5.106c3.807-3.808 9.98-3.808 13.788 0 3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788Z"
clip-rule="evenodd"
/>
</svg>
}
}
#[component]
pub fn Trophy() -> 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="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0"
/>
</svg>
}
}
#[component]
pub fn ArrowTrendingUp() -> 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="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"
/>
</svg>
}
}
#[component]
pub fn ChevronLeft() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
}
}
#[component]
pub fn ChevronRight() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
}
}
#[component]
pub fn CodeBracketSquare() -> impl IntoView {
view! {
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.25 9.75 16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z"
/>
</svg>
}
}

View File

@@ -0,0 +1,49 @@
use crate::gradient::Gradient;
use leptos::prelude::*;
#[component]
pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highlight: MaybeProp<bool>) -> impl IntoView {
let highlight = move || highlight.get().unwrap_or(false);
let outer_classes = move || {
let mut c = "p-0.5 bg-linear-to-br rounded-lg".to_string();
c.push(' ');
c.push_str(color.class_from());
c.push(' ');
c.push_str(color.class_to());
if highlight() {
c.push(' ');
c.push_str("brightness-110");
}
c
};
let inner_classes = move || {
let mut c = "py-1.5 rounded-md".to_string();
if highlight() {
let bg = match color {
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(' ');
c.push_str(bg);
} else {
c.push(' ');
c.push_str("bg-gray-900");
}
c
};
view! {
<div class=outer_classes>
<div class=inner_classes>{children()}</div>
</div>
}
}

View File

@@ -0,0 +1,66 @@
use crate::models::HoldRole;
use crate::models::{self};
use leptos::prelude::*;
/// Displays a grid of the problem
#[component]
#[tracing::instrument(skip_all)]
pub fn Problem(
/// Wall dimensions
#[prop(into)]
dim: Signal<models::WallDimensions>,
/// Problem (route)
#[prop(into)]
problem: Signal<models::Problem>,
) -> impl IntoView {
let holds = move || {
let mut holds = vec![];
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.read().pattern.pattern.get(&hold_position).copied();
let role = Signal::derive(role);
let hold = view! { <Hold role /> };
holds.push(hold);
}
}
holds.into_iter().collect_view()
};
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 style=style class="grid gap-3">
{holds}
</div>
</div>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn Hold(#[prop(into)] role: Signal<Option<HoldRole>>) -> impl IntoView {
let class = move || {
let role_classes = match role.get() {
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
None => Some("brightness-50"),
};
let mut s = "min-w-2 bg-sky-100 aspect-square rounded-sm".to_string();
if let Some(c) = role_classes {
s.push(' ');
s.push_str(c);
}
s
};
view! { <div class=class /> }
}

View File

@@ -0,0 +1,29 @@
use crate::models;
use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
tracing::trace!("Enter problem info");
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-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: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
view! {
<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
View File

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

View File

@@ -0,0 +1,52 @@
#[derive(Debug, Copy, Clone, Default)]
pub enum Gradient {
PurpleBlue,
PinkOrange,
CyanBlue,
TealLime,
PurplePink,
#[default]
Orange,
Pink,
PinkRed,
}
impl Gradient {
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) -> &'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) -> &'static str {
match self {
Gradient::PinkOrange => "text-rose-400",
Gradient::CyanBlue => "text-cyan-500",
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",
}
}
}

View File

@@ -1,21 +1,17 @@
pub mod app; pub mod app;
pub mod pages {
pub mod edit_wall;
pub mod routes;
pub mod wall;
}
pub mod components {
pub mod button;
pub mod header;
}
pub mod codec; 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")] #[cfg(feature = "ssr")]
pub mod server; pub mod server;
pub mod models;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() { pub fn hydrate() {
@@ -24,9 +20,14 @@ pub fn hydrate() {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::builder()
.with_default_directive(::tracing::level_filters::LevelFilter::DEBUG.into())
.from_env_lossy(),
)
.with_writer( .with_writer(
// To avoide trace events in the browser from showing their JS backtrace // To avoide trace events in the browser from showing their JS backtrace
tracing_subscriber_wasm::MakeConsoleWriter::default().map_trace_level_to(tracing::Level::DEBUG), tracing_subscriber_wasm::MakeConsoleWriter::default().map_trace_level_to(::tracing::Level::DEBUG),
) )
// For some reason, if we don't do this in the browser, we get a runtime error. // For some reason, if we don't do this in the browser, we get a runtime error.
.without_time() .without_time()

View File

@@ -1,18 +1,122 @@
//! Shared models between server and client code. //! Shared models between server and client code.
pub use v1::Hold;
pub use v1::HoldPosition; pub use v1::HoldPosition;
pub use v1::HoldRole; pub use v1::HoldRole;
pub use v1::Image; pub use v2::Hold;
pub use v2::Image;
pub use v2::ImageFilename;
pub use v2::ImageResolution;
pub use v2::ImageUid;
pub use v2::Method; pub use v2::Method;
pub use v2::Problem;
pub use v2::ProblemId;
pub use v2::Root; pub use v2::Root;
pub use v2::Wall; pub use v2::WallDimensions;
pub use v2::WallId; pub use v2::WallUid;
pub use v3::Attempt;
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;
/// Registers user interaction with a problem
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserInteraction {
pub wall_uid: v2::WallUid,
pub problem_uid: v2::ProblemUid,
/// Dates on which this problem was attempted, and how it went
pub attempted_on: BTreeMap<chrono::DateTime<chrono::Utc>, Attempt>,
/// Is among favorite problems
pub is_favorite: bool,
/// Added to personal challenges
pub is_saved: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy, Display)]
pub enum Attempt {
/// Tried to climb problem, but was not able to.
Attempt,
/// Climbed problem, but not flashed.
Send,
/// Flashed problem.
Flash,
}
}
pub mod v2 { pub mod v2 {
use super::v1; use super::v1;
use derive_more::Display;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -20,29 +124,32 @@ pub mod v2 {
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct Root { pub struct Root {
pub walls: BTreeSet<WallId>, pub walls: BTreeSet<WallUid>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Wall { pub struct Wall {
pub uid: WallId, pub uid: WallUid,
pub rows: u64, pub rows: u64,
pub cols: u64, pub cols: u64,
pub holds: BTreeMap<v1::HoldPosition, v1::Hold>,
pub problems: BTreeSet<ProblemId>, pub holds: BTreeMap<v1::HoldPosition, Hold>,
pub problems: BTreeSet<ProblemUid>,
} }
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WallId(pub uuid::Uuid); pub struct WallDimensions {
impl WallId { pub rows: u64,
pub fn new() -> Self { pub cols: u64,
Self(uuid::Uuid::new_v4())
}
} }
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
pub struct WallUid(pub uuid::Uuid);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Problem { pub struct Problem {
pub uid: ProblemId, pub uid: ProblemUid,
pub name: String, pub name: String,
pub set_by: String, pub set_by: String,
pub holds: BTreeMap<v1::HoldPosition, v1::HoldRole>, pub holds: BTreeMap<v1::HoldPosition, v1::HoldRole>,
@@ -50,20 +157,46 @@ pub mod v2 {
pub date_added: chrono::DateTime<chrono::Utc>, pub date_added: chrono::DateTime<chrono::Utc>,
} }
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
pub struct ProblemId(pub uuid::Uuid); pub struct ProblemUid(pub uuid::Uuid);
impl ProblemId {
pub fn new() -> Self { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy, Hash)]
Self(uuid::Uuid::new_v4()) pub enum Method {
} #[display("Feet follow hands")]
FeetFollowHands,
#[display("Footless plus kickboard")]
FootlessPlusKickboard,
#[display("Footless")]
Footless,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Hold {
pub position: v1::HoldPosition,
pub image: Option<Image>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Image {
pub uid: ImageUid,
pub resolutions: BTreeMap<ImageResolution, ImageFilename>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Method { pub struct ImageResolution {
FeetFollowHands, pub width: u64,
Footless, pub height: u64,
FootlessPlusKickboard,
} }
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ImageFilename {
pub filename: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
pub struct ImageUid(pub uuid::Uuid);
} }
pub mod v1 { pub mod v1 {

View 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);
}
}

View File

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

View File

@@ -1,184 +0,0 @@
use crate::codec::ron::RonCodec;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
use crate::models;
use crate::models::HoldPosition;
use crate::models::Wall;
use leptos::ev::Event;
use leptos::html::Input;
use leptos::prelude::*;
use serde::Deserialize;
use serde::Serialize;
use server_fn::codec::Cbor;
use std::ops::Deref;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::FileList;
#[component]
pub fn EditWall() -> impl leptos::IntoView {
let load = async move {
// TODO: What to do about this unwrap?
load_initial_data().await.unwrap()
};
let header_items = HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some("/".to_string()),
}],
middle: vec![HeaderItem {
text: "HOLDS".to_string(),
link: None,
}],
right: vec![],
};
leptos::view! {
<div class="min-w-screen min-h-screen bg-slate-900">
<StyledHeader items=header_items />
<div class="container mx-auto mt-2">
<Await future=load let:data>
<Ready data=data.deref().to_owned() />
</Await>
</div>
</div>
}
}
#[component]
fn Ready(data: InitialData) -> impl leptos::IntoView {
tracing::debug!("ready");
let mut holds = vec![];
for hold in data.wall.holds.values().cloned() {
holds.push(view! { <Hold hold /> });
}
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", data.wall.rows, data.wall.cols);
view! {
<div>
<p class="my-4 font-semibold">"Click hold to replace image"</p>
<div class=move || { grid_classes.clone() }>{holds}</div>
</div>
}
}
#[component]
fn Hold(hold: models::Hold) -> impl leptos::IntoView {
let hold_position = hold.position;
let file_input_ref = NodeRef::<Input>::new();
let open_camera = move |_| {
if let Some(input) = file_input_ref.get() {
input.click(); // Trigger the file input click programmatically
}
};
let upload = Action::from(ServerAction::<SetImage>::new());
let hold = Signal::derive(move || {
let refreshed = upload.value().get().map(Result::unwrap);
refreshed.unwrap_or(hold.clone())
});
// Callback to handle file selection
let on_file_input = move |event: Event| {
let files: FileList = event.target().unwrap().unchecked_ref::<web_sys::HtmlInputElement>().files().unwrap();
let file = files.item(0).unwrap();
let file_reader = web_sys::FileReader::new().unwrap();
file_reader.read_as_array_buffer(&file).unwrap();
let on_load = Closure::wrap(Box::new(move |event: Event| {
let file_reader: web_sys::FileReader = event.target().unwrap().dyn_into().unwrap();
let file = file_reader.result().unwrap();
let file = web_sys::js_sys::Uint8Array::new(&file);
let mut file_contents = vec![0; file.length() as usize];
file.copy_to(&mut file_contents);
tracing::debug!("bytes: {:?}", &file_contents.len());
let image = Image {
file_name: "foo".to_string(),
file_contents,
};
upload.dispatch(SetImage { hold_position, image });
}) as Box<dyn FnMut(_)>);
file_reader.set_onload(Some(on_load.as_ref().unchecked_ref()));
on_load.forget();
};
let img = move || {
hold.read().image.as_ref().map(|img| {
let src = format!("/files/holds/{}", img.filename);
view! { <img class="object-cover w-full h-full" src=src /> }
})
};
view! {
<button on:click=open_camera>
<div class="bg-indigo-100 aspect-square rounded">{img}</div>
</button>
<input
node_ref=file_input_ref
type="file"
accept="image/*"
capture="user"
style="display: none;"
on:input=on_file_input
/>
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InitialData {
wall: Wall,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Image {
file_name: String,
file_contents: Vec<u8>,
}
#[server]
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
todo!()
// let wall = state.persistent.with(|s| s.wall.clone()).await;
// Ok(RonCodec::new(InitialData { wall }))
}
#[server(name = SetImage, input = Cbor)]
#[tracing::instrument(skip(image))]
async fn set_image(hold_position: HoldPosition, image: Image) -> Result<models::Hold, ServerFnError> {
tracing::info!("Setting image, {}, {} bytes", image.file_name, image.file_contents.len());
// TODO: Fix file extension presumption, and possibly use uuid
let filename = format!("row{}_col{}.jpg", hold_position.row, hold_position.col);
tokio::fs::create_dir_all("datastore/public/holds").await?;
tokio::fs::write(format!("datastore/public/holds/{filename}"), image.file_contents).await?;
todo!()
// let state = expect_context::<State>();
// state
// .persistent
// .update(|s| {
// if let Some(hold) = s.wall.holds.get_mut(&hold_position) {
// hold.image = Some(models::Image { filename });
// }
// })
// .await?;
// // Return updated hold
// let hold = state.persistent.with(|s| s.wall.holds.get(&hold_position).cloned().unwrap()).await;
// Ok(hold)
}

View File

@@ -0,0 +1,251 @@
use crate::codec::ron::Ron;
use crate::codec::ron::RonEncoded;
use crate::components::StyledHeader;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::models;
use crate::models::HoldPosition;
use crate::models::WallUid;
use leptos::Params;
use leptos::ev::Event;
use leptos::html::Input;
use leptos::prelude::*;
use leptos_router::params::Params;
use serde::Deserialize;
use serde::Serialize;
use server_fn::codec::Cbor;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use web_sys::FileList;
#[derive(Params, PartialEq, Clone)]
struct RouteParams {
wall_uid: Option<models::WallUid>,
}
#[component]
pub fn Page() -> impl IntoView {
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 header_items = move || HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some(format!("/wall/{}", wall_uid.get())),
}],
middle: vec![HeaderItem {
text: "HOLDS".to_string(),
link: None,
}],
right: vec![],
};
leptos::view! {
<div class="min-h-screen min-w-screen bg-slate-900">
<StyledHeader items=Signal::derive(header_items) />
<div class="container mx-auto mt-2">
<Suspense fallback=move || {
view! { <p>"Loading..."</p> }
}>
{move || Suspend::new(async move {
let wall = wall.await;
view! {
<ErrorBoundary fallback=|_errors| {
"error"
}>
{move || -> Result<_, ServerFnError> {
let wall = wall.clone()?;
Ok(view! { <Ready wall /> })
}}
</ErrorBoundary>
}
})}
</Suspense>
</div>
</div>
}
}
#[component]
fn Ready(wall: models::Wall) -> impl IntoView {
tracing::debug!("ready");
let mut holds = vec![];
for hold in wall.holds.values().cloned() {
holds.push(view! { <Hold wall_uid=wall.uid hold /> });
}
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 style=style class="grid gap-3">
{holds}
</div>
</div>
}
}
#[component]
fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
let hold_position = hold.position;
let file_input_ref = NodeRef::<Input>::new();
let open_camera = move |_| {
if let Some(input) = file_input_ref.get() {
input.click(); // Trigger the file input click programmatically
}
};
let upload = ServerAction::<SetImage>::new();
let hold = Signal::derive(move || {
let refreshed = upload.value().get().map(Result::unwrap);
refreshed.map(RonEncoded::into_inner).unwrap_or(hold.clone())
});
// Callback to handle file selection
let on_file_input = move |event: Event| {
let files: FileList = event.target().unwrap().unchecked_ref::<web_sys::HtmlInputElement>().files().unwrap();
let file = files.item(0).unwrap();
let file_reader = web_sys::FileReader::new().unwrap();
file_reader.read_as_array_buffer(&file).unwrap();
let on_load = Closure::wrap(Box::new(move |event: Event| {
let file_reader: web_sys::FileReader = event.target().unwrap().dyn_into().unwrap();
let file = file_reader.result().unwrap();
let file = web_sys::js_sys::Uint8Array::new(&file);
let mut file_contents = vec![0; file.length() as usize];
file.copy_to(&mut file_contents);
tracing::debug!("bytes: {:?}", &file_contents.len());
let image = Image {
file_name: "foo".to_string(),
file_contents,
};
upload.dispatch(SetImage {
wall_uid,
hold_position,
image,
});
}) as Box<dyn FnMut(_)>);
file_reader.set_onload(Some(on_load.as_ref().unchecked_ref()));
on_load.forget();
};
let img = move || {
hold.read().image.as_ref().map(|img| {
let srcset = img.srcset();
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
})
};
view! {
<button on:click=open_camera>
<div class="bg-indigo-100 rounded-sm aspect-square">{img}</div>
</button>
<input
node_ref=file_input_ref
type="file"
accept="image/*"
capture="user"
style="display: none;"
on:input=on_file_input
/>
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Image {
file_name: String,
file_contents: Vec<u8>,
}
#[server(
name = SetImage,
input = Cbor,
output = Ron,
)]
#[tracing::instrument(skip(image), err)]
async fn set_image(wall_uid: WallUid, hold_position: HoldPosition, image: Image) -> Result<RonEncoded<models::Hold>, ServerFnError> {
use image::ImageDecoder;
use std::collections::BTreeMap;
use std::path::Path;
tracing::info!("Setting image, {}, {} bytes", image.file_name, image.file_contents.len());
let db = expect_context::<crate::server::db::Database>();
let image = tokio::task::spawn_blocking(move || -> Result<models::Image, ServerFnError> {
let mut decoder = image::ImageReader::new(std::io::Cursor::new(image.file_contents))
.with_guessed_format()?
.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
let holds_dir = Path::new("datastore/public/holds");
std::fs::create_dir_all(holds_dir)?;
let targets = [(50, 50), (150, 150), (300, 300), (400, 400)];
let uid = models::ImageUid::create();
let mut resolutions = BTreeMap::new();
for (width, height) in targets {
let resized = img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3);
let filename = format!("hold_row{}_col{}_{width}x{height}_{uid}.webp", hold_position.row, hold_position.col);
let path = holds_dir.join(&filename);
let mut file = std::fs::OpenOptions::new().write(true).append(false).create_new(true).open(&path)?;
resized.write_to(&mut file, image::ImageFormat::WebP)?;
let res = models::ImageResolution {
width: width.into(),
height: height.into(),
};
resolutions.insert(res, models::ImageFilename { filename });
}
// TODO: Clean up old image?
Ok(models::Image { uid, resolutions })
})
.await??;
let hold = db
.write(move |txn| {
use redb::ReadableTable;
let mut walls = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
let mut wall = walls.get(wall_uid)?.expect("todo").value();
let hold = wall.holds.get_mut(&hold_position).expect("hold");
hold.image = Some(image);
let hold = hold.clone();
walls.insert(wall_uid, wall)?;
Ok(hold)
})
.await?;
Ok(RonEncoded::new(hold))
}

View File

@@ -1,96 +0,0 @@
use crate::codec::ron::RonCodec;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
use crate::models;
use leptos::prelude::*;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeSet;
use std::ops::Deref;
#[component]
pub fn Routes() -> impl leptos::IntoView {
let load = async move {
// TODO: What to do about this unwrap?
load_initial_data().await.unwrap()
};
let header_items = HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some("/".to_string()),
}],
middle: vec![HeaderItem {
text: "ROUTES".to_string(),
link: None,
}],
right: vec![],
};
leptos::view! {
<div class="min-w-screen min-h-screen bg-slate-900">
<StyledHeader items=header_items />
<div class="container mx-auto mt-2">
<Await future=load let:data>
<Ready data=data.deref().to_owned() />
</Await>
</div>
</div>
}
}
#[component]
fn Ready(data: InitialData) -> impl leptos::IntoView {
tracing::debug!("ready");
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
let onclick = move |_mouse_event| {
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard {});
};
view! {
<p>"Import problems from"</p>
<button on:click=onclick>"Mini Moonboard"</button>
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct InitialData {
problems: BTreeSet<models::Problem>,
}
#[server]
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
todo!()
// let state = expect_context::<State>();
// let problems = state
// .persistent
// .with(|s| {
// let problems = &s.problems.problems;
// problems.clone()
// })
// .await;
// Ok(RonCodec::new(InitialData { problems }))
}
#[server(name = ImportFromMiniMoonboard)]
#[tracing::instrument]
async fn import_from_mini_moonboard() -> Result<(), ServerFnError> {
use crate::server::config::Config;
todo!()
// tracing::info!("Importing mini moonboard problems");
// let config = expect_context::<Config>();
// let state = expect_context::<State>();
// crate::server::operations::import_mini_moonboard_problems(&config, &state).await?;
// // TODO: Return information about what was done
// Ok(())
}

View File

@@ -0,0 +1,60 @@
use crate::components::StyledHeader;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::models::WallUid;
use leptos::prelude::*;
#[component]
#[tracing::instrument(skip_all)]
pub fn Settings() -> impl IntoView {
let header_items = HeaderItems {
left: vec![HeaderItem {
text: "← Ascend".to_string(),
link: Some("/".to_string()),
}],
middle: vec![HeaderItem {
text: "Settings".to_string(),
link: None,
}],
right: vec![],
};
view! {
<div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=header_items />
// {move || view! { <Import wall_uid=wall_uid.get() /> }}
<div class="container mx-auto mt-2" />
</div>
}
}
// #[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>
// }
// }
#[server]
#[tracing::instrument]
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
use crate::server::config::Config;
use crate::server::db::Database;
tracing::info!("Importing mini moonboard problems");
let config = expect_context::<Config>();
let db = expect_context::<Database>();
crate::server::operations::import_mini_moonboard_problems(&config, db, wall_uid).await?;
// TODO: Return information about what was done
Ok(())
}

View File

@@ -1,172 +1,731 @@
use crate::codec::ron::RonCodec; 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::button::Button;
use crate::components::header::HeaderItem; use crate::components::icons::Icon;
use crate::components::header::HeaderItems; use crate::gradient::Gradient;
use crate::components::header::StyledHeader;
use crate::models; use crate::models;
use crate::models::HoldRole; use crate::models::HoldRole;
use crate::server_functions;
use crate::server_functions::SetIsFavorite;
use leptos::Params;
use leptos::prelude::*; use leptos::prelude::*;
use leptos::reactive::graph::ReactiveNode; use leptos_router::params::Params;
use serde::Deserialize; use std::collections::BTreeMap;
use serde::Serialize; use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ops::Deref; use std::ops::Deref;
use std::sync::Arc;
#[derive(Params, PartialEq, Clone)]
struct RouteParams {
wall_uid: Option<models::WallUid>,
}
#[component] #[component]
pub fn Wall() -> impl leptos::IntoView { #[tracing::instrument(skip_all)]
let load = async move { pub fn Page() -> impl IntoView {
// TODO: What to do about this unwrap? crate::tracing::on_enter!();
load_initial_data().await.unwrap()
};
let header_items = HeaderItems { let route_params = leptos_router::hooks::use_params::<RouteParams>();
left: vec![],
middle: vec![HeaderItem { let wall_uid = Signal::derive(move || {
text: "ASCEND".to_string(), route_params
link: None, .get()
}], .expect("gets wall_uid from URL")
right: vec![ .wall_uid
HeaderItem { .expect("wall_uid param is never None")
text: "Routes".to_string(), });
link: Some("/wall/routes".to_string()),
}, let wall = crate::resources::wall_by_uid(wall_uid);
HeaderItem { let user_interactions = crate::resources::user_interactions_for_wall(wall_uid);
text: "Holds".to_string(),
link: Some("/wall/edit".to_string()),
},
],
};
leptos::view! { leptos::view! {
<div class="min-w-screen min-h-screen bg-slate-900"> <div class="min-h-screen min-w-screen bg-neutral-950">
<StyledHeader items=header_items /> <Suspense fallback=|| {
"loading"
}>
{move || Suspend::new(async move {
tracing::debug!("executing main suspend");
let wall = wall.await?;
let user_interactions = user_interactions.await?;
let user_interactions = RwSignal::new(user_interactions);
Ok::<_, ServerFnError>(view! { <Controller wall user_interactions /> })
})}
</Suspense>
</div>
}
}
<div class="m-2"> #[derive(Debug, Clone, Copy)]
<Await future=load let:data> struct Context {
<Ready data=data.deref().to_owned() /> wall: Signal<models::Wall>,
</Await> 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>,
}
#[component]
#[tracing::instrument(skip_all)]
fn Controller(
#[prop(into)] wall: Signal<models::Wall>,
#[prop(into)] user_interactions: RwSignal<BTreeMap<models::Problem, models::UserInteraction>>,
) -> impl IntoView {
crate::tracing::on_enter!();
// Extract data from URL
let (problem, set_problem) = leptos_router::hooks::query_signal::<models::Problem>("problem");
// Filter
let (filter_holds, set_filter_holds) = signal(BTreeSet::new());
let cb_remove_hold_from_filter: Callback<models::HoldPosition> = Callback::new(move |hold_pos: models::HoldPosition| {
set_filter_holds.update(move |set| {
set.remove(&hold_pos);
});
});
// Derive signals
let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
let problem_transformations = signals::problem_transformations(wall);
let filtered_problem_transformations = signals::filtered_problem_transformations(problem_transformations.into(), filter_holds.into());
let todays_attempt = signals::todays_attempt(user_interaction);
let latest_attempt = signals::latest_attempt(user_interaction);
// Submit attempt action
let upsert_todays_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
let cb_upsert_todays_attempt = Callback::new(move |attempt| {
upsert_todays_attempt.dispatch(RonEncoded(attempt));
});
// Set favorite
let set_is_favorite = ServerAction::<RonEncoded<server_functions::SetIsFavorite>>::new();
let cb_set_is_favorite = Callback::new(move |is_favorite| {
let wall_uid = wall.read().uid;
let Some(problem) = problem.get() else {
return;
};
set_is_favorite.dispatch(RonEncoded(SetIsFavorite {
wall_uid,
problem,
is_favorite,
}));
});
// Callback: Set specific problem
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| {
set_problem.set(Some(problem));
});
// Callback: Set next problem to a random problem
let cb_set_random_problem: Callback<()> = Callback::new(move |_| {
// TODO: remove current problem from population
let population = filtered_problem_transformations.read();
let population = population.deref();
use rand::seq::IteratorRandom;
let mut rng = rand::rng();
// Pick pattern
let Some(problem_set) = population.iter().choose(&mut rng) else {
return;
};
// Pick problem out of pattern transformations
let Some(problem) = problem_set.iter().choose(&mut rng) else {
return;
};
set_problem.set(Some(problem.clone()));
});
// Callback: On click hold, Add/Remove hold position to problem filter
let cb_click_hold: Callback<models::HoldPosition> = Callback::new(move |hold_position| {
set_filter_holds.update(|set| {
if !set.remove(&hold_position) {
set.insert(hold_position);
}
});
});
// Set a problem when wall is set (loaded)
Effect::new(move |_prev_value| {
if problem.read().is_none() {
tracing::debug!("Setting initial problem");
cb_set_random_problem.run(());
}
});
// Update user interactions after submitting an attempt
Effect::new(move || {
if let Some(Ok(v)) = upsert_todays_attempt.value().get() {
let v = v.into_inner();
user_interactions.update(|map| {
map.insert(v.problem.clone(), v);
});
}
});
// Update user interactions after setting favorite
Effect::new(move || {
if let Some(Ok(v)) = set_is_favorite.value().get() {
let v = v.into_inner();
user_interactions.update(|map| {
map.insert(v.problem.clone(), v);
});
}
});
provide_context(Context {
wall,
problem: problem.into(),
cb_click_hold,
user_interaction,
latest_attempt,
cb_upsert_todays_attempt,
cb_remove_hold_from_filter,
cb_next_problem: cb_set_random_problem,
cb_set_problem,
cb_set_is_favorite,
todays_attempt,
filter_holds: filter_holds.into(),
filtered_problem_transformations: filtered_problem_transformations.into(),
user_interactions: user_interactions.into(),
});
view! { <View /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn View() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
view! {
<div class="flex">
<div class="flex-initial">
<Wall />
</div>
<div class="flex flex-col px-2 pt-3" style="width:38rem">
<Section title="Problems">
<Filter />
<Separator />
<div class="flex flex-row justify-between">
<NextProblemButton />
</div>
</Section>
<Separator />
<Section title="Current problem">
{move || ctx.problem.get().map(|problem| view! { <ProblemInfo problem /> })}
<Separator /> <div class="flex flex-row gap-2 justify-between">
<Transformations />
<FavoriteButton />
</div> <Separator /> <AttemptRadioGroup /> <Separator /> <History />
</Section>
</div>
<div class="flex flex-col px-2 pt-3 gap-4">
<HoldsButton />
</div> </div>
</div> </div>
} }
} }
#[component] #[component]
fn Ready(data: InitialData) -> impl leptos::IntoView { #[tracing::instrument(skip_all)]
tracing::debug!("ready"); fn Transformations() -> impl IntoView {
crate::tracing::on_enter!();
let (current_problem, current_problem_writer) = signal(None::<models::Problem>); let ctx = use_context::<Context>().unwrap();
let problem_fetcher = LocalResource::new(move || async move {
tracing::info!("Loading random problem"); let left = Signal::derive(move || {
let problem = get_random_problem().await.expect("cannot get random problem"); let mut problem = ctx.problem.get()?;
if problem.is_none() { let new_pattern = problem.pattern.shift_left(1)?;
tracing::info!("No problem returned by server in response to request for random problem"); problem.pattern = new_pattern;
} Some(problem)
current_problem_writer.set(problem.into_inner());
}); });
let mut cells = vec![]; let right = Signal::derive(move || {
for (&hold_position, hold) in &data.wall.holds { let mut problem = ctx.problem.get()?;
let role = move || current_problem.get().and_then(|problem| problem.holds.get(&hold_position).copied()); let wall_dimensions = ctx.wall.read().wall_dimensions;
let role = Signal::derive(role); let new_pattern = problem.pattern.shift_right(wall_dimensions, 1)?;
problem.pattern = new_pattern;
Some(problem)
});
let cell = view! { <Hold role hold=hold.clone() /> }; let on_click_left = Callback::new(move |()| {
cells.push(cell); 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 grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", data.wall.rows, data.wall.cols); let left_disabled = Signal::derive(move || left.read().is_none());
let right_disabled = Signal::derive(move || right.read().is_none());
view! { view! {
<div class="grid grid-cols-[auto,1fr] gap-8"> <div class="flex flex-row gap-2 justify-center">
// Render the wall <Button icon=Icon::ChevronLeft disabled=left_disabled on_click=on_click_left />
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>{cells}</div> <Button icon=Icon::CodeBracketSquare on_click=on_click_mirror />
<Button icon=Icon::ChevronRight disabled=right_disabled on_click=on_click_right />
<div>
<div>
// TODO:
// <p>{current_problem.read().as_ref().map(|p| p.name.clone())}</p>
// <p>{current_problem.read().as_ref().map(|p| p.set_by.clone())}</p>
</div>
<Button onclick=move |_| problem_fetcher.mark_dirty() text="➤ Next problem" />
</ div>
</div> </div>
} }
} }
#[component] #[component]
fn Hold(hold: models::Hold, #[prop(into)] role: Signal<Option<HoldRole>>) -> impl leptos::IntoView { #[tracing::instrument(skip_all)]
let class = move || { fn LikedButton() -> impl IntoView {
let role_classes = match role.get() { crate::tracing::on_enter!();
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
None => Some("brightness-50"),
// None => None,
};
let mut s = "bg-sky-100 aspect-square rounded".to_string();
if let Some(c) = role_classes {
s.push(' ');
s.push_str(c);
}
s
};
let img = hold.image.map(|img| { let _ctx = use_context::<Context>().unwrap();
let src = format!("/files/holds/{}", img.filename);
view! { <img class="object-cover w-full h-full" src=src /> } view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
}
#[component]
#[tracing::instrument(skip_all)]
fn HoldsButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let link = move || format!("/wall/{}/edit", ctx.wall.read().uid);
view! {
<a href=link>
<Button text="Holds" icon=Icon::WrenchSolid />
</a>
}
}
#[component]
#[tracing::instrument(skip_all)]
fn FavoriteButton() -> impl IntoView {
crate::tracing::on_enter!();
let ctx = use_context::<Context>().unwrap();
let ui_toggle = Signal::derive(move || {
let guard = ctx.user_interaction.read();
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
}); });
let on_click = Callback::new(move |_| {
view! { <div class=class>{img}</div> } 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 /> }
} }
#[derive(Serialize, Deserialize, Clone)] #[component]
pub struct InitialData { #[tracing::instrument(skip_all)]
wall: models::Wall, 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 /> }
} }
#[server] #[component]
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all)]
async fn load_initial_data(wall_id: models::WallId) -> Result<RonCodec<InitialData>, ServerFnError> { fn Filter() -> impl IntoView {
let db = expect_context::<Arc<redb::Database>>(); crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display)] let ctx = use_context::<Context>().unwrap();
enum Error {
#[display("Wall not found: {_0:?}")] move || {
NotFound(#[error(not(source))] models::WallId), 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 /> });
} }
let wall = tokio::task::spawn_blocking(move || -> Result<models::Wall, ServerFnError> { view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> }
let read_txn = db.begin_read()?;
let walls_table = read_txn.open_table(crate::server::db::current::TABLE_WALLS)?;
let wall = walls_table.get(wall_id)?.ok_or(Error::NotFound(wall_id))?.value();
Ok(wall)
})
.await??;
Ok(RonCodec::new(InitialData { wall }))
} }
#[server] #[component]
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all)]
async fn get_random_problem() -> Result<RonCodec<Option<models::Problem>>, ServerFnError> { fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView {
todo!() crate::tracing::on_enter!();
// use rand::seq::IteratorRandom;
// let state = expect_context::<State>(); let text = variant.to_string();
let icon = variant.icon();
// let problem = state let color = match variant {
// .persistent models::Attempt::Attempt => Gradient::PinkOrange,
// .with(|s| { models::Attempt::Send => Gradient::TealLime,
// let problems = &s.problems.problems; models::Attempt::Flash => Gradient::CyanBlue,
// let rng = &mut rand::thread_rng(); };
// problems.iter().choose(rng).cloned() view! { <Button text icon color highlight=selected /> }
// }) }
// .await;
#[component]
// tracing::debug!("Returning randomized problem: {problem:?}"); #[tracing::instrument(skip_all)]
fn History() -> impl IntoView {
// Ok(RonCodec::new(problem)) 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>
}
}
}
// TODO: refactor this to use the Problem component
#[component]
#[tracing::instrument(skip_all)]
fn Hold(
hold: models::Hold,
#[prop(optional)]
#[prop(into)]
role: Option<Signal<Option<HoldRole>>>,
) -> impl IntoView {
crate::tracing::on_enter!();
move || {
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 role_classes = match role {
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"),
};
if let Some(c) = role_classes {
class.push(' ');
class.push_str(c);
}
}
let img = hold.image.as_ref().map(|img| {
let srcset = img.srcset();
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
});
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()))
}
} }

View File

@@ -0,0 +1,47 @@
use crate::codec::ron::Ron;
use crate::codec::ron::RonEncoded;
use crate::models::{self};
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>;
pub fn wall_by_uid(wall_uid: Signal<models::WallUid>) -> RonResource<models::Wall> {
Resource::new_with_options(
move || wall_uid.get(),
move |wall_uid| async move { crate::server_functions::get_wall_by_uid(wall_uid).await.map(RonEncoded::into_inner) },
false,
)
}
/// Returns user interaction for a single problem
pub fn user_interaction(wall_uid: Signal<models::WallUid>, problem: Signal<Option<models::Problem>>) -> RonResource<Option<models::UserInteraction>> {
Resource::new_with_options(
move || (wall_uid.get(), problem.get()),
move |(wall_uid, problem)| async move {
let Some(problem) = problem else {
return Ok(None);
};
crate::server_functions::get_user_interaction(wall_uid, problem)
.await
.map(RonEncoded::into_inner)
},
false,
)
}
/// 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_user_interactions_for_wall(wall_uid)
.await
.map(RonEncoded::into_inner)
},
false,
)
}

View File

@@ -5,7 +5,6 @@ use config::Config;
use confik::Configuration; use confik::Configuration;
use confik::EnvSource; use confik::EnvSource;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use type_toppings::ResultExt; use type_toppings::ResultExt;
@@ -16,8 +15,6 @@ pub mod db;
mod migrations; mod migrations;
pub mod operations; pub mod operations;
pub const STATE_FILE: &str = "datastore/private/state.ron";
#[tracing::instrument] #[tracing::instrument]
pub async fn main() { pub async fn main() {
use crate::server::cli::Cli; use crate::server::cli::Cli;
@@ -51,8 +48,9 @@ async fn serve(cli: Cli) -> Result<(), Error> {
use leptos_axum::generate_route_list; use leptos_axum::generate_route_list;
tracing::debug!("Creating DB"); tracing::debug!("Creating DB");
let db = Arc::new(db::create()?); let db = db::Database::create().map_err(db::DatabaseOperationError::from)?;
db::init_at_current_version(&db).await?;
migrations::run_migrations(&db).await.map_err(Error::Migration)?; migrations::run_migrations(&db).await.map_err(Error::Migration)?;
// Setting get_configuration(None) means we'll be using cargo-leptos's env values // Setting get_configuration(None) means we'll be using cargo-leptos's env values
@@ -73,8 +71,8 @@ async fn serve(cli: Cli) -> Result<(), Error> {
&leptos_options, &leptos_options,
routes, routes,
move || { move || {
leptos::prelude::provide_context(Arc::clone(&db)); leptos::prelude::provide_context::<db::Database>(db.clone());
leptos::prelude::provide_context(config.clone()) leptos::prelude::provide_context::<Config>(config.clone())
}, },
{ {
let leptos_options = leptos_options.clone(); let leptos_options = leptos_options.clone();
@@ -126,5 +124,5 @@ pub enum Error {
Confik(confik::Error), Confik(confik::Error),
Database(redb::Error), Database(db::DatabaseOperationError),
} }

View File

@@ -1,36 +1,91 @@
use bincode::Bincode; use bincode::Bincode;
use redb::Database; use redb::ReadTransaction;
use redb::ReadableTable;
use redb::ReadableTableMetadata;
use redb::TableDefinition; use redb::TableDefinition;
use redb::WriteTransaction;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use std::collections::BTreeSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
mod bincode; mod bincode;
pub const DB_FILE: &str = "datastore/private/ascend.redb"; const DB_FILE: &str = "datastore/private/ascend.redb";
#[tracing::instrument(skip_all, err)] #[derive(Debug, Clone)]
pub fn create() -> Result<Database, redb::Error> { pub struct Database {
let file = PathBuf::from(DB_FILE); db: Arc<redb::Database>,
// Create parent dirs
if let Some(parent_dir) = file.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let db = Database::create(file)?;
Ok(db)
} }
#[tracing::instrument(skip_all)] impl Database {
pub fn get_version(db: &Database) -> Result<Option<Version>, redb::Error> { #[tracing::instrument(skip_all, err)]
let txn = db.begin_read()?; pub fn create() -> Result<Database, redb::Error> {
let version = txn.open_table(TABLE_VERSION)?.get(())?.map(|v| v.value()); let file = PathBuf::from(DB_FILE);
Ok(version)
// Create parent dirs
if let Some(parent_dir) = file.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let db = redb::Database::create(file)?;
Ok(Self { db: Arc::new(db) })
}
#[tracing::instrument(skip_all, err)]
pub async fn read<T>(&self, f: impl FnOnce(&'_ ReadTransaction) -> Result<T, DatabaseOperationError>) -> Result<T, DatabaseOperationError> {
tokio::task::block_in_place(|| {
let dbtx = self.db.begin_read()?;
f(&dbtx)
})
}
#[tracing::instrument(skip_all, err)]
pub async fn write<T>(&self, f: impl FnOnce(&'_ WriteTransaction) -> Result<T, DatabaseOperationError>) -> Result<T, DatabaseOperationError> {
tokio::task::block_in_place(|| {
let dbtx = self.db.begin_write()?;
let res = f(&dbtx)?;
dbtx.commit()?;
Ok(res)
})
}
#[tracing::instrument(skip_all)]
pub async fn get_version(&self) -> Result<Option<Version>, DatabaseOperationError> {
self.read(|dbtx| dbtx.open_table(TABLE_VERSION)?.get(()).map(|o| o.map(|v| v.value())).map_err(Into::into))
.await
}
#[tracing::instrument(skip_all)]
pub async fn set_version(&self, version: Version) -> Result<(), DatabaseOperationError> {
self.write(|txn| {
let mut table = txn.open_table(TABLE_VERSION)?;
table.insert((), version)?;
Ok(())
})
.await
}
}
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
#[display("DB operation error: {_variant}")]
pub enum DatabaseOperationError {
#[display("redb error")]
#[from(forward)]
Redb(#[error(source)] redb::Error),
#[from(ignore)]
Custom(#[error(source)] Box<dyn std::error::Error + Send + Sync + 'static>),
}
impl DatabaseOperationError {
pub fn custom(err: impl std::error::Error + Send + Sync + 'static) -> Self {
Self::Custom(Box::new(err))
}
} }
pub const TABLE_VERSION: TableDefinition<(), Bincode<Version>> = TableDefinition::new("version"); pub const TABLE_VERSION: TableDefinition<(), Bincode<Version>> = TableDefinition::new("version");
#[derive(Serialize, Deserialize, Debug, derive_more::Display)] #[derive(Serialize, Deserialize, Debug, derive_more::Display, PartialEq, Eq, PartialOrd, Ord)]
#[display("{version}")] #[display("{version}")]
pub struct Version { pub struct Version {
pub version: u64, pub version: u64,
@@ -41,7 +96,79 @@ impl Version {
} }
} }
pub use v2 as current; // TODO: implement test
#[tracing::instrument(skip_all, err)]
pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperationError> {
db.write(|txn| {
let mut version_table = txn.open_table(TABLE_VERSION)?;
let is_missing_version = version_table.get(())?.is_none();
if is_missing_version {
let v = Version::current();
tracing::warn!("INITIALIZING DATABASE AT VERSION {v}");
version_table.insert((), v)?;
// Root table
{
let mut table = txn.open_table(current::TABLE_ROOT)?;
assert!(table.is_empty()?);
table.insert((), models::Root { walls: BTreeSet::new() })?;
}
// Walls table
{
// Opening the table creates the table
let table = txn.open_table(current::TABLE_WALLS)?;
assert!(table.is_empty()?);
}
// User table
{
// Opening the table creates the table
let table = txn.open_table(current::TABLE_USER)?;
assert!(table.is_empty()?);
}
}
Ok(())
})
.await?;
Ok(())
}
use crate::models;
pub mod current {
use super::v2;
use super::v3;
use super::v4;
pub use v2::TABLE_ROOT;
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 {
use crate::models;
use crate::server::db::bincode::Bincode;
use redb::TableDefinition;
pub const VERSION: u64 = 3;
pub const TABLE_USER: TableDefinition<Bincode<(models::v2::WallUid, models::v2::ProblemUid)>, Bincode<models::v3::UserInteraction>> =
TableDefinition::new("user");
}
pub mod v2 { pub mod v2 {
use crate::models; use crate::models;
@@ -51,8 +178,8 @@ pub mod v2 {
pub const VERSION: u64 = 2; pub const VERSION: u64 = 2;
pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v2::Root>> = TableDefinition::new("root"); pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v2::Root>> = TableDefinition::new("root");
pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallId>, Bincode<models::v2::Wall>> = TableDefinition::new("walls"); pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallUid>, Bincode<models::v2::Wall>> = TableDefinition::new("walls");
pub const TABLE_PROBLEMS: TableDefinition<Bincode<(models::v2::WallId, models::v2::ProblemId)>, Bincode<models::v2::Problem>> = pub const TABLE_PROBLEMS: TableDefinition<Bincode<(models::v2::WallUid, models::v2::ProblemUid)>, Bincode<models::v2::Problem>> =
TableDefinition::new("problems"); TableDefinition::new("problems");
} }

View File

@@ -1,163 +1,140 @@
use super::db; use super::db::Database;
use super::db::DatabaseOperationError;
use super::db::{self};
use crate::models; use crate::models;
use redb::Database;
use redb::ReadableTable; use redb::ReadableTable;
use redb::ReadableTableMetadata; use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::PathBuf;
use type_toppings::ResultExt;
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all, err)]
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> { pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
migrate_from_ron_to_redb(db).await?; if is_at_version(db, 2).await? {
init_at_current_version(db).await?; migrate_to_v3(db).await?;
migrate_to_v2(db).await?;
Ok(())
}
/// Use redb DB instead of Ron state file
#[tracing::instrument(skip_all, err)]
async fn migrate_from_ron_to_redb(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
let ron_state_file_path = PathBuf::from(super::STATE_FILE);
if ron_state_file_path
.try_exists()
.expect_or_report_with(|| format!("Failed to read {}", ron_state_file_path.display()))
{
tracing::warn!("MIGRATING");
let ron_state: models::v1::PersistentState = {
let content = tokio::fs::read_to_string(&ron_state_file_path).await?;
ron::from_str(&content)?
};
let write_txn = db.begin_write()?;
{
let mut version_table = write_txn.open_table(db::TABLE_VERSION)?;
assert!(version_table.is_empty()?);
version_table.insert((), db::Version { version: 1 })?;
let mut root_table = write_txn.open_table(db::v1::TABLE_ROOT)?;
assert!(root_table.is_empty()?);
let persistent_state = models::v1::PersistentState {
version: ron_state.version,
wall: ron_state.wall,
problems: ron_state.problems,
};
root_table.insert((), persistent_state)?;
}
write_txn.commit()?;
tracing::info!("Removing ron state");
tokio::fs::remove_file(ron_state_file_path).await?;
} }
if is_at_version(db, 3).await? {
Ok(()) migrate_to_v4(db).await?;
}
// TODO: Move out, is not really a migration
#[tracing::instrument(skip_all, err)]
async fn init_at_current_version(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
let txn = db.begin_write()?;
{
let mut version_table = txn.open_table(db::TABLE_VERSION)?;
let is_missing_version = version_table.get(())?.is_none();
if is_missing_version {
let v = db::Version::current();
tracing::warn!("INITIALIZING DATABASE AT VERSION {v}");
version_table.insert((), v)?;
// Root table
{
let mut table = txn.open_table(db::current::TABLE_ROOT)?;
assert!(table.is_empty()?);
table.insert((), models::Root { walls: BTreeSet::new() })?;
}
// Walls table
{
// Opening the table creates the table
let table = txn.open_table(db::current::TABLE_WALLS)?;
assert!(table.is_empty()?);
}
// Problems table
{
// Opening the table creates the table
let table = txn.open_table(db::current::TABLE_PROBLEMS)?;
assert!(table.is_empty()?);
}
}
} }
txn.commit()?;
Ok(()) Ok(())
} }
/// migrate: walls table
/// migrate: user table
/// remove: problems table
#[tracing::instrument(skip_all, err)] #[tracing::instrument(skip_all, err)]
async fn migrate_to_v2(db: &Database) -> Result<(), Box<dyn std::error::Error>> { pub async fn migrate_to_v4(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
use super::db; tracing::warn!("MIGRATING TO VERSION 4");
let txn = db.begin_write()?; db.write(|txn| {
{ let walls_dump = txn
let mut version_table = txn.open_table(db::TABLE_VERSION)?; .open_table(db::v2::TABLE_WALLS)?
let version = version_table.get(())?.unwrap().value().version; .iter()?
if version == 1 { .map(|el| {
tracing::warn!("MIGRATING"); let (k, v) = el.unwrap();
version_table.insert((), db::Version { version: 2 })?; (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<_, _>>();
let root_table_v1 = txn.open_table(db::v1::TABLE_ROOT)?; txn.delete_table(db::v2::TABLE_WALLS)?;
let root_v1 = root_table_v1.get(())?.unwrap().value(); txn.delete_table(db::v2::TABLE_PROBLEMS)?;
txn.delete_table(db::v1::TABLE_ROOT)?; txn.delete_table(db::v3::TABLE_USER)?;
let models::v1::PersistentState { version: _, wall, problems } = root_v1; let mut new_walls_table = txn.open_table(db::current::TABLE_WALLS)?;
let mut new_user_table = txn.open_table(db::current::TABLE_USER)?;
// we'll reimport them instead of a lossy conversion. for (wall_uid, wall) in walls_dump.into_iter() {
drop(problems); let models::v2::Wall {
uid: _,
rows,
cols,
holds,
problems,
} = wall;
let mut walls = BTreeSet::new(); let problems = problems
let wall_uid = models::v2::WallId(uuid::Uuid::new_v4());
let holds = wall
.holds
.into_iter() .into_iter()
.map(|(hold_position, hold)| { .map(|problem_uid| {
( let old_prob = &problems_dump[&(wall_uid, problem_uid)];
models::v1::HoldPosition { let method = old_prob.method;
row: hold_position.row,
col: hold_position.col, models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
}, },
models::v1::Hold { method,
position: models::v1::HoldPosition { }
row: hold.position.row,
col: hold.position.col,
},
image: hold.image.map(|i| models::v1::Image { filename: i.filename }),
},
)
}) })
.collect(); .collect();
let wall = models::Wall {
let wall_v2 = models::v2::Wall {
uid: wall_uid, uid: wall_uid,
rows: wall.rows, wall_dimensions: models::WallDimensions { rows, cols },
cols: wall.cols,
holds, holds,
problems: BTreeSet::new(), problems,
}; };
walls.insert(wall_v2.uid); new_walls_table.insert(wall_uid, wall)?;
let root_v2 = models::v2::Root { walls };
let mut root_table_v2 = txn.open_table(db::v2::TABLE_ROOT)?;
root_table_v2.insert((), root_v2)?;
let mut walls_table = txn.open_table(db::v2::TABLE_WALLS)?;
walls_table.insert(wall_v2.uid, wall_v2)?;
} }
}
txn.commit()?; for ((wall_uid, problem_uid), user_interaction) in user_dump.into_iter() {
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
let problem = models::Problem {
pattern: models::Pattern {
pattern: old_prob.holds.clone(),
},
method: old_prob.method,
};
let key = (wall_uid, problem.clone());
let value = models::UserInteraction {
wall_uid,
problem,
attempted_on: user_interaction.attempted_on,
is_favorite: user_interaction.is_favorite,
};
new_user_table.insert(key, value)?;
}
Ok(())
})
.await?;
db.set_version(db::Version { version: db::v4::VERSION }).await?;
Ok(()) Ok(())
} }
#[tracing::instrument(skip_all, err)]
pub async fn migrate_to_v3(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
use redb::ReadableTableMetadata;
tracing::warn!("MIGRATING TO VERSION 3");
db.write(|txn| {
// Opening the table creates the table
let table = txn.open_table(db::current::TABLE_USER)?;
assert!(table.is_empty()?);
Ok(())
})
.await?;
db.set_version(db::Version { version: db::v3::VERSION }).await?;
Ok(())
}
async fn is_at_version(db: &Database, version: u64) -> Result<bool, DatabaseOperationError> {
let v = db.get_version().await?;
Ok(v == Some(db::Version { version }))
}

View File

@@ -1,17 +1,16 @@
//! Server lib module to host re-usable server operations. //! Server lib module to host re-usable server operations.
use super::db::Database;
use crate::models; use crate::models;
use crate::models::HoldPosition; use crate::models::HoldPosition;
use crate::models::HoldRole; use crate::models::HoldRole;
use crate::server::config::Config; use crate::server::config::Config;
use crate::server::db; use crate::server::db;
use redb::Database;
use redb::ReadableTable; use redb::ReadableTable;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc;
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Arc<Database>, wall_id: models::WallId) -> Result<(), Error> { pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database, wall_uid: models::WallUid) -> Result<(), Error> {
use moonboard_parser::mini_moonboard; use moonboard_parser::mini_moonboard;
let mut problems = Vec::new(); let mut problems = Vec::new();
@@ -21,11 +20,9 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Arc<Data
tracing::info!("Parsing mini moonboard problems from {file_path}"); tracing::info!("Parsing mini moonboard problems from {file_path}");
let set_by = "mini-mb-2020-parser";
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?; let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
for mini_mb_problem in mini_moonboard.problems { for mini_mb_problem in mini_moonboard.problems {
let mut holds = BTreeMap::<HoldPosition, HoldRole>::new(); let mut pattern = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in mini_mb_problem.moves { for mv in mini_mb_problem.moves {
let row = mv.description.row(); let row = mv.description.row();
let col = mv.description.column(); let col = mv.description.column();
@@ -37,50 +34,30 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Arc<Data
(false, true) => HoldRole::End, (false, true) => HoldRole::End,
(false, false) => HoldRole::Normal, (false, false) => HoldRole::Normal,
}; };
holds.insert(hold_position, role); pattern.insert(hold_position, role);
} }
let name = mini_mb_problem.name;
let method = match mini_mb_problem.method { let method = match mini_mb_problem.method {
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands, mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
mini_moonboard::Method::Footless => models::Method::Footless, mini_moonboard::Method::Footless => models::Method::Footless,
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard, mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
}; };
let problem_id = models::ProblemId::new(); let pattern = models::Pattern { pattern }.canonicalize();
let problem = models::Problem { pattern, method };
let problem = models::Problem {
uid: problem_id,
name,
set_by: set_by.to_owned(),
holds,
method,
date_added: chrono::Utc::now(),
};
problems.push(problem); problems.push(problem);
} }
tokio::task::spawn_blocking(move || -> Result<(), redb::Error> { db.write(|txn| {
let write_txn = db.begin_write()?; let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?;
{
let mut walls_table = write_txn.open_table(db::current::TABLE_WALLS)?;
let mut problems_table = write_txn.open_table(db::current::TABLE_PROBLEMS)?;
let mut wall = walls_table.get(wall_id)?.unwrap().value(); let mut wall = walls_table.get(wall_uid)?.unwrap().value();
wall.problems.extend(problems.iter().map(|p| p.uid)); wall.problems.extend(problems);
walls_table.insert(wall_id, wall)?; walls_table.insert(wall_uid, wall)?;
for problem in problems {
let key = (wall_id, problem.uid);
problems_table.insert(key, problem)?;
}
}
write_txn.commit()?;
Ok(()) Ok(())
}) })
.await??; .await?;
Ok(()) Ok(())
} }
@@ -88,6 +65,6 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Arc<Data
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)] #[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
pub enum Error { pub enum Error {
Parser(moonboard_parser::Error), Parser(moonboard_parser::Error),
Redb(redb::Error),
Tokio(tokio::task::JoinError), Tokio(tokio::task::JoinError),
DbOperation(crate::server::db::DatabaseOperationError),
} }

View File

@@ -0,0 +1,305 @@
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,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(skip_all, err(Debug))]
pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError> {
use crate::server::db::Database;
use leptos::prelude::expect_context;
use redb::ReadableTable;
crate::tracing::on_enter!();
let db = expect_context::<Database>();
let walls = db
.read(|txn| {
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
let walls: Vec<models::Wall> = walls_table.iter()?.map(|r| r.map(|(_, v)| v.value())).collect::<Result<_, _>>()?;
Ok(walls)
})
.await?;
Ok(RonEncoded::new(walls))
}
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(skip_all, err(Debug))]
pub(crate) async fn get_wall_by_uid(wall_uid: models::WallUid) -> Result<RonEncoded<models::Wall>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display)]
enum Error {
#[display("Wall not found: {_0:?}")]
NotFound(#[error(not(source))] models::WallUid),
}
let db = expect_context::<Database>();
let wall = db
.read(|txn| {
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
let wall = walls_table
.get(wall_uid)?
.ok_or(Error::NotFound(wall_uid))
.map_err(DatabaseOperationError::custom)?
.value();
Ok(wall)
})
.await?;
Ok(RonEncoded::new(wall))
}
/// Returns user interaction for a single wall problem
#[server(
input = Ron,
output = Ron,
custom = RonEncoded
)]
#[tracing::instrument(err(Debug))]
pub(crate) async fn get_user_interaction(
wall_uid: models::WallUid,
problem: models::Problem,
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error {
#[display("Wall not found: {_0:?}")]
WallNotFound(#[error(not(source))] models::WallUid),
DatabaseOperation(DatabaseOperationError),
}
async fn inner(wall_uid: models::WallUid, problem: 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))?.map(|guard| guard.value());
Ok(user_interaction)
})
.await?;
Ok(user_interaction)
}
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(err(Debug))]
pub(crate) async fn get_user_interactions_for_wall(
wall_uid: models::WallUid,
) -> Result<RonEncoded<BTreeMap<models::Problem, models::UserInteraction>>, ServerFnError> {
use crate::server::db::Database;
use crate::server::db::DatabaseOperationError;
use leptos::prelude::expect_context;
use redb::ReadableTable;
crate::tracing::on_enter!();
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
enum Error {
DatabaseOperation(DatabaseOperationError),
}
async fn inner(wall_uid: models::WallUid) -> Result<BTreeMap<models::Problem, models::UserInteraction>, Error> {
let db = expect_context::<Database>();
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)
}

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 392 KiB

18
flake.lock generated
View File

@@ -10,11 +10,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1729251766, "lastModified": 1746696290,
"narHash": "sha256-/tOGBbFKgIii6L0VZdJ2MFdhzTt0BtEsAFbWITXeIxA=", "narHash": "sha256-YokYinNgGIu80OErVMuFoIELhetzb45aWKTiKYNXvWA=",
"owner": "plul", "owner": "plul",
"repo": "basecamp", "repo": "basecamp",
"rev": "aae7006aec576140aadf3fdea4ed7eae904dda14", "rev": "108ef2874fd8f934602cda5bfdc0e58a541c6b4a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -25,11 +25,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1736420959, "lastModified": 1746576598,
"narHash": "sha256-dMGNa5UwdtowEqQac+Dr0d2tFO/60ckVgdhZU9q2E2o=", "narHash": "sha256-FshoQvr6Aor5SnORVvh/ZdJ1Sa2U4ZrIMwKBX5k2wu0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "32af3611f6f05655ca166a0b1f47b57c762b5192", "rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -53,11 +53,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1736649126, "lastModified": 1746671794,
"narHash": "sha256-XCw5sv/ePsroqiF3lJM6Y2X9EhPdHeE47gr3Q8b0UQw=", "narHash": "sha256-V+mpk2frYIEm85iYf+KPDmCGG3zBRAEhbv0E3lHdG2U=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "162ab0edc2936508470199b2e8e6c444a2535019", "rev": "ceec434b8741c66bb8df5db70d7e629a9d9c598f",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -47,7 +47,7 @@
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.cargo-leptos pkgs.cargo-leptos
pkgs.dart-sass pkgs.dart-sass
pkgs.tailwindcss pkgs.tailwindcss_4
# For optimizing wasm release builds # For optimizing wasm release builds
pkgs.binaryen pkgs.binaryen
@@ -75,12 +75,7 @@
}; };
nixosModules.default = nixosModules.default =
{ { config, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.services.ascend; cfg = config.services.ascend;
in in
@@ -135,20 +130,23 @@
basecamp.mkShell pkgs { basecamp.mkShell pkgs {
rust.enable = true; rust.enable = true;
rust.toolchain.targets = [ "wasm32-unknown-unknown" ]; rust.toolchain.targets = [ "wasm32-unknown-unknown" ];
rust.toolchain.components.rust-analyzer.nightly = true;
packages = [ packages = [
pkgs.bacon
pkgs.cargo-leptos pkgs.cargo-leptos
pkgs.leptosfmt pkgs.leptosfmt
pkgs.dart-sass pkgs.dart-sass
pkgs.tailwindcss pkgs.tailwindcss_4
pkgs.tailwindcss-language-server pkgs.tailwindcss-language-server
# For optimizing wasm release builds # For optimizing wasm release builds
pkgs.binaryen pkgs.binaryen
]; ];
env.RUST_LOG = "info,ascend=trace"; env.RUST_LOG = "info,ascend=debug";
env.MOONBOARD_PROBLEMS = "moonboard-problems"; env.MOONBOARD_PROBLEMS = "moonboard-problems";
env.LEPTOS_TAILWIND_VERSION = "v4.0.8";
}; };
}; };
} }

View File

@@ -8,20 +8,22 @@ fmt:
fd --extension=rs --exec-batch leptosfmt fd --extension=rs --exec-batch leptosfmt
bc-fmt bc-fmt
fix:
bc-fix
serve: serve:
RUST_LOG=debug RUST_BACKTRACE=1 cargo leptos watch -- serve RUST_BACKTRACE=1 cargo leptos watch -- serve
build-release: # build-release:
rm -rf dist # rm -rf dist
mkdir dist # mkdir dist
cargo leptos build --release -vv # cargo leptos build --release -vv
cp target/release/ascend dist/ # cp target/release/ascend dist/
cp -r target/site dist/ # cp -r target/site dist/
# run-release:
run-release: # #!/usr/bin/env bash
#!/usr/bin/env bash # cd dist
cd dist # LEPTOS_SITE_ROOT="site" LEPTOS_SITE_ADDR="127.0.0.1:1337" ./ascend serve
LEPTOS_SITE_ROOT="site" LEPTOS_SITE_ADDR="127.0.0.1:1337" ./ascend serve
reset-state: reset-state:
cargo run --features ssr -- reset-state cargo run --features ssr -- reset-state
@@ -37,8 +39,23 @@ cp-prod-datastore:
# Update ascend input and nixos-rebuild switch # Update ascend input and nixos-rebuild switch
prod-deploy: prod-deploy:
# Build on this machine (faster) and copy to prod /nix/store
nix copy --to ssh://192.168.1.3 .
# Update ascend input on prod machine nix configuration
ssh 192.168.1.3 'cd /home/plul/repos/gitlab.com/plul/nixexprs; just update-ascend' ssh 192.168.1.3 'cd /home/plul/repos/gitlab.com/plul/nixexprs; just update-ascend'
# Switch nix configuration on prod (should be fast as /nix/store should be pre-populated)
ssh -t 192.168.1.3 'cd /home/plul/repos/gitlab.com/plul/nixexprs; just switch' ssh -t 192.168.1.3 'cd /home/plul/repos/gitlab.com/plul/nixexprs; just switch'
prod-logs: prod-logs:
ssh 192.168.1.3 'journalctl --unit ascend.service' ssh 192.168.1.3 'journalctl --unit ascend.service'
leptos-discord:
xdg-open "https://discord.com/channels/1031524867910148188/1031524868883218474"
leptos-issues:
xdg-open "https://github.com/leptos-rs/leptos/issues"
icons:
xdg-open "https://heroicons.com/"

4
leptosfmt.toml Normal file
View File

@@ -0,0 +1,4 @@
closing_tag_style = "SelfClosing"
[attr_values]
class = "Tailwind"

16
notes.txt Normal file
View File

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

View File

@@ -1,4 +1,4 @@
style_edition = "2024" edition = "2024"
unstable_features = true unstable_features = true
imports_granularity = "Item" imports_granularity = "Item"
group_imports = "One" group_imports = "One"

View File

@@ -1,5 +1,3 @@
- save images with a uuid
- downscale images
- associate routes with wall - associate routes with wall
- group routes by pattern (pattern family has shift/mirror variations) - group routes by pattern (pattern family has shift/mirror variations)
- generate pattern families of variations when importing problems - generate pattern families of variations when importing problems
@@ -7,5 +5,10 @@
- Record problem success (enum: flash, send, no-send) - Record problem success (enum: flash, send, no-send)
- implement routes page to show all routes for a given wall - implement routes page to show all routes for a given wall
- implement favorite routes feature - implement favorite routes feature
- use wall id in URL.
- decide on routes vs problems terminology - decide on routes vs problems terminology
- decide on holds vs wall-edit terminology
- clock
- hotkeys (enter =next problem, arrow = shift left/right up/down)
- impl `sizes` hint next to `srcset`
- add refresh wall button for when a hold is changed
- fix a font, or why does font-thin not do anything?