This commit is contained in:
Asger Juul Brunshøj 2025-01-25 12:56:55 +01:00
parent 09d09159f2
commit b49c6d475b
16 changed files with 333 additions and 152 deletions

89
Cargo.lock generated
View File

@ -107,6 +107,7 @@ dependencies = [
"axum",
"camino",
"clap",
"confik",
"console_error_panic_hook",
"derive_more",
"http 1.2.0",
@ -130,6 +131,7 @@ dependencies = [
"type-toppings",
"wasm-bindgen",
"web-sys",
"xdg",
]
[[package]]
@ -314,6 +316,9 @@ name = "camino"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3"
dependencies = [
"serde",
]
[[package]]
name = "cfg-if"
@ -433,6 +438,33 @@ dependencies = [
"toml",
]
[[package]]
name = "confik"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcb80c5315aacb4f2a7ef6ddb6b2fe7818191e78098f5ac597372647b9958039"
dependencies = [
"camino",
"cfg-if",
"confik-macros",
"envious",
"serde",
"thiserror 1.0.69",
"toml",
]
[[package]]
name = "confik-macros"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "370f641e4f578df8c52e7c37300d5d9d2b12be6fb6f4732b62a794e2b647d1a2"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@ -490,6 +522,41 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
[[package]]
name = "darling"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "dashmap"
version = "6.1.0"
@ -577,6 +644,16 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "envious"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e52788a407588138195a40c991f500621fea2cffa87e7345d86dbab77287dc7"
dependencies = [
"serde",
"thiserror 1.0.69",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@ -1215,6 +1292,12 @@ dependencies = [
"syn",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.0.3"
@ -3077,6 +3160,12 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "xdg"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"
[[package]]
name = "xxhash-rust"
version = "0.8.15"

View File

@ -3,4 +3,3 @@
<p align="center">
<img src="docs/ascend.jpg" alt="Logo" width="100%">
</p>

View File

@ -34,6 +34,8 @@ ron = { version = "0.8" }
rand = { version = "0.8", optional = true }
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 }
[dev-dependencies.serde_json]
version = "1"
@ -47,6 +49,8 @@ ssr = [
"dep:tower",
"dep:tower-http",
"dep:leptos_axum",
"dep:confik",
"dep:xdg",
"dep:camino",
"dep:moonboard-parser",
"leptos/ssr",

View File

@ -0,0 +1,9 @@
use leptos::prelude::*;
use web_sys::MouseEvent;
#[component]
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>
}
}

View File

@ -14,7 +14,10 @@ pub struct HeaderItem {
/// Header with background color etc.
#[component]
pub fn StyledHeader(items: HeaderItems) -> impl IntoView {
view! {
let fancy = false;
if fancy {
view! {
<div class="flex">
// Left gradient chunk
<div class="flex-grow">
@ -53,6 +56,17 @@ pub fn StyledHeader(items: HeaderItems) -> impl IntoView {
/>
</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()
}
}
@ -84,11 +98,7 @@ pub fn Header(items: HeaderItems) -> impl IntoView {
#[component]
fn Items(items: Vec<HeaderItem>) -> impl IntoView {
let items = items.into_iter().map(|item| view! { <Item item /> }).collect_view();
view! {
<div class="flex gap-4">
{ items }
</div>
}
view! { <div class="flex gap-4">{items}</div> }
}
#[component]

View File

@ -5,6 +5,7 @@ pub mod pages {
pub mod wall;
}
pub mod components {
pub mod button;
pub mod header;
}

View File

@ -6,7 +6,6 @@ use leptos::prelude::*;
use serde::Deserialize;
use serde::Serialize;
use std::ops::Deref;
use std::path::PathBuf;
#[component]
pub fn Routes() -> impl leptos::IntoView {
@ -44,11 +43,15 @@ pub fn Routes() -> impl leptos::IntoView {
fn Ready(data: InitialData) -> impl leptos::IntoView {
tracing::debug!("ready");
// let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
let import_from_mini_moonboard = Action::from(ServerAction::<ImportFromMiniMoonboard>::new());
let onclick = move |_mouse_event| {
import_from_mini_moonboard.dispatch(ImportFromMiniMoonboard {});
};
view! {
// <p>"Import problems from"</p>
// <button on:click=import_from_mini_moonboard>"Mini Moonboard"</button>
<p>"Import problems from"</p>
<button on:click=onclick>"Mini Moonboard"</button>
}
}
@ -60,26 +63,24 @@ async fn load_initial_data() -> Result<RonCodec<InitialData>, ServerFnError> {
// use crate::server::state::State;
// let state = expect_context::<State>();
// TODO: provide info on current routes set
Ok(RonCodec::new(InitialData {}))
}
#[server(name = ImportFromMiniMoonboard)]
#[tracing::instrument]
async fn import_from_mini_moonboard() -> Result<(), ServerFnError> {
use crate::server::config::Config;
use crate::server::state::State;
tracing::info!("Importing mini moonboard problems");
let file_path: PathBuf = todo!();
let problems = crate::server::import_mini_moonboard_problems(&file_path).await?;
use crate::server::state::State;
let config = expect_context::<Config>();
let state = expect_context::<State>();
state
.persistent
.update(|s| {
s.problems.problems.extend(problems);
})
.await?;
crate::server::operations::import_mini_moonboard_problems(&config, &state).await?;
// TODO: Return information about what was done
Ok(())
}

View File

@ -1,4 +1,5 @@
use crate::codec::ron::RonCodec;
use crate::components::button::Button;
use crate::components::header::HeaderItem;
use crate::components::header::HeaderItems;
use crate::components::header::StyledHeader;
@ -20,7 +21,7 @@ pub fn Wall() -> impl leptos::IntoView {
let header_items = HeaderItems {
left: vec![],
middle: vec![HeaderItem {
text: "Ascend".to_string(),
text: "ASCEND".to_string(),
link: None,
}],
right: vec![
@ -37,9 +38,9 @@ pub fn Wall() -> impl leptos::IntoView {
leptos::view! {
<div class="min-w-screen min-h-screen bg-slate-900">
<StyledHeader items=header_items />
<StyledHeader items=header_items />
<div class="container mx-auto mt-2">
<div class="mx-auto m-2">
<Await future=load let:data>
<Ready data=data.deref().to_owned() />
</Await>
@ -74,8 +75,14 @@ fn Ready(data: InitialData) -> impl leptos::IntoView {
let grid_classes = format!("grid grid-rows-{} grid-cols-{} gap-3", data.wall.rows, data.wall.cols);
view! {
<div class=move || { grid_classes.clone() }>{cells}</div>
<button on:click=move |_| problem_fetcher.mark_dirty()>"Random problem"</button>
<div class="grid grid-cols sm:grid-cols-2 gap-8 grid-cols-[auto,1fr]">
// 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>
}
}

View File

@ -1,80 +1,24 @@
//! Server-only features
use crate::models;
use models::HoldPosition;
use models::HoldRole;
use models::Problem;
use cli::Cli;
use config::Config;
use confik::Configuration;
use confik::EnvSource;
use persistence::Persistent;
use state::PersistentState;
use state::State;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use tower_http::services::ServeDir;
use tracing::level_filters::LevelFilter;
use type_toppings::ResultExt;
pub mod cli {
//! Server CLI interface
use std::path::PathBuf;
#[derive(clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(clap::Subcommand, Default)]
pub enum Command {
#[default]
Serve,
/// Resets state, replacing it with defaults
ResetState,
ImportMiniMoonboardProblems {
file_path: PathBuf,
},
}
}
pub mod state {
//! 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>,
}
}
mod cli;
pub mod config;
mod migrations;
pub mod operations;
pub mod persistence;
pub mod state;
pub const STATE_FILE: &str = "datastore/private/state.ron";
@ -96,19 +40,9 @@ pub async fn main() {
.init();
let cli = Cli::parse();
match cli.command {
Command::Serve => serve().await.unwrap_or_report(),
Command::ImportMiniMoonboardProblems { file_path } => {
let problems = import_mini_moonboard_problems(&file_path).await.unwrap_or_report();
let state = load_state().await.unwrap_or_report();
state
.persistent
.update(|s| {
s.problems.problems.extend(problems);
})
.await
.unwrap_or_report();
}
Command::Serve => serve(cli).await.unwrap_or_report(),
Command::ResetState => {
let s = PersistentState::default();
let p = Path::new(STATE_FILE);
@ -118,8 +52,8 @@ pub async fn main() {
}
}
#[tracing::instrument(err)]
async fn serve() -> Result<(), Error> {
#[tracing::instrument(skip(cli), err)]
async fn serve(cli: Cli) -> Result<(), Error> {
use crate::app::App;
use crate::app::shell;
use axum::Router;
@ -127,25 +61,34 @@ async fn serve() -> Result<(), Error> {
use leptos_axum::LeptosRoutes;
use leptos_axum::generate_route_list;
run_migrations().await.map_err(self::Error::Migration)?;
migrations::run_migrations().await;
// 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 conf = get_configuration(None).unwrap_or_report();
let leptos_options = conf.leptos_options;
let leptos_conf_file = 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?;
let app = Router::new()
.leptos_routes_with_context(&leptos_options, routes, move || provide_context(server_state.clone()), {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.leptos_routes_with_context(
&leptos_options,
routes,
move || {
provide_context(server_state.clone());
provide_context(config.clone())
},
{
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
},
)
.nest_service("/files", file_service("datastore/public"))
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
@ -179,46 +122,17 @@ async fn load_state() -> Result<State, Error> {
Ok(State { persistent })
}
#[tracing::instrument]
pub(crate) async fn import_mini_moonboard_problems(file_path: &Path) -> Result<Vec<Problem>, Error> {
let mut problems = Vec::new();
tracing::info!("Parsing mini moonboard problems from {}", file_path.display());
let mini_moonboard = moonboard_parser::mini_moonboard::parse(file_path).await?;
for problem in mini_moonboard.problems {
let mut holds = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in problem.moves {
let row = mv.description.row();
let col = mv.description.column();
let hold_position = HoldPosition { row, col };
let role = match (mv.is_start, mv.is_end) {
(true, true) => unreachable!(),
(true, false) => HoldRole::Start,
(false, true) => HoldRole::End,
(false, false) => HoldRole::Normal,
};
holds.insert(hold_position, role);
}
let route = Problem { holds };
problems.push(route);
}
Ok(problems)
}
async fn run_migrations() -> Result<(), Box<dyn std::error::Error>> {
// State file moved to datastore/private
fn load_config(cli: Cli) -> Result<Config, Error> {
let mut builder = config::Config::builder();
if cli
.config
.try_exists()
.expect_or_report_with(|| format!("Failed to look up config file at {}", cli.config))
{
let m = PathBuf::from("state.ron");
if m.try_exists()? {
tracing::warn!("MIGRATING STATE FILE");
let p = PathBuf::from(STATE_FILE);
tokio::fs::create_dir_all(p.parent().unwrap()).await?;
tokio::fs::rename(m, &p).await?;
}
builder.override_with(confik::FileSource::new(cli.config));
}
Ok(())
let config = builder.override_with(EnvSource::new().allow_secrets()).try_build()?;
Ok(config)
}
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
@ -230,4 +144,6 @@ pub enum Error {
#[display("Failed migration")]
Migration(Box<dyn std::error::Error>),
Confik(confik::Error),
}

View File

@ -0,0 +1,33 @@
//! Server CLI interface
#[derive(clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
/// Path to configuration file.
#[arg(long, default_value_t = default_config_location())]
pub config: camino::Utf8PathBuf,
}
#[derive(clap::Subcommand, Default)]
pub enum Command {
#[default]
Serve,
/// Resets state, replacing it with defaults
ResetState,
}
fn default_config_location() -> camino::Utf8PathBuf {
let xdg_dirs = xdg::BaseDirectories::with_prefix("ascend").unwrap();
let config_path = xdg_dirs.get_config_file("config.toml");
camino::Utf8PathBuf::from_path_buf(config_path).unwrap()
}
#[cfg(test)]
#[test]
fn verify_cli() {
<Cli as clap::CommandFactory>::command().debug_assert()
}

View File

@ -0,0 +1,7 @@
use camino::Utf8PathBuf;
#[derive(Clone, Debug, confik::Configuration)]
pub struct Config {
/// The location of the moonboard problems directory.
pub moonboard_problems: Utf8PathBuf,
}

View File

@ -0,0 +1,20 @@
use crate::server::STATE_FILE;
use std::path::PathBuf;
use type_toppings::ResultExt;
#[tracing::instrument]
pub async fn run_migrations() {
migrate_state_file().await;
}
/// State file moved to datastore/private
#[tracing::instrument]
async fn migrate_state_file() {
let m = PathBuf::from("state.ron");
if m.try_exists().expect_or_report_with(|| format!("Failed to read {}", m.display())) {
tracing::warn!("MIGRATING STATE FILE");
let p = PathBuf::from(STATE_FILE);
tokio::fs::create_dir_all(p.parent().unwrap()).await.unwrap_or_report();
tokio::fs::rename(m, &p).await.unwrap_or_report();
}
}

View File

@ -0,0 +1,54 @@
//! Server lib module to host re-usable server operations.
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 std::collections::BTreeMap;
#[tracing::instrument(skip(state))]
pub(crate) async fn import_mini_moonboard_problems(config: &Config, state: &State) -> Result<(), Error> {
let mut problems = Vec::new();
let file_name = "problems Mini MoonBoard 2020 40.json";
let file_path = config.moonboard_problems.join(file_name);
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 mut holds = BTreeMap::<HoldPosition, HoldRole>::new();
for mv in problem.moves {
let row = mv.description.row();
let col = mv.description.column();
let hold_position = HoldPosition { row, col };
let role = match (mv.is_start, mv.is_end) {
(true, true) => unreachable!(),
(true, false) => HoldRole::Start,
(false, true) => HoldRole::End,
(false, false) => HoldRole::Normal,
};
holds.insert(hold_position, role);
}
let route = Problem { holds };
problems.push(route);
}
state
.persistent
.update(|s| {
s.problems.problems.extend(problems);
})
.await?;
Ok(())
}
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
pub enum Error {
Parser(moonboard_parser::Error),
Persistence(persistence::Error),
}

View File

@ -0,0 +1,31 @@
//! 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>,
}

View File

@ -148,6 +148,7 @@
];
env.RUST_LOG = "info,ascend=trace";
env.MOONBOARD_PROBLEMS = "moonboard-problems";
};
};
}

View File

@ -25,7 +25,6 @@ run-release:
reset-state:
cargo run --features ssr -- reset-state
cargo run --features ssr -- import-mini-moonboard-problems "moonboard-problems/problems Mini MoonBoard 2020 40.json"
# Open firewall port for development
open-firewall: