This commit is contained in:
2025-01-12 16:07:46 +01:00
parent 4299ea3d9a
commit ea43e71c30
10 changed files with 399 additions and 90 deletions

View File

@@ -1,4 +1,3 @@
use leptos::logging;
use leptos::prelude::*;
pub fn shell(options: LeptosOptions) -> impl IntoView {
@@ -26,6 +25,9 @@ pub fn App() -> impl leptos::IntoView {
use leptos_meta::Stylesheet;
use leptos_meta::Title;
#[cfg(feature = "ssr")]
tracing::debug!("Rendering root component");
// Provides context that manages stylesheets, titles, meta tags, etc.
leptos_meta::provide_meta_context();
@@ -35,17 +37,15 @@ pub fn App() -> impl leptos::IntoView {
// sets the document title
<Title text="Ascend" />
<Ascend />
<main>
<Ascend />
</main>
}
}
#[leptos::component]
fn Ascend() -> impl leptos::IntoView {
logging::log!("Rendering root component");
leptos::view! {
<div>
{ "hello world" }
</div>
<crate::pages::wall::Wall />
}
}

View File

@@ -1,5 +1,11 @@
pub mod app;
pub mod pages {}
pub mod pages {
pub mod wall;
}
pub mod components {}
#[cfg(feature = "ssr")]
pub mod server;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
@@ -8,9 +14,3 @@ pub fn hydrate() {
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}
#[cfg(feature = "ssr")]
pub mod server {
#[derive(Debug, Clone)]
pub struct AppState {}
}

View File

@@ -1,73 +1,15 @@
#[cfg(feature = "ssr")]
mod cli {
#[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,
}
}
/// Server-side main function
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use ascend::app::App;
use ascend::app::shell;
use ascend::server::AppState;
use axum::Router;
use clap::Parser as _;
use leptos::logging;
use leptos::prelude::*;
use leptos_axum::LeptosRoutes;
use leptos_axum::generate_route_list;
use tracing_subscriber::EnvFilter;
tracing_subscriber::fmt()
.without_time()
.with_env_filter(EnvFilter::from_default_env())
.pretty()
.init();
let cli = cli::Cli::parse();
match cli.command {
cli::Command::Serve => {}
}
// 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();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
let app_state = AppState {};
// build our application with a route
let app = Router::new()
.leptos_routes_with_context(&leptos_options, routes, move || provide_context(app_state.clone()), {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
logging::log!("listening on http://{addr}");
axum::serve(listener, app.into_make_service()).await.unwrap();
ascend::server::main().await
}
/// Client-side main function
#[cfg(not(feature = "ssr"))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
eprintln!("Main function is empty. Run with the \"ssr\" feature.");
}

22
src/pages/wall.rs Normal file
View File

@@ -0,0 +1,22 @@
use leptos::prelude::*;
#[leptos::component]
pub fn Wall() -> impl leptos::IntoView {
let cells = (1..=(12 * 12))
.into_iter()
.map(|i| {
let i = i.to_string();
view! {
<div class="aspect-square rounded border-2 border-dashed border-sky-500 bg-indigo-100"> { i } </div>
}
})
.collect_view();
leptos::view! {
<div class="container mx-auto border">
<div class="grid grid-rows-4 grid-cols-12 gap-4">
{cells}
</div>
</div>
}
}

178
src/server.rs Normal file
View File

@@ -0,0 +1,178 @@
//! Server-only features
use persistence::Persistent;
use state::PersistentState;
use tracing::level_filters::LevelFilter;
use type_toppings::ResultExt;
pub mod cli {
//! Server CLI interface
#[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,
}
}
pub mod state {
//! Server state
use super::persistence::Persistent;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
#[derive(Clone, Debug)]
pub struct State {
pub persistent: Persistent<PersistentState>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct PersistentState {
pub wall: Wall,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Wall {
pub rows: u32,
pub cols: u32,
pub holds: BTreeMap<HoldPosition, Hold>,
pub routes: BTreeSet<route::Route>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HoldPosition {
pub row: u32,
pub col: u32,
}
#[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 {}
mod route {
use super::HoldPosition;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Route {
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,
}
}
}
pub mod persistence;
pub const STATE_FILE: &str = "state.json";
#[tracing::instrument]
pub async fn main() {
use crate::server::cli::Cli;
use crate::server::cli::Command;
use clap::Parser as _;
use tracing_subscriber::EnvFilter;
tracing_subscriber::fmt()
.without_time()
.with_file(true)
.with_line_number(true)
.with_target(false)
.with_ansi(true)
.with_env_filter(EnvFilter::from_default_env().add_directive(LevelFilter::INFO.into()))
.pretty()
.init();
let cli = Cli::parse();
match cli.command {
Command::Serve => serve().await.unwrap_or_report(),
Command::ResetState => {
let s = PersistentState::default();
let p = camino::Utf8Path::new(STATE_FILE);
tracing::info!("Resetting state to default: {p}");
Persistent::persist(p, &s).await.unwrap_or_report();
}
}
}
#[tracing::instrument(err)]
async fn serve() -> Result<(), Error> {
use crate::app::App;
use crate::app::shell;
use crate::server::state::State;
use axum::Router;
use leptos::prelude::*;
use leptos_axum::LeptosRoutes;
use leptos_axum::generate_route_list;
// 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 addr = leptos_options.site_addr;
let routes = generate_route_list(App);
tracing::info!("Loading state");
let server_state = State {
persistent: Persistent::<PersistentState>::load(STATE_FILE.into()).await?,
};
// build our application with a route
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())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on http://{addr}");
axum::serve(listener, app.into_make_service()).await?;
Ok(())
}
#[derive(Debug, derive_more::Error, derive_more::Display, derive_more::From)]
#[display("Server crash")]
pub enum Error {
Io(std::io::Error),
Persistence(persistence::Error),
}

122
src/server/persistence.rs Normal file
View File

@@ -0,0 +1,122 @@
use camino::Utf8Path;
use camino::Utf8PathBuf;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct Persistent<T> {
state: Arc<Mutex<T>>,
file_path: Utf8PathBuf,
}
impl<T> Persistent<T> {
#[tracing::instrument(skip(state))]
pub fn new(state: T, file_path: Utf8PathBuf) -> Self {
Self {
state: Arc::new(Mutex::new(state)),
file_path,
}
}
/// Instantiates state from file system
#[tracing::instrument]
pub async fn load(file_path: Utf8PathBuf) -> 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 = serde_json::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,
};
Ok(persistent)
}
/// 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
#[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: &Utf8Path, state: &T) -> Result<(), Error>
where
T: Serialize,
{
let serialized = serde_json::to_string(state).map_err(|source| Error::Serialize { source })?;
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}")]
Read { file_path: Utf8PathBuf, source: std::io::Error },
#[display("Failed to deserialize state from file: {file_path}")]
Deserialize { file_path: Utf8PathBuf, source: serde_json::Error },
#[display("Failed to serialize state")]
Serialize { source: serde_json::Error },
#[display("Failed to write file: {file_path}")]
Write { file_path: Utf8PathBuf, source: std::io::Error },
}