feat: store attempts
This commit is contained in:
parent
7118b66104
commit
2e83efcf12
@ -5,9 +5,15 @@ language-servers = ["rust-analyzer", "tailwindcss-ls"]
|
||||
[language-server.rust-analyzer.config]
|
||||
# procMacro = { ignored = { leptos_macro = ["server"] } }
|
||||
cargo = { features = ["ssr", "hydrate"] }
|
||||
check = { command = "check" }
|
||||
|
||||
[language-server.rust-analyzer.config.check]
|
||||
command = "clippy"
|
||||
rustfmt = { overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"] }
|
||||
|
||||
# rustfmt = { overrideCommand = [
|
||||
# "sh",
|
||||
# "-c",
|
||||
# "set -euo pipefail; rustfmt --emit stdout --edition 2024 | leptosfmt --stdin",
|
||||
# ] }
|
||||
|
||||
[language-server.tailwindcss-ls]
|
||||
config = { userLanguages = { rust = "html", "*.rs" = "html" } }
|
||||
|
52
Cargo.lock
generated
52
Cargo.lock
generated
@ -1760,9 +1760,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
|
||||
|
||||
[[package]]
|
||||
name = "leptos"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78329c12843d64766d8f00216aae665416d804327302ce8e0ab83884dfa91887"
|
||||
checksum = "88613d81f70f4e267473b2ee107e1ee70cf765a3c3dfee945929c8e9c520b957"
|
||||
dependencies = [
|
||||
"any_spawner",
|
||||
"base64 0.22.1",
|
||||
@ -1823,9 +1823,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "leptos_config"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "132a18e8ffc4fbe2d624f3743d88a1b4989bff2d5e12be2b0d2749201d9dfb52"
|
||||
checksum = "4172cfee12576224775ccfbb9d3e76625017a8b4207c4641a2f9b96a70e6d524"
|
||||
dependencies = [
|
||||
"config",
|
||||
"regex",
|
||||
@ -1836,9 +1836,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "leptos_dom"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468f638f2f13d70d99d9952be98d671a75366034472f3828e586ba62d770049"
|
||||
checksum = "a41f6dc3ddaa09d876d7015f08f4f3905787da4ea5460cef130c365419483a89"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"or_poisoned",
|
||||
@ -1852,9 +1852,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "leptos_hot_reload"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ba37d76693fc6228554e0bb06a9aa41c59e2b5180caf423c7913557b81d01dd"
|
||||
checksum = "31f5c961e5d9b2aa6deab39d5d842272e8b1b165744b5caf674770d5cf0daa04"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"camino",
|
||||
@ -1885,9 +1885,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "leptos_macro"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064d0c8b144b93f8d7e84b30c16d1da0e64a63c7e91b9a872f7be63601c5868b"
|
||||
checksum = "2b9165909eabb02188a4b33b0ab6acff408bdf440018bf65b30bba0d38d61b19"
|
||||
dependencies = [
|
||||
"attribute-derive",
|
||||
"cfg-if",
|
||||
@ -1961,9 +1961,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "leptos_server"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb1779f1f0570915066c132fb11f999add8b13d02ca5221735193eb02b3fa69a"
|
||||
checksum = "4fee9ed4526484b17561bc8ce1532c613e37be2c01788fed3d1c4104db674dd9"
|
||||
dependencies = [
|
||||
"any_spawner",
|
||||
"base64 0.22.1",
|
||||
@ -2663,9 +2663,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reactive_graph"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "059aede5acae8f5c25b1d34b6df34700006418b3c493db3698b7ebcd4a8a6287"
|
||||
checksum = "9996b4c0f501d64a755ff3dfbe9276e9f834d105d7d45059ad4bd6d2a56477d0"
|
||||
dependencies = [
|
||||
"any_spawner",
|
||||
"async-lock",
|
||||
@ -2685,9 +2685,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reactive_stores"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c7edacf4298579a5772285b8e2dc0b9953c8fbaa9c3f56c3dd69d56e5af7a48"
|
||||
checksum = "74c3d2a20d8edd8ac6628718209f743da86349d7f10a4458304666c2ddfc082e"
|
||||
dependencies = [
|
||||
"guardian",
|
||||
"itertools 0.13.0",
|
||||
@ -2700,9 +2700,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reactive_stores_macro"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "178b1cd8b2871a45bfc8e13ff8076049b6e9a5132e72414e5cab3894c4a6adb3"
|
||||
checksum = "6d4d8e40112b8ee1424e5ec636fcbc9764c1a099e81f8fa818f6762b43cc10cd"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro-error2",
|
||||
@ -2941,9 +2941,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "server_fn"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c183c31152fd00e994a3ea0ca43e6017056ccf7812160b0ae008acc3de8241c"
|
||||
checksum = "055476c2a42c9a98a69e3f0ce29b86aa3acbdef19a84e0523330f095097defcf"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"bytes",
|
||||
@ -2978,9 +2978,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "server_fn_macro"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c43b2266308c118be1a1cc60602f8efb07a64e72deed8d317704d5cfda092ca1"
|
||||
checksum = "e65737414a9583ce3b43dddd4e5dfb33fe385a6933ed79a9b539b8eb0767cd07"
|
||||
dependencies = [
|
||||
"const_format",
|
||||
"convert_case",
|
||||
@ -2992,9 +2992,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "server_fn_macro_default"
|
||||
version = "0.7.5"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "087eca61bc8f93d868b8c10ca058da358fd7aaeb7bc8415b572f9f3f27ce0b93"
|
||||
checksum = "563909a43390341403ab76fbc33fde306712613da02244e692eabeae8ffde949"
|
||||
dependencies = [
|
||||
"server_fn_macro",
|
||||
"syn",
|
||||
@ -3148,9 +3148,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tachys"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59a3bbcf8e3b52cad5f0aa860837d4d1796c7c4873b083c9520a1bbba4747973"
|
||||
checksum = "4c05fed41ed4e334257090500510df21bb1611680c0cfd3be14acec7ffdf3d95"
|
||||
dependencies = [
|
||||
"any_spawner",
|
||||
"async-trait",
|
||||
|
@ -23,7 +23,7 @@ derive_more = { version = "2", features = [
|
||||
] }
|
||||
http = "1"
|
||||
image = { version = "0.25", optional = true }
|
||||
leptos = { version = "0.7.4", features = ["tracing"] }
|
||||
leptos = { version = "0.7.7", features = ["tracing"] }
|
||||
leptos_axum = { version = "0.7", optional = true }
|
||||
leptos_meta = { version = "0.7" }
|
||||
leptos_router = { version = "0.7.0" }
|
||||
|
@ -17,7 +17,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
<HydrationScripts options />
|
||||
<MetaTags />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-white">
|
||||
<body class="text-white bg-slate-950">
|
||||
<App />
|
||||
</body>
|
||||
</html>
|
||||
|
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 leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Attempt(#[prop(into)] date: Signal<chrono::DateTime<chrono::Utc>>, #[prop(into)] attempt: Signal<Option<models::Attempt>>) -> impl IntoView {
|
||||
tracing::trace!("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(models::Attempt::Flash) => "text-cyan-500",
|
||||
Some(models::Attempt::Send) => "text-teal-500",
|
||||
Some(models::Attempt::Attempt) => "text-pink-500",
|
||||
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,15 +1,79 @@
|
||||
use super::icons::Icon;
|
||||
use crate::components::outlined_box::OutlinedBox;
|
||||
use crate::gradient::Gradient;
|
||||
use leptos::prelude::*;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[component]
|
||||
pub fn Button(#[prop(into)] text: String, onclick: impl Fn(MouseEvent) + 'static) -> impl IntoView {
|
||||
pub fn Button(
|
||||
#[prop(into, optional)] icon: MaybeProp<Icon>,
|
||||
|
||||
#[prop(into)] text: Signal<String>,
|
||||
|
||||
#[prop(optional)] color: Gradient,
|
||||
|
||||
#[prop(into, optional)] highlight: MaybeProp<bool>,
|
||||
|
||||
onclick: impl FnMut(MouseEvent) + 'static,
|
||||
) -> impl IntoView {
|
||||
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
|
||||
|
||||
let icon_view = 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 = icon.get().is_some().then(|| {
|
||||
let mut classes = "w-0.5 bg-gradient-to-br min-w-0.5".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_from());
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_to());
|
||||
|
||||
view! { <div class=classes /> }
|
||||
});
|
||||
|
||||
let text_view = {
|
||||
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
|
||||
view! { <div class=classes>{text.get()}</div> }
|
||||
};
|
||||
|
||||
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 on:click=onclick type="button" class="hover:brightness-125 active:brightness-90">
|
||||
<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 onclick /> };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple() {
|
||||
let icon = Icon::ForwardSolid;
|
||||
let text = "foo";
|
||||
let onclick = |_| {};
|
||||
|
||||
view! { <Button icon text 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 text-white bg-white rounded-sm aspect-square mx-5 my-2.5">
|
||||
<span class=("text-gray-950", move || checked.get())>
|
||||
<icons::Check />
|
||||
</span>
|
||||
</div>
|
||||
};
|
||||
|
||||
let separator = {
|
||||
let mut classes = "w-0.5 bg-gradient-to-br min-w-0.5".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_from());
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_to());
|
||||
view! { <div class=classes /> }
|
||||
};
|
||||
|
||||
let text_view = view! {
|
||||
<div class="self-center mx-5 my-2.5 uppercase w-full text-lg font-thin">
|
||||
{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>
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ pub struct HeaderItem {
|
||||
#[component]
|
||||
pub fn StyledHeader(#[prop(into)] items: Signal<HeaderItems>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="bg-orange-300 text-black border-b-2 border-b-orange-400">
|
||||
<div class="text-black bg-orange-300 border-b-2 border-b-orange-400">
|
||||
// <div class="container mx-auto" >
|
||||
<Header items />
|
||||
// </div>
|
||||
@ -33,7 +33,7 @@ pub fn Header(#[prop(into)] items: Signal<HeaderItems>) -> impl IntoView {
|
||||
let right = move || items.read().right.clone();
|
||||
|
||||
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
|
||||
<div class="justify-self-start">
|
||||
<Items items=Signal::derive(left) />
|
||||
|
228
crates/ascend/src/components/icons.rs
Normal file
228
crates/ascend/src/components/icons.rs
Normal file
@ -0,0 +1,228 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Copy, Debug, Clone)]
|
||||
pub enum Icon {
|
||||
BoltSolid,
|
||||
BoltSlashSolid,
|
||||
WrenchSolid,
|
||||
ForwardSolid,
|
||||
Check,
|
||||
HeartOutline,
|
||||
ArrowPath,
|
||||
PaperAirplaneSolid,
|
||||
NoSymbol,
|
||||
Trophy,
|
||||
ArrowTrendingUp,
|
||||
}
|
||||
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::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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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>
|
||||
}
|
||||
}
|
46
crates/ascend/src/components/outlined_box.rs
Normal file
46
crates/ascend/src/components/outlined_box.rs
Normal file
@ -0,0 +1,46 @@
|
||||
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-gradient-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-pink-900",
|
||||
Gradient::CyanBlue => "bg-cyan-800",
|
||||
Gradient::TealLime => "bg-teal-700",
|
||||
Gradient::PurplePink => "bg-purple-900",
|
||||
Gradient::PurpleBlue => "bg-purple-900",
|
||||
};
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
@ -31,7 +31,7 @@ pub fn Problem(
|
||||
let grid_classes = move || format!("grid grid-rows-{} grid-cols-{} gap-3", dim.get().rows, dim.get().cols);
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-[auto,1fr] gap-8">
|
||||
<div class="grid gap-8 grid-cols-[auto,1fr]">
|
||||
<div class=move || { grid_classes }>{holds}</div>
|
||||
</div>
|
||||
}
|
||||
|
@ -3,17 +3,17 @@ use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
|
||||
pub fn ProblemInfo(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
|
||||
tracing::trace!("Enter problem info");
|
||||
|
||||
let name = problem.name;
|
||||
let set_by = problem.set_by;
|
||||
let method = problem.method;
|
||||
let name = Signal::derive(move || problem.read().name.clone());
|
||||
let set_by = Signal::derive(move || problem.read().set_by.clone());
|
||||
let method = Signal::derive(move || problem.read().method.to_string());
|
||||
|
||||
view! {
|
||||
<div class="grid grid-rows-none grid-cols-[auto,1fr] gap-0.5">
|
||||
<div class="grid grid-rows-none gap-y-1 gap-x-0.5 grid-cols-[auto,1fr]">
|
||||
<NameValue name="Name:" value=name />
|
||||
<NameValue name="Method:" value=method.to_string() />
|
||||
<NameValue name="Method:" value=method />
|
||||
<NameValue name="Set By:" value=set_by />
|
||||
</div>
|
||||
}
|
||||
@ -21,9 +21,9 @@ pub fn ProblemInfo(problem: models::Problem) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn NameValue(#[prop(into)] name: String, #[prop(into)] value: String) -> impl IntoView {
|
||||
fn NameValue(#[prop(into)] name: Signal<String>, #[prop(into)] value: Signal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<p class="text-orange-300 mr-4 text-right">{name}</p>
|
||||
<p class="text-white font-semibold">{value}</p>
|
||||
<p class="text-sm font-light mr-4 text-right text-orange-300">{name.get()}</p>
|
||||
<p class="text-white">{value.get()}</p>
|
||||
}
|
||||
}
|
||||
|
40
crates/ascend/src/gradient.rs
Normal file
40
crates/ascend/src/gradient.rs
Normal file
@ -0,0 +1,40 @@
|
||||
#[derive(Debug, Copy, Clone, Default)]
|
||||
pub enum Gradient {
|
||||
#[default]
|
||||
PurpleBlue,
|
||||
PinkOrange,
|
||||
CyanBlue,
|
||||
TealLime,
|
||||
PurplePink,
|
||||
}
|
||||
impl Gradient {
|
||||
pub fn class_from(&self) -> &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",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_to(&self) -> &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",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_text(&self) -> &str {
|
||||
match self {
|
||||
Gradient::PinkOrange => "text-pink-500",
|
||||
Gradient::CyanBlue => "text-cyan-500",
|
||||
Gradient::TealLime => "text-teal-300",
|
||||
Gradient::PurplePink => "text-purple-500",
|
||||
Gradient::PurpleBlue => "text-purple-600",
|
||||
}
|
||||
}
|
||||
}
|
@ -6,55 +6,26 @@ pub mod pages {
|
||||
pub mod wall;
|
||||
}
|
||||
pub mod components {
|
||||
pub use attempt::Attempt;
|
||||
pub use button::Button;
|
||||
pub use header::StyledHeader;
|
||||
pub use problem::Problem;
|
||||
pub use problem_info::ProblemInfo;
|
||||
|
||||
pub mod attempt;
|
||||
pub mod button;
|
||||
pub mod checkbox;
|
||||
pub mod header;
|
||||
pub mod icons;
|
||||
pub mod outlined_box;
|
||||
pub mod problem;
|
||||
pub mod problem_info;
|
||||
}
|
||||
pub mod resources {
|
||||
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;
|
||||
|
||||
type RonResource<T> = Resource<Result<T, ServerFnError>, Ron>;
|
||||
pub mod gradient;
|
||||
|
||||
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(wall_uid).await.map(RonEncoded::into_inner) },
|
||||
false,
|
||||
)
|
||||
}
|
||||
pub mod resources;
|
||||
|
||||
pub fn problem_by_uid(wall_uid: Signal<models::WallUid>, problem_uid: Signal<models::ProblemUid>) -> RonResource<models::Problem> {
|
||||
Resource::new_with_options(
|
||||
move || (wall_uid.get(), problem_uid.get()),
|
||||
move |(wall_uid, problem_uid)| async move {
|
||||
crate::server_functions::get_problem(wall_uid, problem_uid)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn problems_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<Vec<models::Problem>> {
|
||||
Resource::new_with_options(
|
||||
move || wall_uid.get(),
|
||||
move |wall_uid| async move { crate::server_functions::get_problems_for_wall(wall_uid).await.map(RonEncoded::into_inner) },
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
pub mod codec;
|
||||
pub mod models;
|
||||
pub mod server_functions;
|
||||
|
@ -14,6 +14,61 @@ pub use v2::Root;
|
||||
pub use v2::Wall;
|
||||
pub use v2::WallDimensions;
|
||||
pub use v2::WallUid;
|
||||
pub use v3::Attempt;
|
||||
pub use v3::UserInteraction;
|
||||
|
||||
pub mod v3 {
|
||||
use super::v2;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Registers user interaction with a problem
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct UserInteraction {
|
||||
pub wall_uid: v2::WallUid,
|
||||
pub problem_uid: v2::ProblemUid,
|
||||
|
||||
/// Dates on which this problem was attempted, and how it went
|
||||
pub attempted_on: BTreeMap<chrono::DateTime<chrono::Utc>, Attempt>,
|
||||
|
||||
/// Is among favorite problems
|
||||
pub is_favorite: bool,
|
||||
|
||||
/// Added to personal challenges
|
||||
pub is_saved: bool,
|
||||
}
|
||||
impl UserInteraction {
|
||||
pub fn new(wall_uid: v2::WallUid, problem_uid: v2::ProblemUid) -> Self {
|
||||
Self {
|
||||
wall_uid,
|
||||
problem_uid,
|
||||
is_favorite: false,
|
||||
attempted_on: BTreeMap::new(),
|
||||
is_saved: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn best_attempt(&self) -> Option<(chrono::DateTime<chrono::Utc>, Attempt)> {
|
||||
self.attempted_on
|
||||
.iter()
|
||||
.max_by_key(|(_date, attempt)| *attempt)
|
||||
.map(|(date, attempt)| (*date, *attempt))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Copy)]
|
||||
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;
|
||||
|
@ -49,7 +49,7 @@ pub fn EditWall() -> impl IntoView {
|
||||
};
|
||||
|
||||
leptos::view! {
|
||||
<div class="min-w-screen min-h-screen bg-slate-900">
|
||||
<div class="min-h-screen min-w-screen bg-slate-900">
|
||||
<StyledHeader items=Signal::derive(header_items) />
|
||||
|
||||
<div class="container mx-auto mt-2">
|
||||
@ -105,7 +105,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
let upload = Action::from(ServerAction::<SetImage>::new());
|
||||
let upload = ServerAction::<SetImage>::new();
|
||||
|
||||
let hold = Signal::derive(move || {
|
||||
let refreshed = upload.value().get().map(Result::unwrap);
|
||||
@ -155,7 +155,7 @@ fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
|
||||
|
||||
view! {
|
||||
<button on:click=open_camera>
|
||||
<div class="bg-indigo-100 aspect-square rounded">{img}</div>
|
||||
<div class="bg-indigo-100 rounded aspect-square">{img}</div>
|
||||
</button>
|
||||
|
||||
<input
|
||||
|
@ -30,10 +30,10 @@ pub fn Routes() -> impl IntoView {
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
let problems = crate::resources::problems_for_wall(wall_uid);
|
||||
|
||||
let header_items = HeaderItems {
|
||||
let header_items = move || HeaderItems {
|
||||
left: vec![HeaderItem {
|
||||
text: "← Ascend".to_string(),
|
||||
link: Some("/".to_string()),
|
||||
link: Some(format!("/wall/{}", wall_uid.get())),
|
||||
}],
|
||||
middle: vec![HeaderItem {
|
||||
text: "Routes".to_string(),
|
||||
@ -63,7 +63,10 @@ pub fn Routes() -> impl IntoView {
|
||||
each=problems_sample
|
||||
key=|problem| problem.uid
|
||||
children=move |problem: models::Problem| {
|
||||
view! { <Problem dim=wall_dimensions problem /> <hr class="h-px my-8 border-0 bg-gray-700" />}
|
||||
view! {
|
||||
<Problem dim=wall_dimensions problem />
|
||||
<hr class="my-8 h-px bg-gray-700 border-0" />
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@ -75,8 +78,8 @@ pub fn Routes() -> impl IntoView {
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="min-w-screen min-h-screen bg-neutral-950">
|
||||
<StyledHeader items=header_items />
|
||||
<div class="min-h-screen min-w-screen bg-neutral-950">
|
||||
<StyledHeader items=Signal::derive(header_items) />
|
||||
|
||||
<div class="container mx-auto mt-6">
|
||||
<Suspense fallback=|| view! { <p>"loading"</p> }>{suspend}</Suspense>
|
||||
@ -91,7 +94,7 @@ fn Problem(#[prop(into)] dim: Signal<models::WallDimensions>, #[prop(into)] prob
|
||||
view! {
|
||||
<div class="flex items-start">
|
||||
<div class="flex-none">
|
||||
<components::Problem dim problem />
|
||||
<components::Problem dim problem />
|
||||
</div>
|
||||
<components::ProblemInfo problem=problem.get() />
|
||||
</div>
|
||||
|
@ -20,12 +20,11 @@ pub fn Settings() -> impl IntoView {
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="min-w-screen min-h-screen bg-neutral-950">
|
||||
<div class="min-h-screen min-w-screen bg-neutral-950">
|
||||
<StyledHeader items=header_items />
|
||||
|
||||
<div class="container mx-auto mt-2">
|
||||
// {move || view! { <Import wall_uid=wall_uid.get() /> }}
|
||||
</div>
|
||||
// {move || view! { <Import wall_uid=wall_uid.get() /> }}
|
||||
<div class="container mx-auto mt-2" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@ -33,7 +32,7 @@ pub fn Settings() -> impl IntoView {
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Import(wall_uid: WallUid) -> impl IntoView {
|
||||
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
|
||||
let import_from_mini_moonboard = ServerAction::<ImportFromMiniMoonboard>::new();
|
||||
|
||||
let onclick = move |_mouse_event| {
|
||||
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
|
||||
@ -45,7 +44,7 @@ fn Import(wall_uid: WallUid) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[server(name = ImportFromMiniMoonboard)]
|
||||
#[server]
|
||||
#[tracing::instrument]
|
||||
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
|
||||
use crate::server::config::Config;
|
||||
|
@ -1,14 +1,51 @@
|
||||
// +--------------- Filter ----------- ↓ -+
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// | |
|
||||
// +--------------------------------------+
|
||||
|
||||
// +---------------------------+
|
||||
// | Next Problem |
|
||||
// +---------------------------+
|
||||
|
||||
// +--------------- Problem --------------+
|
||||
// | Name: ... |
|
||||
// | Method: ... |
|
||||
// | Set by: ... |
|
||||
// | |
|
||||
// | | Flash | Top | Attempt | |
|
||||
// | |
|
||||
// +--------------------------------------+
|
||||
|
||||
// +---------+ +---------+ +---------+
|
||||
// | Flash | | Send | | Attempt |
|
||||
// +---------+ +---------+ +---------+
|
||||
|
||||
// +---------- <Latest attempt> ----------+
|
||||
// | Today: <Attempt> |
|
||||
// | 14 days ago: <Attempt> |
|
||||
// +--------------------------------------+
|
||||
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::components::ProblemInfo;
|
||||
use crate::components::attempt::Attempt;
|
||||
use crate::components::button::Button;
|
||||
use crate::components::header::HeaderItem;
|
||||
use crate::components::header::HeaderItems;
|
||||
use crate::components::header::StyledHeader;
|
||||
use crate::components::icons::Icon;
|
||||
use crate::gradient::Gradient;
|
||||
use crate::models;
|
||||
use crate::models::HoldRole;
|
||||
use crate::server_functions;
|
||||
use leptos::Params;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::params::Params;
|
||||
use web_sys::MouseEvent;
|
||||
|
||||
#[derive(Params, PartialEq, Clone)]
|
||||
struct RouteParams {
|
||||
@ -22,8 +59,6 @@ pub fn Wall() -> impl IntoView {
|
||||
|
||||
let route_params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
|
||||
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
|
||||
|
||||
let wall_uid = Signal::derive(move || {
|
||||
route_params
|
||||
.get()
|
||||
@ -34,49 +69,6 @@ pub fn Wall() -> impl IntoView {
|
||||
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
|
||||
let problem_action = Action::new(move |&(wall_uid, problem_uid): &(models::WallUid, models::ProblemUid)| async move {
|
||||
tracing::info!("fetching");
|
||||
crate::server_functions::get_problem(wall_uid, problem_uid)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
});
|
||||
let problem_signal = Signal::derive(move || {
|
||||
let v = problem_action.value().read_only().get();
|
||||
v.and_then(Result::ok)
|
||||
});
|
||||
|
||||
let fn_next_problem = move |wall: &models::Wall| {
|
||||
set_problem_uid.set(wall.random_problem());
|
||||
};
|
||||
|
||||
// Set a problem when wall is set (loaded)
|
||||
Effect::new(move |_prev_value| {
|
||||
problem_action.value().write_only().set(None);
|
||||
|
||||
match &*wall.read() {
|
||||
Some(Ok(wall)) => {
|
||||
if problem_uid.get().is_none() {
|
||||
tracing::debug!("Setting next problem");
|
||||
fn_next_problem(wall);
|
||||
}
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
tracing::error!("Error getting wall: {err}");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
// On change of problem UID, dispatch an action to fetch the problem
|
||||
Effect::new(move |_prev_value| match problem_uid.get() {
|
||||
Some(problem_uid) => {
|
||||
problem_action.dispatch((wall_uid.get(), problem_uid));
|
||||
}
|
||||
None => {
|
||||
problem_action.value().write_only().set(None);
|
||||
}
|
||||
});
|
||||
|
||||
let header_items = move || HeaderItems {
|
||||
left: vec![],
|
||||
middle: vec![HeaderItem {
|
||||
@ -96,37 +88,18 @@ pub fn Wall() -> impl IntoView {
|
||||
};
|
||||
|
||||
leptos::view! {
|
||||
<div class="min-w-screen min-h-screen bg-neutral-950">
|
||||
<div class="min-h-screen min-w-screen bg-neutral-950">
|
||||
<StyledHeader items=Signal::derive(header_items) />
|
||||
|
||||
<div class="m-2">
|
||||
<Suspense fallback=move || {
|
||||
view! { <p>"Loading..."</p> }
|
||||
}>
|
||||
<Transition fallback=|| ()>
|
||||
{move || Suspend::new(async move {
|
||||
tracing::info!("executing Suspend future");
|
||||
tracing::info!("executing main suspend");
|
||||
let wall = wall.await?;
|
||||
let v = view! {
|
||||
<div class="grid grid-cols-1 md:grid-cols-[auto,1fr] gap-8">
|
||||
<div>
|
||||
<Grid wall=wall.clone() problem=problem_signal />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onclick=move |_| fn_next_problem(&wall)
|
||||
text="➤ Next problem"
|
||||
/>
|
||||
|
||||
<div class="m-4"/>
|
||||
|
||||
{move || problem_signal.get().map(|problem| view! { <ProblemInfo problem /> })}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
let v = view! { <WithWall wall /> };
|
||||
Ok::<_, ServerFnError>(v)
|
||||
})}
|
||||
</Suspense>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -134,12 +107,271 @@ pub fn Wall() -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Grid(wall: models::Wall, problem: Signal<Option<models::Problem>>) -> impl IntoView {
|
||||
fn WithProblem(#[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
|
||||
tracing::trace!("Enter");
|
||||
|
||||
view! { <ProblemInfo problem /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn WithWall(#[prop(into)] wall: Signal<models::Wall>) -> impl IntoView {
|
||||
tracing::trace!("Enter");
|
||||
|
||||
let wall_uid = Signal::derive(move || wall.read().uid);
|
||||
|
||||
let (problem_uid, set_problem_uid) = leptos_router::hooks::query_signal::<models::ProblemUid>("problem");
|
||||
|
||||
let problem = crate::resources::problem_by_uid_optional(wall_uid, problem_uid.into());
|
||||
let user_interaction = crate::resources::user_interaction(wall_uid, problem_uid.into());
|
||||
|
||||
let fn_next_problem = move |wall: &models::Wall| {
|
||||
set_problem_uid.set(wall.random_problem());
|
||||
};
|
||||
|
||||
// Set a problem when wall is set (loaded)
|
||||
Effect::new(move |_prev_value| {
|
||||
if problem_uid.get().is_none() {
|
||||
tracing::debug!("Setting next problem");
|
||||
fn_next_problem(&wall.read());
|
||||
}
|
||||
});
|
||||
|
||||
// merge outer option (resource hasn't resolved yet) with inner option (there is no problem for the wall)
|
||||
let problem_signal = Signal::derive(move || problem.get().transpose().map(Option::flatten));
|
||||
|
||||
let grid = {
|
||||
let wall = wall.clone();
|
||||
view! {
|
||||
<Transition fallback=|| ()>
|
||||
{
|
||||
let wall = wall.clone();
|
||||
move || {
|
||||
let wall = wall.clone();
|
||||
Suspend::new(async move {
|
||||
let wall = wall.clone();
|
||||
tracing::info!("executing grid suspend");
|
||||
let view = view! { <Grid wall=wall.get() problem=problem_signal /> };
|
||||
Ok::<_, ServerFnError>(view)
|
||||
})
|
||||
}
|
||||
}
|
||||
</Transition>
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="inline-grid grid-cols-1 gap-8 md:grid-cols-[auto,1fr]">
|
||||
<div>{grid}</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<Section title="Filter">{}</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="self-center">
|
||||
<Button
|
||||
icon=Icon::ArrowPath
|
||||
text="Next problem"
|
||||
onclick=move |_| fn_next_problem(&wall.read())
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section title="Problem">
|
||||
<Transition fallback=|| ()>
|
||||
{move || Suspend::new(async move {
|
||||
tracing::info!("executing problem suspend");
|
||||
let problem = problem.await?;
|
||||
let view = problem
|
||||
.map(|problem| {
|
||||
view! { <WithProblem problem /> }
|
||||
});
|
||||
Ok::<_, ServerFnError>(view)
|
||||
})}
|
||||
</Transition>
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Transition fallback=move || ()>
|
||||
{move || {
|
||||
Suspend::new(async move {
|
||||
tracing::debug!("getting user interaction");
|
||||
let user_interaction: Option<_> = user_interaction.await?;
|
||||
tracing::debug!("got user interaction");
|
||||
let Some(problem_uid) = problem_uid.get() else {
|
||||
return Ok(view! {}.into_any());
|
||||
};
|
||||
let view = view! {
|
||||
<WithUserInteraction wall_uid problem_uid user_interaction />
|
||||
}
|
||||
.into_any();
|
||||
Ok::<_, ServerFnError>(view)
|
||||
})
|
||||
}}
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn WithUserInteraction(
|
||||
#[prop(into)] wall_uid: Signal<models::WallUid>,
|
||||
#[prop(into)] problem_uid: Signal<models::ProblemUid>,
|
||||
#[prop(into)] user_interaction: Signal<Option<models::UserInteraction>>,
|
||||
) -> impl IntoView {
|
||||
tracing::debug!("Enter WithUserInteraction");
|
||||
|
||||
let user_interaction_rw = RwSignal::new(None);
|
||||
Effect::new(move || {
|
||||
let i = user_interaction.get();
|
||||
tracing::info!("setting user interaction to parent user interaction value: {i:?}");
|
||||
user_interaction_rw.set(i);
|
||||
});
|
||||
|
||||
let submit_attempt = ServerAction::<RonEncoded<server_functions::UpsertTodaysAttempt>>::new();
|
||||
let submit_attempt_value = submit_attempt.value();
|
||||
Effect::new(move || {
|
||||
tracing::info!("flaf");
|
||||
if let Some(Ok(v)) = submit_attempt_value.get() {
|
||||
tracing::info!("setting user interaction to action return value: {v:?}");
|
||||
user_interaction_rw.set(Some(v.into_inner()));
|
||||
}
|
||||
});
|
||||
|
||||
let latest_attempt = move || -> Option<_> {
|
||||
let i = user_interaction_rw.read();
|
||||
let i = (*i).as_ref();
|
||||
let i = i?;
|
||||
i.attempted_on.last_key_value().map(|(date, attempt)| (date.clone(), attempt.clone()))
|
||||
};
|
||||
|
||||
let todays_attempt = move || -> Option<_> {
|
||||
match latest_attempt() {
|
||||
Some((datetime, attempt)) => {
|
||||
let today_local_naive = chrono::Local::now().date_naive();
|
||||
let datetime_local_naive = datetime.with_timezone(&chrono::Local).date_naive();
|
||||
(datetime_local_naive == today_local_naive).then_some(attempt)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
let ui_is_flash = RwSignal::new(false);
|
||||
let ui_is_send = RwSignal::new(false);
|
||||
let ui_is_attempt = RwSignal::new(false);
|
||||
let ui_is_favorite = RwSignal::new(false);
|
||||
|
||||
Effect::new(move || {
|
||||
let attempt = todays_attempt();
|
||||
ui_is_flash.set(matches!(attempt, Some(models::Attempt::Flash)));
|
||||
ui_is_send.set(matches!(attempt, Some(models::Attempt::Send)));
|
||||
ui_is_attempt.set(matches!(attempt, Some(models::Attempt::Attempt)));
|
||||
});
|
||||
|
||||
let onclick_flash = move |_| {
|
||||
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
|
||||
wall_uid: wall_uid.get(),
|
||||
problem_uid: problem_uid.get(),
|
||||
attempt: models::Attempt::Flash,
|
||||
}));
|
||||
};
|
||||
let onclick_send = move |_| {
|
||||
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
|
||||
wall_uid: wall_uid.get(),
|
||||
problem_uid: problem_uid.get(),
|
||||
attempt: models::Attempt::Send,
|
||||
}));
|
||||
};
|
||||
let onclick_attempt = move |_| {
|
||||
submit_attempt.dispatch(RonEncoded(server_functions::UpsertTodaysAttempt {
|
||||
wall_uid: wall_uid.get(),
|
||||
problem_uid: problem_uid.get(),
|
||||
attempt: models::Attempt::Attempt,
|
||||
}));
|
||||
};
|
||||
|
||||
// TODO: loop over attempts in user_interaction
|
||||
let v = move || latest_attempt().map(|(date, attempt)| view! { <Attempt date attempt /> });
|
||||
|
||||
let placeholder = move || {
|
||||
latest_attempt().is_none().then(|| {
|
||||
let today = chrono::Utc::now();
|
||||
view! { <Attempt date=today attempt=None /> }
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<AttemptRadio
|
||||
flash=ui_is_flash
|
||||
send=ui_is_send
|
||||
attempt=ui_is_attempt
|
||||
onclick_flash
|
||||
onclick_send
|
||||
onclick_attempt
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Section title="History">{placeholder} {v}</Section>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn AttemptRadio(
|
||||
#[prop(into)] flash: Signal<bool>,
|
||||
#[prop(into)] send: Signal<bool>,
|
||||
#[prop(into)] attempt: Signal<bool>,
|
||||
onclick_flash: impl FnMut(MouseEvent) + 'static,
|
||||
onclick_send: impl FnMut(MouseEvent) + 'static,
|
||||
onclick_attempt: impl FnMut(MouseEvent) + 'static,
|
||||
) -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
view! {
|
||||
<div class="gap-2 flex flex-row justify-evenly md:flex-col 2xl:flex-row">
|
||||
<Button
|
||||
onclick=onclick_flash
|
||||
text="Flash"
|
||||
icon=Icon::BoltSolid
|
||||
color=Gradient::CyanBlue
|
||||
highlight=Signal::derive(move || { flash.get() })
|
||||
/>
|
||||
|
||||
<Button
|
||||
onclick=onclick_send
|
||||
text="Send"
|
||||
icon=Icon::Trophy
|
||||
color=Gradient::TealLime
|
||||
highlight=Signal::derive(move || { send.get() })
|
||||
/>
|
||||
|
||||
<Button
|
||||
onclick=onclick_attempt
|
||||
text="Attempt"
|
||||
icon=Icon::ArrowTrendingUp
|
||||
color=Gradient::PinkOrange
|
||||
highlight=Signal::derive(move || { attempt.get() })
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Grid(wall: models::Wall, #[prop(into)] problem: Signal<Result<Option<models::Problem>, ServerFnError>>) -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
let mut cells = vec![];
|
||||
for (&hold_position, hold) in &wall.holds {
|
||||
let role = move || problem.get().and_then(|p| p.holds.get(&hold_position).copied());
|
||||
let role = move || problem.get().map(|o| o.and_then(|p| p.holds.get(&hold_position).copied()));
|
||||
let role = Signal::derive(role);
|
||||
let cell = view! { <Hold role hold=hold.clone() /> };
|
||||
cells.push(cell);
|
||||
@ -158,30 +390,51 @@ fn Grid(wall: models::Wall, problem: Signal<Option<models::Problem>>) -> impl In
|
||||
// TODO: refactor this to use the Problem component
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Hold(hold: models::Hold, role: Signal<Option<HoldRole>>) -> impl IntoView {
|
||||
fn Hold(hold: models::Hold, role: Signal<Result<Option<HoldRole>, ServerFnError>>) -> impl IntoView {
|
||||
tracing::trace!("Enter");
|
||||
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"),
|
||||
// None => None,
|
||||
|
||||
move || {
|
||||
let role = role.get()?;
|
||||
|
||||
let class = {
|
||||
let role_classes = match role {
|
||||
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
|
||||
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
|
||||
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
|
||||
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
|
||||
None => Some("brightness-50"),
|
||||
};
|
||||
let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string();
|
||||
if let Some(c) = role_classes {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
}
|
||||
s
|
||||
};
|
||||
let mut s = "bg-sky-100 aspect-square rounded hover:brightness-125".to_string();
|
||||
if let Some(c) = role_classes {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
let img = hold.image.map(|img| {
|
||||
let srcset = img.srcset();
|
||||
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
|
||||
});
|
||||
let img = hold.image.as_ref().map(|img| {
|
||||
let srcset = img.srcset();
|
||||
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
|
||||
});
|
||||
|
||||
tracing::trace!("view");
|
||||
view! { <div class=class>{img}</div> }
|
||||
let view = view! { <div class=class>{img}</div> };
|
||||
Ok::<_, ServerFnError>(view)
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Separator() -> impl IntoView {
|
||||
view! { <div class="m-2 sm:m-3 md:m-4 h-4" /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Section(children: Children, #[prop(into)] title: MaybeProp<String>) -> impl IntoView {
|
||||
view! {
|
||||
<div class="bg-neutral-900 px-5 pt-3 pb-8 rounded-lg">
|
||||
<div class="py-3 text-lg text-center text-orange-400 border-t border-orange-400">
|
||||
{move || title.get()}
|
||||
</div>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
63
crates/ascend/src/resources.rs
Normal file
63
crates/ascend/src/resources.rs
Normal file
@ -0,0 +1,63 @@
|
||||
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;
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
/// Version of [problem_by_uid] that short circuits if the input problem_uid signal is None.
|
||||
pub fn problem_by_uid_optional(
|
||||
wall_uid: Signal<models::WallUid>,
|
||||
problem_uid: Signal<Option<models::ProblemUid>>,
|
||||
) -> RonResource<Option<models::Problem>> {
|
||||
Resource::new_with_options(
|
||||
move || (wall_uid.get(), problem_uid.get()),
|
||||
move |(wall_uid, problem_uid)| async move {
|
||||
let Some(problem_uid) = problem_uid else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::server_functions::get_problem_by_uid(wall_uid, problem_uid)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
.map(Some)
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn problems_for_wall(wall_uid: Signal<models::WallUid>) -> RonResource<Vec<models::Problem>> {
|
||||
Resource::new_with_options(
|
||||
move || wall_uid.get(),
|
||||
move |wall_uid| async move { crate::server_functions::get_problems_for_wall(wall_uid).await.map(RonEncoded::into_inner) },
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn user_interaction(
|
||||
wall_uid: Signal<models::WallUid>,
|
||||
problem_uid: Signal<Option<models::ProblemUid>>,
|
||||
) -> RonResource<Option<models::UserInteraction>> {
|
||||
Resource::new_with_options(
|
||||
move || (wall_uid.get(), problem_uid.get()),
|
||||
move |(wall_uid, problem_uid)| async move {
|
||||
let Some(problem_uid) = problem_uid else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::server_functions::get_user_interaction(wall_uid, problem_uid)
|
||||
.await
|
||||
.map(RonEncoded::into_inner)
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
@ -56,6 +56,16 @@ impl Database {
|
||||
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)]
|
||||
@ -75,7 +85,7 @@ impl DatabaseOperationError {
|
||||
}
|
||||
|
||||
pub const TABLE_VERSION: TableDefinition<(), Bincode<Version>> = TableDefinition::new("version");
|
||||
#[derive(Serialize, Deserialize, Debug, derive_more::Display)]
|
||||
#[derive(Serialize, Deserialize, Debug, derive_more::Display, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[display("{version}")]
|
||||
pub struct Version {
|
||||
pub version: u64,
|
||||
@ -86,6 +96,7 @@ impl Version {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: implement test
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperationError> {
|
||||
db.write(|txn| {
|
||||
@ -116,6 +127,13 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat
|
||||
let table = txn.open_table(current::TABLE_PROBLEMS)?;
|
||||
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(())
|
||||
@ -126,7 +144,26 @@ pub async fn init_at_current_version(db: &Database) -> Result<(), DatabaseOperat
|
||||
}
|
||||
|
||||
use crate::models;
|
||||
pub use v2 as current;
|
||||
pub mod current {
|
||||
use super::v2;
|
||||
use super::v3;
|
||||
pub use v2::TABLE_PROBLEMS;
|
||||
pub use v2::TABLE_ROOT;
|
||||
pub use v2::TABLE_WALLS;
|
||||
pub use v3::TABLE_USER;
|
||||
pub use v3::VERSION;
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -1,6 +1,34 @@
|
||||
use super::db::Database;
|
||||
use super::db::DatabaseOperationError;
|
||||
use super::db::{self};
|
||||
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn run_migrations(_db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if is_at_version(db, 2).await? {
|
||||
migrate_to_v3(db).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,6 +1,11 @@
|
||||
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;
|
||||
|
||||
@ -14,7 +19,7 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
|
||||
use crate::server::db::Database;
|
||||
use leptos::prelude::expect_context;
|
||||
use redb::ReadableTable;
|
||||
tracing::debug!("Enter");
|
||||
tracing::trace!("Enter");
|
||||
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
@ -26,8 +31,6 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Exit");
|
||||
|
||||
Ok(RonEncoded::new(walls))
|
||||
}
|
||||
|
||||
@ -37,11 +40,11 @@ pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError>
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub(crate) async fn get_wall(wall_uid: models::WallUid) -> Result<RonEncoded<models::Wall>, ServerFnError> {
|
||||
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;
|
||||
tracing::debug!("Enter");
|
||||
tracing::trace!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
enum Error {
|
||||
@ -63,8 +66,6 @@ pub(crate) async fn get_wall(wall_uid: models::WallUid) -> Result<RonEncoded<mod
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::debug!("ok");
|
||||
|
||||
Ok(RonEncoded::new(wall))
|
||||
}
|
||||
|
||||
@ -78,7 +79,7 @@ pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<R
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::debug!("Enter");
|
||||
tracing::trace!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
enum Error {
|
||||
@ -122,8 +123,6 @@ pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<R
|
||||
|
||||
let problems = inner(wall_uid).await.map_err(error_reporter::Report::new).map_err(ServerFnError::new)?;
|
||||
|
||||
tracing::debug!("ok");
|
||||
|
||||
Ok(RonEncoded::new(problems))
|
||||
}
|
||||
|
||||
@ -132,12 +131,59 @@ pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<R
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub(crate) async fn get_problem(wall_uid: models::WallUid, problem_uid: models::ProblemUid) -> Result<RonEncoded<models::Problem>, ServerFnError> {
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn get_user_interaction(
|
||||
wall_uid: models::WallUid,
|
||||
problem_uid: models::ProblemUid,
|
||||
) -> Result<RonEncoded<Option<models::UserInteraction>>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::debug!("Enter");
|
||||
tracing::trace!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
enum Error {
|
||||
#[display("Wall not found: {_0:?}")]
|
||||
WallNotFound(#[error(not(source))] models::WallUid),
|
||||
|
||||
DatabaseOperation(DatabaseOperationError),
|
||||
}
|
||||
|
||||
async fn inner(wall_uid: models::WallUid, problem_uid: models::ProblemUid) -> Result<Option<UserInteraction>, Error> {
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let user_interaction = db
|
||||
.read(|txn| {
|
||||
let user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
|
||||
let user_interaction = user_table.get((wall_uid, problem_uid))?.map(|guard| guard.value());
|
||||
Ok(user_interaction)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(user_interaction)
|
||||
}
|
||||
|
||||
let user_interaction = inner(wall_uid, problem_uid)
|
||||
.await
|
||||
.map_err(error_reporter::Report::new)
|
||||
.map_err(ServerFnError::new)?;
|
||||
Ok(RonEncoded::new(user_interaction))
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub(crate) async fn get_problem_by_uid(
|
||||
wall_uid: models::WallUid,
|
||||
problem_uid: models::ProblemUid,
|
||||
) -> Result<RonEncoded<models::Problem>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::trace!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
enum Error {
|
||||
@ -160,3 +206,74 @@ pub(crate) async fn get_problem(wall_uid: models::WallUid, problem_uid: models::
|
||||
|
||||
Ok(RonEncoded::new(problem))
|
||||
}
|
||||
|
||||
/// 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_uid: models::ProblemUid,
|
||||
attempt: models::Attempt,
|
||||
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
|
||||
tracing::trace!("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_uid: models::ProblemUid, attempt: 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_uid);
|
||||
|
||||
// Pop or default
|
||||
let mut user_interaction = user_table
|
||||
.remove(key)?
|
||||
.map(|guard| guard.value())
|
||||
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem_uid));
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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_uid, attempt)
|
||||
.await
|
||||
.map_err(error_reporter::Report::new)
|
||||
.map_err(ServerFnError::new)
|
||||
.map(RonEncoded::new)
|
||||
}
|
||||
|
@ -6,6 +6,9 @@
|
||||
},
|
||||
// https://tailwindcss.com/docs/content-configuration#using-regular-expressions
|
||||
safelist: [
|
||||
{
|
||||
pattern: /bg-transparent/,
|
||||
},
|
||||
{
|
||||
pattern: /grid-cols-.+/,
|
||||
},
|
||||
|
18
flake.lock
generated
18
flake.lock
generated
@ -10,11 +10,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1740139153,
|
||||
"narHash": "sha256-Xa1wCQBbsFHCaXgVBjtraZcWywuXBN+YhdqGle4nLVc=",
|
||||
"lastModified": 1740523129,
|
||||
"narHash": "sha256-q/k/T9Hf+aCo8/xQnqyw+E7dYx8Nq1u7KQ2ylORcP+M=",
|
||||
"owner": "plul",
|
||||
"repo": "basecamp",
|
||||
"rev": "0a29da733dc2f7b386dd3667b63a51c55238fbfd",
|
||||
"rev": "0882906c106ab0bf193b3417c845c5accbec2419",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -25,11 +25,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1740396192,
|
||||
"narHash": "sha256-ATMHHrg3sG1KgpQA5x8I+zcYpp5Sf17FaFj/fN+8OoQ=",
|
||||
"lastModified": 1740547748,
|
||||
"narHash": "sha256-Ly2fBL1LscV+KyCqPRufUBuiw+zmWrlJzpWOWbahplg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d9b69c3ec2a2e2e971c534065bdd53374bd68b97",
|
||||
"rev": "3a05eebede89661660945da1f151959900903b6a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -53,11 +53,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1740450604,
|
||||
"narHash": "sha256-T/lqASXzCzp5lJISCUw+qwfRmImVUnhKgAhn8ymRClI=",
|
||||
"lastModified": 1740623427,
|
||||
"narHash": "sha256-3SdPQrZoa4odlScFDUHd4CUPQ/R1gtH4Mq9u8CBiK8M=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "5961ca311c85c31fc5f51925b4356899eed36221",
|
||||
"rev": "d342e8b5fd88421ff982f383c853f0fc78a847ab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -130,8 +130,10 @@
|
||||
basecamp.mkShell pkgs {
|
||||
rust.enable = true;
|
||||
rust.toolchain.targets = [ "wasm32-unknown-unknown" ];
|
||||
rust.toolchain.components.rust-analyzer.nightly = true;
|
||||
|
||||
packages = [
|
||||
pkgs.bacon
|
||||
pkgs.cargo-leptos
|
||||
pkgs.leptosfmt
|
||||
pkgs.dart-sass
|
||||
@ -142,7 +144,7 @@
|
||||
pkgs.binaryen
|
||||
];
|
||||
|
||||
env.RUST_LOG = "info,ascend=trace";
|
||||
env.RUST_LOG = "info,ascend=debug";
|
||||
env.MOONBOARD_PROBLEMS = "moonboard-problems";
|
||||
};
|
||||
};
|
||||
|
3
justfile
3
justfile
@ -53,3 +53,6 @@ leptos-discord:
|
||||
|
||||
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"
|
@ -1,4 +1,4 @@
|
||||
style_edition = "2024"
|
||||
edition = "2024"
|
||||
unstable_features = true
|
||||
imports_granularity = "Item"
|
||||
group_imports = "One"
|
||||
|
3
todo.md
3
todo.md
@ -9,5 +9,6 @@
|
||||
- decide on holds vs wall-edit terminology
|
||||
- clock
|
||||
- hotkeys (enter =next problem, arrow = shift left/right up/down)
|
||||
- remove brightness reduction when mousing over holds
|
||||
- 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?
|
||||
|
Loading…
x
Reference in New Issue
Block a user