feat: use redb
This commit is contained in:
parent
ba56a82926
commit
a164b0628d
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
|
@ -6,5 +6,8 @@ language-servers = ["rust-analyzer", "tailwindcss-ls"]
|
||||
# procMacro = { ignored = { leptos_macro = ["server"] } }
|
||||
cargo = { features = ["ssr", "hydrate"] }
|
||||
|
||||
[language-server.rust-analyzer.config.check]
|
||||
command = "clippy"
|
||||
|
||||
[language-server.tailwindcss-ls]
|
||||
config = { userLanguages = { rust = "html", "*.rs" = "html" } }
|
||||
|
1015
Cargo.lock
generated
1015
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -9,43 +9,59 @@ publish = false
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
moonboard-parser = { workspace = true, optional = true }
|
||||
axum = { version = "0.7", optional = true }
|
||||
camino = { version = "1.1", optional = true }
|
||||
chrono = { version = "0.4.39", features = ["now", "serde"] }
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
confik = { version = "0.12", optional = true, features = ["camino"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
derive_more = { version = "1", features = [
|
||||
"display",
|
||||
"error",
|
||||
"from",
|
||||
"from_str",
|
||||
] }
|
||||
http = "1"
|
||||
image = { version = "0.25", optional = true }
|
||||
leptos = { version = "0.7.4", features = ["tracing"] }
|
||||
server_fn = { version = "0.7.4", features = ["cbor"] }
|
||||
leptos_axum = { version = "0.7", optional = true }
|
||||
leptos_meta = { version = "0.7" }
|
||||
leptos_router = { version = "0.7.0" }
|
||||
moonboard-parser = { workspace = true, optional = true }
|
||||
rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
|
||||
ron = { version = "0.8" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
server_fn = { version = "0.7.4", features = ["cbor"] }
|
||||
smart-default = "0.7.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||
tower = { version = "0.4", optional = true }
|
||||
tower-http = { version = "0.5", features = ["fs"], optional = true }
|
||||
wasm-bindgen = "=0.2.99"
|
||||
http = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
derive_more = { version = "1", features = ["display", "error", "from"] }
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
camino = { version = "1.1", optional = true }
|
||||
type-toppings = { version = "0.2.1", features = ["result"] }
|
||||
tracing = { version = "0.1" }
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
tracing-subscriber-wasm = "0.1.0"
|
||||
ron = { version = "0.8" }
|
||||
rand = { version = "0.8", optional = true }
|
||||
type-toppings = { version = "0.2.1", features = ["result", "iterator"] }
|
||||
wasm-bindgen = "=0.2.100"
|
||||
web-sys = { version = "0.3.76", features = ["File", "FileList"] }
|
||||
smart-default = "0.7.1"
|
||||
confik = { version = "0.12", optional = true, features = ["camino"] }
|
||||
xdg = { version = "2.5", optional = true }
|
||||
uuid = { version = "1.12", features = ["serde", "v4"] }
|
||||
redb = { version = "2.4", optional = true }
|
||||
bincode = { version = "1.3", optional = true }
|
||||
serde_json = { version = "1" }
|
||||
codee = { version = "0.3" }
|
||||
error_reporter = { version = "1" }
|
||||
getrandom = { version = "0.3.1" }
|
||||
|
||||
[dev-dependencies.serde_json]
|
||||
version = "1"
|
||||
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
hydrate = ["leptos/hydrate", "getrandom/wasm_js", "uuid/js"]
|
||||
ssr = [
|
||||
"dep:axum",
|
||||
"dep:redb",
|
||||
"dep:image",
|
||||
"dep:bincode",
|
||||
"dep:tokio",
|
||||
"dep:rand",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:leptos_axum",
|
||||
|
@ -1,3 +1,4 @@
|
||||
use crate::codec::ron::Ron;
|
||||
use crate::pages;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::components::*;
|
||||
@ -24,7 +25,7 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl leptos::IntoView {
|
||||
pub fn App() -> impl IntoView {
|
||||
use leptos_meta::Stylesheet;
|
||||
use leptos_meta::Title;
|
||||
|
||||
@ -39,13 +40,48 @@ pub fn App() -> impl leptos::IntoView {
|
||||
<Title text="Ascend" />
|
||||
|
||||
<Router>
|
||||
<main>
|
||||
<Routes fallback=|| "Not found">
|
||||
<Route path=path!("/") view=pages::wall::Wall />
|
||||
<Route path=path!("/wall/edit") view=pages::edit_wall::EditWall />
|
||||
<Route path=path!("/wall/routes") view=pages::routes::Routes />
|
||||
</Routes>
|
||||
</main>
|
||||
<Routes fallback=|| "Not found">
|
||||
<Route path=path!("/") view=Home />
|
||||
<Route path=path!("/wall/:wall_uid") view=pages::wall::Wall />
|
||||
<Route path=path!("/wall/:wall_uid/edit") view=pages::edit_wall::EditWall />
|
||||
<Route path=path!("/wall/:wall_uid/routes") view=pages::routes::Routes />
|
||||
</Routes>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
// TODO: show cards with walls, and a "new wall" button
|
||||
|
||||
tracing::debug!("Rendering home component");
|
||||
|
||||
// dbg!(leptos::prelude::Owner::current().map(|o| o.ancestry()));
|
||||
|
||||
let wall_uid = OnceResource::<_, Ron>::new_with_options(
|
||||
async move {
|
||||
// dbg!(leptos::prelude::Owner::current().map(|o| o.ancestry()));
|
||||
let walls = crate::server_functions::get_walls()
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
dbg!(e);
|
||||
})
|
||||
.expect("failed to get walls")
|
||||
.into_inner();
|
||||
walls.first().map(|wall| wall.uid)
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
Effect::new(move || {
|
||||
tracing::debug!("running effect");
|
||||
|
||||
if let Some(wall_uid) = wall_uid.get().flatten() {
|
||||
tracing::debug!("navigating");
|
||||
let navigate = leptos_router::hooks::use_navigate();
|
||||
let url = format!("/wall/{}", wall_uid);
|
||||
navigate(&url, Default::default());
|
||||
tracing::debug!("navigated");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1,59 +1,129 @@
|
||||
pub mod ron {
|
||||
//! Wrap T in RonCodec<T> that when serialized, always serializes to a [ron] string.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RonCodec<T> {
|
||||
t: T,
|
||||
use codee::Decoder;
|
||||
use codee::Encoder;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use server_fn::ServerFnError;
|
||||
use server_fn::codec::Encoding;
|
||||
use server_fn::codec::FromReq;
|
||||
use server_fn::codec::FromRes;
|
||||
use server_fn::codec::IntoReq;
|
||||
use server_fn::codec::IntoRes;
|
||||
use server_fn::request::ClientReq;
|
||||
use server_fn::request::Req;
|
||||
use server_fn::response::ClientRes;
|
||||
use server_fn::response::Res;
|
||||
|
||||
pub struct Ron;
|
||||
|
||||
impl<T> Encoder<T> for Ron
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
type Encoded = String;
|
||||
type Error = ron::Error;
|
||||
|
||||
fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
|
||||
ron::to_string(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> RonCodec<T> {
|
||||
impl<T> Decoder<T> for Ron
|
||||
where
|
||||
for<'de> T: Deserialize<'de>,
|
||||
{
|
||||
type Encoded = str;
|
||||
type Error = ron::error::SpannedError;
|
||||
|
||||
fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
|
||||
ron::from_str(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoding for Ron {
|
||||
const CONTENT_TYPE: &'static str = "application/ron";
|
||||
const METHOD: http::Method = http::Method::POST;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RonEncoded<T>(pub T);
|
||||
|
||||
impl<T> RonEncoded<T> {
|
||||
pub fn into_inner(self) -> T {
|
||||
self.t
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn new(t: T) -> Self {
|
||||
Self { t }
|
||||
Self(t)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for RonCodec<T> {
|
||||
impl<T> std::ops::Deref for RonEncoded<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.t
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> serde::Serialize for RonCodec<T>
|
||||
// IntoReq
|
||||
impl<T, Request, Err> IntoReq<Ron, Request, Err> for RonEncoded<T>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
Request: ClientReq<Err>,
|
||||
T: Serialize,
|
||||
{
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
let serialized = ron::to_string(&self.t).map_err(serde::ser::Error::custom)?;
|
||||
serializer.serialize_str(&serialized)
|
||||
fn into_req(self, path: &str, accepts: &str) -> Result<Request, ServerFnError<Err>> {
|
||||
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
Request::try_new_post(path, Ron::CONTENT_TYPE, accepts, data)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T> serde::Deserialize<'de> for RonCodec<T>
|
||||
// FromReq
|
||||
impl<T, Request, Err> FromReq<Ron, Request, Err> for RonEncoded<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned + 'static,
|
||||
Request: Req<Err> + Send,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let t: T = ron::from_str(&s).map_err(serde::de::Error::custom)?;
|
||||
Ok(Self { t })
|
||||
async fn from_req(req: Request) -> Result<Self, ServerFnError<Err>> {
|
||||
let data = req.try_into_string().await?;
|
||||
Ron::decode(&data).map(RonEncoded).map_err(|e| ServerFnError::Args(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// IntoRes
|
||||
impl<CustErr, T, Response> IntoRes<Ron, Response, CustErr> for RonEncoded<T>
|
||||
where
|
||||
Response: Res<CustErr>,
|
||||
T: Serialize + Send,
|
||||
{
|
||||
async fn into_res(self) -> Result<Response, ServerFnError<CustErr>> {
|
||||
let data = Ron::encode(&self.0).map_err(|e| ServerFnError::Serialization(e.to_string()))?;
|
||||
Response::try_from_string(Ron::CONTENT_TYPE, data)
|
||||
}
|
||||
}
|
||||
|
||||
// FromRes
|
||||
impl<T, Response, Err> FromRes<Ron, Response, Err> for RonEncoded<T>
|
||||
where
|
||||
Response: ClientRes<Err> + Send,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
async fn from_res(res: Response) -> Result<Self, ServerFnError<Err>> {
|
||||
let data = res.try_into_string().await?;
|
||||
Ron::decode(&data)
|
||||
.map(RonEncoded)
|
||||
.map_err(|e| ServerFnError::Deserialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RonCodec;
|
||||
use super::Ron;
|
||||
use codee::Decoder;
|
||||
use codee::Encoder;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
@ -69,19 +139,9 @@ pub mod ron {
|
||||
name: "Test".to_string(),
|
||||
value: 42,
|
||||
};
|
||||
|
||||
// Wrap in RonCodec
|
||||
let wrapped = RonCodec::new(original.clone());
|
||||
|
||||
// Serialize
|
||||
let serialized = serde_json::to_string(&wrapped).expect("Serialization failed");
|
||||
println!("Serialized: {}", serialized);
|
||||
|
||||
// Deserialize
|
||||
let deserialized: RonCodec<TestStruct> = serde_json::from_str(&serialized).expect("Deserialization failed");
|
||||
|
||||
// Compare
|
||||
assert_eq!(deserialized.into_inner(), original);
|
||||
let enc = Ron::encode(&original).unwrap();
|
||||
let dec: TestStruct = Ron::decode(&enc).unwrap();
|
||||
assert_eq!(dec, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,14 @@ 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)] text: String, onclick: impl Fn(MouseEvent) + 'static) -> impl IntoView {
|
||||
view! {
|
||||
<button on:click=onclick type="button" class="text-black bg-orange-300 hover:bg-orange-400 focus:ring-4 focus:ring-orange-500 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none">{ text }</button>
|
||||
<button
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeaderItems {
|
||||
pub left: Vec<HeaderItem>,
|
||||
pub middle: Vec<HeaderItem>,
|
||||
pub right: Vec<HeaderItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeaderItem {
|
||||
pub text: String,
|
||||
pub link: Option<String>,
|
||||
@ -13,91 +15,46 @@ pub struct HeaderItem {
|
||||
|
||||
/// Header with background color etc.
|
||||
#[component]
|
||||
pub fn StyledHeader(items: HeaderItems) -> impl IntoView {
|
||||
let fancy = false;
|
||||
|
||||
if fancy {
|
||||
view! {
|
||||
<div class="flex">
|
||||
// Left gradient chunk
|
||||
<div class="flex-grow">
|
||||
<div class="h-2/5" style="background: #eaac53" />
|
||||
<div
|
||||
class="h-3/5"
|
||||
style="background: linear-gradient(to bottom left, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-none container mx-auto text-black" style="background: #eaac53">
|
||||
<Header items />
|
||||
</div>
|
||||
|
||||
// Right gradient chunk
|
||||
<div class="flex-grow" style="background: #eaac53" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
// Left gradient chunk
|
||||
<div class="flex-grow" />
|
||||
|
||||
<div class="flex-none container mx-auto">
|
||||
// Background color gradient
|
||||
<div
|
||||
class="h-6"
|
||||
style="background: linear-gradient(to bottom left, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
// Right gradient chunk
|
||||
<div class="flex-grow">
|
||||
<div class="h-4/5" style="background: #eaac53" />
|
||||
<div
|
||||
class="h-1/5"
|
||||
style="background: linear-gradient(to bottom right, #eaac53 49.5%, rgb(15 23 42) 50.5%)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="bg-orange-300 text-black border-b-2 border-b-orange-400">
|
||||
<div class="container mx-auto" >
|
||||
<Header items />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
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="container mx-auto" >
|
||||
<Header items />
|
||||
// </div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Function header without styling
|
||||
#[component]
|
||||
pub fn Header(items: HeaderItems) -> impl IntoView {
|
||||
let HeaderItems { left, middle, right } = items;
|
||||
pub fn Header(#[prop(into)] items: Signal<HeaderItems>) -> impl IntoView {
|
||||
let left = move || items.read().left.clone();
|
||||
let middle = move || items.read().middle.clone();
|
||||
let right = move || items.read().right.clone();
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-[1fr_3fr_1fr] text-xl font-semibold p-4">
|
||||
// Left side of header
|
||||
<div class="justify-self-start">
|
||||
<Items items=left />
|
||||
<Items items=Signal::derive(left) />
|
||||
</div>
|
||||
|
||||
// Expanding space in the middle
|
||||
<div class="justify-self-center font-semibold">
|
||||
<Items items=middle />
|
||||
<Items items=Signal::derive(middle) />
|
||||
</div>
|
||||
|
||||
// Right side of header
|
||||
<div class="justify-self-end">
|
||||
<Items items=right />
|
||||
<Items items=Signal::derive(right) />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Items(items: Vec<HeaderItem>) -> impl IntoView {
|
||||
let items = items.into_iter().map(|item| view! { <Item item /> }).collect_view();
|
||||
fn Items(#[prop(into)] items: Signal<Vec<HeaderItem>>) -> impl IntoView {
|
||||
let items = move || items.get().into_iter().map(|item| view! { <Item item /> }).collect_view();
|
||||
view! { <div class="flex gap-4">{items}</div> }
|
||||
}
|
||||
|
||||
|
51
crates/ascend/src/components/problem.rs
Normal file
51
crates/ascend/src/components/problem.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use crate::models::HoldRole;
|
||||
use crate::models::{self};
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Problem(#[prop(into)] dim: Signal<models::WallDimensions>, #[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
|
||||
let holds = move || {
|
||||
let mut holds = vec![];
|
||||
for row in 0..dim.get().rows {
|
||||
for col in 0..dim.get().cols {
|
||||
let hold_position = models::HoldPosition { row, col };
|
||||
let role = move || problem.get().holds.get(&hold_position).copied();
|
||||
let role = Signal::derive(role);
|
||||
let hold = view! { <Hold role /> };
|
||||
holds.push(hold);
|
||||
}
|
||||
}
|
||||
holds.into_iter().collect_view()
|
||||
};
|
||||
|
||||
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=move || { grid_classes }>{holds}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Hold(#[prop(into)] role: Signal<Option<HoldRole>>) -> impl IntoView {
|
||||
let class = move || {
|
||||
let role_classes = match role.get() {
|
||||
Some(HoldRole::Start) => Some("outline outline-offset-2 outline-green-500"),
|
||||
Some(HoldRole::Normal) => Some("outline outline-offset-2 outline-blue-500"),
|
||||
Some(HoldRole::Zone) => Some("outline outline-offset-2 outline-amber-500"),
|
||||
Some(HoldRole::End) => Some("outline outline-offset-2 outline-red-500"),
|
||||
None => Some("brightness-50"),
|
||||
};
|
||||
let mut s = "min-w-2 bg-sky-100 aspect-square rounded".to_string();
|
||||
if let Some(c) = role_classes {
|
||||
s.push(' ');
|
||||
s.push_str(c);
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
view! { <div class=class /> }
|
||||
}
|
@ -5,17 +5,60 @@ pub mod pages {
|
||||
pub mod wall;
|
||||
}
|
||||
pub mod components {
|
||||
pub use button::Button;
|
||||
pub use header::StyledHeader;
|
||||
pub use problem::Problem;
|
||||
|
||||
pub mod button;
|
||||
pub mod header;
|
||||
pub mod problem;
|
||||
}
|
||||
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 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 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;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
pub mod models;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
@ -24,6 +67,11 @@ pub fn hydrate() {
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::builder()
|
||||
.with_default_directive(tracing::level_filters::LevelFilter::DEBUG.into())
|
||||
.from_env_lossy(),
|
||||
)
|
||||
.with_writer(
|
||||
// To avoide trace events in the browser from showing their JS backtrace
|
||||
tracing_subscriber_wasm::MakeConsoleWriter::default().map_trace_level_to(tracing::Level::DEBUG),
|
||||
|
@ -1,71 +1,218 @@
|
||||
//! Shared models between server and client code.
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
pub use v1::HoldPosition;
|
||||
pub use v1::HoldRole;
|
||||
pub use v2::Hold;
|
||||
pub use v2::Image;
|
||||
pub use v2::ImageFilename;
|
||||
pub use v2::ImageResolution;
|
||||
pub use v2::ImageUid;
|
||||
pub use v2::Method;
|
||||
pub use v2::Problem;
|
||||
pub use v2::ProblemUid;
|
||||
pub use v2::Root;
|
||||
pub use v2::Wall;
|
||||
pub use v2::WallDimensions;
|
||||
pub use v2::WallUid;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Wall {
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
pub holds: BTreeMap<HoldPosition, Hold>,
|
||||
}
|
||||
impl Wall {
|
||||
pub fn new(rows: u64, cols: u64) -> Self {
|
||||
let mut holds = BTreeMap::new();
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let position = HoldPosition { row, col };
|
||||
let hold = Hold { position, image: None };
|
||||
holds.insert(position, hold);
|
||||
}
|
||||
pub mod v2 {
|
||||
use super::v1;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Root {
|
||||
pub walls: BTreeSet<WallUid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Wall {
|
||||
pub uid: WallUid,
|
||||
|
||||
// TODO: Replace by walldimensions
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
|
||||
pub holds: BTreeMap<v1::HoldPosition, Hold>,
|
||||
pub problems: BTreeSet<ProblemUid>,
|
||||
}
|
||||
impl Wall {
|
||||
pub fn random_problem(&self) -> Option<ProblemUid> {
|
||||
use rand::seq::IteratorRandom;
|
||||
let mut rng = rand::rng();
|
||||
self.problems.iter().choose(&mut rng).copied()
|
||||
}
|
||||
Self { rows, cols, holds }
|
||||
}
|
||||
}
|
||||
impl Default for Wall {
|
||||
fn default() -> Self {
|
||||
Self::new(12, 12)
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
|
||||
pub struct WallDimensions {
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct WallUid(pub uuid::Uuid);
|
||||
impl WallUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Problem {
|
||||
pub uid: ProblemUid,
|
||||
pub name: String,
|
||||
pub set_by: String,
|
||||
pub holds: BTreeMap<v1::HoldPosition, v1::HoldRole>,
|
||||
pub method: Method,
|
||||
pub date_added: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct ProblemUid(pub uuid::Uuid);
|
||||
impl ProblemUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Method {
|
||||
FeetFollowHands,
|
||||
Footless,
|
||||
FootlessPlusKickboard,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Hold {
|
||||
pub position: v1::HoldPosition,
|
||||
pub image: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Image {
|
||||
pub uid: ImageUid,
|
||||
pub resolutions: BTreeMap<ImageResolution, ImageFilename>,
|
||||
}
|
||||
impl Image {
|
||||
pub(crate) fn srcset(&self) -> String {
|
||||
self.resolutions
|
||||
.iter()
|
||||
.map(|(res, filename)| format!("/files/holds/{} {}w", filename.filename, res.width))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ImageResolution {
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ImageFilename {
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, derive_more::FromStr, derive_more::Display)]
|
||||
pub struct ImageUid(pub uuid::Uuid);
|
||||
impl ImageUid {
|
||||
pub fn create() -> Self {
|
||||
Self(uuid::Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)]
|
||||
pub struct HoldPosition {
|
||||
/// Starting from 0
|
||||
pub row: u64,
|
||||
pub mod v1 {
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use smart_default::SmartDefault;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Starting from 0
|
||||
pub col: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Problem {
|
||||
pub holds: BTreeMap<HoldPosition, HoldRole>,
|
||||
}
|
||||
|
||||
/// The role of a hold on a route
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum HoldRole {
|
||||
/// Start hold
|
||||
Start,
|
||||
|
||||
/// Any hold on the route without a specific role
|
||||
Normal,
|
||||
|
||||
/// Zone hold
|
||||
Zone,
|
||||
|
||||
/// End hold
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct Hold {
|
||||
pub position: HoldPosition,
|
||||
pub image: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct Image {
|
||||
pub filename: String,
|
||||
const STATE_VERSION: u64 = 1;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)]
|
||||
pub struct PersistentState {
|
||||
/// State schema version
|
||||
#[default(STATE_VERSION)]
|
||||
pub version: u64,
|
||||
|
||||
pub wall: Wall,
|
||||
pub problems: Problems,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct Problems {
|
||||
pub problems: BTreeSet<Problem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Wall {
|
||||
pub rows: u64,
|
||||
pub cols: u64,
|
||||
pub holds: BTreeMap<HoldPosition, Hold>,
|
||||
}
|
||||
impl Wall {
|
||||
pub fn new(rows: u64, cols: u64) -> Self {
|
||||
let mut holds = BTreeMap::new();
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
let position = HoldPosition { row, col };
|
||||
let hold = Hold { position, image: None };
|
||||
holds.insert(position, hold);
|
||||
}
|
||||
}
|
||||
Self { rows, cols, holds }
|
||||
}
|
||||
}
|
||||
impl Default for Wall {
|
||||
fn default() -> Self {
|
||||
Self::new(12, 12)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Copy)]
|
||||
pub struct HoldPosition {
|
||||
/// Starting from 0
|
||||
pub row: u64,
|
||||
|
||||
/// Starting from 0
|
||||
pub col: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Problem {
|
||||
pub holds: BTreeMap<HoldPosition, HoldRole>,
|
||||
}
|
||||
|
||||
/// The role of a hold on a route
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum HoldRole {
|
||||
/// Start hold
|
||||
Start,
|
||||
|
||||
/// Any hold on the route without a specific role
|
||||
Normal,
|
||||
|
||||
/// Zone hold
|
||||
Zone,
|
||||
|
||||
/// End hold
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Hold {
|
||||
pub position: HoldPosition,
|
||||
pub image: Option<Image>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Image {
|
||||
pub filename: String,
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +1,48 @@
|
||||
use crate::codec::ron::RonCodec;
|
||||
use crate::codec::ron::Ron;
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::components::StyledHeader;
|
||||
use crate::components::header::HeaderItem;
|
||||
use crate::components::header::HeaderItems;
|
||||
use crate::components::header::StyledHeader;
|
||||
use crate::models;
|
||||
use crate::models::HoldPosition;
|
||||
use crate::models::Wall;
|
||||
use crate::models::WallUid;
|
||||
use leptos::Params;
|
||||
use leptos::ev::Event;
|
||||
use leptos::html::Input;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::params::Params;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use server_fn::codec::Cbor;
|
||||
use std::ops::Deref;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::FileList;
|
||||
|
||||
#[component]
|
||||
pub fn EditWall() -> impl leptos::IntoView {
|
||||
let load = async move {
|
||||
// TODO: What to do about this unwrap?
|
||||
load_initial_data().await.unwrap()
|
||||
};
|
||||
#[derive(Params, PartialEq, Clone)]
|
||||
struct RouteParams {
|
||||
wall_uid: Option<models::WallUid>,
|
||||
}
|
||||
|
||||
let header_items = HeaderItems {
|
||||
#[component]
|
||||
pub fn EditWall() -> impl IntoView {
|
||||
let params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
let wall_uid = Signal::derive(move || {
|
||||
params
|
||||
.get()
|
||||
.expect("gets wall_uid from URL")
|
||||
.wall_uid
|
||||
.expect("wall_uid param is never None")
|
||||
});
|
||||
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
|
||||
let header_items = move || HeaderItems {
|
||||
left: vec![HeaderItem {
|
||||
text: "← Ascend".to_string(),
|
||||
link: Some("/".to_string()),
|
||||
link: Some(format!("/wall/{}", wall_uid.get())),
|
||||
}],
|
||||
middle: vec![HeaderItem {
|
||||
text: "EDIT WALL".to_string(),
|
||||
text: "HOLDS".to_string(),
|
||||
link: None,
|
||||
}],
|
||||
right: vec![],
|
||||
@ -37,27 +50,41 @@ pub fn EditWall() -> impl leptos::IntoView {
|
||||
|
||||
leptos::view! {
|
||||
<div class="min-w-screen min-h-screen bg-slate-900">
|
||||
<StyledHeader items=header_items />
|
||||
<StyledHeader items=Signal::derive(header_items) />
|
||||
|
||||
<div class="container mx-auto mt-2">
|
||||
<Await future=load let:data>
|
||||
<Ready data=data.deref().to_owned() />
|
||||
</Await>
|
||||
<Suspense fallback=move || {
|
||||
view! { <p>"Loading..."</p> }
|
||||
}>
|
||||
{move || Suspend::new(async move {
|
||||
let wall = wall.await;
|
||||
view! {
|
||||
<ErrorBoundary fallback=|_errors| {
|
||||
"error"
|
||||
}>
|
||||
{move || -> Result<_, ServerFnError> {
|
||||
let wall = wall.clone()?;
|
||||
Ok(view! { <Ready wall /> })
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Ready(data: InitialData) -> impl leptos::IntoView {
|
||||
fn Ready(wall: models::Wall) -> impl IntoView {
|
||||
tracing::debug!("ready");
|
||||
|
||||
let mut holds = vec![];
|
||||
for hold in data.wall.holds.values().cloned() {
|
||||
holds.push(view! { <Hold hold /> });
|
||||
for hold in wall.holds.values().cloned() {
|
||||
holds.push(view! { <Hold wall_uid=wall.uid hold /> });
|
||||
}
|
||||
|
||||
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", data.wall.rows, data.wall.cols);
|
||||
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", wall.rows, wall.cols);
|
||||
|
||||
view! {
|
||||
<div>
|
||||
@ -68,7 +95,7 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Hold(hold: models::Hold) -> impl leptos::IntoView {
|
||||
fn Hold(wall_uid: models::WallUid, hold: models::Hold) -> impl IntoView {
|
||||
let hold_position = hold.position;
|
||||
let file_input_ref = NodeRef::<Input>::new();
|
||||
|
||||
@ -82,7 +109,7 @@ fn Hold(hold: models::Hold) -> impl leptos::IntoView {
|
||||
|
||||
let hold = Signal::derive(move || {
|
||||
let refreshed = upload.value().get().map(Result::unwrap);
|
||||
refreshed.unwrap_or(hold.clone())
|
||||
refreshed.map(RonEncoded::into_inner).unwrap_or(hold.clone())
|
||||
});
|
||||
|
||||
// Callback to handle file selection
|
||||
@ -108,7 +135,11 @@ fn Hold(hold: models::Hold) -> impl leptos::IntoView {
|
||||
file_contents,
|
||||
};
|
||||
|
||||
upload.dispatch(SetImage { hold_position, image });
|
||||
upload.dispatch(SetImage {
|
||||
wall_uid,
|
||||
hold_position,
|
||||
image,
|
||||
});
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
file_reader.set_onload(Some(on_load.as_ref().unchecked_ref()));
|
||||
@ -117,8 +148,8 @@ fn Hold(hold: models::Hold) -> impl leptos::IntoView {
|
||||
|
||||
let img = move || {
|
||||
hold.read().image.as_ref().map(|img| {
|
||||
let src = format!("/files/holds/{}", img.filename);
|
||||
view! { <img class="object-cover w-full h-full" src=src /> }
|
||||
let srcset = img.srcset();
|
||||
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
|
||||
})
|
||||
};
|
||||
|
||||
@ -138,51 +169,77 @@ fn Hold(hold: models::Hold) -> impl leptos::IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InitialData {
|
||||
wall: Wall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Image {
|
||||
file_name: String,
|
||||
file_contents: Vec<u8>,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
|
||||
use crate::server::state::State;
|
||||
#[server(
|
||||
name = SetImage,
|
||||
input = Cbor,
|
||||
output = Ron,
|
||||
)]
|
||||
#[tracing::instrument(skip(image), err)]
|
||||
async fn set_image(wall_uid: WallUid, hold_position: HoldPosition, image: Image) -> Result<RonEncoded<models::Hold>, ServerFnError> {
|
||||
use image::ImageDecoder;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
let state = expect_context::<State>();
|
||||
|
||||
let wall = state.persistent.with(|s| s.wall.clone()).await;
|
||||
Ok(RonCodec::new(InitialData { wall }))
|
||||
}
|
||||
|
||||
#[server(name = SetImage, input = Cbor)]
|
||||
#[tracing::instrument(skip(image))]
|
||||
async fn set_image(hold_position: HoldPosition, image: Image) -> Result<models::Hold, ServerFnError> {
|
||||
tracing::info!("Setting image, {}, {} bytes", image.file_name, image.file_contents.len());
|
||||
|
||||
use crate::server::state::State;
|
||||
let db = expect_context::<crate::server::db::Database>();
|
||||
|
||||
// TODO: Fix file extension presumption, and possibly use uuid
|
||||
let filename = format!("row{}_col{}.jpg", hold_position.row, hold_position.col);
|
||||
tokio::fs::create_dir_all("datastore/public/holds").await?;
|
||||
tokio::fs::write(format!("datastore/public/holds/{filename}"), image.file_contents).await?;
|
||||
let image = tokio::task::spawn_blocking(move || -> Result<models::Image, ServerFnError> {
|
||||
let mut decoder = image::ImageReader::new(std::io::Cursor::new(image.file_contents))
|
||||
.with_guessed_format()?
|
||||
.into_decoder()?;
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
||||
img.apply_orientation(orientation);
|
||||
|
||||
let state = expect_context::<State>();
|
||||
state
|
||||
.persistent
|
||||
.update(|s| {
|
||||
if let Some(hold) = s.wall.holds.get_mut(&hold_position) {
|
||||
hold.image = Some(models::Image { filename });
|
||||
}
|
||||
let holds_dir = Path::new("datastore/public/holds");
|
||||
std::fs::create_dir_all(holds_dir)?;
|
||||
|
||||
let targets = [(50, 50), (150, 150), (300, 300), (400, 400)];
|
||||
|
||||
let uid = models::ImageUid::create();
|
||||
let mut resolutions = BTreeMap::new();
|
||||
for (width, height) in targets {
|
||||
let resized = img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
let filename = format!("hold_row{}_col{}_{width}x{height}_{uid}.webp", hold_position.row, hold_position.col);
|
||||
let path = holds_dir.join(&filename);
|
||||
let mut file = std::fs::OpenOptions::new().write(true).append(false).create_new(true).open(&path)?;
|
||||
resized.write_to(&mut file, image::ImageFormat::WebP)?;
|
||||
|
||||
let res = models::ImageResolution {
|
||||
width: width.into(),
|
||||
height: height.into(),
|
||||
};
|
||||
resolutions.insert(res, models::ImageFilename { filename });
|
||||
}
|
||||
|
||||
// TODO: Clean up old image?
|
||||
|
||||
Ok(models::Image { uid, resolutions })
|
||||
})
|
||||
.await??;
|
||||
|
||||
let hold = db
|
||||
.write(move |txn| {
|
||||
use redb::ReadableTable;
|
||||
let mut walls = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
|
||||
let mut wall = walls.get(wall_uid)?.expect("todo").value();
|
||||
|
||||
let hold = wall.holds.get_mut(&hold_position).expect("hold");
|
||||
hold.image = Some(image);
|
||||
let hold = hold.clone();
|
||||
walls.insert(wall_uid, wall)?;
|
||||
|
||||
Ok(hold)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Return updated hold
|
||||
let hold = state.persistent.with(|s| s.wall.holds.get(&hold_position).cloned().unwrap()).await;
|
||||
|
||||
Ok(hold)
|
||||
Ok(RonEncoded::new(hold))
|
||||
}
|
||||
|
@ -1,18 +1,35 @@
|
||||
use crate::codec::ron::RonCodec;
|
||||
use crate::components;
|
||||
use crate::components::header::HeaderItem;
|
||||
use crate::components::header::HeaderItems;
|
||||
use crate::components::header::StyledHeader;
|
||||
use crate::models;
|
||||
use crate::models::WallUid;
|
||||
use leptos::Params;
|
||||
use leptos::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::ops::Deref;
|
||||
use leptos_router::params::Params;
|
||||
|
||||
#[derive(Params, PartialEq, Clone)]
|
||||
struct RouteParams {
|
||||
// Is never None
|
||||
wall_uid: Option<models::WallUid>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Routes() -> impl leptos::IntoView {
|
||||
let load = async move {
|
||||
// TODO: What to do about this unwrap?
|
||||
load_initial_data().await.unwrap()
|
||||
};
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Routes() -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
let params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
let wall_uid = Signal::derive(move || {
|
||||
params
|
||||
.get()
|
||||
.expect("gets wall_uid from URL")
|
||||
.wall_uid
|
||||
.expect("wall_uid param is never None")
|
||||
});
|
||||
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
let problems = crate::resources::problems_for_wall(wall_uid);
|
||||
|
||||
let header_items = HeaderItems {
|
||||
left: vec![HeaderItem {
|
||||
@ -26,27 +43,70 @@ pub fn Routes() -> impl leptos::IntoView {
|
||||
right: vec![],
|
||||
};
|
||||
|
||||
leptos::view! {
|
||||
let suspend = move || {
|
||||
Suspend::new(async move {
|
||||
let wall = wall.await;
|
||||
let problems = problems.await;
|
||||
|
||||
let v = move || -> Result<_, ServerFnError> {
|
||||
let wall = wall.clone()?;
|
||||
let problems = problems.clone()?;
|
||||
|
||||
let wall_dimensions = models::WallDimensions {
|
||||
rows: wall.rows,
|
||||
cols: wall.cols,
|
||||
};
|
||||
let problems_sample = move || problems.iter().take(10).cloned().collect::<Vec<_>>();
|
||||
|
||||
Ok(view! {
|
||||
<div>
|
||||
<For
|
||||
each=problems_sample
|
||||
key=|problem| problem.uid
|
||||
children=move |problem: models::Problem| {
|
||||
view! { <Problem dim=wall_dimensions problem /> }
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
})
|
||||
};
|
||||
|
||||
view! { <ErrorBoundary fallback=|_errors| "error">{v}</ErrorBoundary> }
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="min-w-screen min-h-screen bg-slate-900">
|
||||
<StyledHeader items=header_items />
|
||||
|
||||
<div class="container mx-auto mt-2">
|
||||
<Await future=load let:data>
|
||||
<Ready data=data.deref().to_owned() />
|
||||
</Await>
|
||||
{move || view! { <Import wall_uid=wall_uid.get() /> }}
|
||||
<Suspense fallback=|| view! { <p>"loading"</p> }>{suspend}</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Ready(data: InitialData) -> impl leptos::IntoView {
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Problem(#[prop(into)] dim: Signal<models::WallDimensions>, #[prop(into)] problem: Signal<models::Problem>) -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
view! {
|
||||
<components::Problem dim problem />
|
||||
<p>{move || problem.get().name.clone()}</p>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Import(wall_uid: WallUid) -> impl IntoView {
|
||||
tracing::debug!("ready");
|
||||
|
||||
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
|
||||
|
||||
let onclick = move |_mouse_event| {
|
||||
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard {});
|
||||
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard { wall_uid });
|
||||
};
|
||||
|
||||
view! {
|
||||
@ -55,31 +115,18 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InitialData {}
|
||||
|
||||
#[server]
|
||||
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
|
||||
// use crate::server::state::State;
|
||||
// let state = expect_context::<State>();
|
||||
|
||||
// TODO: provide info on current routes set
|
||||
|
||||
Ok(RonCodec::new(InitialData {}))
|
||||
}
|
||||
|
||||
#[server(name = ImportFromMiniMoonboard)]
|
||||
#[tracing::instrument]
|
||||
async fn import_from_mini_moonboard() -> Result<(), ServerFnError> {
|
||||
async fn import_from_mini_moonboard(wall_uid: WallUid) -> Result<(), ServerFnError> {
|
||||
use crate::server::config::Config;
|
||||
use crate::server::state::State;
|
||||
use crate::server::db::Database;
|
||||
|
||||
tracing::info!("Importing mini moonboard problems");
|
||||
|
||||
let config = expect_context::<Config>();
|
||||
let state = expect_context::<State>();
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
crate::server::operations::import_mini_moonboard_problems(&config, &state).await?;
|
||||
crate::server::operations::import_mini_moonboard_problems(&config, db, wall_uid).await?;
|
||||
|
||||
// TODO: Return information about what was done
|
||||
Ok(())
|
||||
|
@ -1,24 +1,62 @@
|
||||
use crate::codec::ron::RonCodec;
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::components::button::Button;
|
||||
use crate::components::header::HeaderItem;
|
||||
use crate::components::header::HeaderItems;
|
||||
use crate::components::header::StyledHeader;
|
||||
use crate::models;
|
||||
use crate::models::HoldRole;
|
||||
use leptos::Params;
|
||||
use leptos::prelude::*;
|
||||
use leptos::reactive::graph::ReactiveNode;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::ops::Deref;
|
||||
use leptos_router::params::Params;
|
||||
|
||||
#[derive(Params, PartialEq, Clone)]
|
||||
struct RouteParams {
|
||||
wall_uid: Option<models::WallUid>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Wall() -> impl leptos::IntoView {
|
||||
let load = async move {
|
||||
// TODO: What to do about this unwrap?
|
||||
load_initial_data().await.unwrap()
|
||||
};
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub fn Wall() -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
let header_items = HeaderItems {
|
||||
let params = leptos_router::hooks::use_params::<RouteParams>();
|
||||
let wall_uid = Signal::derive(move || {
|
||||
params
|
||||
.get()
|
||||
.expect("gets wall_uid from URL")
|
||||
.wall_uid
|
||||
.expect("wall_uid param is never None")
|
||||
});
|
||||
|
||||
let wall = crate::resources::wall_by_uid(wall_uid);
|
||||
|
||||
let 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();
|
||||
tracing::debug!("val: {:?}", v);
|
||||
v.and_then(Result::ok)
|
||||
});
|
||||
|
||||
Effect::new(move |_prev_value| {
|
||||
problem_action.value().write_only().set(None);
|
||||
match wall.get() {
|
||||
Some(Ok(wall)) => {
|
||||
if let Some(problem_uid) = wall.random_problem() {
|
||||
tracing::debug!("dispatching from effect");
|
||||
problem_action.dispatch((wall.uid, problem_uid));
|
||||
}
|
||||
}
|
||||
Some(Err(_err)) => {}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
let header_items = move || HeaderItems {
|
||||
left: vec![],
|
||||
middle: vec![HeaderItem {
|
||||
text: "ASCEND".to_string(),
|
||||
@ -27,67 +65,87 @@ pub fn Wall() -> impl leptos::IntoView {
|
||||
right: vec![
|
||||
HeaderItem {
|
||||
text: "Routes".to_string(),
|
||||
link: Some("/wall/routes".to_string()),
|
||||
link: Some(format!("/wall/{}/routes", wall_uid.get())),
|
||||
},
|
||||
HeaderItem {
|
||||
text: "Holds".to_string(),
|
||||
link: Some("/wall/edit".to_string()),
|
||||
link: Some(format!("/wall/{}/edit", wall_uid.get())),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
leptos::view! {
|
||||
<div class="min-w-screen min-h-screen bg-slate-900">
|
||||
<StyledHeader items=header_items />
|
||||
<StyledHeader items=Signal::derive(header_items) />
|
||||
|
||||
<div class="m-2">
|
||||
<Await future=load let:data>
|
||||
<Ready data=data.deref().to_owned() />
|
||||
</Await>
|
||||
<Suspense fallback=move || {
|
||||
view! { <p>"Loading..."</p> }
|
||||
}>
|
||||
{move || Suspend::new(async move {
|
||||
tracing::info!("executing Suspend future");
|
||||
let wall = wall.await?;
|
||||
let v = view! {
|
||||
<div class="grid grid-cols-[auto,1fr] gap-8">
|
||||
<Grid wall=wall.clone() problem=problem_signal />
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<p>
|
||||
{move || problem_signal.get().map(|p| p.name.clone())}
|
||||
</p>
|
||||
<p>
|
||||
{move || problem_signal.get().map(|p| p.set_by.clone())}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onclick=move |_| {
|
||||
if let Some(problem_uid) = wall.random_problem() {
|
||||
tracing::info!("dispatching from button click handler");
|
||||
problem_action.dispatch((wall.uid, problem_uid));
|
||||
}
|
||||
}
|
||||
text="➤ Next problem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
Ok::<_, ServerFnError>(v)
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Ready(data: InitialData) -> impl leptos::IntoView {
|
||||
tracing::debug!("ready");
|
||||
|
||||
let (current_problem, current_problem_writer) = signal(None::<models::Problem>);
|
||||
let problem_fetcher = LocalResource::new(move || async move {
|
||||
tracing::info!("Loading random problem");
|
||||
let problem = get_random_problem().await.expect("cannot get random problem");
|
||||
if problem.is_none() {
|
||||
tracing::info!("No problem returned by server in response to request for random problem");
|
||||
}
|
||||
current_problem_writer.set(problem.into_inner());
|
||||
});
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Grid(wall: models::Wall, problem: Signal<Option<models::Problem>>) -> impl IntoView {
|
||||
tracing::debug!("Enter");
|
||||
|
||||
let mut cells = vec![];
|
||||
for (&hold_position, hold) in &data.wall.holds {
|
||||
let role = move || current_problem.get().and_then(|problem| problem.holds.get(&hold_position).copied());
|
||||
for (&hold_position, hold) in &wall.holds {
|
||||
let role = move || problem.get().and_then(|p| p.holds.get(&hold_position).copied());
|
||||
let role = Signal::derive(role);
|
||||
|
||||
let cell = view! { <Hold role hold=hold.clone() /> };
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", data.wall.rows, data.wall.cols);
|
||||
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", wall.rows, wall.cols,);
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-[auto,1fr] gap-8">
|
||||
// Render the wall
|
||||
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>{cells}</div>
|
||||
|
||||
<div>
|
||||
<Button onclick=move |_| problem_fetcher.mark_dirty() text="Next problem ➤" />
|
||||
</ div>
|
||||
<div style="max-height: 90vh; max-width: 90vh;" class=move || { grid_classes.clone() }>
|
||||
{cells}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Hold(hold: models::Hold, #[prop(into)] role: Signal<Option<HoldRole>>) -> impl leptos::IntoView {
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn Hold(hold: models::Hold, role: Signal<Option<HoldRole>>) -> 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"),
|
||||
@ -106,45 +164,10 @@ fn Hold(hold: models::Hold, #[prop(into)] role: Signal<Option<HoldRole>>) -> imp
|
||||
};
|
||||
|
||||
let img = hold.image.map(|img| {
|
||||
let src = format!("/files/holds/{}", img.filename);
|
||||
view! { <img class="object-cover w-full h-full" src=src /> }
|
||||
let srcset = img.srcset();
|
||||
view! { <img class="object-cover w-full h-full" srcset=srcset /> }
|
||||
});
|
||||
|
||||
tracing::trace!("view");
|
||||
view! { <div class=class>{img}</div> }
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct InitialData {
|
||||
wall: models::Wall,
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
|
||||
use crate::server::state::State;
|
||||
|
||||
let state = expect_context::<State>();
|
||||
|
||||
let wall = state.persistent.with(|s| s.wall.clone()).await;
|
||||
Ok(RonCodec::new(InitialData { wall }))
|
||||
}
|
||||
|
||||
#[server]
|
||||
async fn get_random_problem() -> Result<RonCodec<Option<models::Problem>>, ServerFnError> {
|
||||
use crate::server::state::State;
|
||||
use rand::seq::IteratorRandom;
|
||||
|
||||
let state = expect_context::<State>();
|
||||
|
||||
let problem = state
|
||||
.persistent
|
||||
.with(|s| {
|
||||
let problems = &s.problems.problems;
|
||||
let rng = &mut rand::thread_rng();
|
||||
problems.iter().choose(rng).cloned()
|
||||
})
|
||||
.await;
|
||||
|
||||
tracing::debug!("Returning randomized problem: {problem:?}");
|
||||
|
||||
Ok(RonCodec::new(problem))
|
||||
}
|
||||
|
@ -4,21 +4,16 @@ use cli::Cli;
|
||||
use config::Config;
|
||||
use confik::Configuration;
|
||||
use confik::EnvSource;
|
||||
use persistence::Persistent;
|
||||
use state::PersistentState;
|
||||
use state::State;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::level_filters::LevelFilter;
|
||||
use type_toppings::ResultExt;
|
||||
|
||||
mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
mod migrations;
|
||||
pub mod operations;
|
||||
pub mod persistence;
|
||||
pub mod state;
|
||||
|
||||
pub const STATE_FILE: &str = "datastore/private/state.ron";
|
||||
|
||||
@ -43,12 +38,6 @@ pub async fn main() {
|
||||
|
||||
match cli.command {
|
||||
Command::Serve => serve(cli).await.unwrap_or_report(),
|
||||
Command::ResetState => {
|
||||
let s = PersistentState::default();
|
||||
let p = Path::new(STATE_FILE);
|
||||
tracing::info!("Resetting state to default: {}", p.display());
|
||||
Persistent::persist(p, &s).await.unwrap_or_report();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,32 +46,34 @@ async fn serve(cli: Cli) -> Result<(), Error> {
|
||||
use crate::app::App;
|
||||
use crate::app::shell;
|
||||
use axum::Router;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::LeptosRoutes;
|
||||
use leptos_axum::generate_route_list;
|
||||
|
||||
migrations::run_migrations().await;
|
||||
tracing::debug!("Creating DB");
|
||||
let db = db::Database::create()?;
|
||||
|
||||
migrations::run_migrations(&db).await.map_err(Error::Migration)?;
|
||||
|
||||
// Setting get_configuration(None) means we'll be using cargo-leptos's env values
|
||||
// For deployment these variables are:
|
||||
// <https://github.com/leptos-rs/start-axum#executing-a-server-on-a-remote-machine-without-the-toolchain>
|
||||
// Alternately a file can be specified such as Some("Cargo.toml")
|
||||
// The file would need to be included with the executable when moved to deployment
|
||||
let leptos_conf_file = get_configuration(None).unwrap_or_report();
|
||||
let leptos_conf_file = leptos::config::get_configuration(None).unwrap_or_report();
|
||||
let leptos_options = leptos_conf_file.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let config = load_config(cli)?;
|
||||
let server_state = load_state().await?;
|
||||
|
||||
tracing::debug!("Creating app router");
|
||||
let app = Router::new()
|
||||
.leptos_routes_with_context(
|
||||
&leptos_options,
|
||||
routes,
|
||||
move || {
|
||||
provide_context(server_state.clone());
|
||||
provide_context(config.clone())
|
||||
leptos::prelude::provide_context::<db::Database>(db.clone());
|
||||
leptos::prelude::provide_context::<Config>(config.clone())
|
||||
},
|
||||
{
|
||||
let leptos_options = leptos_options.clone();
|
||||
@ -93,7 +84,9 @@ async fn serve(cli: Cli) -> Result<(), Error> {
|
||||
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||
.with_state(leptos_options);
|
||||
|
||||
tracing::debug!("Binding TCP listener");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
tracing::info!("Listening on http://{addr}");
|
||||
axum::serve(listener, app.into_make_service()).await?;
|
||||
|
||||
@ -106,22 +99,6 @@ fn file_service(path: impl AsRef<Path>) -> ServeDir {
|
||||
ServeDir::new(path)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn load_state() -> Result<State, Error> {
|
||||
tracing::info!("Loading state");
|
||||
|
||||
let p = PathBuf::from(STATE_FILE);
|
||||
|
||||
let persistent = if p.try_exists()? {
|
||||
Persistent::<PersistentState>::load(&p).await?
|
||||
} else {
|
||||
tracing::info!("No state found at {STATE_FILE}, creating default state");
|
||||
Persistent::<PersistentState>::new(PersistentState::default(), p)
|
||||
};
|
||||
|
||||
Ok(State { persistent })
|
||||
}
|
||||
|
||||
fn load_config(cli: Cli) -> Result<Config, Error> {
|
||||
let mut builder = config::Config::builder();
|
||||
if cli
|
||||
@ -139,11 +116,14 @@ fn load_config(cli: Cli) -> Result<Config, Error> {
|
||||
#[display("Server crash: {_variant}")]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
Persistence(persistence::Error),
|
||||
|
||||
Parser(moonboard_parser::Error),
|
||||
|
||||
#[display("Failed migration")]
|
||||
#[from(ignore)]
|
||||
Migration(Box<dyn std::error::Error>),
|
||||
|
||||
Confik(confik::Error),
|
||||
|
||||
Database(redb::Error),
|
||||
}
|
||||
|
@ -15,9 +15,6 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
#[default]
|
||||
Serve,
|
||||
|
||||
/// Resets state, replacing it with defaults
|
||||
ResetState,
|
||||
}
|
||||
|
||||
fn default_config_location() -> camino::Utf8PathBuf {
|
||||
|
107
crates/ascend/src/server/db.rs
Normal file
107
crates/ascend/src/server/db.rs
Normal file
@ -0,0 +1,107 @@
|
||||
use bincode::Bincode;
|
||||
use redb::ReadTransaction;
|
||||
use redb::TableDefinition;
|
||||
use redb::WriteTransaction;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod bincode;
|
||||
|
||||
const DB_FILE: &str = "datastore/private/ascend.redb";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Database {
|
||||
db: Arc<redb::Database>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub fn create() -> Result<Database, redb::Error> {
|
||||
let file = PathBuf::from(DB_FILE);
|
||||
|
||||
// Create parent dirs
|
||||
if let Some(parent_dir) = file.parent() {
|
||||
std::fs::create_dir_all(parent_dir)?;
|
||||
}
|
||||
|
||||
let db = redb::Database::create(file)?;
|
||||
Ok(Self { db: Arc::new(db) })
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn read<T>(&self, f: impl FnOnce(&'_ ReadTransaction) -> Result<T, DatabaseOperationError>) -> Result<T, DatabaseOperationError> {
|
||||
tokio::task::block_in_place(|| {
|
||||
let dbtx = self.db.begin_read()?;
|
||||
f(&dbtx)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn write<T>(&self, f: impl FnOnce(&'_ WriteTransaction) -> Result<T, DatabaseOperationError>) -> Result<T, DatabaseOperationError> {
|
||||
tokio::task::block_in_place(|| {
|
||||
let dbtx = self.db.begin_write()?;
|
||||
let res = f(&dbtx)?;
|
||||
dbtx.commit()?;
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn get_version(&self) -> Result<Option<Version>, DatabaseOperationError> {
|
||||
self.read(|dbtx| dbtx.open_table(TABLE_VERSION)?.get(()).map(|o| o.map(|v| v.value())).map_err(Into::into))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
#[display("DB operation error: {_variant}")]
|
||||
pub enum DatabaseOperationError {
|
||||
#[display("redb error")]
|
||||
#[from(forward)]
|
||||
Redb(#[error(source)] redb::Error),
|
||||
|
||||
#[from(ignore)]
|
||||
Custom(#[error(source)] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
}
|
||||
impl DatabaseOperationError {
|
||||
pub fn custom(err: impl std::error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::Custom(Box::new(err))
|
||||
}
|
||||
}
|
||||
|
||||
pub const TABLE_VERSION: TableDefinition<(), Bincode<Version>> = TableDefinition::new("version");
|
||||
#[derive(Serialize, Deserialize, Debug, derive_more::Display)]
|
||||
#[display("{version}")]
|
||||
pub struct Version {
|
||||
pub version: u64,
|
||||
}
|
||||
impl Version {
|
||||
pub fn current() -> Version {
|
||||
Version { version: current::VERSION }
|
||||
}
|
||||
}
|
||||
|
||||
pub use v2 as current;
|
||||
|
||||
pub mod v2 {
|
||||
use crate::models;
|
||||
use crate::server::db::bincode::Bincode;
|
||||
use redb::TableDefinition;
|
||||
|
||||
pub const VERSION: u64 = 2;
|
||||
|
||||
pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v2::Root>> = TableDefinition::new("root");
|
||||
pub const TABLE_WALLS: TableDefinition<Bincode<models::v2::WallUid>, Bincode<models::v2::Wall>> = TableDefinition::new("walls");
|
||||
pub const TABLE_PROBLEMS: TableDefinition<Bincode<(models::v2::WallUid, models::v2::ProblemUid)>, Bincode<models::v2::Problem>> =
|
||||
TableDefinition::new("problems");
|
||||
}
|
||||
|
||||
pub mod v1 {
|
||||
use crate::models;
|
||||
use crate::server::db::bincode::Bincode;
|
||||
use redb::TableDefinition;
|
||||
|
||||
pub const TABLE_ROOT: TableDefinition<(), Bincode<models::v1::PersistentState>> = TableDefinition::new("root");
|
||||
}
|
52
crates/ascend/src/server/db/bincode.rs
Normal file
52
crates/ascend/src/server/db/bincode.rs
Normal file
@ -0,0 +1,52 @@
|
||||
use redb::Value;
|
||||
|
||||
/// Wrapper type to handle keys and values using bincode serialization
|
||||
#[derive(Debug)]
|
||||
pub struct Bincode<T>(pub T);
|
||||
|
||||
impl<T> Value for Bincode<T>
|
||||
where
|
||||
T: std::fmt::Debug + serde::Serialize + for<'a> serde::Deserialize<'a>,
|
||||
{
|
||||
type SelfType<'a>
|
||||
= T
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
type AsBytes<'a>
|
||||
= Vec<u8>
|
||||
where
|
||||
Self: 'a;
|
||||
|
||||
fn fixed_width() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
{
|
||||
bincode::deserialize(data).unwrap()
|
||||
}
|
||||
|
||||
fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a>
|
||||
where
|
||||
Self: 'a,
|
||||
Self: 'b,
|
||||
{
|
||||
bincode::serialize(value).unwrap()
|
||||
}
|
||||
|
||||
fn type_name() -> redb::TypeName {
|
||||
redb::TypeName::new(&format!("Bincode<{}>", std::any::type_name::<T>()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> redb::Key for Bincode<T>
|
||||
where
|
||||
T: std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + Ord,
|
||||
{
|
||||
fn compare(data1: &[u8], data2: &[u8]) -> std::cmp::Ordering {
|
||||
Self::from_bytes(data1).cmp(&Self::from_bytes(data2))
|
||||
}
|
||||
}
|
@ -1,20 +1,218 @@
|
||||
use crate::server::STATE_FILE;
|
||||
use super::db;
|
||||
use super::db::Database;
|
||||
use crate::models;
|
||||
use image::ImageDecoder;
|
||||
use redb::ReadableTable;
|
||||
use redb::ReadableTableMetadata;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use type_toppings::ResultExt;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn run_migrations() {
|
||||
migrate_state_file().await;
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn run_migrations(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
migrate_from_ron_to_redb(db).await?;
|
||||
init_at_current_version(db).await?;
|
||||
migrate_to_v2(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// State file moved to datastore/private
|
||||
#[tracing::instrument]
|
||||
async fn migrate_state_file() {
|
||||
let m = PathBuf::from("state.ron");
|
||||
if m.try_exists().expect_or_report_with(|| format!("Failed to read {}", m.display())) {
|
||||
tracing::warn!("MIGRATING STATE FILE");
|
||||
let p = PathBuf::from(STATE_FILE);
|
||||
tokio::fs::create_dir_all(p.parent().unwrap()).await.unwrap_or_report();
|
||||
tokio::fs::rename(m, &p).await.unwrap_or_report();
|
||||
/// Use redb DB instead of Ron state file
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
async fn migrate_from_ron_to_redb(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ron_state_file_path = PathBuf::from(super::STATE_FILE);
|
||||
|
||||
if ron_state_file_path
|
||||
.try_exists()
|
||||
.expect_or_report_with(|| format!("Failed to read {}", ron_state_file_path.display()))
|
||||
{
|
||||
tracing::warn!("MIGRATING");
|
||||
|
||||
let ron_state: models::v1::PersistentState = {
|
||||
let content = tokio::fs::read_to_string(&ron_state_file_path).await?;
|
||||
ron::from_str(&content)?
|
||||
};
|
||||
|
||||
db.write(|txn| {
|
||||
let mut version_table = txn.open_table(db::TABLE_VERSION)?;
|
||||
assert!(version_table.is_empty()?);
|
||||
version_table.insert((), db::Version { version: 1 })?;
|
||||
|
||||
let mut root_table = txn.open_table(db::v1::TABLE_ROOT)?;
|
||||
assert!(root_table.is_empty()?);
|
||||
|
||||
let persistent_state = models::v1::PersistentState {
|
||||
version: ron_state.version,
|
||||
wall: ron_state.wall,
|
||||
problems: ron_state.problems,
|
||||
};
|
||||
|
||||
root_table.insert((), persistent_state)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::info!("Removing ron state");
|
||||
tokio::fs::remove_file(ron_state_file_path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// TODO: Move out, is not really a migration
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
async fn init_at_current_version(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
db.write(|txn| {
|
||||
let mut version_table = txn.open_table(db::TABLE_VERSION)?;
|
||||
let is_missing_version = version_table.get(())?.is_none();
|
||||
if is_missing_version {
|
||||
let v = db::Version::current();
|
||||
tracing::warn!("INITIALIZING DATABASE AT VERSION {v}");
|
||||
version_table.insert((), v)?;
|
||||
|
||||
// Root table
|
||||
{
|
||||
let mut table = txn.open_table(db::current::TABLE_ROOT)?;
|
||||
assert!(table.is_empty()?);
|
||||
table.insert((), models::Root { walls: BTreeSet::new() })?;
|
||||
}
|
||||
|
||||
// Walls table
|
||||
{
|
||||
// Opening the table creates the table
|
||||
let table = txn.open_table(db::current::TABLE_WALLS)?;
|
||||
assert!(table.is_empty()?);
|
||||
}
|
||||
|
||||
// Problems table
|
||||
{
|
||||
// Opening the table creates the table
|
||||
let table = txn.open_table(db::current::TABLE_PROBLEMS)?;
|
||||
assert!(table.is_empty()?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
async fn migrate_to_v2(db: &Database) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use super::db;
|
||||
|
||||
db.write(|txn| {
|
||||
let mut version_table = txn.open_table(db::TABLE_VERSION)?;
|
||||
let version = version_table.get(())?.unwrap().value().version;
|
||||
if version == 1 {
|
||||
tracing::warn!("MIGRATING");
|
||||
version_table.insert((), db::Version { version: 2 })?;
|
||||
|
||||
let root_table_v1 = txn.open_table(db::v1::TABLE_ROOT)?;
|
||||
let root_v1 = root_table_v1.get(())?.unwrap().value();
|
||||
drop(root_table_v1);
|
||||
txn.delete_table(db::v1::TABLE_ROOT)?;
|
||||
|
||||
let models::v1::PersistentState { version: _, wall, problems } = root_v1;
|
||||
|
||||
// we'll reimport them instead of a lossy conversion.
|
||||
drop(problems);
|
||||
|
||||
let mut walls = BTreeSet::new();
|
||||
let wall_uid = models::v2::WallUid(uuid::Uuid::new_v4());
|
||||
let holds = wall
|
||||
.holds
|
||||
.into_iter()
|
||||
.map(|(hold_position, hold)| -> Result<_, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let image = hold
|
||||
.image
|
||||
.map(|i| -> Result<_, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let holds_dir = Path::new("datastore/public/holds");
|
||||
|
||||
let p = holds_dir.join(i.filename);
|
||||
tracing::info!("reading {}", p.display());
|
||||
let file_contents = std::fs::read(p)?;
|
||||
|
||||
let mut decoder = image::ImageReader::new(std::io::Cursor::new(file_contents))
|
||||
.with_guessed_format()?
|
||||
.into_decoder()?;
|
||||
let orientation = decoder.orientation()?;
|
||||
let mut img = image::DynamicImage::from_decoder(decoder)?;
|
||||
img.apply_orientation(orientation);
|
||||
|
||||
std::fs::create_dir_all(holds_dir)?;
|
||||
|
||||
let targets = [(50, 50), (150, 150), (300, 300), (400, 400)];
|
||||
|
||||
let uid = models::ImageUid::create();
|
||||
let mut resolutions = BTreeMap::new();
|
||||
for (width, height) in targets {
|
||||
tracing::info!("resizing to {width}x{height}");
|
||||
let resized = img.resize_to_fill(width, height, image::imageops::FilterType::Lanczos3);
|
||||
|
||||
let filename = format!("hold_row{}_col{}_{width}x{height}_{uid}.webp", hold_position.row, hold_position.col);
|
||||
let path = holds_dir.join(&filename);
|
||||
tracing::info!("opening {}", path.display());
|
||||
let mut file = std::fs::OpenOptions::new().write(true).append(false).create_new(true).open(&path)?;
|
||||
resized.write_to(&mut file, image::ImageFormat::WebP)?;
|
||||
|
||||
let res = models::ImageResolution {
|
||||
width: width.into(),
|
||||
height: height.into(),
|
||||
};
|
||||
resolutions.insert(res, models::ImageFilename { filename });
|
||||
}
|
||||
|
||||
Ok(models::Image { uid, resolutions })
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
Ok((
|
||||
models::v1::HoldPosition {
|
||||
row: hold_position.row,
|
||||
col: hold_position.col,
|
||||
},
|
||||
models::v2::Hold {
|
||||
position: models::v1::HoldPosition {
|
||||
row: hold.position.row,
|
||||
col: hold.position.col,
|
||||
},
|
||||
image,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
|
||||
let wall_v2 = models::v2::Wall {
|
||||
uid: wall_uid,
|
||||
rows: wall.rows,
|
||||
cols: wall.cols,
|
||||
holds,
|
||||
problems: BTreeSet::new(),
|
||||
};
|
||||
|
||||
walls.insert(wall_v2.uid);
|
||||
let root_v2 = models::v2::Root { walls };
|
||||
|
||||
let mut root_table_v2 = txn.open_table(db::v2::TABLE_ROOT)?;
|
||||
root_table_v2.insert((), root_v2)?;
|
||||
drop(root_table_v2);
|
||||
|
||||
let mut walls_table = txn.open_table(db::v2::TABLE_WALLS)?;
|
||||
walls_table.insert(wall_v2.uid, wall_v2)?;
|
||||
drop(walls_table);
|
||||
|
||||
let problems_table = txn.open_table(db::v2::TABLE_PROBLEMS)?;
|
||||
drop(problems_table);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,15 +1,18 @@
|
||||
//! Server lib module to host re-usable server operations.
|
||||
|
||||
use super::db::Database;
|
||||
use crate::models;
|
||||
use crate::models::HoldPosition;
|
||||
use crate::models::HoldRole;
|
||||
use crate::models::Problem;
|
||||
use crate::server::config::Config;
|
||||
use crate::server::persistence;
|
||||
use crate::server::state::State;
|
||||
use crate::server::db;
|
||||
use redb::ReadableTable;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &State) -> Result<(), Error> {
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub(crate) async fn import_mini_moonboard_problems(config: &Config, db: Database, wall_uid: models::WallUid) -> Result<(), Error> {
|
||||
use moonboard_parser::mini_moonboard;
|
||||
|
||||
let mut problems = Vec::new();
|
||||
|
||||
let file_name = "problems Mini MoonBoard 2020 40.json";
|
||||
@ -17,10 +20,12 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
||||
|
||||
tracing::info!("Parsing mini moonboard problems from {file_path}");
|
||||
|
||||
let mini_moonboard = moonboard_parser::mini_moonboard::parse(file_path.as_std_path()).await?;
|
||||
for problem in mini_moonboard.problems {
|
||||
let set_by = "mini-mb-2020-parser";
|
||||
|
||||
let mini_moonboard = mini_moonboard::parse(file_path.as_std_path()).await?;
|
||||
for mini_mb_problem in mini_moonboard.problems {
|
||||
let mut holds = BTreeMap::<HoldPosition, HoldRole>::new();
|
||||
for mv in problem.moves {
|
||||
for mv in mini_mb_problem.moves {
|
||||
let row = mv.description.row();
|
||||
let col = mv.description.column();
|
||||
let hold_position = HoldPosition { row, col };
|
||||
@ -33,16 +38,44 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
||||
};
|
||||
holds.insert(hold_position, role);
|
||||
}
|
||||
let route = Problem { holds };
|
||||
problems.push(route);
|
||||
|
||||
let name = mini_mb_problem.name;
|
||||
|
||||
let method = match mini_mb_problem.method {
|
||||
mini_moonboard::Method::FeetFollowHands => models::Method::FeetFollowHands,
|
||||
mini_moonboard::Method::Footless => models::Method::Footless,
|
||||
mini_moonboard::Method::FootlessPlusKickboard => models::Method::FootlessPlusKickboard,
|
||||
};
|
||||
|
||||
let problem_id = models::ProblemUid::create();
|
||||
|
||||
let problem = models::Problem {
|
||||
uid: problem_id,
|
||||
name,
|
||||
set_by: set_by.to_owned(),
|
||||
holds,
|
||||
method,
|
||||
date_added: chrono::Utc::now(),
|
||||
};
|
||||
problems.push(problem);
|
||||
}
|
||||
|
||||
state
|
||||
.persistent
|
||||
.update(|s| {
|
||||
s.problems.problems.extend(problems);
|
||||
})
|
||||
.await?;
|
||||
db.write(|txn| {
|
||||
let mut walls_table = txn.open_table(db::current::TABLE_WALLS)?;
|
||||
let mut problems_table = txn.open_table(db::current::TABLE_PROBLEMS)?;
|
||||
|
||||
let mut wall = walls_table.get(wall_uid)?.unwrap().value();
|
||||
wall.problems.extend(problems.iter().map(|p| p.uid));
|
||||
walls_table.insert(wall_uid, wall)?;
|
||||
|
||||
for problem in problems {
|
||||
let key = (wall_uid, problem.uid);
|
||||
problems_table.insert(key, problem)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -50,5 +83,6 @@ pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &Stat
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
pub enum Error {
|
||||
Parser(moonboard_parser::Error),
|
||||
Persistence(persistence::Error),
|
||||
Tokio(tokio::task::JoinError),
|
||||
DbOperation(crate::server::db::DatabaseOperationError),
|
||||
}
|
||||
|
@ -1,134 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Persistent<T> {
|
||||
state: Arc<Mutex<T>>,
|
||||
file_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<T> Persistent<T> {
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub fn new(state: T, file_path: PathBuf) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
file_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Instantiates state from file system
|
||||
#[tracing::instrument]
|
||||
pub async fn load(file_path: &Path) -> Result<Self, Error>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let content = tokio::fs::read_to_string(&file_path).await.map_err(|source| Error::Read {
|
||||
file_path: file_path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let t = ron::from_str(&content).map_err(|source| Error::Deserialize {
|
||||
file_path: file_path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
let persistent = Self {
|
||||
state: Arc::new(Mutex::new(t)),
|
||||
file_path: file_path.to_owned(),
|
||||
};
|
||||
|
||||
Ok(persistent)
|
||||
}
|
||||
|
||||
// TODO: This is pretty poor - clones the entire state on access
|
||||
/// Returns state
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn get(&self) -> T
|
||||
where
|
||||
T: Clone,
|
||||
{
|
||||
let state = self.state.lock().await;
|
||||
state.clone()
|
||||
}
|
||||
|
||||
/// Returns state passed through given function
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn with<F, R>(&self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&T) -> R,
|
||||
{
|
||||
let state = self.state.lock().await;
|
||||
f(&state)
|
||||
}
|
||||
|
||||
/// Updates and persists state
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn update<F>(&self, f: F) -> Result<(), Error>
|
||||
where
|
||||
F: FnOnce(&mut T),
|
||||
T: Serialize,
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
f(&mut state);
|
||||
Self::persist(&self.file_path, &state).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets and persists state
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn set(&self, new_state: T) -> Result<(), Error>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
self.update(move |state| {
|
||||
*state = new_state;
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persist.
|
||||
///
|
||||
/// Implicitly called by `set` and `update`.
|
||||
#[tracing::instrument(skip_all, err)]
|
||||
pub async fn persist(file_path: &Path, state: &T) -> Result<(), Error>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
tracing::debug!("Persisting state");
|
||||
let serialized = ron::ser::to_string_pretty(state, ron::ser::PrettyConfig::default()).map_err(|source| Error::Serialize { source })?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|source| Error::CreateDir {
|
||||
source,
|
||||
dir: parent.to_owned(),
|
||||
})?;
|
||||
}
|
||||
tokio::fs::write(file_path, serialized).await.map_err(|source| Error::Write {
|
||||
file_path: file_path.to_owned(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
#[display("Persistent state error: {_variant}")]
|
||||
pub enum Error {
|
||||
#[display("Failed to read file: {}", file_path.display())]
|
||||
Read { file_path: PathBuf, source: std::io::Error },
|
||||
|
||||
#[display("Failed to deserialize state from file: {}", file_path.display())]
|
||||
Deserialize { file_path: PathBuf, source: ron::error::SpannedError },
|
||||
|
||||
#[display("Failed to serialize state")]
|
||||
Serialize { source: ron::Error },
|
||||
|
||||
#[display("Failed to write file: {}", file_path.display())]
|
||||
Write { file_path: PathBuf, source: std::io::Error },
|
||||
|
||||
#[display("Failed to create directory: {}", dir.display())]
|
||||
CreateDir { dir: PathBuf, source: std::io::Error },
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
//! Server state
|
||||
|
||||
const STATE_VERSION: u64 = 1;
|
||||
|
||||
use super::persistence::Persistent;
|
||||
use crate::models;
|
||||
use crate::models::Wall;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use smart_default::SmartDefault;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct State {
|
||||
pub persistent: Persistent<PersistentState>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, SmartDefault)]
|
||||
pub struct PersistentState {
|
||||
/// State schema version
|
||||
#[default(STATE_VERSION)]
|
||||
pub version: u64,
|
||||
|
||||
pub wall: Wall,
|
||||
pub problems: Problems,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
pub struct Problems {
|
||||
pub problems: BTreeSet<models::Problem>,
|
||||
}
|
162
crates/ascend/src/server_functions.rs
Normal file
162
crates/ascend/src/server_functions.rs
Normal file
@ -0,0 +1,162 @@
|
||||
use crate::codec::ron::Ron;
|
||||
use crate::codec::ron::RonEncoded;
|
||||
use crate::models;
|
||||
use leptos::server;
|
||||
use server_fn::ServerFnError;
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub async fn get_walls() -> Result<RonEncoded<Vec<models::Wall>>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use leptos::prelude::expect_context;
|
||||
use redb::ReadableTable;
|
||||
tracing::debug!("Enter");
|
||||
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let walls = db
|
||||
.read(|txn| {
|
||||
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
|
||||
let walls: Vec<models::Wall> = walls_table.iter()?.map(|r| r.map(|(_, v)| v.value())).collect::<Result<_, _>>()?;
|
||||
Ok(walls)
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Exit");
|
||||
|
||||
Ok(RonEncoded::new(walls))
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(skip_all, err(Debug))]
|
||||
pub(crate) async fn get_wall(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");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
enum Error {
|
||||
#[display("Wall not found: {_0:?}")]
|
||||
NotFound(#[error(not(source))] models::WallUid),
|
||||
}
|
||||
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let wall = db
|
||||
.read(|txn| {
|
||||
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
|
||||
let wall = walls_table
|
||||
.get(wall_uid)?
|
||||
.ok_or(Error::NotFound(wall_uid))
|
||||
.map_err(DatabaseOperationError::custom)?
|
||||
.value();
|
||||
Ok(wall)
|
||||
})
|
||||
.await?;
|
||||
|
||||
tracing::debug!("ok");
|
||||
|
||||
Ok(RonEncoded::new(wall))
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn get_problems_for_wall(wall_uid: models::WallUid) -> Result<RonEncoded<Vec<models::Problem>>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::debug!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
|
||||
enum Error {
|
||||
#[display("Wall not found: {_0:?}")]
|
||||
WallNotFound(#[error(not(source))] models::WallUid),
|
||||
|
||||
DatabaseOperation(DatabaseOperationError),
|
||||
}
|
||||
|
||||
async fn inner(wall_uid: models::WallUid) -> Result<Vec<models::Problem>, Error> {
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let problems = db
|
||||
.read(|txn| {
|
||||
let walls_table = txn.open_table(crate::server::db::current::TABLE_WALLS)?;
|
||||
tracing::debug!("getting wall");
|
||||
let wall = walls_table
|
||||
.get(wall_uid)?
|
||||
.ok_or(Error::WallNotFound(wall_uid))
|
||||
.map_err(DatabaseOperationError::custom)?
|
||||
.value();
|
||||
tracing::debug!("got wall");
|
||||
drop(walls_table);
|
||||
|
||||
tracing::debug!("open problems table");
|
||||
let problems_table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
|
||||
tracing::debug!("opened problems table");
|
||||
|
||||
let mut problems = Vec::new();
|
||||
for &problem_uid in &wall.problems {
|
||||
if let Some(problem) = problems_table.get((wall_uid, problem_uid))? {
|
||||
problems.push(problem.value());
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(problems)
|
||||
}
|
||||
|
||||
let problems = inner(wall_uid).await.map_err(error_reporter::Report::new).map_err(ServerFnError::new)?;
|
||||
|
||||
tracing::debug!("ok");
|
||||
|
||||
Ok(RonEncoded::new(problems))
|
||||
}
|
||||
|
||||
#[server(
|
||||
input = Ron,
|
||||
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> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
tracing::debug!("Enter");
|
||||
|
||||
#[derive(Debug, derive_more::Error, derive_more::Display)]
|
||||
enum Error {
|
||||
#[display("Problem not found: {_0:?}")]
|
||||
NotFound(#[error(not(source))] models::ProblemUid),
|
||||
}
|
||||
|
||||
let db = expect_context::<Database>();
|
||||
let problem = db
|
||||
.read(|txn| {
|
||||
let table = txn.open_table(crate::server::db::current::TABLE_PROBLEMS)?;
|
||||
let problem = table
|
||||
.get((wall_uid, problem_uid))?
|
||||
.ok_or(Error::NotFound(problem_uid))
|
||||
.map_err(DatabaseOperationError::custom)?
|
||||
.value();
|
||||
Ok(problem)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(RonEncoded::new(problem))
|
||||
}
|
18
flake.lock
generated
18
flake.lock
generated
@ -10,11 +10,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1729251766,
|
||||
"narHash": "sha256-/tOGBbFKgIii6L0VZdJ2MFdhzTt0BtEsAFbWITXeIxA=",
|
||||
"lastModified": 1739793388,
|
||||
"narHash": "sha256-mf0FJ7JJi5gTUFz0SyWF8bqqonxoFD2DG9D785uyYJM=",
|
||||
"owner": "plul",
|
||||
"repo": "basecamp",
|
||||
"rev": "aae7006aec576140aadf3fdea4ed7eae904dda14",
|
||||
"rev": "f0f702ef6d5e8446eb8cd64e56fe1fe3cfbc677d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -25,11 +25,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1736420959,
|
||||
"narHash": "sha256-dMGNa5UwdtowEqQac+Dr0d2tFO/60ckVgdhZU9q2E2o=",
|
||||
"lastModified": 1738797219,
|
||||
"narHash": "sha256-KRwX9Z1XavpgeSDVM/THdFd6uH8rNm/6R+7kIbGa+2s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "32af3611f6f05655ca166a0b1f47b57c762b5192",
|
||||
"rev": "1da52dd49a127ad74486b135898da2cef8c62665",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -53,11 +53,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1736649126,
|
||||
"narHash": "sha256-XCw5sv/ePsroqiF3lJM6Y2X9EhPdHeE47gr3Q8b0UQw=",
|
||||
"lastModified": 1738895285,
|
||||
"narHash": "sha256-4Ukr4reJfQ67c6QqIxbX47wnPIGxE8BXCAEPu1C3MFM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "162ab0edc2936508470199b2e8e6c444a2535019",
|
||||
"rev": "85f3aed5f4b8eb312c6e8fe8c476bac248aed75f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -75,12 +75,7 @@
|
||||
};
|
||||
|
||||
nixosModules.default =
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.services.ascend;
|
||||
in
|
||||
|
28
justfile
28
justfile
@ -9,19 +9,19 @@ fmt:
|
||||
bc-fmt
|
||||
|
||||
serve:
|
||||
RUST_LOG=debug RUST_BACKTRACE=1 cargo leptos watch -- serve
|
||||
RUST_BACKTRACE=1 cargo leptos watch -- serve
|
||||
|
||||
build-release:
|
||||
rm -rf dist
|
||||
mkdir dist
|
||||
cargo leptos build --release -vv
|
||||
cp target/release/ascend dist/
|
||||
cp -r target/site dist/
|
||||
# build-release:
|
||||
# rm -rf dist
|
||||
# mkdir dist
|
||||
# cargo leptos build --release -vv
|
||||
# cp target/release/ascend dist/
|
||||
# cp -r target/site dist/
|
||||
|
||||
run-release:
|
||||
#!/usr/bin/env bash
|
||||
cd dist
|
||||
LEPTOS_SITE_ROOT="site" LEPTOS_SITE_ADDR="127.0.0.1:1337" ./ascend serve
|
||||
# run-release:
|
||||
# #!/usr/bin/env bash
|
||||
# cd dist
|
||||
# LEPTOS_SITE_ROOT="site" LEPTOS_SITE_ADDR="127.0.0.1:1337" ./ascend serve
|
||||
|
||||
reset-state:
|
||||
cargo run --features ssr -- reset-state
|
||||
@ -42,3 +42,9 @@ prod-deploy:
|
||||
|
||||
prod-logs:
|
||||
ssh 192.168.1.3 'journalctl --unit ascend.service'
|
||||
|
||||
leptos-discord:
|
||||
xdg-open "https://discord.com/channels/1031524867910148188/1031524868883218474"
|
||||
|
||||
leptos-issues:
|
||||
xdg-open "https://github.com/leptos-rs/leptos/issues"
|
||||
|
12
todo.md
Normal file
12
todo.md
Normal file
@ -0,0 +1,12 @@
|
||||
- save images with a uuid
|
||||
- downscale images
|
||||
- associate routes with wall
|
||||
- group routes by pattern (pattern family has shift/mirror variations)
|
||||
- generate pattern families of variations when importing problems
|
||||
- implement pattern challenge (start an "adventure mode" based on a pattern family)
|
||||
- Record problem success (enum: flash, send, no-send)
|
||||
- implement routes page to show all routes for a given wall
|
||||
- implement favorite routes feature
|
||||
- use wall id in URL.
|
||||
- decide on routes vs problems terminology
|
||||
- decide on holds vs wall-edit terminology
|
Loading…
x
Reference in New Issue
Block a user