Compare commits
40 Commits
08e742f8bb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d609118de | |||
| d11bf28625 | |||
| 9bbe1dd214 | |||
| e3ef695069 | |||
| 5bdfd6835d | |||
| 27716c5ec0 | |||
| dea8c45939 | |||
| 22367f45f2 | |||
| e5853268de | |||
| b37386b9e8 | |||
| bd8b0fecf1 | |||
| c15db2847d | |||
| 0a95aca872 | |||
| 91bea767d0 | |||
| ed6aa4b9c9 | |||
| d11f8510b4 | |||
| 221e15d7ac | |||
| e403be8090 | |||
| 58698a1087 | |||
| f1be2dd735 | |||
| d9406f98d1 | |||
| 98703f2c8b | |||
| 9b15daaf6d | |||
| 7d95e48941 | |||
| f8aa1e29a2 | |||
| 83bd8e0e5e | |||
| 9898af1bf7 | |||
| ed9eba8dc1 | |||
| 3740224f79 | |||
| 2e83efcf12 | |||
| 7118b66104 | |||
| f1d0fc68c5 | |||
| 1d7374d387 | |||
| 91430f8985 | |||
| fc96105091 | |||
| a6942156e4 | |||
| aebd30c1c2 | |||
| bf8e79b88c | |||
| b28546d2de | |||
| a164b0628d |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[target.wasm32-unknown-unknown]
|
||||||
|
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
|
||||||
@@ -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" } }
|
||||||
|
|||||||
1587
Cargo.lock
generated
1587
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||||
|
|||||||
@@ -9,44 +9,57 @@ publish = false
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
moonboard-parser = { workspace = true, optional = true }
|
axum = { version = "0.8", optional = true }
|
||||||
axum = { version = "0.7", optional = true }
|
|
||||||
console_error_panic_hook = "0.1"
|
|
||||||
leptos = { version = "0.7.4", features = ["tracing"] }
|
|
||||||
server_fn = { version = "0.7.4", features = ["cbor"] }
|
|
||||||
leptos_axum = { version = "0.7", optional = true }
|
|
||||||
leptos_meta = { version = "0.7" }
|
|
||||||
leptos_router = { version = "0.7.0" }
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
|
||||||
tower = { version = "0.4", optional = true }
|
|
||||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
|
||||||
wasm-bindgen = "=0.2.99"
|
|
||||||
http = "1"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
derive_more = { version = "1", features = ["display", "error", "from"] }
|
|
||||||
clap = { version = "4.5.7", features = ["derive"] }
|
|
||||||
camino = { version = "1.1", optional = true }
|
camino = { version = "1.1", optional = true }
|
||||||
type-toppings = { version = "0.2.1", features = ["result"] }
|
chrono = { version = "0.4.39", features = ["now", "serde"] }
|
||||||
|
clap = { version = "4.5.7", features = ["derive"] }
|
||||||
|
confik = { version = "0.12", optional = true, features = ["camino"] }
|
||||||
|
console_error_panic_hook = "0.1"
|
||||||
|
derive_more = { version = "2", features = [
|
||||||
|
"display",
|
||||||
|
"error",
|
||||||
|
"from",
|
||||||
|
"from_str",
|
||||||
|
] }
|
||||||
|
http = "1"
|
||||||
|
image = { version = "0.25", optional = true }
|
||||||
|
leptos = { version = "0.8", features = ["tracing"] }
|
||||||
|
leptos_axum = { version = "0.8", optional = true }
|
||||||
|
leptos_meta = { version = "0.8" }
|
||||||
|
leptos_router = { version = "0.8" }
|
||||||
|
moonboard-parser = { workspace = true, optional = true }
|
||||||
|
rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
|
||||||
|
ron = { version = "0.8" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
server_fn = { version = "0.8", features = ["cbor"] }
|
||||||
|
smart-default = "0.7.1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"], 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"
|
||||||
ron = { version = "0.8" }
|
type-toppings = { version = "0.2.1", features = ["result", "iterator"] }
|
||||||
rand = { version = "0.8", optional = true }
|
wasm-bindgen = "=0.2.100"
|
||||||
web-sys = { version = "0.3.76", features = ["File", "FileList"] }
|
web-sys = { version = "0.3.76", features = ["File", "FileList"] }
|
||||||
smart-default = "0.7.1"
|
|
||||||
confik = { version = "0.12", optional = true, features = ["camino"] }
|
|
||||||
xdg = { version = "2.5", optional = true }
|
xdg = { version = "2.5", optional = true }
|
||||||
|
uuid = { version = "1.12", features = ["serde", "v4"] }
|
||||||
|
redb = { version = "2.4", optional = true }
|
||||||
|
bincode = { version = "1.3", optional = true }
|
||||||
|
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:image",
|
||||||
|
"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",
|
||||||
@@ -58,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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
crates/ascend/src/components.rs
Normal file
27
crates/ascend/src/components.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
pub use attempt::Attempt;
|
||||||
|
pub use button::Button;
|
||||||
|
pub use header::StyledHeader;
|
||||||
|
pub use problem::Problem;
|
||||||
|
pub use problem_info::ProblemInfo;
|
||||||
|
|
||||||
|
pub mod attempt;
|
||||||
|
pub mod button;
|
||||||
|
pub mod checkbox;
|
||||||
|
pub mod header;
|
||||||
|
pub mod header_v2;
|
||||||
|
pub mod icons;
|
||||||
|
pub mod outlined_box;
|
||||||
|
pub mod problem;
|
||||||
|
pub mod problem_info;
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn OnHoverRed(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="relative group">
|
||||||
|
<div>{children()}</div>
|
||||||
|
<div class="absolute inset-0 bg-red-500 opacity-0 group-hover:opacity-50" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
58
crates/ascend/src/components/attempt.rs
Normal file
58
crates/ascend/src/components/attempt.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 /> };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
crates/ascend/src/components/checkbox.rs
Normal file
50
crates/ascend/src/components/checkbox.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,91 +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 {
|
||||||
let fancy = false;
|
view! {
|
||||||
|
<div class="text-black bg-orange-300 border-b-2 border-b-orange-400">
|
||||||
if fancy {
|
// <div class="container mx-auto" >
|
||||||
view! {
|
<Header items />
|
||||||
<div class="flex">
|
// </div>
|
||||||
// Left gradient chunk
|
</div>
|
||||||
<div class="flex-grow">
|
|
||||||
<div class="h-2/5" style="background: #eaac53" />
|
|
||||||
<div
|
|
||||||
class="h-3/5"
|
|
||||||
style="background: linear-gradient(to bottom left, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-none container mx-auto text-black" style="background: #eaac53">
|
|
||||||
<Header items />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Right gradient chunk
|
|
||||||
<div class="flex-grow" style="background: #eaac53" />
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
// Left gradient chunk
|
|
||||||
<div class="flex-grow" />
|
|
||||||
|
|
||||||
<div class="flex-none container mx-auto">
|
|
||||||
// Background color gradient
|
|
||||||
<div
|
|
||||||
class="h-6"
|
|
||||||
style="background: linear-gradient(to bottom left, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Right gradient chunk
|
|
||||||
<div class="flex-grow">
|
|
||||||
<div class="h-4/5" style="background: #eaac53" />
|
|
||||||
<div
|
|
||||||
class="h-1/5"
|
|
||||||
style="background: linear-gradient(to bottom right, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
<div class="bg-orange-300 text-black border-b-2 border-b-orange-400">
|
|
||||||
<div class="container mx-auto" >
|
|
||||||
<Header items />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
.into_any()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
crates/ascend/src/components/header_v2.rs
Normal file
13
crates/ascend/src/components/header_v2.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub fn Header(children: Children) -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="w-full text-black bg-orange-300 border-b-2 border-b-orange-400">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
302
crates/ascend/src/components/icons.rs
Normal file
302
crates/ascend/src/components/icons.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
49
crates/ascend/src/components/outlined_box.rs
Normal file
49
crates/ascend/src/components/outlined_box.rs
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
66
crates/ascend/src/components/problem.rs
Normal file
66
crates/ascend/src/components/problem.rs
Normal 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 /> }
|
||||||
|
}
|
||||||
29
crates/ascend/src/components/problem_info.rs
Normal file
29
crates/ascend/src/components/problem_info.rs
Normal 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
9
crates/ascend/src/css.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// Tailwind's grid-rows-<n>
|
||||||
|
pub fn grid_rows_n(n: u64) -> String {
|
||||||
|
format!("grid-template-rows: repeat({n}, minmax(0, 1fr));")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tailwind's grid-cols-<n>
|
||||||
|
pub fn grid_cols_n(n: u64) -> String {
|
||||||
|
format!("grid-template-columns: repeat({n}, minmax(0, 1fr));")
|
||||||
|
}
|
||||||
52
crates/ascend/src/gradient.rs
Normal file
52
crates/ascend/src/gradient.rs
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,71 +1,291 @@
|
|||||||
//! Shared models between server and client code.
|
//! Shared models between server and client code.
|
||||||
|
|
||||||
use serde::Deserialize;
|
pub use v1::HoldPosition;
|
||||||
use serde::Serialize;
|
pub use v1::HoldRole;
|
||||||
use std::collections::BTreeMap;
|
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::Root;
|
||||||
|
pub use v2::WallDimensions;
|
||||||
|
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;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
mod semantics;
|
||||||
pub struct Wall {
|
|
||||||
pub rows: u64,
|
pub mod v4 {
|
||||||
pub cols: u64,
|
use super::v1;
|
||||||
pub holds: BTreeMap<HoldPosition, Hold>,
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl Wall {
|
|
||||||
pub fn new(rows: u64, cols: u64) -> Self {
|
pub mod v3 {
|
||||||
let mut holds = BTreeMap::new();
|
use super::v2;
|
||||||
for row in 0..rows {
|
use derive_more::Display;
|
||||||
for col in 0..cols {
|
use serde::Deserialize;
|
||||||
let position = HoldPosition { row, col };
|
use serde::Serialize;
|
||||||
let hold = Hold { position, image: None };
|
use std::collections::BTreeMap;
|
||||||
holds.insert(position, hold);
|
|
||||||
|
/// 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 {
|
||||||
|
use super::v1;
|
||||||
|
use derive_more::Display;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Root {
|
||||||
|
pub walls: BTreeSet<WallUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct Wall {
|
||||||
|
pub uid: WallUid,
|
||||||
|
|
||||||
|
pub rows: u64,
|
||||||
|
pub cols: u64,
|
||||||
|
|
||||||
|
pub holds: BTreeMap<v1::HoldPosition, Hold>,
|
||||||
|
pub problems: BTreeSet<ProblemUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WallDimensions {
|
||||||
|
pub rows: u64,
|
||||||
|
pub cols: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)]
|
||||||
|
pub struct Problem {
|
||||||
|
pub uid: ProblemUid,
|
||||||
|
pub name: String,
|
||||||
|
pub set_by: String,
|
||||||
|
pub holds: BTreeMap<v1::HoldPosition, v1::HoldRole>,
|
||||||
|
pub method: Method,
|
||||||
|
pub date_added: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||||
|
pub struct ProblemUid(pub uuid::Uuid);
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Display, Copy, Hash)]
|
||||||
|
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)]
|
||||||
|
pub struct ImageResolution {
|
||||||
|
pub width: u64,
|
||||||
|
pub height: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use smart_default::SmartDefault;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
const STATE_VERSION: u64 = 1;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)]
|
||||||
|
pub struct PersistentState {
|
||||||
|
/// State schema version
|
||||||
|
#[default(STATE_VERSION)]
|
||||||
|
pub version: u64,
|
||||||
|
|
||||||
|
pub wall: Wall,
|
||||||
|
pub problems: Problems,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||||
|
pub struct Problems {
|
||||||
|
pub problems: BTreeSet<Problem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct Wall {
|
||||||
|
pub rows: u64,
|
||||||
|
pub cols: u64,
|
||||||
|
pub holds: BTreeMap<HoldPosition, Hold>,
|
||||||
|
}
|
||||||
|
impl Wall {
|
||||||
|
pub fn new(rows: u64, cols: u64) -> Self {
|
||||||
|
let mut holds = BTreeMap::new();
|
||||||
|
for row in 0..rows {
|
||||||
|
for col in 0..cols {
|
||||||
|
let position = HoldPosition { row, col };
|
||||||
|
let hold = Hold { position, image: None };
|
||||||
|
holds.insert(position, hold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Self { rows, cols, holds }
|
||||||
}
|
}
|
||||||
Self { rows, cols, holds }
|
}
|
||||||
|
impl Default for Wall {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(12, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)]
|
||||||
|
pub struct HoldPosition {
|
||||||
|
/// Starting from 0
|
||||||
|
pub row: u64,
|
||||||
|
|
||||||
|
/// Starting from 0
|
||||||
|
pub col: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct Problem {
|
||||||
|
pub holds: BTreeMap<HoldPosition, HoldRole>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The role of a hold on a route
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub enum HoldRole {
|
||||||
|
/// Start hold
|
||||||
|
Start,
|
||||||
|
|
||||||
|
/// Any hold on the route without a specific role
|
||||||
|
Normal,
|
||||||
|
|
||||||
|
/// Zone hold
|
||||||
|
Zone,
|
||||||
|
|
||||||
|
/// End hold
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct Hold {
|
||||||
|
pub position: HoldPosition,
|
||||||
|
pub image: Option<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct Image {
|
||||||
|
pub filename: String,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Default for Wall {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(12, 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)]
|
|
||||||
pub struct HoldPosition {
|
|
||||||
/// Starting from 0
|
|
||||||
pub row: u64,
|
|
||||||
|
|
||||||
/// Starting from 0
|
|
||||||
pub col: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub struct Problem {
|
|
||||||
pub holds: BTreeMap<HoldPosition, HoldRole>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The role of a hold on a route
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub enum HoldRole {
|
|
||||||
/// Start hold
|
|
||||||
Start,
|
|
||||||
|
|
||||||
/// Any hold on the route without a specific role
|
|
||||||
Normal,
|
|
||||||
|
|
||||||
/// Zone hold
|
|
||||||
Zone,
|
|
||||||
|
|
||||||
/// End hold
|
|
||||||
End,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
||||||
pub struct Hold {
|
|
||||||
pub position: HoldPosition,
|
|
||||||
pub image: Option<Image>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
||||||
pub struct Image {
|
|
||||||
pub filename: String,
|
|
||||||
}
|
|
||||||
|
|||||||
295
crates/ascend/src/models/semantics.rs
Normal file
295
crates/ascend/src/models/semantics.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::gradient::Gradient;
|
||||||
|
use chrono::DateTime;
|
||||||
|
use chrono::Utc;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
impl Problem {
|
||||||
|
/// Returns all possible transformations for the pattern. Not for the method.
|
||||||
|
#[must_use]
|
||||||
|
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
|
||||||
|
self.pattern
|
||||||
|
.transformations(wall_dimensions)
|
||||||
|
.into_iter()
|
||||||
|
.map(|pattern| Self {
|
||||||
|
pattern,
|
||||||
|
method: self.method,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
#[must_use]
|
||||||
|
pub fn canonicalize(&self) -> Self {
|
||||||
|
let mut pattern = self.clone();
|
||||||
|
let min_col = pattern.pattern.keys().map(|hold_position| hold_position.col).min().unwrap_or(0);
|
||||||
|
pattern.pattern = pattern
|
||||||
|
.pattern
|
||||||
|
.iter()
|
||||||
|
.map(|(hold_position, hold_role)| {
|
||||||
|
let hold_position = HoldPosition {
|
||||||
|
row: hold_position.row,
|
||||||
|
col: hold_position.col - min_col,
|
||||||
|
};
|
||||||
|
(hold_position, *hold_role)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
std::cmp::min(pattern.mirror(), pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn shift_left(&self, shift: u64) -> Option<Self> {
|
||||||
|
// Out of bounds check
|
||||||
|
if let Some(min_col) = self.pattern.keys().map(|hold_position| hold_position.col).min() {
|
||||||
|
if shift > min_col {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern: BTreeMap<HoldPosition, HoldRole> = self
|
||||||
|
.pattern
|
||||||
|
.iter()
|
||||||
|
.map(|(hold_position, hold_role)| {
|
||||||
|
let mut hold_position = *hold_position;
|
||||||
|
hold_position.col -= shift;
|
||||||
|
(hold_position, *hold_role)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(Self { pattern })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn shift_right(&self, wall_dimensions: WallDimensions, shift: u64) -> Option<Self> {
|
||||||
|
// Out of bounds check
|
||||||
|
if let Some(max_col) = self.pattern.keys().map(|hold_position| hold_position.col).max() {
|
||||||
|
if max_col + shift >= wall_dimensions.cols {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pattern: BTreeMap<HoldPosition, HoldRole> = self
|
||||||
|
.pattern
|
||||||
|
.iter()
|
||||||
|
.map(|(hold_position, hold_role)| {
|
||||||
|
let mut hold_position = *hold_position;
|
||||||
|
hold_position.col += shift;
|
||||||
|
(hold_position, *hold_role)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(Self { pattern })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn mirror(&self) -> Self {
|
||||||
|
let mut pattern = self.clone();
|
||||||
|
|
||||||
|
let min_col = pattern.pattern.keys().map(|hold_position| hold_position.col).min().unwrap_or(0);
|
||||||
|
let max_col = pattern.pattern.keys().map(|hold_position| hold_position.col).max().unwrap_or(0);
|
||||||
|
|
||||||
|
pattern.pattern = pattern
|
||||||
|
.pattern
|
||||||
|
.iter()
|
||||||
|
.map(|(hold_position, hold_role)| {
|
||||||
|
let HoldPosition { row, col } = *hold_position;
|
||||||
|
let mut mirrored_col = col;
|
||||||
|
mirrored_col += 2 * (max_col - col);
|
||||||
|
mirrored_col -= max_col - min_col;
|
||||||
|
let hold_position = HoldPosition { row, col: mirrored_col };
|
||||||
|
(hold_position, *hold_role)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all possible transformations for the pattern
|
||||||
|
#[must_use]
|
||||||
|
pub fn transformations(&self, wall_dimensions: WallDimensions) -> HashSet<Self> {
|
||||||
|
let mut transformations = HashSet::new();
|
||||||
|
|
||||||
|
let pattern = self.canonicalize();
|
||||||
|
for mut pat in [pattern.mirror(), pattern] {
|
||||||
|
transformations.insert(pat.clone());
|
||||||
|
while let Some(p) = pat.shift_right(wall_dimensions, 1) {
|
||||||
|
transformations.insert(p.clone());
|
||||||
|
pat = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transformations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserInteraction {
|
||||||
|
pub(crate) fn new(wall_uid: WallUid, problem: Problem) -> Self {
|
||||||
|
Self {
|
||||||
|
wall_uid,
|
||||||
|
problem,
|
||||||
|
is_favorite: false,
|
||||||
|
attempted_on: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn latest_attempt(&self) -> Option<DatedAttempt> {
|
||||||
|
self.attempted_on.last_key_value().map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn todays_attempt(&self) -> Option<Attempt> {
|
||||||
|
self.latest_attempt()
|
||||||
|
.filter(|latest_attempt| {
|
||||||
|
let today_local_naive = chrono::Local::now().date_naive();
|
||||||
|
let datetime_local_naive = latest_attempt.date_time.with_timezone(&chrono::Local).date_naive();
|
||||||
|
datetime_local_naive == today_local_naive
|
||||||
|
})
|
||||||
|
.map(|dated_attempt| dated_attempt.attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn best_attempt(&self) -> Option<DatedAttempt> {
|
||||||
|
self.attempted_on.iter().max_by_key(|(_date_time, attempt)| *attempt).map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn attempted_on(&self) -> impl IntoIterator<Item = DatedAttempt> {
|
||||||
|
self.attempted_on.iter().rev().map(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(DateTime<Utc>, Attempt)> for DatedAttempt {
|
||||||
|
fn from(value: (DateTime<Utc>, Attempt)) -> Self {
|
||||||
|
let (date_time, attempt) = value;
|
||||||
|
DatedAttempt { date_time, attempt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&(DateTime<Utc>, Attempt)> for DatedAttempt {
|
||||||
|
fn from(value: &(DateTime<Utc>, Attempt)) -> Self {
|
||||||
|
let &(date_time, attempt) = value;
|
||||||
|
DatedAttempt { date_time, attempt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(&DateTime<Utc>, &Attempt)> for DatedAttempt {
|
||||||
|
fn from(value: (&DateTime<Utc>, &Attempt)) -> Self {
|
||||||
|
let (&date_time, &attempt) = value;
|
||||||
|
DatedAttempt { date_time, attempt }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WallUid {
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) fn create() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) fn min() -> Self {
|
||||||
|
Self(uuid::Uuid::nil())
|
||||||
|
}
|
||||||
|
#[expect(dead_code)]
|
||||||
|
pub(crate) fn max() -> Self {
|
||||||
|
Self(uuid::Uuid::max())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Image {
|
||||||
|
pub(crate) fn srcset(&self) -> String {
|
||||||
|
self.resolutions
|
||||||
|
.iter()
|
||||||
|
.map(|(res, filename)| format!("/files/holds/{} {}w", filename.filename, res.width))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageUid {
|
||||||
|
pub(crate) fn create() -> Self {
|
||||||
|
Self(uuid::Uuid::new_v4())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attempt {
|
||||||
|
pub(crate) fn icon(&self) -> crate::components::icons::Icon {
|
||||||
|
use crate::components::icons::Icon;
|
||||||
|
match self {
|
||||||
|
Attempt::Attempt => Icon::ArrowTrendingUp,
|
||||||
|
Attempt::Send => Icon::Trophy,
|
||||||
|
Attempt::Flash => Icon::BoltSolid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn gradient(&self) -> Gradient {
|
||||||
|
match self {
|
||||||
|
Attempt::Attempt => Gradient::PinkOrange,
|
||||||
|
Attempt::Send => Gradient::TealLime,
|
||||||
|
Attempt::Flash => Gradient::CyanBlue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Problem {
|
||||||
|
type Err = ron::Error;
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let problem = ron::from_str(s)?;
|
||||||
|
Ok(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Problem {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = ron::to_string(self).unwrap();
|
||||||
|
write!(f, "{s}")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test_try::test_try]
|
||||||
|
fn canonicalize_empty_pattern() {
|
||||||
|
let pattern = Pattern {
|
||||||
|
pattern: [].into_iter().collect(),
|
||||||
|
};
|
||||||
|
let canonicalized = pattern.canonicalize();
|
||||||
|
assert_eq!(pattern, canonicalized);
|
||||||
|
let mirrored = pattern.mirror();
|
||||||
|
assert_eq!(pattern, mirrored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_try::test_try]
|
||||||
|
fn canonicalize_pattern() {
|
||||||
|
let pattern = Pattern {
|
||||||
|
pattern: [
|
||||||
|
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
|
||||||
|
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let canonicalized = pattern.canonicalize();
|
||||||
|
|
||||||
|
assert_eq!(canonicalized.pattern[&HoldPosition { row: 0, col: 0 }], HoldRole::End);
|
||||||
|
assert_eq!(canonicalized.pattern[&HoldPosition { row: 7, col: 5 }], HoldRole::Start);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test_try::test_try]
|
||||||
|
fn mirror_pattern() {
|
||||||
|
let pattern = Pattern {
|
||||||
|
pattern: [
|
||||||
|
(HoldPosition { row: 0, col: 1 }, HoldRole::End),
|
||||||
|
(HoldPosition { row: 7, col: 6 }, HoldRole::Start),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mirrored = pattern.mirror();
|
||||||
|
|
||||||
|
assert_eq!(mirrored.pattern[&HoldPosition { row: 0, col: 6 }], HoldRole::End);
|
||||||
|
assert_eq!(mirrored.pattern[&HoldPosition { row: 7, col: 1 }], HoldRole::Start);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/ascend/src/pages.rs
Normal file
3
crates/ascend/src/pages.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod holds;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod wall;
|
||||||
@@ -1,188 +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: "EDIT WALL".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> {
|
|
||||||
use crate::server::state::State;
|
|
||||||
|
|
||||||
let state = expect_context::<State>();
|
|
||||||
|
|
||||||
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());
|
|
||||||
|
|
||||||
use crate::server::state::State;
|
|
||||||
|
|
||||||
// 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?;
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
251
crates/ascend/src/pages/holds.rs
Normal file
251
crates/ascend/src/pages/holds.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
use crate::codec::ron::RonCodec;
|
|
||||||
use crate::components::header::HeaderItem;
|
|
||||||
use crate::components::header::HeaderItems;
|
|
||||||
use crate::components::header::StyledHeader;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde::Serialize;
|
|
||||||
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 {}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
|
|
||||||
// use crate::server::state::State;
|
|
||||||
// let state = expect_context::<State>();
|
|
||||||
|
|
||||||
// TODO: provide info on current routes set
|
|
||||||
|
|
||||||
Ok(RonCodec::new(InitialData {}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server(name = ImportFromMiniMoonboard)]
|
|
||||||
#[tracing::instrument]
|
|
||||||
async fn import_from_mini_moonboard() -> Result<(), ServerFnError> {
|
|
||||||
use crate::server::config::Config;
|
|
||||||
use crate::server::state::State;
|
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
60
crates/ascend/src/pages/settings.rs
Normal file
60
crates/ascend/src/pages/settings.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -1,150 +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;
|
||||||
|
|
||||||
#[component]
|
#[derive(Params, PartialEq, Clone)]
|
||||||
pub fn Wall() -> impl leptos::IntoView {
|
struct RouteParams {
|
||||||
let load = async move {
|
wall_uid: Option<models::WallUid>,
|
||||||
// TODO: What to do about this unwrap?
|
}
|
||||||
load_initial_data().await.unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let header_items = HeaderItems {
|
#[component]
|
||||||
left: vec![],
|
#[tracing::instrument(skip_all)]
|
||||||
middle: vec![HeaderItem {
|
pub fn Page() -> impl IntoView {
|
||||||
text: "ASCEND".to_string(),
|
crate::tracing::on_enter!();
|
||||||
link: None,
|
|
||||||
}],
|
let route_params = leptos_router::hooks::use_params::<RouteParams>();
|
||||||
right: vec![
|
|
||||||
HeaderItem {
|
let wall_uid = Signal::derive(move || {
|
||||||
text: "Routes".to_string(),
|
route_params
|
||||||
link: Some("/wall/routes".to_string()),
|
.get()
|
||||||
},
|
.expect("gets wall_uid from URL")
|
||||||
HeaderItem {
|
.wall_uid
|
||||||
text: "Holds".to_string(),
|
.expect("wall_uid param is never None")
|
||||||
link: Some("/wall/edit".to_string()),
|
});
|
||||||
},
|
|
||||||
],
|
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||||
};
|
let user_interactions = crate::resources::user_interactions_for_wall(wall_uid);
|
||||||
|
|
||||||
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>
|
|
||||||
<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"),
|
let _ctx = use_context::<Context>().unwrap();
|
||||||
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
|
|
||||||
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
|
view! { <Button text="Saved" icon=Icon::HeartOutline color=Gradient::PinkRed /> }
|
||||||
None => Some("brightness-50"),
|
}
|
||||||
// None => None,
|
|
||||||
};
|
#[component]
|
||||||
let mut s = "bg-sky-100 aspect-square rounded".to_string();
|
#[tracing::instrument(skip_all)]
|
||||||
if let Some(c) = role_classes {
|
fn HoldsButton() -> impl IntoView {
|
||||||
s.push(' ');
|
crate::tracing::on_enter!();
|
||||||
s.push_str(c);
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
let link = move || format!("/wall/{}/edit", ctx.wall.read().uid);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a href=link>
|
||||||
|
<Button text="Holds" icon=Icon::WrenchSolid />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn FavoriteButton() -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
let ui_toggle = Signal::derive(move || {
|
||||||
|
let guard = ctx.user_interaction.read();
|
||||||
|
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
|
||||||
|
});
|
||||||
|
let on_click = Callback::new(move |_| {
|
||||||
|
ctx.cb_set_is_favorite.run(!ui_toggle.get());
|
||||||
|
});
|
||||||
|
let icon = Signal::derive(move || if ui_toggle.get() { Icon::Heart } else { Icon::HeartOutline });
|
||||||
|
let text = Signal::derive(move || if ui_toggle.get() { "Saved" } else { "Save" }.to_string());
|
||||||
|
view! { <Button text icon on_click color=Gradient::PinkRed /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn NextProblemButton() -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
let on_click = Callback::new(move |_| ctx.cb_next_problem.run(()));
|
||||||
|
view! { <Button icon=Icon::ArrowPath text="Randomize" on_click color=Gradient::PurpleBlue /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn Filter() -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
move || {
|
||||||
|
let mut cells = vec![];
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s
|
|
||||||
|
let problems_count = ctx.filtered_problem_transformations.read().iter().map(|set| set.len()).sum::<usize>();
|
||||||
|
|
||||||
|
let problems_counter = {
|
||||||
|
let name = view! { <p class="mr-4 font-light text-right text-orange-300">{"Problems:"}</p> };
|
||||||
|
let value = view! { <p class="text-white">{problems_count}</p> };
|
||||||
|
view! {
|
||||||
|
<div class="grid grid-rows-none gap-x-0.5 gap-y-1 grid-cols-[auto_1fr]">
|
||||||
|
{name} {value}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sep = (!cells.is_empty()).then_some(view! { <Separator /> });
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct InteractionCounters {
|
||||||
|
flash: u64,
|
||||||
|
send: u64,
|
||||||
|
attempt: u64,
|
||||||
|
}
|
||||||
|
let mut interaction_counters = InteractionCounters::default();
|
||||||
|
let interaction_counters_view = {
|
||||||
|
let user_ints = ctx.user_interactions.read();
|
||||||
|
for problem_set in ctx.filtered_problem_transformations.read().iter() {
|
||||||
|
for problem in problem_set {
|
||||||
|
if let Some(user_int) = user_ints.get(problem) {
|
||||||
|
match user_int.best_attempt().map(|da| da.attempt) {
|
||||||
|
Some(models::Attempt::Flash) => interaction_counters.flash += 1,
|
||||||
|
Some(models::Attempt::Send) => interaction_counters.send += 1,
|
||||||
|
Some(models::Attempt::Attempt) => interaction_counters.attempt += 1,
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let flash = (interaction_counters.flash > 0).then(|| {
|
||||||
|
let class = Gradient::CyanBlue.class_text();
|
||||||
|
view! {
|
||||||
|
<span class="mx-1">
|
||||||
|
<span class=class>{interaction_counters.flash}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let send = (interaction_counters.send > 0).then(|| {
|
||||||
|
let class = Gradient::TealLime.class_text();
|
||||||
|
view! {
|
||||||
|
<span class="mx-1">
|
||||||
|
<span class=class>{interaction_counters.send}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let attempt = (interaction_counters.attempt > 0).then(|| {
|
||||||
|
let class = Gradient::PinkOrange.class_text();
|
||||||
|
view! {
|
||||||
|
<span class="mx-1">
|
||||||
|
<span class=class>{interaction_counters.attempt}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if flash.is_some() || send.is_some() || attempt.is_some() {
|
||||||
|
view! {
|
||||||
|
<span>{"("}</span>
|
||||||
|
{flash}
|
||||||
|
{send}
|
||||||
|
{attempt}
|
||||||
|
<span>{")"}</span>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
().into_any()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="grid grid-cols-5 gap-1">{cells}</div>
|
||||||
|
{sep}
|
||||||
|
{problems_counter}
|
||||||
|
{interaction_counters_view}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn AttemptRadioGroup() -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
let problem = ctx.problem;
|
||||||
|
|
||||||
|
let mut attempt_radio_buttons = vec![];
|
||||||
|
for variant in [models::Attempt::Flash, models::Attempt::Send, models::Attempt::Attempt] {
|
||||||
|
let ui_toggle = Signal::derive(move || ctx.todays_attempt.get() == Some(variant));
|
||||||
|
|
||||||
|
let onclick = move |_| {
|
||||||
|
let attempt = if ui_toggle.get() { None } else { Some(variant) };
|
||||||
|
|
||||||
|
if let Some(problem) = problem.get() {
|
||||||
|
ctx.cb_upsert_todays_attempt.run(server_functions::UpsertTodaysAttempt {
|
||||||
|
wall_uid: ctx.wall.read().uid,
|
||||||
|
problem,
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
attempt_radio_buttons.push(view! { <AttemptRadioButton on:click=onclick variant selected=ui_toggle /> });
|
||||||
|
}
|
||||||
|
|
||||||
|
view! { <div class="flex flex-col gap-2 justify-evenly 2xl:flex-row">{attempt_radio_buttons}</div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn AttemptRadioButton(variant: models::Attempt, #[prop(into)] selected: Signal<bool>) -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let text = variant.to_string();
|
||||||
|
let icon = variant.icon();
|
||||||
|
let color = match variant {
|
||||||
|
models::Attempt::Attempt => Gradient::PinkOrange,
|
||||||
|
models::Attempt::Send => Gradient::TealLime,
|
||||||
|
models::Attempt::Flash => Gradient::CyanBlue,
|
||||||
|
};
|
||||||
|
view! { <Button text icon color highlight=selected /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
fn History() -> impl IntoView {
|
||||||
|
crate::tracing::on_enter!();
|
||||||
|
|
||||||
|
let ctx = use_context::<Context>().unwrap();
|
||||||
|
|
||||||
|
let attempts = move || {
|
||||||
|
ctx.user_interaction
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|x| x.attempted_on())
|
||||||
|
.map(|dated_attempt| {
|
||||||
|
let date = dated_attempt.date_time;
|
||||||
|
let attempt = dated_attempt.attempt;
|
||||||
|
view! { <Attempt date attempt /> }
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
};
|
};
|
||||||
|
|
||||||
let img = hold.image.map(|img| {
|
let placeholder = move || {
|
||||||
let src = format!("/files/holds/{}", img.filename);
|
ctx.latest_attempt.read().is_none().then(|| {
|
||||||
view! { <img class="object-cover w-full h-full" src=src /> }
|
let today = chrono::Utc::now();
|
||||||
});
|
view! { <Attempt date=today attempt=None /> }
|
||||||
|
|
||||||
view! { <div class=class>{img}</div> }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
pub struct InitialData {
|
|
||||||
wall: models::Wall,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
|
|
||||||
use crate::server::state::State;
|
|
||||||
|
|
||||||
let state = expect_context::<State>();
|
|
||||||
|
|
||||||
let wall = state.persistent.with(|s| s.wall.clone()).await;
|
|
||||||
Ok(RonCodec::new(InitialData { wall }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn get_random_problem() -> Result<RonCodec<Option<models::Problem>>, ServerFnError> {
|
|
||||||
use crate::server::state::State;
|
|
||||||
use rand::seq::IteratorRandom;
|
|
||||||
|
|
||||||
let state = expect_context::<State>();
|
|
||||||
|
|
||||||
let problem = state
|
|
||||||
.persistent
|
|
||||||
.with(|s| {
|
|
||||||
let problems = &s.problems.problems;
|
|
||||||
let rng = &mut rand::thread_rng();
|
|
||||||
problems.iter().choose(rng).cloned()
|
|
||||||
})
|
})
|
||||||
.await;
|
};
|
||||||
|
|
||||||
tracing::debug!("Returning randomized problem: {problem:?}");
|
view! {
|
||||||
|
{placeholder}
|
||||||
Ok(RonCodec::new(problem))
|
{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()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
crates/ascend/src/resources.rs
Normal file
47
crates/ascend/src/resources.rs
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,23 +4,16 @@ use cli::Cli;
|
|||||||
use config::Config;
|
use config::Config;
|
||||||
use confik::Configuration;
|
use confik::Configuration;
|
||||||
use confik::EnvSource;
|
use confik::EnvSource;
|
||||||
use persistence::Persistent;
|
|
||||||
use state::PersistentState;
|
|
||||||
use state::State;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
|
||||||
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;
|
||||||
|
|
||||||
mod cli;
|
mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod persistence;
|
|
||||||
pub mod state;
|
|
||||||
|
|
||||||
pub const STATE_FILE: &str = "datastore/private/state.ron";
|
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn main() {
|
pub async fn main() {
|
||||||
@@ -43,12 +36,6 @@ pub async fn main() {
|
|||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Serve => serve(cli).await.unwrap_or_report(),
|
Command::Serve => serve(cli).await.unwrap_or_report(),
|
||||||
Command::ResetState => {
|
|
||||||
let s = PersistentState::default();
|
|
||||||
let p = Path::new(STATE_FILE);
|
|
||||||
tracing::info!("Resetting state to default: {}", p.display());
|
|
||||||
Persistent::persist(p, &s).await.unwrap_or_report();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,32 +44,35 @@ async fn serve(cli: Cli) -> Result<(), Error> {
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::app::shell;
|
use crate::app::shell;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use leptos::prelude::*;
|
|
||||||
use leptos_axum::LeptosRoutes;
|
use leptos_axum::LeptosRoutes;
|
||||||
use leptos_axum::generate_route_list;
|
use leptos_axum::generate_route_list;
|
||||||
|
|
||||||
migrations::run_migrations().await;
|
tracing::debug!("Creating DB");
|
||||||
|
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)?;
|
||||||
|
|
||||||
// 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
|
||||||
// For deployment these variables are:
|
// For deployment these variables are:
|
||||||
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
|
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
|
||||||
// Alternately a file can be specified such as Some("Cargo.toml")
|
// Alternately a file can be specified such as Some("Cargo.toml")
|
||||||
// The file would need to be included with the executable when moved to deployment
|
// The file would need to be included with the executable when moved to deployment
|
||||||
let leptos_conf_file = get_configuration(None).unwrap_or_report();
|
let leptos_conf_file = leptos::config::get_configuration(None).unwrap_or_report();
|
||||||
let leptos_options = leptos_conf_file.leptos_options;
|
let leptos_options = leptos_conf_file.leptos_options;
|
||||||
let addr = leptos_options.site_addr;
|
let addr = leptos_options.site_addr;
|
||||||
let routes = generate_route_list(App);
|
let routes = generate_route_list(App);
|
||||||
|
|
||||||
let config = load_config(cli)?;
|
let config = load_config(cli)?;
|
||||||
let server_state = load_state().await?;
|
|
||||||
|
|
||||||
|
tracing::debug!("Creating app router");
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.leptos_routes_with_context(
|
.leptos_routes_with_context(
|
||||||
&leptos_options,
|
&leptos_options,
|
||||||
routes,
|
routes,
|
||||||
move || {
|
move || {
|
||||||
provide_context(server_state.clone());
|
leptos::prelude::provide_context::<db::Database>(db.clone());
|
||||||
provide_context(config.clone())
|
leptos::prelude::provide_context::<Config>(config.clone())
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
let leptos_options = leptos_options.clone();
|
let leptos_options = leptos_options.clone();
|
||||||
@@ -93,7 +83,9 @@ async fn serve(cli: Cli) -> Result<(), Error> {
|
|||||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||||
.with_state(leptos_options);
|
.with_state(leptos_options);
|
||||||
|
|
||||||
|
tracing::debug!("Binding TCP listener");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
tracing::info!("Listening on http://{addr}");
|
tracing::info!("Listening on http://{addr}");
|
||||||
axum::serve(listener, app.into_make_service()).await?;
|
axum::serve(listener, app.into_make_service()).await?;
|
||||||
|
|
||||||
@@ -106,22 +98,6 @@ fn file_service(path: impl AsRef<Path>) -> ServeDir {
|
|||||||
ServeDir::new(path)
|
ServeDir::new(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
|
||||||
async fn load_state() -> Result<State, Error> {
|
|
||||||
tracing::info!("Loading state");
|
|
||||||
|
|
||||||
let p = PathBuf::from(STATE_FILE);
|
|
||||||
|
|
||||||
let persistent = if p.try_exists()? {
|
|
||||||
Persistent::<PersistentState>::load(&p).await?
|
|
||||||
} else {
|
|
||||||
tracing::info!("No state found at {STATE_FILE}, creating default state");
|
|
||||||
Persistent::<PersistentState>::new(PersistentState::default(), p)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(State { persistent })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_config(cli: Cli) -> Result<Config, Error> {
|
fn load_config(cli: Cli) -> Result<Config, Error> {
|
||||||
let mut builder = config::Config::builder();
|
let mut builder = config::Config::builder();
|
||||||
if cli
|
if cli
|
||||||
@@ -139,11 +115,14 @@ fn load_config(cli: Cli) -> Result<Config, Error> {
|
|||||||
#[display("Server crash: {_variant}")]
|
#[display("Server crash: {_variant}")]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Persistence(persistence::Error),
|
|
||||||
Parser(moonboard_parser::Error),
|
Parser(moonboard_parser::Error),
|
||||||
|
|
||||||
#[display("Failed migration")]
|
#[display("Failed migration")]
|
||||||
|
#[from(ignore)]
|
||||||
Migration(Box<dyn std::error::Error>),
|
Migration(Box<dyn std::error::Error>),
|
||||||
|
|
||||||
Confik(confik::Error),
|
Confik(confik::Error),
|
||||||
|
|
||||||
|
Database(db::DatabaseOperationError),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ pub struct Cli {
|
|||||||
pub enum Command {
|
pub enum Command {
|
||||||
#[default]
|
#[default]
|
||||||
Serve,
|
Serve,
|
||||||
|
|
||||||
/// Resets state, replacing it with defaults
|
|
||||||
ResetState,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_config_location() -> camino::Utf8PathBuf {
|
fn default_config_location() -> camino::Utf8PathBuf {
|
||||||
|
|||||||
192
crates/ascend/src/server/db.rs
Normal file
192
crates/ascend/src/server/db.rs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
use bincode::Bincode;
|
||||||
|
use redb::ReadTransaction;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
use redb::ReadableTableMetadata;
|
||||||
|
use redb::TableDefinition;
|
||||||
|
use redb::WriteTransaction;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
mod bincode;
|
||||||
|
|
||||||
|
const DB_FILE: &str = "datastore/private/ascend.redb";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
db: Arc<redb::Database>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
#[tracing::instrument(skip_all, err)]
|
||||||
|
pub fn create() -> Result<Database, redb::Error> {
|
||||||
|
let file = PathBuf::from(DB_FILE);
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
#[derive(Serialize, Deserialize, Debug, derive_more::Display, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
#[display("{version}")]
|
||||||
|
pub struct Version {
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
impl Version {
|
||||||
|
pub fn current() -> Version {
|
||||||
|
Version { version: current::VERSION }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
use crate::models;
|
||||||
|
use crate::server::db::bincode::Bincode;
|
||||||
|
use redb::TableDefinition;
|
||||||
|
|
||||||
|
pub const VERSION: u64 = 2;
|
||||||
|
|
||||||
|
pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v2::Root>> = TableDefinition::new("root");
|
||||||
|
pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallUid>, Bincode<models::v2::Wall>> = TableDefinition::new("walls");
|
||||||
|
pub const TABLE_PROBLEMS: TableDefinition<Bincode<(models::v2::WallUid, models::v2::ProblemUid)>, Bincode<models::v2::Problem>> =
|
||||||
|
TableDefinition::new("problems");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod v1 {
|
||||||
|
use crate::models;
|
||||||
|
use crate::server::db::bincode::Bincode;
|
||||||
|
use redb::TableDefinition;
|
||||||
|
|
||||||
|
pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v1::PersistentState>> = TableDefinition::new("root");
|
||||||
|
}
|
||||||
52
crates/ascend/src/server/db/bincode.rs
Normal file
52
crates/ascend/src/server/db/bincode.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use redb::Value;
|
||||||
|
|
||||||
|
/// Wrapper type to handle keys and values using bincode serialization
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Bincode<T>(pub T);
|
||||||
|
|
||||||
|
impl<T> Value for Bincode<T>
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + serde::Serialize + for<'a> serde::Deserialize<'a>,
|
||||||
|
{
|
||||||
|
type SelfType<'a>
|
||||||
|
= T
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
type AsBytes<'a>
|
||||||
|
= Vec<u8>
|
||||||
|
where
|
||||||
|
Self: 'a;
|
||||||
|
|
||||||
|
fn fixed_width() -> Option<usize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a>
|
||||||
|
where
|
||||||
|
Self: 'a,
|
||||||
|
{
|
||||||
|
bincode::deserialize(data).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a>
|
||||||
|
where
|
||||||
|
Self: 'a,
|
||||||
|
Self: 'b,
|
||||||
|
{
|
||||||
|
bincode::serialize(value).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_name() -> redb::TypeName {
|
||||||
|
redb::TypeName::new(&format!("Bincode<{}>", std::any::type_name::<T>()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> redb::Key for Bincode<T>
|
||||||
|
where
|
||||||
|
T: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + Ord,
|
||||||
|
{
|
||||||
|
fn compare(data1: &[u8], data2: &[u8]) -> std::cmp::Ordering {
|
||||||
|
Self::from_bytes(data1).cmp(&Self::from_bytes(data2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,140 @@
|
|||||||
use crate::server::STATE_FILE;
|
use super::db::Database;
|
||||||
use std::path::PathBuf;
|
use super::db::DatabaseOperationError;
|
||||||
use type_toppings::ResultExt;
|
use super::db::{self};
|
||||||
|
use crate::models;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument(skip_all, err)]
|
||||||
pub async fn run_migrations() {
|
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
migrate_state_file().await;
|
if is_at_version(db, 2).await? {
|
||||||
}
|
migrate_to_v3(db).await?;
|
||||||
|
|
||||||
/// State file moved to datastore/private
|
|
||||||
#[tracing::instrument]
|
|
||||||
async fn migrate_state_file() {
|
|
||||||
let m = PathBuf::from("state.ron");
|
|
||||||
if m.try_exists().expect_or_report_with(|| format!("Failed to read {}", m.display())) {
|
|
||||||
tracing::warn!("MIGRATING STATE FILE");
|
|
||||||
let p = PathBuf::from(STATE_FILE);
|
|
||||||
tokio::fs::create_dir_all(p.parent().unwrap()).await.unwrap_or_report();
|
|
||||||
tokio::fs::rename(m, &p).await.unwrap_or_report();
|
|
||||||
}
|
}
|
||||||
|
if is_at_version(db, 3).await? {
|
||||||
|
migrate_to_v4(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// migrate: walls table
|
||||||
|
/// migrate: user table
|
||||||
|
/// remove: problems table
|
||||||
|
#[tracing::instrument(skip_all, err)]
|
||||||
|
pub async fn migrate_to_v4(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing::warn!("MIGRATING TO VERSION 4");
|
||||||
|
|
||||||
|
db.write(|txn| {
|
||||||
|
let walls_dump = txn
|
||||||
|
.open_table(db::v2::TABLE_WALLS)?
|
||||||
|
.iter()?
|
||||||
|
.map(|el| {
|
||||||
|
let (k, v) = el.unwrap();
|
||||||
|
(k.value(), v.value())
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let problems_dump = txn
|
||||||
|
.open_table(db::v2::TABLE_PROBLEMS)?
|
||||||
|
.iter()?
|
||||||
|
.map(|el| {
|
||||||
|
let (k, v) = el.unwrap();
|
||||||
|
(k.value(), v.value())
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let user_dump = txn
|
||||||
|
.open_table(db::v3::TABLE_USER)?
|
||||||
|
.iter()?
|
||||||
|
.map(|el| {
|
||||||
|
let (k, v) = el.unwrap();
|
||||||
|
(k.value(), v.value())
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
txn.delete_table(db::v2::TABLE_WALLS)?;
|
||||||
|
txn.delete_table(db::v2::TABLE_PROBLEMS)?;
|
||||||
|
txn.delete_table(db::v3::TABLE_USER)?;
|
||||||
|
|
||||||
|
let mut new_walls_table = txn.open_table(db::current::TABLE_WALLS)?;
|
||||||
|
let mut new_user_table = txn.open_table(db::current::TABLE_USER)?;
|
||||||
|
|
||||||
|
for (wall_uid, wall) in walls_dump.into_iter() {
|
||||||
|
let models::v2::Wall {
|
||||||
|
uid: _,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
holds,
|
||||||
|
problems,
|
||||||
|
} = wall;
|
||||||
|
|
||||||
|
let problems = problems
|
||||||
|
.into_iter()
|
||||||
|
.map(|problem_uid| {
|
||||||
|
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
|
||||||
|
let method = old_prob.method;
|
||||||
|
|
||||||
|
models::Problem {
|
||||||
|
pattern: models::Pattern {
|
||||||
|
pattern: old_prob.holds.clone(),
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let wall = models::Wall {
|
||||||
|
uid: wall_uid,
|
||||||
|
wall_dimensions: models::WallDimensions { rows, cols },
|
||||||
|
holds,
|
||||||
|
problems,
|
||||||
|
};
|
||||||
|
|
||||||
|
new_walls_table.insert(wall_uid, wall)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((wall_uid, problem_uid), user_interaction) in user_dump.into_iter() {
|
||||||
|
let old_prob = &problems_dump[&(wall_uid, problem_uid)];
|
||||||
|
let problem = models::Problem {
|
||||||
|
pattern: models::Pattern {
|
||||||
|
pattern: old_prob.holds.clone(),
|
||||||
|
},
|
||||||
|
method: old_prob.method,
|
||||||
|
};
|
||||||
|
let key = (wall_uid, problem.clone());
|
||||||
|
let value = models::UserInteraction {
|
||||||
|
wall_uid,
|
||||||
|
problem,
|
||||||
|
attempted_on: user_interaction.attempted_on,
|
||||||
|
is_favorite: user_interaction.is_favorite,
|
||||||
|
};
|
||||||
|
new_user_table.insert(key, value)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
db.set_version(db::Version { version: db::v4::VERSION }).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
//! 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::HoldPosition;
|
use crate::models::HoldPosition;
|
||||||
use crate::models::HoldRole;
|
use crate::models::HoldRole;
|
||||||
use crate::models::Problem;
|
|
||||||
use crate::server::config::Config;
|
use crate::server::config::Config;
|
||||||
use crate::server::persistence;
|
use crate::server::db;
|
||||||
use crate::server::state::State;
|
use redb::ReadableTable;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
#[tracing::instrument(skip(state))]
|
#[tracing::instrument(skip_all)]
|
||||||
pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &State) -> 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;
|
||||||
|
|
||||||
let mut problems = Vec::new();
|
let mut problems = Vec::new();
|
||||||
|
|
||||||
let file_name = "problems Mini MoonBoard 2020 40.json";
|
let file_name = "problems Mini MoonBoard 2020 40.json";
|
||||||
@@ -17,10 +20,10 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
|||||||
|
|
||||||
tracing::info!("Parsing mini moonboard problems from {file_path}");
|
tracing::info!("Parsing mini moonboard problems from {file_path}");
|
||||||
|
|
||||||
let mini_moonboard = moonboard_parser::mini_moonboard::parse(file_path.as_std_path()).await?;
|
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
|
||||||
for 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 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();
|
||||||
let hold_position = HoldPosition { row, col };
|
let hold_position = HoldPosition { row, col };
|
||||||
@@ -31,18 +34,30 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
|||||||
(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 route = Problem { holds };
|
|
||||||
problems.push(route);
|
let method = match mini_mb_problem.method {
|
||||||
|
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
|
||||||
|
mini_moonboard::Method::Footless => models::Method::Footless,
|
||||||
|
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pattern = models::Pattern { pattern }.canonicalize();
|
||||||
|
let problem = models::Problem { pattern, method };
|
||||||
|
problems.push(problem);
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
db.write(|txn| {
|
||||||
.persistent
|
let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?;
|
||||||
.update(|s| {
|
|
||||||
s.problems.problems.extend(problems);
|
let mut wall = walls_table.get(wall_uid)?.unwrap().value();
|
||||||
})
|
wall.problems.extend(problems);
|
||||||
.await?;
|
walls_table.insert(wall_uid, wall)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -50,5 +65,6 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
|||||||
#[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),
|
||||||
Persistence(persistence::Error),
|
Tokio(tokio::task::JoinError),
|
||||||
|
DbOperation(crate::server::db::DatabaseOperationError),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
use serde::Serialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Persistent<T> {
|
|
||||||
state: Arc<Mutex<T>>,
|
|
||||||
file_path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Persistent<T> {
|
|
||||||
#[tracing::instrument(skip(state))]
|
|
||||||
pub fn new(state: T, file_path: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
state: Arc::new(Mutex::new(state)),
|
|
||||||
file_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Instantiates state from file system
|
|
||||||
#[tracing::instrument]
|
|
||||||
pub async fn load(file_path: &Path) -> Result<Self, Error>
|
|
||||||
where
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
let content = tokio::fs::read_to_string(&file_path).await.map_err(|source| Error::Read {
|
|
||||||
file_path: file_path.to_owned(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let t = ron::from_str(&content).map_err(|source| Error::Deserialize {
|
|
||||||
file_path: file_path.to_owned(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let persistent = Self {
|
|
||||||
state: Arc::new(Mutex::new(t)),
|
|
||||||
file_path: file_path.to_owned(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(persistent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: This is pretty poor - clones the entire state on access
|
|
||||||
/// Returns state
|
|
||||||
#[tracing::instrument(skip(self))]
|
|
||||||
pub async fn get(&self) -> T
|
|
||||||
where
|
|
||||||
T: Clone,
|
|
||||||
{
|
|
||||||
let state = self.state.lock().await;
|
|
||||||
state.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns state passed through given function
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
pub async fn with<F, R>(&self, f: F) -> R
|
|
||||||
where
|
|
||||||
F: FnOnce(&T) -> R,
|
|
||||||
{
|
|
||||||
let state = self.state.lock().await;
|
|
||||||
f(&state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Updates and persists state
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
pub async fn update<F>(&self, f: F) -> Result<(), Error>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut T),
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
let mut state = self.state.lock().await;
|
|
||||||
f(&mut state);
|
|
||||||
Self::persist(&self.file_path, &state).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets and persists state
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
pub async fn set(&self, new_state: T) -> Result<(), Error>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
self.update(move |state| {
|
|
||||||
*state = new_state;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist.
|
|
||||||
///
|
|
||||||
/// Implicitly called by `set` and `update`.
|
|
||||||
#[tracing::instrument(skip_all, err)]
|
|
||||||
pub async fn persist(file_path: &Path, state: &T) -> Result<(), Error>
|
|
||||||
where
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
tracing::debug!("Persisting state");
|
|
||||||
let serialized = ron::ser::to_string_pretty(state, ron::ser::PrettyConfig::default()).map_err(|source| Error::Serialize { source })?;
|
|
||||||
if let Some(parent) = file_path.parent() {
|
|
||||||
tokio::fs::create_dir_all(parent).await.map_err(|source| Error::CreateDir {
|
|
||||||
source,
|
|
||||||
dir: parent.to_owned(),
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
tokio::fs::write(file_path, serialized).await.map_err(|source| Error::Write {
|
|
||||||
file_path: file_path.to_owned(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
|
||||||
#[display("Persistent state error: {_variant}")]
|
|
||||||
pub enum Error {
|
|
||||||
#[display("Failed to read file: {}", file_path.display())]
|
|
||||||
Read { file_path: PathBuf, source: std::io::Error },
|
|
||||||
|
|
||||||
#[display("Failed to deserialize state from file: {}", file_path.display())]
|
|
||||||
Deserialize { file_path: PathBuf, source: ron::error::SpannedError },
|
|
||||||
|
|
||||||
#[display("Failed to serialize state")]
|
|
||||||
Serialize { source: ron::Error },
|
|
||||||
|
|
||||||
#[display("Failed to write file: {}", file_path.display())]
|
|
||||||
Write { file_path: PathBuf, source: std::io::Error },
|
|
||||||
|
|
||||||
#[display("Failed to create directory: {}", dir.display())]
|
|
||||||
CreateDir { dir: PathBuf, source: std::io::Error },
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
//! Server state
|
|
||||||
|
|
||||||
const STATE_VERSION: u64 = 1;
|
|
||||||
|
|
||||||
use super::persistence::Persistent;
|
|
||||||
use crate::models;
|
|
||||||
use crate::models::Wall;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde::Serialize;
|
|
||||||
use smart_default::SmartDefault;
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct State {
|
|
||||||
pub persistent: Persistent<PersistentState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)]
|
|
||||||
pub struct PersistentState {
|
|
||||||
/// State schema version
|
|
||||||
#[default(STATE_VERSION)]
|
|
||||||
pub version: u64,
|
|
||||||
|
|
||||||
pub wall: Wall,
|
|
||||||
pub problems: Problems,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
|
||||||
pub struct Problems {
|
|
||||||
pub problems: BTreeSet<models::Problem>,
|
|
||||||
}
|
|
||||||
305
crates/ascend/src/server_functions.rs
Normal file
305
crates/ascend/src/server_functions.rs
Normal 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)
|
||||||
|
}
|
||||||
22
crates/ascend/src/tracing.rs
Normal file
22
crates/ascend/src/tracing.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
macro_rules! where_am_i {
|
||||||
|
() => {{
|
||||||
|
fn f() {}
|
||||||
|
|
||||||
|
fn type_name_of<T>(_: T) -> &'static str {
|
||||||
|
std::any::type_name::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = type_name_of(f);
|
||||||
|
|
||||||
|
// `3` is the length of the `::f`.
|
||||||
|
&name[..name.len() - 3]
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
pub(crate) use where_am_i;
|
||||||
|
|
||||||
|
macro_rules! on_enter {
|
||||||
|
() => {
|
||||||
|
tracing::trace!("Entering {}", crate::tracing::where_am_i!());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pub(crate) use on_enter;
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
@tailwind base;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
BIN
docs/ascend.jpg
BIN
docs/ascend.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 392 KiB |
18
flake.lock
generated
18
flake.lock
generated
@@ -10,11 +10,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"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": {
|
||||||
|
|||||||
16
flake.nix
16
flake.nix
@@ -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";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
41
justfile
41
justfile
@@ -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
4
leptosfmt.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
closing_tag_style = "SelfClosing"
|
||||||
|
|
||||||
|
[attr_values]
|
||||||
|
class = "Tailwind"
|
||||||
16
notes.txt
Normal file
16
notes.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Random selection: Sample from filtered
|
||||||
|
[patterns with variations that satisfy filter]
|
||||||
|
[variations]
|
||||||
|
|
||||||
|
# Random selection no filter: Sample equally weighted patterns
|
||||||
|
[patterns]
|
||||||
|
[random variation within pattern]
|
||||||
|
|
||||||
|
Normalize: shift left, and use minimum of mirrored pattern pair
|
||||||
|
|
||||||
|
|
||||||
|
# Filter stats:
|
||||||
|
patterns: X
|
||||||
|
pattern variations: Y
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
14
todo.md
Normal file
14
todo.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
- associate routes with wall
|
||||||
|
- group routes by pattern (pattern family has shift/mirror variations)
|
||||||
|
- generate pattern families of variations when importing problems
|
||||||
|
- implement pattern challenge (start an "adventure mode" based on a pattern family)
|
||||||
|
- Record problem success (enum: flash, send, no-send)
|
||||||
|
- implement routes page to show all routes for a given wall
|
||||||
|
- implement favorite routes feature
|
||||||
|
- 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?
|
||||||
Reference in New Issue
Block a user