Add initial commit

Copied from https://github.com/vsquad-gitea/pool-elo
This commit is contained in:
2023-12-10 02:28:49 -05:00
parent d74b45a681
commit 4a3f8f5004
28 changed files with 777 additions and 17 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
rustflags = [ "--cfg", "engine" ]

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.github
.vscode
LICENSE
README.md
.gitignore
Dockerfile
node_modules
target

23
.gitignore vendored
View File

@@ -1,16 +1,9 @@
# ---> Rust node_modules
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock Cargo.lock
dist
# These are backup files generated by rustfmt target
**/*.rs.bk static
pkg
# MSVC Windows builds of rustc generate these, which store debugging information ./Cargo.lock
*.pdb package-lock.json
/data/

34
Cargo.toml Normal file
View File

@@ -0,0 +1,34 @@
[package]
resolver = "2"
name = "yugioh-inv"
version = "0.1.0"
edition = "2021"
[dependencies]
perseus = { version = "0.4.2", features = ["hydrate"] }
sycamore = { version = "0.8.2", features = [
"suspense",
"web",
"wasm-bindgen-interning",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
env_logger = "0.10.0"
log = "0.4.20"
once_cell = "1.18.0"
web-sys = "0.3.64"
cfg-if = "1.0.0"
chrono = { version = "0.4.31", features = ["serde"] }
[target.'cfg(engine)'.dev-dependencies]
fantoccini = "0.19"
[target.'cfg(engine)'.dependencies]
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
perseus-axum = { version = "0.4.2" }
axum = "0.6"
tower-http = { version = "0.3", features = ["fs"] }
[target.'cfg(client)'.dependencies]
wasm-bindgen = "0.2"
reqwest = { version = "0.11", features = ["json"] }

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM debian:stable-slim
WORKDIR /var/www/app
COPY pkg server
RUN addgroup --system server && \
usermod -a -G server www-data && \
chown -R www-data:server /var/www/app
USER www-data
EXPOSE 80
CMD ["./server/server"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 matkam7 Copyright (c) 2023 vsquad
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -1,2 +1,53 @@
# yugioh-inventory # Installing requirements
## 1. Install rust:
### Windows:
Download installer from https://www.rust-lang.org/tools/install
### Unix based systems:
Run `curl --proto '=https' --tlsv1.3 https://sh.rustup.rs -sSf | sh`
## 2. Install npm
### Windows:
https://nodejs.org/en
### Unix based systems:
`sudo apt install nodejs`
`sudo apt install npm`
## 3. Install Perseus, for real-time updates while developing
`cargo install perseus-cli`
`rustup target add wasm32-unknown-unknown`
## 4. Install tailwindcss, for styling
`npm install -D tailwindcss`
Also take a look at
Website:
https://framesurge.sh/perseus/en-US/
Simple tutorial:
https://blog.logrocket.com/building-rust-app-perseus/
# Building the project
To build CSS run:
`npm run build`
To build the project for testing, run
`perseus serve --verbose`
# Deploying the project
First run
`perseus deploy`
The folder with everything necessary will be in `/pkg`

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "yugioh-inv",
"version": "1.0.0",
"description": "yugioh-inv",
"main": "index.js",
"scripts": {
"build": "npx tailwindcss -i ./style/tailwind.css -o ./static/style.css",
"watch": "npx tailwindcss -i ./style/tailwind.css -o ./static/style.css --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"tailwindcss": "^3.3.3"
}
}

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]

51
src/components/layout.rs Normal file
View File

@@ -0,0 +1,51 @@
use sycamore::prelude::*;
#[derive(Prop)]
pub struct LayoutProps<'a, G: Html> {
pub title: &'a str,
pub children: Children<'a, G>,
}
#[component]
pub fn Layout<'a, G: Html>(
cx: Scope<'a>,
LayoutProps { title: _, children }: LayoutProps<'a, G>,
) -> View<G> {
let children = children.call(cx);
view! { cx,
header {
div (class = "flex items-center justify-between") {
div (class = "w-full text-gray-700 md:text-center text-2xl font-semibold") {
"Yugioh Inventory"
}
}
div (class = "container mx-auto px-6 py-3") {
nav (class = "sm:flex sm:justify-center sm:items-center mt-4 hidden") {
div (class = "flex flex-col sm:flex-row"){
a(href = "add-game-form",
class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0"
) { "Add game result" }
a(href = "one-v-one-board",
class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0"
) { "1v1 Leaderboard" }
a(href = "overall-board",
class = "mt-3 text-gray-600 hover:underline sm:mx-3 sm:mt-0"
) { "Overall Leaderboard" }
}
}
}
}
main(style = "my-8") {
div(class = "container mx-auto px-6") {
div(class = "md:flex mt-8 md:-mx-4") {
div(class = "rounded-md overflow-hidden bg-cover bg-center") {
(children)
}
}
}
}
}
}

1
src/components/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod layout;

5
src/data/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod pool_match;
pub mod user;
#[cfg(engine)]
pub mod store;

55
src/data/pool_match.rs Normal file
View File

@@ -0,0 +1,55 @@
use crate::data::user::PlayerId;
use chrono::serde::ts_seconds;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub type MatchId = u32;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum MatchType {
Standard8Ball,
Standard9Ball,
CutThroat,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct MatchData {
pub type_: MatchType,
pub winners: Vec<PlayerId>,
pub losers: Vec<PlayerId>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PoolMatch {
pub id: MatchId,
pub data: MatchData,
#[serde(with = "ts_seconds")]
pub time: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PoolMatchList {
pub pool_matches: Vec<PoolMatch>,
pub max_id: MatchId,
}
impl PoolMatch {
pub fn new(data: MatchData, time: DateTime<Utc>) -> PoolMatch {
PoolMatch { id: 0, data, time }
}
}
impl PoolMatchList {
pub fn new() -> PoolMatchList {
PoolMatchList {
pool_matches: vec![],
max_id: 0,
}
}
pub fn add_pool_match(&mut self, mut pool_match: PoolMatch) {
pool_match.id = self.max_id + 1;
self.max_id += 1;
self.pool_matches.push(pool_match);
}
}

34
src/data/store.rs Normal file
View File

@@ -0,0 +1,34 @@
// (Server only) In-memory data storage and persistent storage
use crate::data::pool_match::PoolMatchList;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{fs, path::Path, sync::Mutex};
#[derive(Serialize, Deserialize, Clone)]
pub struct Store {
pub matches: PoolMatchList,
}
impl Store {
fn new() -> Store {
fs::create_dir_all("data").unwrap();
match Path::new("data/store.json").exists() {
false => Store {
matches: PoolMatchList::new(),
},
true => {
let contents = fs::read_to_string("data/store.json").unwrap();
serde_json::from_str(&contents).unwrap()
}
}
}
// TODO -> Store data
#[allow(dead_code)]
pub fn write(&self) {
let contents = serde_json::to_string(&self).unwrap();
fs::write("data/store.json", contents).unwrap();
}
}
pub static DATA: Lazy<Mutex<Store>> = Lazy::new(|| Mutex::new(Store::new()));

3
src/data/user.rs Normal file
View File

@@ -0,0 +1,3 @@
pub type PlayerId = u32;

1
src/endpoints.rs Normal file
View File

@@ -0,0 +1 @@
pub const MATCH: &str = "/api/post-match";

62
src/error_views.rs Normal file
View File

@@ -0,0 +1,62 @@
use perseus::errors::ClientError;
use perseus::prelude::*;
use sycamore::prelude::*;
pub fn get_error_views<G: Html>() -> ErrorViews<G> {
ErrorViews::new(|cx, err, _err_info, _err_pos| {
match err {
ClientError::ServerError { status, message: _ } => match status {
404 => (
view! { cx,
title { "Page not found" }
},
view! { cx,
p { "Sorry, that page doesn't seem to exist." }
},
),
// 4xx is a client error
_ if (400..500).contains(&status) => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "There was something wrong with the last request, please try reloading the page." }
},
),
// 5xx is a server error
_ => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "Sorry, our server experienced an internal error. Please try reloading the page." }
},
),
},
ClientError::Panic(_) => (
view! { cx,
title { "Critical error" }
},
view! { cx,
p { "Sorry, but a critical internal error has occurred. This has been automatically reported to our team, who'll get on it as soon as possible. In the mean time, please try reloading the page." }
},
),
ClientError::FetchError(_) => (
view! { cx,
title { "Error" }
},
view! { cx,
p { "A network error occurred, do you have an internet connection? (If you do, try reloading the page.)" }
},
),
_ => (
view! { cx,
title { "Error" }
},
view! { cx,
p { (format!("An internal error has occurred: '{}'.", err)) }
},
),
}
})
}

71
src/main.rs Normal file
View File

@@ -0,0 +1,71 @@
mod components;
mod data;
mod endpoints;
mod error_views;
#[cfg(engine)]
mod server;
mod templates;
use perseus::prelude::*;
use sycamore::prelude::view;
cfg_if::cfg_if! {
if #[cfg(engine)] {
use std::net::SocketAddr;
use perseus::{
i18n::TranslationsManager,
server::ServerOptions,
stores::MutableStore,
turbine::Turbine,
};
use crate::server::routes::register_routes;
}
}
#[cfg(engine)]
pub async fn dflt_server<M: MutableStore + 'static, T: TranslationsManager + 'static>(
turbine: &'static Turbine<M, T>,
opts: ServerOptions,
(host, port): (String, u16),
) {
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
.expect("Invalid address provided to bind to.");
let mut app = perseus_axum::get_router(turbine, opts).await;
app = register_routes(app);
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
#[perseus::main(dflt_server)]
pub fn main<G: Html>() -> PerseusApp<G> {
env_logger::init();
PerseusApp::new()
.global_state_creator(crate::templates::global_state::get_global_state_creator())
.template(crate::templates::index::get_template())
.template(crate::templates::add_game_form::get_template())
.template(crate::templates::one_v_one_board::get_template())
.template(crate::templates::overall_board::get_template())
.error_views(crate::error_views::get_error_views())
.index_view(|cx| {
view! { cx,
html (class = "flex w-full h-full"){
head {
meta(charset = "UTF-8")
meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
// Perseus automatically resolves `/.perseus/static/` URLs to the contents of the `static/` directory at the project root
link(rel = "stylesheet", href = ".perseus/static/style.css")
}
body (class = "w-full"){
// Quirk: this creates a wrapper `<div>` around the root `<div>` by necessity
PerseusRoot()
}
}
}
})
}

2
src/server/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod routes;

34
src/server/routes.rs Normal file
View File

@@ -0,0 +1,34 @@
// (Server only) Routes
use crate::{
data::{
pool_match::{PoolMatch, PoolMatchList},
store::DATA,
},
endpoints::MATCH,
};
use axum::{
extract::Json,
routing::{post, Router},
};
use std::thread;
pub fn register_routes(app: Router) -> Router {
let app = app.route(MATCH, post(post_match));
app
}
async fn post_match(Json(pool_match): Json<PoolMatch>) -> Json<PoolMatchList> {
// Update the store with the new match
let matches = thread::spawn(move || {
// Get the store
let mut data = DATA.lock().unwrap();
(*data).matches.add_pool_match(pool_match);
println!("{:?}", (*data).matches.pool_matches);
(*data).matches.clone()
})
.join()
.unwrap();
Json(matches)
}

View File

@@ -0,0 +1,101 @@
use crate::data::pool_match::MatchType;
use crate::{components::layout::Layout, data::pool_match::MatchData};
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use web_sys::Event;
cfg_if::cfg_if! {
if #[cfg(client)] {
use crate::data::pool_match::{PoolMatch, PoolMatchList};
use crate::templates::global_state::AppStateRx;
use crate::endpoints::MATCH;
use crate::templates::get_api_path;
use chrono::Utc;
}
}
// Reactive page
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "PageStateRx")]
struct PageState {
name: String,
}
fn add_game_form_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, state: &'a PageStateRx) -> View<G> {
let handle_add_match = move |_event: Event| {
#[cfg(client)]
{
// state.name.get().as_ref().clone()
spawn_local_scoped(cx, async move {
let new_match = PoolMatch::new(
MatchData {
type_: MatchType::Standard8Ball,
winners: vec![1],
losers: vec![2, 3, 4],
},
Utc::now(),
);
let client = reqwest::Client::new();
let new_matches = client
.post(get_api_path(MATCH).as_str())
.json(&new_match)
.send()
.await
.unwrap()
.json::<PoolMatchList>()
.await
.unwrap();
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
global_state.matches.set(new_matches);
})
}
};
view! { cx,
Layout(title = "Add Game Results") {
div (class = "flex flex-wrap") {
input (bind:value = state.name,
class = "appearance-none block w-full bg-gray-200 text-gray-700 border \
border-red-500 rounded py-3 px-4 mb-3 leading-tight focus:outline-none \
focus:bg-white",)
}
div (class = "flex flex-wrap") {
button(on:click = handle_add_match,
class = "flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 \
hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded",
) {
"Add result"
}
}
}
}
}
#[engine_only_fn]
async fn get_request_state(
_info: StateGeneratorInfo<()>,
_req: Request,
) -> Result<PageState, BlamedError<std::convert::Infallible>> {
Ok(PageState {
name: "Ferris".to_string(),
})
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "Add Game Form" }
}
}
// Template
pub fn get_template<G: Html>() -> Template<G> {
Template::build("add-game-form")
.request_state_fn(get_request_state)
.view_with_state(add_game_form_page)
.head(head)
.build()
}

View File

@@ -0,0 +1,44 @@
// Not a page, global state that is shared between all pages
use crate::data::pool_match::PoolMatchList;
use perseus::{prelude::*, state::GlobalStateCreator};
use serde::{Deserialize, Serialize};
cfg_if::cfg_if! {
if #[cfg(engine)] {
use std::thread;
use std::ops::Deref;
use crate::data::store::DATA;
}
}
#[derive(Serialize, Deserialize, ReactiveState, Clone)]
#[rx(alias = "AppStateRx")]
pub struct AppState {
pub matches: PoolMatchList,
}
pub fn get_global_state_creator() -> GlobalStateCreator {
GlobalStateCreator::new()
.build_state_fn(get_build_state)
.request_state_fn(get_request_state)
}
#[engine_only_fn]
fn get_state() -> AppState {
let matches = thread::spawn(move || DATA.lock().unwrap().deref().matches.clone())
.join()
.unwrap();
AppState { matches }
}
#[engine_only_fn]
pub async fn get_build_state() -> AppState {
get_state()
}
#[engine_only_fn]
pub async fn get_request_state(_req: Request) -> AppState {
get_state()
}

24
src/templates/index.rs Normal file
View File

@@ -0,0 +1,24 @@
use crate::components::layout::Layout;
use perseus::prelude::*;
use sycamore::prelude::*;
fn index_page<G: Html>(cx: Scope) -> View<G> {
view! { cx,
Layout(title = "Index") {
// Anything we put in here will be rendered inside the `<main>` block of the layout
p { "Hello World!" }
br {}
}
}
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "Index Page" }
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("").view(index_page).head(head).build()
}

22
src/templates/mod.rs Normal file
View File

@@ -0,0 +1,22 @@
pub mod add_game_form;
pub mod global_state;
pub mod index;
pub mod one_v_one_board;
pub mod overall_board;
#[cfg(client)]
use perseus::utils::get_path_prefix_client;
#[allow(dead_code)]
pub fn get_api_path(path: &str) -> String {
#[cfg(engine)]
{
path.to_string()
}
#[cfg(client)]
{
let origin = web_sys::window().unwrap().origin();
let base_path = get_path_prefix_client();
format!("{}{}{}", origin, base_path, path)
}
}

View File

@@ -0,0 +1,39 @@
use crate::components::layout::Layout;
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "PageStateRx")]
struct PageState {}
fn one_v_one_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View<G> {
view! { cx,
Layout(title = "1v1 Leaderboard") {
p { "leaderboard" }
}
}
}
#[engine_only_fn]
async fn get_request_state(
_info: StateGeneratorInfo<()>,
_req: Request,
) -> Result<PageState, BlamedError<std::convert::Infallible>> {
Ok(PageState {})
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "1v1 Leaderboard" }
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("one-v-one-board")
.request_state_fn(get_request_state)
.view_with_state(one_v_one_board_page)
.head(head)
.build()
}

View File

@@ -0,0 +1,72 @@
use crate::{components::layout::Layout, templates::global_state::AppStateRx, data::user::PlayerId};
use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*;
use crate::data::pool_match::PoolMatch;
#[derive(Serialize, Deserialize, Clone, ReactiveState)]
#[rx(alias = "PageStateRx")]
struct PageState {}
fn format_list_or_single(to_format: &Vec<PlayerId>) -> String{
match to_format.len() {
1 => to_format[0].to_string(),
_ => format!("{:?}", to_format),
}
}
fn overall_board_page<'a, G: Html>(cx: BoundedScope<'_, 'a>, _state: &'a PageStateRx) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx);
view! { cx,
Layout(title = "Overall Leaderboard") {
ul {
(View::new_fragment(
global_state.matches.get()
.pool_matches
.iter()
.rev()
.map(|item: &PoolMatch| {
let game = item.clone();
view! { cx,
li (class = "text-blue-700", id = "ha",) {
(game.id)
(" ")
(format_list_or_single(&game.data.winners))
(" ")
(format_list_or_single(&game.data.losers))
}
}
})
.collect(),
))
}
}
}
}
#[engine_only_fn]
async fn get_request_state(
_info: StateGeneratorInfo<()>,
_req: Request,
) -> Result<PageState, BlamedError<std::convert::Infallible>> {
Ok(PageState {})
}
#[engine_only_fn]
fn head(cx: Scope) -> View<SsrNode> {
view! { cx,
title { "Overall leaderboard" }
}
}
pub fn get_template<G: Html>() -> Template<G> {
Template::build("overall-board")
.request_state_fn(get_request_state)
.view_with_state(overall_board_page)
.head(head)
.build()
}

4
style/tailwind.css Normal file
View File

@@ -0,0 +1,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

14
tailwind.config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = {
purge: {
mode: "all",
content: [
"./src/**/*.rs",
"./index.html",
"./src/**/*.html",
"./src/**/*.css",
],
},
theme: {},
variants: {},
plugins: [],
};