feat: store attempts

This commit is contained in:
Asger Juul Brunshøj 2025-02-26 23:41:55 +01:00
parent 7118b66104
commit 2e83efcf12
30 changed files with 1252 additions and 221 deletions

View File

@ -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
View File

@ -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",

View File

@ -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" }

View File

@ -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>

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

View File

@ -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 /> };
}
}

View File

@ -0,0 +1,50 @@
use crate::components::icons;
use crate::components::outlined_box::OutlinedBox;
use crate::gradient::Gradient;
use leptos::prelude::*;
#[component]
pub fn Checkbox(checked: RwSignal<bool>, #[prop(into)] text: Signal<String>, #[prop(optional)] color: Gradient) -> impl IntoView {
let unique_id = Oco::from(format!("checkbox-{}", uuid::Uuid::new_v4()));
let checkbox_view = view! {
<div class="self-center 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>
}
}

View File

@ -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) />

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

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

View File

@ -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>
}

View File

@ -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>
}
}

View 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",
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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;

View File

@ -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>
}
}

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

View File

@ -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;

View File

@ -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 }))
}

View File

@ -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)
}

View File

@ -6,6 +6,9 @@
},
// https://tailwindcss.com/docs/content-configuration#using-regular-expressions
safelist: [
{
pattern: /bg-transparent/,
},
{
pattern: /grid-cols-.+/,
},

18
flake.lock generated
View File

@ -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": {

View File

@ -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";
};
};

View File

@ -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
View File

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

View File

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

View File

@ -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?