Compare commits
No commits in common. "main" and "cargo-leptos" have entirely different histories.
main
...
cargo-lept
15
application/.gitignore
vendored
15
application/.gitignore
vendored
@ -1,2 +1,13 @@
|
||||
/target
|
||||
/dist
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
pkg
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# node e2e test tools and outputs
|
||||
node_modules/
|
||||
test-results/
|
||||
end2end/playwright-report/
|
||||
playwright/.cache/
|
||||
|
@ -2,46 +2,119 @@
|
||||
name = "application"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["xeovalyte <me+gitea@xeovalyte.dev>"]
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.6", features = ["csr"] }
|
||||
leptos_meta = { version = "0.6", features = ["csr"] }
|
||||
leptos_router = { version = "0.6", features = ["csr"] }
|
||||
console_log = "1"
|
||||
log = "0.4"
|
||||
axum = { version = "0.7", optional = true, features = [ "ws", "macros" ] }
|
||||
console_error_panic_hook = "0.1"
|
||||
leptos-use = "0.10.2"
|
||||
serde = "1.0.196"
|
||||
serde_json = "1.0.113"
|
||||
rand = "0.8.5"
|
||||
gloo-timers = "0.3.0"
|
||||
strsim = "0.11.0"
|
||||
leptos = { version = "0.6", features = [] }
|
||||
leptos_axum = { version = "0.6", optional = true }
|
||||
leptos_meta = { version = "0.6", features = [] }
|
||||
leptos_router = { version = "0.6", features = [] }
|
||||
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.92"
|
||||
thiserror = "1"
|
||||
tracing = { version = "0.1", optional = true }
|
||||
http = "1"
|
||||
surrealdb = { version = "1.3.1", optional = true }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0.115"
|
||||
cfg-if = "1.0.0"
|
||||
once_cell = "1.19.0"
|
||||
futures = "0.3.30"
|
||||
uuid = "1.8.0"
|
||||
leptos-use = "0.10.6"
|
||||
strsim = "0.11.1"
|
||||
web-sys = { version = "0.3.69", features = ["Document", "Window", "Element", "ScrollIntoViewOptions", "ScrollLogicalPosition", "ScrollBehavior" ] }
|
||||
leptos_toaster = { version = "0.1.7", optional = true, features = [ "builtin_toast" ] }
|
||||
|
||||
# utils
|
||||
# strum = { version = "0.25", features = ["derive", "strum_macros"] }
|
||||
# strum_macros = "0.25"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
"Document",
|
||||
"Window",
|
||||
"Element",
|
||||
"ScrollIntoViewOptions",
|
||||
"ScrollLogicalPosition",
|
||||
"ScrollBehavior",
|
||||
[features]
|
||||
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate", "leptos_toaster/hydrate"]
|
||||
ssr = [
|
||||
"dep:surrealdb",
|
||||
"dep:axum",
|
||||
"dep:tokio",
|
||||
"dep:tower",
|
||||
"dep:tower-http",
|
||||
"dep:leptos_axum",
|
||||
"leptos/ssr",
|
||||
"leptos-use/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
"dep:tracing",
|
||||
"leptos_toaster/ssr"
|
||||
]
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
|
||||
[profile.release]
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "application"
|
||||
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||
assets-dir = "public"
|
||||
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "0.0.0.0:3000"
|
||||
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
|
||||
# The profile to use for the lib target when compiling for release
|
||||
#
|
||||
# Optional. Defaults to "release".
|
||||
lib-profile-release = "wasm-release"
|
||||
|
31
application/Dockerfile
Normal file
31
application/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Get started with a build env with Rust nightly
|
||||
FROM rustlang/rust:nightly-alpine as builder
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache bash curl npm libc-dev binaryen
|
||||
|
||||
RUN npm install -g sass
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/leptos-rs/cargo-leptos/releases/latest/download/cargo-leptos-installer.sh | sh
|
||||
|
||||
# Add the WASM target
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
|
||||
WORKDIR /work
|
||||
COPY . .
|
||||
|
||||
RUN cargo leptos build --release -vv
|
||||
|
||||
FROM rustlang/rust:nightly-alpine as runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /work/target/release/leptos_start /app/
|
||||
COPY --from=builder /work/target/site /app/site
|
||||
COPY --from=builder /work/Cargo.toml /app/
|
||||
|
||||
EXPOSE $PORT
|
||||
ENV LEPTOS_SITE_ROOT=./site
|
||||
|
||||
CMD ["/app/leptos_start"]
|
||||
|
24
application/LICENSE
Normal file
24
application/LICENSE
Normal file
@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
@ -3,77 +3,88 @@
|
||||
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
|
||||
</picture>
|
||||
|
||||
# Leptos Client-Side Rendered (CSR) App Starter Template
|
||||
# Leptos Axum Starter Template
|
||||
|
||||
This is a template for use with the [Leptos][Leptos] web framework using the [Trunk][Trunk] tool to compile and serve your app in development.
|
||||
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool using [Axum](https://github.com/tokio-rs/axum).
|
||||
|
||||
## Creating your repo from the template
|
||||
## Creating your template repo
|
||||
|
||||
This template requires you to have `cargo-generate` installed. You can install it with
|
||||
If you don't have `cargo-leptos` installed you can install it with
|
||||
|
||||
```sh
|
||||
cargo install cargo-generate
|
||||
```bash
|
||||
cargo install cargo-leptos
|
||||
```
|
||||
|
||||
|
||||
To set up your project with this template, run
|
||||
|
||||
```sh
|
||||
cargo generate --git https://github.com/leptos-community/start-csr
|
||||
Then run
|
||||
```bash
|
||||
cargo leptos new --git leptos-rs/start-axum
|
||||
```
|
||||
|
||||
to generate your new project, then
|
||||
to generate a new project template.
|
||||
|
||||
```sh
|
||||
```bash
|
||||
cd application
|
||||
```
|
||||
|
||||
to go to your newly created project.
|
||||
to go to your newly created project.
|
||||
Feel free to explore the project structure, but the best place to start with your application code is in `src/app.rs`.
|
||||
Addtionally, Cargo.toml may need updating as new versions of the dependencies are released, especially if things are not working after a `cargo update`.
|
||||
|
||||
By default, this template uses Rust `nightly` and requires that you've installed the `wasm` compilation target for your toolchain.
|
||||
## Running your project
|
||||
|
||||
|
||||
Sass and Tailwind are also supported by the Trunk build tool, but are optional additions: [see here for more info on how to set those up with Trunk][Trunk-instructions].
|
||||
|
||||
|
||||
If you don't have Rust nightly, you can install it with
|
||||
```sh
|
||||
rustup toolchain install nightly --allow-downgrade
|
||||
```bash
|
||||
cargo leptos watch
|
||||
```
|
||||
|
||||
You can add the `wasm` compilation target to rust using
|
||||
```sh
|
||||
rustup target add wasm32-unknown-unknown
|
||||
## Installing Additional Tools
|
||||
|
||||
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
|
||||
|
||||
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
|
||||
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
|
||||
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
|
||||
4. `npm install -g sass` - install `dart-sass` (should be optional in future
|
||||
|
||||
## Compiling for Release
|
||||
```bash
|
||||
cargo leptos build --release
|
||||
```
|
||||
|
||||
Will generate your server binary in target/server/release and your site package in target/site
|
||||
|
||||
## Developing your Leptos CSR project
|
||||
|
||||
To develop your Leptos CSR project, running
|
||||
|
||||
```sh
|
||||
trunk serve --port 3000 --open
|
||||
## Testing Your Project
|
||||
```bash
|
||||
cargo leptos end-to-end
|
||||
```
|
||||
|
||||
will open your app in your default browser at `http://localhost:3000`.
|
||||
|
||||
|
||||
## Deploying your Leptos CSR project
|
||||
|
||||
To build a Leptos CSR app for release, use the command
|
||||
|
||||
```sh
|
||||
trunk build --release
|
||||
```bash
|
||||
cargo leptos end-to-end --release
|
||||
```
|
||||
|
||||
This will output the files necessary to run your app into the `dist` folder; you can then use any static site host to serve these files.
|
||||
Cargo-leptos uses Playwright as the end-to-end test tool.
|
||||
Tests are located in end2end/tests directory.
|
||||
|
||||
For further information about hosting Leptos CSR apps, please refer to [the Leptos Book chapter on deployment available here][deploy-csr].
|
||||
## Executing a Server on a Remote Machine Without the Toolchain
|
||||
After running a `cargo leptos build --release` the minimum files needed are:
|
||||
|
||||
1. The server binary located in `target/server/release`
|
||||
2. The `site` directory and all files within located in `target/site`
|
||||
|
||||
[Leptos]: https://github.com/leptos-rs/leptos
|
||||
Copy these files to your remote server. The directory structure should be:
|
||||
```text
|
||||
application
|
||||
site/
|
||||
```
|
||||
Set the following environment variables (updating for your project as needed):
|
||||
```text
|
||||
LEPTOS_OUTPUT_NAME="application"
|
||||
LEPTOS_SITE_ROOT="site"
|
||||
LEPTOS_SITE_PKG_DIR="pkg"
|
||||
LEPTOS_SITE_ADDR="127.0.0.1:3000"
|
||||
LEPTOS_RELOAD_PORT="3001"
|
||||
```
|
||||
Finally, run the server binary.
|
||||
|
||||
[Trunk]: https://github.com/trunk-rs/trunk
|
||||
[Trunk-instructions]: https://trunkrs.dev/assets/
|
||||
## Licensing
|
||||
|
||||
[deploy-csr]: https://book.leptos.dev/deployment/csr.html
|
||||
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.
|
||||
|
74
application/end2end/package-lock.json
generated
Normal file
74
application/end2end/package-lock.json
generated
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
|
||||
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
|
||||
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
|
||||
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@playwright/test": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz",
|
||||
"integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*",
|
||||
"playwright-core": "1.28.0"
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "18.11.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz",
|
||||
"integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==",
|
||||
"dev": true
|
||||
},
|
||||
"playwright-core": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz",
|
||||
"integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
}
|
13
application/end2end/package.json
Normal file
13
application/end2end/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.0"
|
||||
}
|
||||
}
|
107
application/end2end/playwright.config.ts
Normal file
107
application/end2end/playwright.config.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import type { PlaywrightTestConfig } from "@playwright/test";
|
||||
import { devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
const config: PlaywrightTestConfig = {
|
||||
testDir: "./tests",
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
9
application/end2end/tests/example.spec.ts
Normal file
9
application/end2end/tests/example.spec.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and links to intro page", async ({ page }) => {
|
||||
await page.goto("http://localhost:3000/");
|
||||
|
||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
||||
});
|
@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Add a plain CSS file: see https://trunkrs.dev/assets/#css -->
|
||||
<!-- If using Tailwind with Leptos CSR, see https://trunkrs.dev/assets/#tailwind instead-->
|
||||
<link data-trunk rel="sass" href="public/styles.scss" />
|
||||
|
||||
<!-- Include favicon in dist output: see https://trunkrs.dev/assets/#icon -->
|
||||
<link data-trunk rel="icon" href="public/favicon.ico" />
|
||||
|
||||
<!-- include support for `wasm-bindgen --weak-refs` - see: https://rustwasm.github.io/docs/wasm-bindgen/reference/weak-references.html -->
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
@ -1,196 +0,0 @@
|
||||
$primary-color: #eb6330;
|
||||
$primary-color-light: hsl(16.36, 82.38%, 55.49%, 0.8);
|
||||
$secondary-color: #465651;
|
||||
$accent-color: #89969f;
|
||||
$primary-bg-color: #0d0b0b;
|
||||
$secondary-bg-color: #151719;
|
||||
$secondary-bg-color-light: hsl(204, 11%, 12%, 1);
|
||||
$secondary-bg-color-lighter: hsl(204, 11%, 15%, 1);
|
||||
$accent-bg-color: #181a19;
|
||||
$text-color: #f3efef;
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: $primary-bg-color;
|
||||
color: $text-color;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.route-active {
|
||||
color: $primary-color !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: $secondary-bg-color;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.navbar > a {
|
||||
margin: 0px 20px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 100ms;
|
||||
}
|
||||
|
||||
.navbar > a:hover {
|
||||
background-color: $primary-color-light;
|
||||
color: $text-color !important;
|
||||
}
|
||||
|
||||
.db-connection {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.btn-add-link {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
background-color: $secondary-bg-color;
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
border: dashed $secondary-bg-color-lighter;
|
||||
}
|
||||
|
||||
.btn-add-link:hover {
|
||||
background-color: $secondary-bg-color-light;
|
||||
}
|
||||
|
||||
input,select {
|
||||
background-color: $secondary-bg-color-light;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
color: $text-color;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input[type=password] {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
input[type=submit]:hover {
|
||||
background-color: $secondary-bg-color-lighter;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin: 40px auto 0 auto;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.toastcontainer {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.toastcontainer span {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
width: 300px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toastcontainer span:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: #f29826;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #d9534f;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: #2181d4;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #4bb24b;
|
||||
}
|
||||
|
||||
.participants-container {
|
||||
height: 200px;
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
list-style-type: none;
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.participants-container li {
|
||||
text-align: left;
|
||||
background-color: $secondary-bg-color-light;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.participants-container li:hover {
|
||||
cursor: pointer;
|
||||
background-color: $secondary-bg-color-lighter;
|
||||
}
|
||||
|
||||
.participants-container .selected {
|
||||
border: 1px solid $primary-color;
|
||||
}
|
||||
|
||||
.time-input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 10px 0px;
|
||||
}
|
||||
|
||||
.time-input-container input[type=number] {
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.time-input-container input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
|
||||
[toolchain]
|
||||
channel = "nightly"
|
||||
|
58
application/src/app.rs
Normal file
58
application/src/app.rs
Normal file
@ -0,0 +1,58 @@
|
||||
#![feature(diagnostic_namespace)]
|
||||
use crate::error_template::{AppError, ErrorTemplate};
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
|
||||
use crate::components;
|
||||
use crate::pages;
|
||||
use crate::util;
|
||||
use leptos_toaster::{Toaster, ToasterPosition};
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
let participants: util::surrealdb::schemas::ParticipantsContext = create_rw_signal(vec![]);
|
||||
let participant: RwSignal<Option<String>> = create_rw_signal(None);
|
||||
|
||||
provide_context(participant);
|
||||
|
||||
provide_context(participants);
|
||||
util::surrealdb::init_participants();
|
||||
util::websocket::client::init_websocket();
|
||||
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||
<Stylesheet id="leptos" href="/pkg/application.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="WRB Timings"/>
|
||||
|
||||
// content for this welcome page
|
||||
<Router fallback=|| {
|
||||
let mut outside_errors = Errors::default();
|
||||
outside_errors.insert_with_default_key(AppError::NotFound);
|
||||
view! {
|
||||
<ErrorTemplate outside_errors/>
|
||||
}
|
||||
.into_view()
|
||||
}>
|
||||
<Toaster
|
||||
position=ToasterPosition::BottomCenter
|
||||
>
|
||||
<components::header::Header />
|
||||
<components::participant::Modal />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" view=pages::index::HomePage />
|
||||
<Route path="/add-participant" view=pages::add_participant::AddParticipant />
|
||||
<Route path="/add-time" view=pages::add_time::AddTime />
|
||||
<Route path="/groups" view=pages::groups::Groups />
|
||||
</Routes>
|
||||
</main>
|
||||
</Toaster>
|
||||
</Router>
|
||||
}
|
||||
}
|
3
application/src/components.rs
Normal file
3
application/src/components.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod header;
|
||||
pub mod participant;
|
||||
pub mod participants;
|
18
application/src/components/header.rs
Normal file
18
application/src/components/header.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use leptos_use::core::ConnectionReadyState;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
let ready_state = use_context::<Signal<ConnectionReadyState>>();
|
||||
// Creates a reactive value to update the button
|
||||
view! {
|
||||
<header>
|
||||
<div class="header-container">
|
||||
<A href="/">"WRB Timings"</A>
|
||||
<div>"Connection: " { move || format!("{}", ready_state.unwrap().get()) }</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
pub mod navbar;
|
||||
pub mod toast;
|
@ -1,18 +0,0 @@
|
||||
use crate::util;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
/// Navigation bar
|
||||
#[component]
|
||||
pub fn Navbar() -> impl IntoView {
|
||||
let websocket = expect_context::<util::surrealdb::SurrealContext>();
|
||||
|
||||
view! {
|
||||
<div class="navbar">
|
||||
<span class="db-connection">"Connection: "{ move || websocket.ready_state.get().to_string()}</span>
|
||||
<A href="/" active_class="route-active">Home</A>
|
||||
<A href="/participants" active_class="route-active">Deelnemers</A>
|
||||
<A href="/times" active_class="route-active">Tijden</A>
|
||||
</div>
|
||||
}
|
||||
}
|
359
application/src/components/participant.rs
Normal file
359
application/src/components/participant.rs
Normal file
@ -0,0 +1,359 @@
|
||||
use crate::util::surrealdb::{client::Time, schemas};
|
||||
use leptos::*;
|
||||
use leptos_toaster::{Toast, ToastId, ToastVariant, Toasts};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::util::surrealdb::{DB, schemas::Participant};
|
||||
use crate::util::websocket::{server, ParticipantsAction};
|
||||
use leptos::logging;
|
||||
}
|
||||
}
|
||||
|
||||
#[server(ModifyParticipant)]
|
||||
async fn modify_participant(
|
||||
mut participant: schemas::ParticipantUpdate,
|
||||
id: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let websocket_state = &server::WEBSOCKET_STATE;
|
||||
|
||||
let updated: Option<schemas::ParticipantRecord> = DB
|
||||
.update(("participant", &id))
|
||||
.content(&participant)
|
||||
.await?;
|
||||
|
||||
match updated {
|
||||
Some(participant_updated) => {
|
||||
logging::log!(
|
||||
"Updated participant: {} ({})",
|
||||
participant_updated.name,
|
||||
participant_updated.group
|
||||
);
|
||||
let action = ParticipantsAction::Replace {
|
||||
participant: Participant {
|
||||
name: participant_updated.name.clone(),
|
||||
group: participant_updated.group.clone(),
|
||||
id,
|
||||
events: participant_updated.events.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
match websocket_state.apply(action) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(ServerFnError::new("Error sending websocket action")),
|
||||
}
|
||||
}
|
||||
None => Err(ServerFnError::ServerError(String::from(
|
||||
"Could not update participant",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn Modal() -> impl IntoView {
|
||||
let participant_id = use_context::<RwSignal<Option<String>>>().unwrap();
|
||||
let participants = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
let toasts_context = expect_context::<Toasts>();
|
||||
|
||||
let time_lifesaver = create_rw_signal(Time {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
|
||||
let time_hindernis = create_rw_signal(Time {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
|
||||
let time_popduiken = create_rw_signal(Time {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
|
||||
let name = create_rw_signal(String::from(""));
|
||||
let group = create_rw_signal(String::from(""));
|
||||
|
||||
let participant = move || {
|
||||
let x = participants
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter(|item| item.id == participant_id.get().unwrap_or("".to_string()))
|
||||
.collect::<Vec<schemas::ParticipantSignal>>();
|
||||
|
||||
match x.get(0) {
|
||||
Some(participant) => {
|
||||
let participant_clone = participant.value.get_untracked();
|
||||
|
||||
name.set(participant_clone.name);
|
||||
group.set(participant_clone.group);
|
||||
|
||||
time_lifesaver.set(Time::from_milliseconds(match participant_clone.events {
|
||||
Some(ref events) => events.lifesaver.unwrap_or(0),
|
||||
None => 0,
|
||||
}));
|
||||
|
||||
time_hindernis.set(Time::from_milliseconds(match participant_clone.events {
|
||||
Some(ref events) => events.hindernis.unwrap_or(0),
|
||||
None => 0,
|
||||
}));
|
||||
|
||||
time_popduiken.set(Time::from_milliseconds(match participant_clone.events {
|
||||
Some(ref events) => events.popduiken.unwrap_or(0),
|
||||
None => 0,
|
||||
}));
|
||||
|
||||
Some(participant.value)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
let modify_participant_action =
|
||||
create_action(|input: &(schemas::ParticipantUpdate, String)| {
|
||||
let input = input.to_owned();
|
||||
async move { modify_participant(input.0, input.1).await }
|
||||
});
|
||||
|
||||
let form_submit = move |ev: ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
let p = schemas::ParticipantUpdate {
|
||||
name: name.get(),
|
||||
group: group.get(),
|
||||
events: Some(schemas::Events {
|
||||
lifesaver: Some(time_lifesaver.get().as_milliseconds()),
|
||||
hindernis: Some(time_hindernis.get().as_milliseconds()),
|
||||
popduiken: Some(time_popduiken.get().as_milliseconds()),
|
||||
}),
|
||||
};
|
||||
|
||||
modify_participant_action.dispatch((p, participant().unwrap().get().id));
|
||||
|
||||
let toast_id = ToastId::new();
|
||||
|
||||
toasts_context.toast(
|
||||
view! {
|
||||
<Toast
|
||||
toast_id
|
||||
variant=ToastVariant::Success
|
||||
title=view! { "Successfully modified participant" }.into_view()
|
||||
/>
|
||||
},
|
||||
Some(toast_id),
|
||||
None,
|
||||
);
|
||||
|
||||
participant_id.set(None);
|
||||
};
|
||||
|
||||
view! {
|
||||
{ move || match participant() {
|
||||
Some(_p) => view! {
|
||||
<div class="modal-background">
|
||||
<div class="modal">
|
||||
<form on:submit=form_submit>
|
||||
<h2>"Deelnemer bewerken"</h2>
|
||||
<label>Naam</label>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
on:input=move |ev| {
|
||||
name.set(event_target_value(&ev))
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<label>Groep</label>
|
||||
<select
|
||||
name="group"
|
||||
autocomplete="off"
|
||||
on:change=move |ev| {
|
||||
group.set(event_target_value(&ev))
|
||||
}
|
||||
>
|
||||
<SelectOption value=group is="A1" />
|
||||
<SelectOption value=group is="A2" />
|
||||
<SelectOption value=group is="A3" />
|
||||
<SelectOption value=group is="A4" />
|
||||
<SelectOption value=group is="A5" />
|
||||
<SelectOption value=group is="A6" />
|
||||
|
||||
<SelectOption value=group is="B1" />
|
||||
<SelectOption value=group is="B2" />
|
||||
<SelectOption value=group is="B3" />
|
||||
<SelectOption value=group is="B4" />
|
||||
<SelectOption value=group is="B5" />
|
||||
<SelectOption value=group is="B6" />
|
||||
|
||||
<SelectOption value=group is="C1" />
|
||||
<SelectOption value=group is="C2" />
|
||||
<SelectOption value=group is="C3" />
|
||||
<SelectOption value=group is="C4" />
|
||||
<SelectOption value=group is="C5" />
|
||||
<SelectOption value=group is="C6" />
|
||||
|
||||
<SelectOption value=group is="D1" />
|
||||
<SelectOption value=group is="D2" />
|
||||
<SelectOption value=group is="D3" />
|
||||
<SelectOption value=group is="D4" />
|
||||
<SelectOption value=group is="D5" />
|
||||
<SelectOption value=group is="D6" />
|
||||
|
||||
<SelectOption value=group is="Z1" />
|
||||
<SelectOption value=group is="Z2" />
|
||||
<SelectOption value=group is="Z3" />
|
||||
<SelectOption value=group is="Z4" />
|
||||
<SelectOption value=group is="Z5" />
|
||||
<SelectOption value=group is="Z6" />
|
||||
</select>
|
||||
<label>Tijd Lifesaver</label>
|
||||
<div class="time">
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="mm"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_lifesaver.update(|time| time.minutes = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_lifesaver.get().minutes
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ss"
|
||||
min=0
|
||||
max=59
|
||||
on:input=move |ev| {
|
||||
time_lifesaver.update(|time| time.seconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_lifesaver.get().seconds
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ms"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_lifesaver.update(|time| time.milliseconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_lifesaver.get().milliseconds
|
||||
/>
|
||||
</div>
|
||||
<label>Tijd Hindernis</label>
|
||||
<div class="time">
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="mm"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_hindernis.update(|time| time.minutes = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_hindernis.get().minutes
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ss"
|
||||
min=0
|
||||
max=59
|
||||
on:input=move |ev| {
|
||||
time_hindernis.update(|time| time.seconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_hindernis.get().seconds
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ms"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_hindernis.update(|time| time.milliseconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_hindernis.get().milliseconds
|
||||
/>
|
||||
</div>
|
||||
<label>Tijd Popduiken</label>
|
||||
<div class="time">
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="mm"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_popduiken.update(|time| time.minutes = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_popduiken.get().minutes
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ss"
|
||||
min=0
|
||||
max=59
|
||||
on:input=move |ev| {
|
||||
time_popduiken.update(|time| time.seconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_popduiken.get().seconds
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ms"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time_popduiken.update(|time| time.milliseconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time_popduiken.get().milliseconds
|
||||
/>
|
||||
</div>
|
||||
<input type="submit" value="Deelnemer bewerken" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}.into_view(),
|
||||
None => view! {}.into_view()
|
||||
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(is: &'static str, value: RwSignal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<option
|
||||
value=is
|
||||
selected=move || value.get() == is
|
||||
>
|
||||
{is}
|
||||
</option>
|
||||
}
|
||||
}
|
74
application/src/components/participants.rs
Normal file
74
application/src/components/participants.rs
Normal file
@ -0,0 +1,74 @@
|
||||
use crate::util::surrealdb::{
|
||||
client::Time,
|
||||
schemas::{self, ParticipantSignal},
|
||||
};
|
||||
use leptos::*;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn Participants(
|
||||
participants: Signal<Vec<schemas::ParticipantSignal>>,
|
||||
#[prop(default = true)] show_group: bool,
|
||||
#[prop(default = true)] show_lifesaver: bool,
|
||||
#[prop(default = true)] show_hindernis: bool,
|
||||
#[prop(default = true)] show_popduiken: bool,
|
||||
) -> impl IntoView {
|
||||
let participant_modal = use_context::<RwSignal<Option<String>>>().unwrap();
|
||||
|
||||
view! {
|
||||
<table class="participants-table">
|
||||
<tr>
|
||||
<th>"Naam"</th>
|
||||
<Show when=move || show_group>
|
||||
<th>"Groep"</th>
|
||||
</Show>
|
||||
<Show when=move || show_popduiken>
|
||||
<th>"Popduiken"</th>
|
||||
</Show>
|
||||
<Show when=move || show_hindernis>
|
||||
<th>"Hindernis"</th>
|
||||
</Show>
|
||||
<Show when=move || show_lifesaver>
|
||||
<th>"Lifesaver"</th>
|
||||
</Show>
|
||||
</tr>
|
||||
<For
|
||||
each=move || participants.get()
|
||||
key=|state| state.id.clone()
|
||||
let:child
|
||||
>
|
||||
<tr on:click=move |_| participant_modal.set(Some(child.id.clone()))>
|
||||
<td>{ move || child.value.get().name }</td>
|
||||
<Show when=move || show_group>
|
||||
<td>{ move || child.value.get().group }</td>
|
||||
</Show>
|
||||
{ move || match child.value.get().events {
|
||||
Some(events) => view! {
|
||||
<Show when=move || show_popduiken>
|
||||
<td>{ Time::from_milliseconds_to_string(events.popduiken.unwrap_or(0)) }</td>
|
||||
</Show>
|
||||
<Show when=move || show_hindernis>
|
||||
<td>{ Time::from_milliseconds_to_string(events.hindernis.unwrap_or(0)) }</td>
|
||||
</Show>
|
||||
<Show when=move || show_lifesaver>
|
||||
<td>{ Time::from_milliseconds_to_string(events.lifesaver.unwrap_or(0)) }</td>
|
||||
</Show>
|
||||
},
|
||||
None => view! {
|
||||
<Show when=move || show_popduiken>
|
||||
<td>"0"</td>
|
||||
</Show>
|
||||
<Show when=move || show_hindernis>
|
||||
<td>"0"</td>
|
||||
</Show>
|
||||
<Show when=move || show_lifesaver>
|
||||
<td>"0"</td>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
</For>
|
||||
</table>
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
use crate::util;
|
||||
use leptos::*;
|
||||
|
||||
/// Navigation bar
|
||||
#[component]
|
||||
pub fn Toasts() -> impl IntoView {
|
||||
let notifications = expect_context::<util::toast::NotificationsContext>();
|
||||
|
||||
view! {
|
||||
<div class="toastcontainer">
|
||||
{move || notifications.notifications.get().into_iter()
|
||||
.map(|n| view! {
|
||||
<span on:click=move |_| util::toast::remove_toast((*n.id).to_string()) class=n.option>{n.text}</span>
|
||||
}
|
||||
).collect_view()}
|
||||
</div>
|
||||
}
|
||||
}
|
72
application/src/error_template.rs
Normal file
72
application/src/error_template.rs
Normal file
@ -0,0 +1,72 @@
|
||||
use http::status::StatusCode;
|
||||
use leptos::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("Not Found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A basic function to display errors served by the error boundaries.
|
||||
// Feel free to do more complicated things here than just displaying the error.
|
||||
#[component]
|
||||
pub fn ErrorTemplate(
|
||||
#[prop(optional)] outside_errors: Option<Errors>,
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => create_rw_signal(e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
},
|
||||
};
|
||||
// Get Errors from Signal
|
||||
let errors = errors.get_untracked();
|
||||
|
||||
// Downcast lets us take a type that implements `std::error::Error`
|
||||
let errors: Vec<AppError> = errors
|
||||
.into_iter()
|
||||
.filter_map(|(_k, v)| v.downcast_ref::<AppError>().cloned())
|
||||
.collect();
|
||||
println!("Errors: {errors:#?}");
|
||||
|
||||
// Only the response code for the first error is actually sent from the server
|
||||
// this may be customized by the specific application
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
use leptos_axum::ResponseOptions;
|
||||
let response = use_context::<ResponseOptions>();
|
||||
if let Some(response) = response {
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
|
||||
view! {
|
||||
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
|
||||
<For
|
||||
// a function that returns the items we're iterating over; a signal is fine
|
||||
each= move || {errors.clone().into_iter().enumerate()}
|
||||
// a unique key for each item as a reference
|
||||
key=|(index, _error)| *index
|
||||
// renders each item to a view
|
||||
children=move |error| {
|
||||
let error_string = error.1.to_string();
|
||||
let error_code= error.1.status_code();
|
||||
view! {
|
||||
<h2>{error_code.to_string()}</h2>
|
||||
<p>"Error: " {error_string}</p>
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
42
application/src/fileserv.rs
Normal file
42
application/src/fileserv.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use leptos::*;
|
||||
use crate::app::App;
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_static_file(
|
||||
uri: Uri,
|
||||
root: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
let req = Request::builder()
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
|
||||
// This path is relative to the cargo root
|
||||
match ServeDir::new(root).oneshot(req).await {
|
||||
Ok(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
@ -1,89 +1,15 @@
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
pub mod app;
|
||||
pub mod error_template;
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod fileserv;
|
||||
pub mod pages;
|
||||
pub mod util;
|
||||
pub mod components;
|
||||
|
||||
// Modules
|
||||
mod components;
|
||||
mod pages;
|
||||
mod util;
|
||||
|
||||
// Top-Level pages
|
||||
use crate::pages::home::Home;
|
||||
use crate::pages::login;
|
||||
use crate::pages::not_found::NotFound;
|
||||
use crate::pages::participants;
|
||||
use crate::pages::times;
|
||||
|
||||
/// An app router which renders the homepage and handles 404's
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
util::surrealdb::init_surrealdb();
|
||||
util::toast::init_toast();
|
||||
|
||||
let websocket = expect_context::<util::surrealdb::SurrealContext>();
|
||||
let _participants = use_context::<util::surrealdb::ParticipantsContext>()
|
||||
.expect("Could not find participants context");
|
||||
|
||||
view! {
|
||||
|
||||
// injects info into HTML tag from application code
|
||||
<Html
|
||||
lang="en"
|
||||
dir="ltr"
|
||||
attr:data-theme="light"
|
||||
/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="WRB Timings"/>
|
||||
|
||||
// injects metadata in the <head> of the page
|
||||
<Meta charset="UTF-8" />
|
||||
<Meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
<h1>"Uh oh! Something went wrong!"</h1>
|
||||
|
||||
<p>"Errors: "</p>
|
||||
// Render a list of errors as strings - good for development purposes
|
||||
<ul>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <li>{e.to_string()}</li>})
|
||||
.collect_view()
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<Router>
|
||||
<components::toast::Toasts />
|
||||
<components::navbar::Navbar />
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" view=move || {
|
||||
view! {
|
||||
// only show the outlet if the signin was successfull
|
||||
<Show when=move || !websocket.loading.get() fallback=|| view! { <p>"Connection to database..."</p>}>
|
||||
<Show when=move || websocket.authenticated.get() fallback=login::Login>
|
||||
<Outlet/>
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
}>
|
||||
<Route path="/" view=Home />
|
||||
<Route path="/participants" view=participants::Participants />
|
||||
<Route path="/participants/add" view=participants::add::Add />
|
||||
<Route path="/times" view=times::Times />
|
||||
<Route path="/times/add" view=times::add::Add />
|
||||
<Route path="/*" view=NotFound />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
</ErrorBoundary>
|
||||
}
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use crate::app::*;
|
||||
console_error_panic_hook::set_once();
|
||||
leptos::mount_to_body(App);
|
||||
}
|
||||
|
@ -1,14 +1,45 @@
|
||||
use application::App;
|
||||
use leptos::*;
|
||||
#![feature(diagnostic_namespace)]
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use application::fileserv::file_and_error_handler;
|
||||
use application::{app::*, util::websocket::server};
|
||||
use axum::{routing::get, Router};
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
|
||||
fn main() {
|
||||
// set up logging
|
||||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
application::util::surrealdb::connect()
|
||||
.await
|
||||
.expect("Database connection failed");
|
||||
|
||||
mount_to_body(|| {
|
||||
view! {
|
||||
<App />
|
||||
}
|
||||
})
|
||||
// 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).await.unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.route("/ws", get(server::websocket_handler))
|
||||
.leptos_routes(&leptos_options, routes, App)
|
||||
.fallback(file_and_error_handler)
|
||||
.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();
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
|
4
application/src/pages.rs
Normal file
4
application/src/pages.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod add_participant;
|
||||
pub mod add_time;
|
||||
pub mod groups;
|
||||
pub mod index;
|
144
application/src/pages/add_participant.rs
Normal file
144
application/src/pages/add_participant.rs
Normal file
@ -0,0 +1,144 @@
|
||||
use leptos::*;
|
||||
use leptos_toaster::{Toast, ToastId, ToastVariant, Toasts};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::util::surrealdb::{DB, schemas};
|
||||
use crate::util::websocket::{server, ParticipantsAction};
|
||||
use leptos::logging;
|
||||
}
|
||||
}
|
||||
|
||||
#[server(AddParticipant)]
|
||||
async fn add_participant(name: String, group: String) -> Result<(), ServerFnError> {
|
||||
let websocket_state = &server::WEBSOCKET_STATE;
|
||||
|
||||
let created: Vec<schemas::ParticipantRecord> = DB
|
||||
.create("participant")
|
||||
.content(schemas::NewParticipant { name, group })
|
||||
.await?;
|
||||
|
||||
match created.first() {
|
||||
Some(participant) => {
|
||||
logging::log!(
|
||||
"Created participant: {} ({})",
|
||||
participant.name,
|
||||
participant.group
|
||||
);
|
||||
|
||||
let action = ParticipantsAction::Add {
|
||||
participant: schemas::Participant {
|
||||
name: participant.name.clone(),
|
||||
group: participant.group.clone(),
|
||||
id: participant.id.id.to_string(),
|
||||
events: participant.events.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
match websocket_state.apply(action) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(ServerFnError::new("Error sending websocket action")),
|
||||
}
|
||||
}
|
||||
None => Err(ServerFnError::ServerError(String::from(
|
||||
"Could not create participant",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn AddParticipant() -> impl IntoView {
|
||||
let toasts_context = expect_context::<Toasts>();
|
||||
|
||||
let name = create_rw_signal("".to_string());
|
||||
let group = create_rw_signal("A1".to_string());
|
||||
|
||||
let name_input_ref: NodeRef<html::Input> = create_node_ref();
|
||||
|
||||
let form_submit_action = create_action(|input: &(String, String)| {
|
||||
let input = input.to_owned();
|
||||
|
||||
async move { add_participant(input.0, input.1).await }
|
||||
});
|
||||
|
||||
let form_submit = move |ev: ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
form_submit_action.dispatch((name.get(), group.get()));
|
||||
|
||||
let toast_id = ToastId::new();
|
||||
|
||||
toasts_context.toast(
|
||||
view! {
|
||||
<Toast
|
||||
toast_id
|
||||
variant=ToastVariant::Success
|
||||
title=view! { "Successfully added participant" }.into_view()
|
||||
/>
|
||||
},
|
||||
Some(toast_id),
|
||||
None,
|
||||
);
|
||||
|
||||
name.set("".to_string());
|
||||
let _ = name_input_ref.get().unwrap().focus();
|
||||
};
|
||||
|
||||
view! {
|
||||
<h2>"Deelnemer toevoegen"</h2>
|
||||
<form on:submit=form_submit>
|
||||
<label>Naam</label>
|
||||
<input
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
node_ref=name_input_ref
|
||||
on:input=move |ev| {
|
||||
name.set(event_target_value(&ev));
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<label>Groep</label>
|
||||
<select on:change=move |ev| {
|
||||
let new_value = event_target_value(&ev);
|
||||
group.set(new_value);
|
||||
} autocomplete="off">
|
||||
<option value="A1">A1</option>
|
||||
<option value="A2">A2</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="A4">A4</option>
|
||||
<option value="A5">A5</option>
|
||||
<option value="A6">A6</option>
|
||||
|
||||
<option value="B1">B1</option>
|
||||
<option value="B2">B2</option>
|
||||
<option value="B3">B3</option>
|
||||
<option value="B4">B4</option>
|
||||
<option value="B5">B5</option>
|
||||
<option value="B6">B6</option>
|
||||
|
||||
<option value="C1">C1</option>
|
||||
<option value="C2">C2</option>
|
||||
<option value="C3">C3</option>
|
||||
<option value="C4">C4</option>
|
||||
<option value="C5">C5</option>
|
||||
<option value="C6">C6</option>
|
||||
|
||||
<option value="D1">D1</option>
|
||||
<option value="D2">D2</option>
|
||||
<option value="D3">D3</option>
|
||||
<option value="D4">D4</option>
|
||||
<option value="D5">D5</option>
|
||||
<option value="D6">D6</option>
|
||||
|
||||
<option value="Z1">Z1</option>
|
||||
<option value="Z2">Z2</option>
|
||||
<option value="Z3">Z3</option>
|
||||
<option value="Z4">Z4</option>
|
||||
<option value="Z5">Z5</option>
|
||||
<option value="Z6">Z6</option>
|
||||
</select>
|
||||
<input type="submit" value="Deelnemer toevoegen" />
|
||||
</form>
|
||||
}
|
||||
}
|
231
application/src/pages/add_time.rs
Normal file
231
application/src/pages/add_time.rs
Normal file
@ -0,0 +1,231 @@
|
||||
use crate::util::surrealdb::{client::sort_participants, client::Time, schemas};
|
||||
use leptos::{ev::keydown, *};
|
||||
use leptos_toaster::{Toast, ToastId, ToastVariant, Toasts};
|
||||
use leptos_use::*;
|
||||
use web_sys::ScrollIntoViewOptions;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::util::surrealdb::{DB, schemas::Participant};
|
||||
use crate::util::websocket::{server, ParticipantsAction};
|
||||
use surrealdb::opt::PatchOp;
|
||||
use leptos::logging;
|
||||
}
|
||||
}
|
||||
|
||||
#[server(AddTime)]
|
||||
async fn add_time(
|
||||
mut participant: schemas::Participant,
|
||||
event: String,
|
||||
time: u32,
|
||||
) -> Result<(), ServerFnError> {
|
||||
let websocket_state = &server::WEBSOCKET_STATE;
|
||||
|
||||
let updated: Option<schemas::ParticipantRecord> = DB
|
||||
.update(("participant", &participant.id))
|
||||
.patch(PatchOp::replace(&("/events/".to_owned() + &event), time))
|
||||
.await?;
|
||||
|
||||
match updated {
|
||||
Some(participant_updated) => {
|
||||
logging::log!(
|
||||
"Updated participant: {} ({})",
|
||||
participant_updated.name,
|
||||
participant_updated.group
|
||||
);
|
||||
let action = ParticipantsAction::Replace {
|
||||
participant: Participant {
|
||||
name: participant_updated.name.clone(),
|
||||
group: participant_updated.group.clone(),
|
||||
id: participant.id,
|
||||
events: participant_updated.events.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
match websocket_state.apply(action) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(ServerFnError::new("Error sending websocket action")),
|
||||
}
|
||||
}
|
||||
None => Err(ServerFnError::ServerError(String::from(
|
||||
"Could not update participant",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn AddTime() -> impl IntoView {
|
||||
let participants = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
let toasts_context = expect_context::<Toasts>();
|
||||
|
||||
let container_ref: NodeRef<html::Ul> = create_node_ref();
|
||||
let name_input_ref: NodeRef<html::Input> = create_node_ref();
|
||||
|
||||
let event = create_rw_signal("lifesaver".to_string());
|
||||
let name = create_rw_signal("".to_string());
|
||||
let time = create_rw_signal(Time {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
let selected_index = create_rw_signal::<usize>(0);
|
||||
|
||||
let participants_sorted =
|
||||
create_memo(move |_| sort_participants(participants.get(), name.get()));
|
||||
|
||||
let add_time_action = create_action(|input: &(schemas::Participant, String, u32)| {
|
||||
let input = input.to_owned();
|
||||
async move { add_time(input.0, input.1, input.2).await }
|
||||
});
|
||||
|
||||
let form_submit = move |ev: ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
let participant = &participants_sorted.get()[selected_index.get()];
|
||||
add_time_action.dispatch((
|
||||
participant.value.get(),
|
||||
event.get(),
|
||||
time.get().as_milliseconds(),
|
||||
));
|
||||
|
||||
let toast_id = ToastId::new();
|
||||
|
||||
toasts_context.toast(
|
||||
view! {
|
||||
<Toast
|
||||
toast_id
|
||||
variant=ToastVariant::Success
|
||||
title=view! { "Successfully added time" }.into_view()
|
||||
/>
|
||||
},
|
||||
Some(toast_id),
|
||||
None,
|
||||
);
|
||||
|
||||
name.set("".to_string());
|
||||
time.set(Time {
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
let _ = name_input_ref.get().unwrap().focus();
|
||||
};
|
||||
|
||||
let _ = use_event_listener(name_input_ref, keydown, move |evt| {
|
||||
match evt.key().as_str() {
|
||||
"ArrowDown" => selected_index.update(|x| {
|
||||
let len = participants.get_untracked().len();
|
||||
if *x != len {
|
||||
*x += 1;
|
||||
}
|
||||
}),
|
||||
"ArrowUp" => selected_index.update(|x| {
|
||||
if *x != 0 {
|
||||
*x -= 1;
|
||||
}
|
||||
}),
|
||||
"Enter" => evt.prevent_default(),
|
||||
_ => (),
|
||||
}
|
||||
let el: web_sys::Element = container_ref
|
||||
.get_untracked()
|
||||
.unwrap()
|
||||
.children()
|
||||
.item(selected_index.get_untracked().try_into().unwrap())
|
||||
.unwrap();
|
||||
el.scroll_into_view_with_scroll_into_view_options(
|
||||
&ScrollIntoViewOptions::new().block(web_sys::ScrollLogicalPosition::Center),
|
||||
);
|
||||
});
|
||||
|
||||
view! {
|
||||
<h2>"Tijd toevoegen"</h2>
|
||||
<form on:submit=form_submit>
|
||||
<label>Onderdeel</label>
|
||||
<select autocomplete="off"
|
||||
on:change=move |ev| {
|
||||
event.set(event_target_value(&ev))
|
||||
}
|
||||
>
|
||||
<option value="lifesaver">"Lifesaver"</option>
|
||||
<option value="hindernis">"Hindernis"</option>
|
||||
<option value="popduiken">"Popduiken"</option>
|
||||
</select>
|
||||
<label>Naam</label>
|
||||
<div class="autocomplete">
|
||||
<input type="text"
|
||||
name="name"
|
||||
autocomplete="off"
|
||||
autofocus=true
|
||||
node_ref=name_input_ref
|
||||
on:input=move |ev| {
|
||||
name.set(event_target_value(&ev));
|
||||
selected_index.set(0);
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<ul node_ref=container_ref tabindex=-1>
|
||||
{move || participants_sorted.get().into_iter().enumerate().map(|(i, participant)| view! {
|
||||
<li on:click=move |_| selected_index.set(i) class:selected=move || selected_index.get() == i>{participant.value.get().name + " " + "(" + &participant.value.get().group + ")" }</li>
|
||||
}).collect_view()}
|
||||
</ul>
|
||||
</div>
|
||||
<label>Tijd</label>
|
||||
<div class="time">
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="mm"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time.update(|time| time.minutes = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time.get().minutes
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ss"
|
||||
min=0
|
||||
max=59
|
||||
on:input=move |ev| {
|
||||
time.update(|time| time.seconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time.get().seconds
|
||||
/>
|
||||
<input type="number"
|
||||
autocomplete="off"
|
||||
placeholder="ms"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
time.update(|time| time.milliseconds = match event_target_value(&ev).parse::<u32>() {
|
||||
Ok(x) => x,
|
||||
Err(_) => 0,
|
||||
});
|
||||
}
|
||||
prop:value=move || time.get().milliseconds
|
||||
/>
|
||||
</div>
|
||||
<input type="submit" value="Tijd toevoegen" />
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(is: &'static str, value: ReadSignal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<option
|
||||
value=is
|
||||
selected=move || value.get() == is
|
||||
>
|
||||
{is}
|
||||
</option>
|
||||
}
|
||||
}
|
228
application/src/pages/groups.rs
Normal file
228
application/src/pages/groups.rs
Normal file
@ -0,0 +1,228 @@
|
||||
use crate::components::{self, participant};
|
||||
use crate::util::surrealdb::schemas;
|
||||
use futures::stream::ForEach;
|
||||
use leptos::*;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn Groups() -> impl IntoView {
|
||||
let group_hour = create_rw_signal("A");
|
||||
let group_lane = create_rw_signal("1");
|
||||
|
||||
let participants_context = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
|
||||
let participants_filtered: Memo<Vec<schemas::ParticipantSignal>> = create_memo(move |_| {
|
||||
participants_context
|
||||
.get()
|
||||
.into_iter()
|
||||
.filter(|participant| {
|
||||
participant.value.get().group == group_hour.get().to_owned() + group_lane.get()
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let (participants_lifesaver, participants_hindernis, participants_popduiken) =
|
||||
sort_by_events(participants_filtered);
|
||||
|
||||
let lifesaver_best = create_memo(move |_| match participants_lifesaver.get().get(0) {
|
||||
Some(p) => match p.value.get().events {
|
||||
Some(e) => e.lifesaver.unwrap_or(0),
|
||||
None => 6_000_000,
|
||||
},
|
||||
None => 6_000_000,
|
||||
});
|
||||
|
||||
let hindernis_best = create_memo(move |_| match participants_hindernis.get().get(0) {
|
||||
Some(p) => match p.value.get().events {
|
||||
Some(e) => e.hindernis.unwrap_or(0),
|
||||
None => 6_000_000,
|
||||
},
|
||||
None => 6_000_000,
|
||||
});
|
||||
|
||||
let popduiken_best = create_memo(move |_| match participants_popduiken.get().get(0) {
|
||||
Some(p) => match p.value.get().events {
|
||||
Some(e) => e.popduiken.unwrap_or(0),
|
||||
None => 6_000_000,
|
||||
},
|
||||
None => 6_000_000,
|
||||
});
|
||||
|
||||
let total_score_participants = sort_by_total_score(
|
||||
participants_filtered,
|
||||
lifesaver_best,
|
||||
hindernis_best,
|
||||
popduiken_best,
|
||||
);
|
||||
|
||||
view! {
|
||||
<h2>"Groups"</h2>
|
||||
<div class="groups-select-container">
|
||||
<div class="groups-select-row">
|
||||
<div on:click=move |_| group_hour.set("A") class:selected=move || group_hour.get() == "A">"A"</div>
|
||||
<div on:click=move |_| group_hour.set("B") class:selected=move || group_hour.get() == "B">"B"</div>
|
||||
<div on:click=move |_| group_hour.set("C") class:selected=move || group_hour.get() == "C">"C"</div>
|
||||
<div on:click=move |_| group_hour.set("D") class:selected=move || group_hour.get() == "D">"D"</div>
|
||||
<div on:click=move |_| group_hour.set("Z") class:selected=move || group_hour.get() == "Z">"Z"</div>
|
||||
</div>
|
||||
<div class="groups-select-row">
|
||||
<div on:click=move |_| group_lane.set("1") class:selected=move || group_lane.get() == "1">"1"</div>
|
||||
<div on:click=move |_| group_lane.set("2") class:selected=move || group_lane.get() == "2">"2"</div>
|
||||
<div on:click=move |_| group_lane.set("3") class:selected=move || group_lane.get() == "3">"3"</div>
|
||||
<div on:click=move |_| group_lane.set("4") class:selected=move || group_lane.get() == "4">"4"</div>
|
||||
<div on:click=move |_| group_lane.set("5") class:selected=move || group_lane.get() == "5">"5"</div>
|
||||
<div on:click=move |_| group_lane.set("6") class:selected=move || group_lane.get() == "6">"6"</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>"Algemene score"</h3>
|
||||
<components::participants::Participants participants=total_score_participants.into() show_group=false />
|
||||
<div class="events-container">
|
||||
<div>
|
||||
<h3>"Popduiken"</h3>
|
||||
<components::participants::Participants participants=participants_popduiken.into() show_group=false show_hindernis=false show_lifesaver=false />
|
||||
</div>
|
||||
<div>
|
||||
<h3>"Hindernis"</h3>
|
||||
<components::participants::Participants participants=participants_hindernis.into() show_group=false show_popduiken=false show_lifesaver=false />
|
||||
</div>
|
||||
<div>
|
||||
<h3>"Lifesaver"</h3>
|
||||
<components::participants::Participants participants=participants_lifesaver.into() show_group=false show_popduiken=false show_hindernis=false />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_by_total_score(
|
||||
participants_filtered: Memo<Vec<schemas::ParticipantSignal>>,
|
||||
lifesaver_best: Memo<u32>,
|
||||
popduiken_best: Memo<u32>,
|
||||
hindernis_best: Memo<u32>,
|
||||
) -> Memo<Vec<schemas::ParticipantSignal>> {
|
||||
let total_score_participants: Memo<Vec<schemas::ParticipantSignal>> = create_memo(move |_| {
|
||||
let mut all_participants: Vec<(usize, schemas::ParticipantSignal)> = participants_filtered
|
||||
.get()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.collect();
|
||||
|
||||
let mut lifesaver_best = lifesaver_best.get();
|
||||
let mut popduiken_best = popduiken_best.get();
|
||||
let mut hindernis_best = hindernis_best.get();
|
||||
|
||||
if lifesaver_best == 0 {
|
||||
lifesaver_best = 10 * 60 * 1000;
|
||||
}
|
||||
|
||||
if popduiken_best == 0 {
|
||||
popduiken_best = 10 * 60 * 1000;
|
||||
}
|
||||
|
||||
if hindernis_best == 0 {
|
||||
hindernis_best = 10 * 60 * 1000;
|
||||
}
|
||||
|
||||
all_participants.sort_by(|(_, a), (_, b)| {
|
||||
let part_a = match a.value.get().events {
|
||||
Some(events) => {
|
||||
((events.lifesaver.unwrap_or(6_000_000) * 100) / lifesaver_best)
|
||||
+ ((events.hindernis.unwrap_or(6_000_000) * 100) / hindernis_best)
|
||||
+ ((events.popduiken.unwrap_or(6_000_000) * 100) / popduiken_best)
|
||||
}
|
||||
None => 1000,
|
||||
};
|
||||
let part_b = match b.value.get().events {
|
||||
Some(events) => {
|
||||
((events.lifesaver.unwrap_or(6_000_000) * 100) / lifesaver_best)
|
||||
+ ((events.hindernis.unwrap_or(6_000_000) * 100) / hindernis_best)
|
||||
+ ((events.popduiken.unwrap_or(6_000_000) * 100) / popduiken_best)
|
||||
}
|
||||
None => 1000,
|
||||
};
|
||||
|
||||
part_a.cmp(&part_b)
|
||||
});
|
||||
|
||||
all_participants
|
||||
.into_iter()
|
||||
.map(|(_, value)| value)
|
||||
.collect()
|
||||
});
|
||||
total_score_participants
|
||||
}
|
||||
|
||||
fn sort_by_events(
|
||||
participants_filtered: Memo<Vec<schemas::ParticipantSignal>>,
|
||||
) -> (
|
||||
Memo<Vec<schemas::ParticipantSignal>>,
|
||||
Memo<Vec<schemas::ParticipantSignal>>,
|
||||
Memo<Vec<schemas::ParticipantSignal>>,
|
||||
) {
|
||||
let lifesaver: Memo<Vec<schemas::ParticipantSignal>> = create_memo(move |_| {
|
||||
let mut participants: Vec<(usize, schemas::ParticipantSignal)> = participants_filtered
|
||||
.get()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.collect();
|
||||
participants.sort_by(|(_, a), (_, b)| {
|
||||
let event_a = match a.value.get().events {
|
||||
Some(events) => events.lifesaver.unwrap_or(6_000_000),
|
||||
None => 6_000_000,
|
||||
};
|
||||
let event_b = match b.value.get().events {
|
||||
Some(events) => events.lifesaver.unwrap_or(5_999_100),
|
||||
None => 6_000_000,
|
||||
};
|
||||
|
||||
event_a.cmp(&event_b)
|
||||
});
|
||||
|
||||
participants.into_iter().map(|(_, value)| value).collect()
|
||||
});
|
||||
|
||||
let hindernis: Memo<Vec<schemas::ParticipantSignal>> = create_memo(move |_| {
|
||||
let mut participants: Vec<(usize, schemas::ParticipantSignal)> = participants_filtered
|
||||
.get()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.collect();
|
||||
participants.sort_by(|(_, a), (_, b)| {
|
||||
let event_a = match a.value.get().events {
|
||||
Some(events) => events.hindernis.unwrap_or(6_000_000),
|
||||
None => 6_000_000,
|
||||
};
|
||||
let event_b = match b.value.get().events {
|
||||
Some(events) => events.hindernis.unwrap_or(6_000_000),
|
||||
None => 6_000_000,
|
||||
};
|
||||
|
||||
event_a.cmp(&event_b)
|
||||
});
|
||||
|
||||
participants.into_iter().map(|(_, value)| value).collect()
|
||||
});
|
||||
|
||||
let popduiken: Memo<Vec<schemas::ParticipantSignal>> = create_memo(move |_| {
|
||||
let mut participants: Vec<(usize, schemas::ParticipantSignal)> = participants_filtered
|
||||
.get()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.collect();
|
||||
participants.sort_by(|(_, a), (_, b)| {
|
||||
let event_a = match a.value.get().events {
|
||||
Some(events) => events.popduiken.unwrap_or(6_000_000),
|
||||
None => 6_000_000,
|
||||
};
|
||||
let event_b = match b.value.get().events {
|
||||
Some(events) => events.popduiken.unwrap_or(6_000_000),
|
||||
None => 6_000_000,
|
||||
};
|
||||
|
||||
event_a.cmp(&event_b)
|
||||
});
|
||||
|
||||
participants.into_iter().map(|(_, value)| value).collect()
|
||||
});
|
||||
|
||||
(lifesaver, hindernis, popduiken)
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
/// Default Home Page
|
||||
#[component]
|
||||
pub fn Home() -> impl IntoView {
|
||||
view! {
|
||||
<div class="container">
|
||||
|
||||
<h1>"Welcome to WRB Timings"</h1>
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
34
application/src/pages/index.rs
Normal file
34
application/src/pages/index.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use crate::components;
|
||||
use crate::util::surrealdb::{client::sort_participants, schemas};
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
pub fn HomePage() -> impl IntoView {
|
||||
let participants_context = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
|
||||
let name = create_rw_signal("".to_string());
|
||||
|
||||
let participants_sorted =
|
||||
create_memo(move |_| sort_participants(participants_context.get(), name.get()));
|
||||
|
||||
view! {
|
||||
<div class="actions-container">
|
||||
<A href="/add-participant">"Deelnemer toevoegen"</A>
|
||||
<A href="/add-time">"Tijd toevoegen"</A>
|
||||
<A href="/groups">"Group view"</A>
|
||||
</div>
|
||||
<input type="search"
|
||||
class="participants-search"
|
||||
placeholder="Search"
|
||||
autocomplete="off"
|
||||
autofocus=true
|
||||
on:input=move |ev| {
|
||||
name.set(event_target_value(&ev));
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<components::participants::Participants participants=participants_sorted.into() />
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
use crate::util;
|
||||
use leptos::*;
|
||||
|
||||
/// Login page
|
||||
#[component]
|
||||
pub fn Login() -> impl IntoView {
|
||||
let websocket = expect_context::<util::surrealdb::SurrealContext>();
|
||||
|
||||
let input_element: NodeRef<html::Input> = create_node_ref();
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
let value = input_element
|
||||
.get()
|
||||
.expect("<input> should be mounted")
|
||||
.value();
|
||||
|
||||
websocket.signin(value)
|
||||
};
|
||||
|
||||
view! {
|
||||
<form class="login" on:submit=on_submit>
|
||||
<h1>"WRB Timings"</h1>
|
||||
<input type="password"
|
||||
node_ref=input_element
|
||||
placeholder="Password"
|
||||
/>
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
pub mod home;
|
||||
pub mod login;
|
||||
pub mod not_found;
|
||||
pub mod participants;
|
||||
pub mod times;
|
@ -1,7 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
/// 404 Not Found Page
|
||||
#[component]
|
||||
pub fn NotFound() -> impl IntoView {
|
||||
view! { <h1>"Uh oh!" <br/> "We couldn't find that page!"</h1> }
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
pub mod add;
|
||||
|
||||
/// Default Home Page
|
||||
#[component]
|
||||
pub fn Participants() -> impl IntoView {
|
||||
view! {
|
||||
<A class="btn-add-link" href="/participants/add">"Deelnemer toevoegen"</A>
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(is: &'static str, value: ReadSignal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<option
|
||||
value=is
|
||||
selected=move || value.get() == is
|
||||
>
|
||||
{is}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation bar
|
||||
#[component]
|
||||
pub fn Add() -> impl IntoView {
|
||||
let websocket = expect_context::<crate::util::surrealdb::SurrealContext>();
|
||||
|
||||
let (name, set_name) = create_signal(String::from(""));
|
||||
let (group, set_group) = create_signal(String::from("A1"));
|
||||
let (error, set_error) = create_signal(String::from(""));
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(String::from(""));
|
||||
|
||||
match websocket.add_person(name.get(), group.get()) {
|
||||
Ok(_) => set_name.set(String::from("")),
|
||||
Err(err) => set_error.set(err),
|
||||
}
|
||||
};
|
||||
view! {
|
||||
<h1>"Deelnemer toevoegen"</h1>
|
||||
<form class="add" on:submit=on_submit>
|
||||
<h1>"WRB Timings"</h1>
|
||||
<select
|
||||
on:change=move |ev| {
|
||||
set_group.set(event_target_value(&ev));
|
||||
}
|
||||
>
|
||||
<SelectOption value=group is="A1"/>
|
||||
<SelectOption value=group is="A2"/>
|
||||
<SelectOption value=group is="A3"/>
|
||||
<SelectOption value=group is="A4"/>
|
||||
<SelectOption value=group is="A5"/>
|
||||
<SelectOption value=group is="A6"/>
|
||||
|
||||
<SelectOption value=group is="B1"/>
|
||||
<SelectOption value=group is="B2"/>
|
||||
<SelectOption value=group is="B3"/>
|
||||
<SelectOption value=group is="B4"/>
|
||||
<SelectOption value=group is="B5"/>
|
||||
<SelectOption value=group is="B6"/>
|
||||
|
||||
<SelectOption value=group is="C1"/>
|
||||
<SelectOption value=group is="C2"/>
|
||||
<SelectOption value=group is="C3"/>
|
||||
<SelectOption value=group is="C4"/>
|
||||
<SelectOption value=group is="C5"/>
|
||||
<SelectOption value=group is="C6"/>
|
||||
|
||||
<SelectOption value=group is="D1"/>
|
||||
<SelectOption value=group is="D2"/>
|
||||
<SelectOption value=group is="D3"/>
|
||||
<SelectOption value=group is="D4"/>
|
||||
<SelectOption value=group is="D5"/>
|
||||
<SelectOption value=group is="D6"/>
|
||||
|
||||
<SelectOption value=group is="Z1"/>
|
||||
<SelectOption value=group is="Z2"/>
|
||||
<SelectOption value=group is="Z3"/>
|
||||
<SelectOption value=group is="Z4"/>
|
||||
<SelectOption value=group is="Z5"/>
|
||||
<SelectOption value=group is="Z6"/>
|
||||
</select>
|
||||
<input type="text"
|
||||
placeholder="Name"
|
||||
on:input=move |ev| {
|
||||
set_name.set(event_target_value(&ev));
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
<p class="error">
|
||||
{ error }
|
||||
</p>
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
|
||||
pub mod add;
|
||||
|
||||
/// Default Home Page
|
||||
#[component]
|
||||
pub fn Times() -> impl IntoView {
|
||||
view! {
|
||||
<A class="btn-add-link" href="/times/add">"Tijd toevoegen"</A>
|
||||
}
|
||||
}
|
@ -1,209 +0,0 @@
|
||||
use crate::util::surrealdb;
|
||||
use leptos::{ev::keydown, *};
|
||||
use leptos_use::*;
|
||||
use strsim::normalized_damerau_levenshtein;
|
||||
use web_sys::{ScrollIntoViewOptions, ScrollLogicalPosition};
|
||||
|
||||
use crate::util::surrealdb::Participant;
|
||||
|
||||
// Functions to sort all the participants and include a search term
|
||||
fn sort_participants(participants: Vec<Participant>, search: String) -> Vec<Participant> {
|
||||
let mut filtered_sorted_list: Vec<(Participant, f64)> = participants
|
||||
.into_iter()
|
||||
.map(|participant| {
|
||||
(
|
||||
participant.clone(),
|
||||
normalized_damerau_levenshtein(
|
||||
&participant.name.to_lowercase(),
|
||||
&search.to_lowercase(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
filtered_sorted_list.sort_by(|a, b| {
|
||||
let (_, sim_score_a) = a;
|
||||
let (_, sim_score_b) = b;
|
||||
sim_score_b
|
||||
.partial_cmp(sim_score_a)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
filtered_sorted_list
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SelectOption(is: &'static str, value: ReadSignal<String>) -> impl IntoView {
|
||||
view! {
|
||||
<option
|
||||
value=is
|
||||
selected=move || value.get() == is
|
||||
>
|
||||
{is}
|
||||
</option>
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation bar
|
||||
#[component]
|
||||
pub fn Add() -> impl IntoView {
|
||||
let websocket = expect_context::<crate::util::surrealdb::SurrealContext>();
|
||||
let participants = expect_context::<crate::util::surrealdb::ParticipantsContext>();
|
||||
|
||||
let container_ref: NodeRef<html::Ul> = create_node_ref();
|
||||
let name_ref: NodeRef<html::Input> = create_node_ref();
|
||||
|
||||
let (name, set_name) = create_signal(String::from(""));
|
||||
let (event, set_event) = create_signal(String::from("lifesaver"));
|
||||
let (error, set_error) = create_signal(String::from(""));
|
||||
let (minutes, set_minutes) = create_signal::<u64>(0);
|
||||
let (seconds, set_seconds) = create_signal::<u64>(0);
|
||||
let (miliseconds, set_miliseconds) = create_signal::<u64>(0);
|
||||
let (selected, set_selected) = create_signal::<usize>(0);
|
||||
|
||||
let participants_sorted =
|
||||
create_memo(move |_| sort_participants(participants.read.get(), name.get()));
|
||||
|
||||
let _ = use_event_listener(use_document(), keydown, move |evt| {
|
||||
if evt.key() == "ArrowDown".to_string() {
|
||||
set_selected.update(|x| {
|
||||
let len = participants.read.get_untracked().len();
|
||||
if *x != len {
|
||||
*x += 1;
|
||||
}
|
||||
});
|
||||
} else if evt.key() == "ArrowUp".to_string() {
|
||||
set_selected.update(|x| {
|
||||
if *x != 0 {
|
||||
*x -= 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let el: web_sys::Element = container_ref
|
||||
.get_untracked()
|
||||
.unwrap()
|
||||
.children()
|
||||
.item(selected.get_untracked().try_into().unwrap())
|
||||
.expect("No element found");
|
||||
|
||||
el.scroll_into_view_with_scroll_into_view_options(
|
||||
&ScrollIntoViewOptions::new().block(ScrollLogicalPosition::Center),
|
||||
);
|
||||
});
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
set_error.set(String::from(""));
|
||||
|
||||
let person = &participants_sorted.get()[selected.get()];
|
||||
|
||||
match websocket.add_time(
|
||||
surrealdb::CreateTimeParam::new(
|
||||
person.clone().id,
|
||||
minutes.get(),
|
||||
seconds.get(),
|
||||
miliseconds.get(),
|
||||
),
|
||||
event.get(),
|
||||
) {
|
||||
Ok(_) => {
|
||||
set_name.set(String::from(""));
|
||||
set_minutes.set(0);
|
||||
set_seconds.set(0);
|
||||
set_miliseconds.set(0);
|
||||
|
||||
let _ = name_ref.get().unwrap().focus();
|
||||
}
|
||||
Err(err) => set_error.set(err),
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<h1>"Tijd toevoegen"</h1>
|
||||
<form class="add" on:submit=on_submit>
|
||||
<div class="time-input-container">
|
||||
<span>"Onderdeel:"</span>
|
||||
<select
|
||||
on:change=move |ev| {
|
||||
set_event.set(event_target_value(&ev));
|
||||
}
|
||||
>
|
||||
<SelectOption value=event is="lifesaver"/>
|
||||
<SelectOption value=event is="popduiken"/>
|
||||
<SelectOption value=event is="hindernis"/>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text"
|
||||
placeholder="Name"
|
||||
tabindex=0
|
||||
node_ref=name_ref
|
||||
on:input=move |ev| {
|
||||
set_name.set(event_target_value(&ev));
|
||||
set_selected.set(0);
|
||||
}
|
||||
prop:value=name
|
||||
/>
|
||||
<ul class="participants-container" tabindex=-1 node_ref=container_ref>
|
||||
{move || participants_sorted.get().into_iter().enumerate().map(|(i, participant)| view! {
|
||||
<li on:click=move |_| set_selected.set(i) class:selected=move || selected.get() == i>{participant.name + " " + "(" + &participant.group[6..].to_string() + ")" }</li>
|
||||
}).collect_view()}
|
||||
</ul>
|
||||
<div class="time-input-container">
|
||||
<span>"Tijd:"</span>
|
||||
<input
|
||||
type="number"
|
||||
tabindex=0
|
||||
placeholder="mm"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
let x = event_target_value(&ev).parse::<u64>();
|
||||
match x {
|
||||
Ok(x) => set_minutes.set(x),
|
||||
Err(_) => set_minutes.set(0),
|
||||
}
|
||||
}
|
||||
prop:value=minutes
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
tabindex=0
|
||||
placeholder="ss"
|
||||
min=0
|
||||
max=59
|
||||
on:input=move |ev| {
|
||||
let x = event_target_value(&ev).parse::<u64>();
|
||||
match x {
|
||||
Ok(x) => set_seconds.set(x),
|
||||
Err(_) => set_seconds.set(0),
|
||||
}
|
||||
}
|
||||
prop:value=seconds
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
tabindex=0
|
||||
placeholder="ms"
|
||||
min=0
|
||||
max=99
|
||||
on:input=move |ev| {
|
||||
let x = event_target_value(&ev).parse::<u64>();
|
||||
match x {
|
||||
Ok(x) => set_miliseconds.set(x),
|
||||
Err(_) => set_miliseconds.set(0),
|
||||
}
|
||||
}
|
||||
prop:value=miliseconds
|
||||
/>
|
||||
</div>
|
||||
<input type="submit" tabindex=0 value="Submit" />
|
||||
</form>
|
||||
<p class="error">
|
||||
{ error }
|
||||
</p>
|
||||
}
|
||||
}
|
@ -1,2 +1,2 @@
|
||||
pub mod surrealdb;
|
||||
pub mod toast;
|
||||
pub mod websocket;
|
@ -1,387 +1,69 @@
|
||||
use crate::util;
|
||||
use leptos::*;
|
||||
use leptos_use::{
|
||||
core::ConnectionReadyState,
|
||||
storage::use_local_storage,
|
||||
use_websocket,
|
||||
utils::{FromToStringCodec, StringCodec},
|
||||
UseWebsocketReturn,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum SurrealId {
|
||||
String(String),
|
||||
Integer(u32),
|
||||
}
|
||||
pub mod client;
|
||||
pub mod schemas;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SurrealRequest {
|
||||
id: SurrealId,
|
||||
method: String,
|
||||
params: Vec<SurrealParams>,
|
||||
}
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use once_cell::sync::Lazy;
|
||||
use surrealdb::engine::remote::ws::{Client, Ws};
|
||||
use surrealdb::opt::auth::Root;
|
||||
use surrealdb::Surreal;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum SurrealParams {
|
||||
Participant,
|
||||
SigninParam(SigninParam),
|
||||
CreatePersonParam(CreatePersonParam),
|
||||
CreateTimeParam(CreateTimeParam),
|
||||
String(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SigninParam {
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreatePersonParam {
|
||||
name: String,
|
||||
group: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateTimeParam {
|
||||
person_id: String,
|
||||
time: u64,
|
||||
}
|
||||
|
||||
impl CreateTimeParam {
|
||||
pub fn new(person_id: String, minutes: u64, seconds: u64, miliseconds: u64) -> Self {
|
||||
Self {
|
||||
person_id,
|
||||
time: minutes * 60 * 1000 + seconds * 1000 + miliseconds * 10,
|
||||
}
|
||||
pub static DB: Lazy<Surreal<Client>> = Lazy::new(Surreal::init);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Participant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn connect() -> Result<(), ServerFnError> {
|
||||
DB.connect::<Ws>("localhost:80").await?;
|
||||
|
||||
DB.signin(Root {
|
||||
username: "root",
|
||||
password: "root",
|
||||
})
|
||||
.await?;
|
||||
|
||||
DB.use_ns("wrb").use_db("timings").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParticipantsContext {
|
||||
pub read: ReadSignal<Vec<Participant>>,
|
||||
write: WriteSignal<Vec<Participant>>,
|
||||
}
|
||||
#[server]
|
||||
pub async fn get_participants() -> Result<Vec<schemas::Participant>, ServerFnError> {
|
||||
let participant_records: Vec<schemas::ParticipantRecord> = DB.select("participant").await?;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SurrealContext {
|
||||
pub message: Signal<Option<String>>,
|
||||
send: Rc<dyn Fn(&str)>,
|
||||
pub ready_state: Signal<ConnectionReadyState>,
|
||||
pub authenticated: ReadSignal<bool>,
|
||||
pub set_authenticated: WriteSignal<bool>,
|
||||
pub loading: ReadSignal<bool>,
|
||||
pub set_loading: WriteSignal<bool>,
|
||||
}
|
||||
let mut participants: Vec<schemas::Participant> = vec![];
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SurrealResponse {
|
||||
id: Option<u32>,
|
||||
result: SurrealResult,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SurrealResultError {
|
||||
code: i32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct SurrealResponseError {
|
||||
id: u32,
|
||||
error: SurrealResultError,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
struct SurrealAction {
|
||||
id: String,
|
||||
action: String,
|
||||
result: Participant,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum SurrealResult {
|
||||
String(String),
|
||||
Participants(Vec<Participant>),
|
||||
Action(SurrealAction),
|
||||
Null,
|
||||
}
|
||||
|
||||
impl SurrealContext {
|
||||
pub fn new(
|
||||
message: Signal<Option<String>>,
|
||||
send: Rc<dyn Fn(&str)>,
|
||||
ready_state: Signal<ConnectionReadyState>,
|
||||
authenticated: ReadSignal<bool>,
|
||||
set_authenticated: WriteSignal<bool>,
|
||||
loading: ReadSignal<bool>,
|
||||
set_loading: WriteSignal<bool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
message,
|
||||
send,
|
||||
ready_state,
|
||||
authenticated,
|
||||
set_authenticated,
|
||||
loading,
|
||||
set_loading,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a string to surrealDB
|
||||
#[inline(always)]
|
||||
pub fn send(&self, message: &str) {
|
||||
(self.send)(message)
|
||||
}
|
||||
|
||||
/// sigin SurrealDB
|
||||
pub fn signin(&self, pass: String) {
|
||||
log::debug!("Signing into surrealdb");
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(0),
|
||||
method: String::from("signin"),
|
||||
params: vec![SurrealParams::SigninParam(SigninParam {
|
||||
user: String::from("root"),
|
||||
pass,
|
||||
})],
|
||||
};
|
||||
|
||||
self.send(&json!(request).to_string());
|
||||
}
|
||||
|
||||
pub fn add_person(&self, name: String, group: String) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err(String::from("Name cannot be empty"));
|
||||
}
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(10),
|
||||
method: String::from("create"),
|
||||
params: vec![
|
||||
SurrealParams::String(String::from("person")),
|
||||
SurrealParams::CreatePersonParam(CreatePersonParam {
|
||||
name,
|
||||
group: "group:".to_owned() + &group,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
Ok(self.send(&json!(request).to_string()))
|
||||
}
|
||||
|
||||
pub fn add_time(&self, body: CreateTimeParam, event: String) -> Result<(), String> {
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(20),
|
||||
method: String::from("create"),
|
||||
params: vec![
|
||||
SurrealParams::String(event),
|
||||
SurrealParams::CreateTimeParam(body),
|
||||
],
|
||||
};
|
||||
|
||||
Ok(self.send(&json!(request).to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_surrealdb() {
|
||||
// Initialize a connection to surrealDB
|
||||
let UseWebsocketReturn {
|
||||
ready_state,
|
||||
message,
|
||||
send,
|
||||
..
|
||||
} = use_websocket("ws://localhost:80/rpc");
|
||||
|
||||
// Create reactive signals for the websocket connection to surrealDB
|
||||
let (participants, set_participants) = create_signal::<Vec<Participant>>(vec![]);
|
||||
let (authenticated, set_authenticated) = create_signal::<bool>(false);
|
||||
let (loading, set_loading) = create_signal::<bool>(true);
|
||||
|
||||
// Bind the websocket connection to a context
|
||||
let websocket = SurrealContext::new(
|
||||
message,
|
||||
Rc::new(send.clone()),
|
||||
ready_state,
|
||||
authenticated,
|
||||
set_authenticated,
|
||||
loading,
|
||||
set_loading,
|
||||
);
|
||||
|
||||
provide_context(websocket.clone());
|
||||
provide_context(ParticipantsContext {
|
||||
read: participants,
|
||||
write: set_participants,
|
||||
participant_records.iter().for_each(|participant| {
|
||||
participants.push(schemas::Participant {
|
||||
id: participant.id.id.to_string(),
|
||||
name: participant.name.clone(),
|
||||
group: participant.group.clone(),
|
||||
events: participant.events.clone(),
|
||||
})
|
||||
});
|
||||
|
||||
create_effect(move |prev_value| {
|
||||
let status = ready_state.get();
|
||||
Ok(participants)
|
||||
}
|
||||
|
||||
if prev_value != Some(status.clone()) && status == ConnectionReadyState::Open {
|
||||
let (token, _, _) = use_local_storage::<String, FromToStringCodec>("surrealdb-token");
|
||||
pub fn init_participants() {
|
||||
let participants = create_local_resource(|| (), |_| async move { get_participants().await });
|
||||
|
||||
if token.get().is_empty() {
|
||||
set_loading.set(false);
|
||||
return status;
|
||||
create_effect(move |_| {
|
||||
participants.and_then(|data: &Vec<schemas::Participant>| {
|
||||
let participants_context = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
|
||||
let mut participants_new: Vec<schemas::ParticipantSignal> = vec![];
|
||||
|
||||
for participant in data {
|
||||
participants_new.push(schemas::ParticipantSignal {
|
||||
id: participant.id.clone(),
|
||||
value: create_rw_signal(participant.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(0),
|
||||
method: String::from("authenticate"),
|
||||
params: vec![SurrealParams::String(token.get())],
|
||||
};
|
||||
|
||||
websocket.send(&json!(request).to_string());
|
||||
};
|
||||
|
||||
status
|
||||
});
|
||||
|
||||
// Watch for a message recieved from the surrealDB connection
|
||||
create_effect(move |prev_value| {
|
||||
let msg = message.get();
|
||||
|
||||
if prev_value != Some(msg.clone()) {
|
||||
match msg {
|
||||
Some(ref text) => {
|
||||
log::debug!("{:?}", text);
|
||||
surrealdb_response(text.to_string());
|
||||
}
|
||||
None => (),
|
||||
}
|
||||
}
|
||||
|
||||
msg
|
||||
participants_context.set(participants_new);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Function to execute when SurrealDB returns a message
|
||||
fn surrealdb_response(response: String) {
|
||||
let response: SurrealResponse = match serde_json::from_str(&response) {
|
||||
Ok(res) => res,
|
||||
Err(_) => {
|
||||
handle_error(response);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match response.id {
|
||||
Some(0) => use_surrealdb(response.clone()),
|
||||
Some(1) => get_participants(),
|
||||
Some(2) => log::debug!("Subscribed to live timings"),
|
||||
Some(5) => got_participants(response.result.clone()),
|
||||
Some(_) => (),
|
||||
None => (),
|
||||
}
|
||||
|
||||
match response.result {
|
||||
SurrealResult::Action(action) => handle_action(action),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
/// Function to execute when an error is returned from Surrealdb
|
||||
fn handle_error(response: String) {
|
||||
let response: SurrealResponseError = match serde_json::from_str(&response) {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
log::warn!("{}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if response.id == 0 && response.error.code == -32000 {
|
||||
let (_, _, remove_token) =
|
||||
use_local_storage::<String, FromToStringCodec>("surrealdb-token");
|
||||
let websocket = expect_context::<SurrealContext>();
|
||||
|
||||
remove_token();
|
||||
websocket.set_loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Function to execute when DB signin is succesful
|
||||
fn use_surrealdb(response: SurrealResponse) {
|
||||
util::toast::add_toast(
|
||||
"Succesfully signed into DB".to_string(),
|
||||
"success".to_string(),
|
||||
);
|
||||
|
||||
let (_, set_token, _) = use_local_storage::<String, FromToStringCodec>("surrealdb-token");
|
||||
|
||||
match response.result {
|
||||
SurrealResult::String(str) => set_token.set(str),
|
||||
_ => (),
|
||||
};
|
||||
|
||||
let websocket = expect_context::<SurrealContext>();
|
||||
|
||||
websocket.set_authenticated.set(true);
|
||||
websocket.set_loading.set(false);
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(1),
|
||||
method: String::from("use"),
|
||||
params: vec![
|
||||
SurrealParams::String(String::from("wrb")),
|
||||
SurrealParams::String(String::from("timings")),
|
||||
],
|
||||
};
|
||||
|
||||
websocket.send(&json!(request).to_string())
|
||||
}
|
||||
|
||||
/// Function to get all participants and subscribe to changes
|
||||
fn get_participants() {
|
||||
let websocket = expect_context::<SurrealContext>();
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(5),
|
||||
method: String::from("select"),
|
||||
params: vec![SurrealParams::String(String::from("person"))],
|
||||
};
|
||||
|
||||
websocket.send(&json!(request).to_string());
|
||||
|
||||
let request = SurrealRequest {
|
||||
id: SurrealId::Integer(2),
|
||||
method: String::from("live"),
|
||||
params: vec![SurrealParams::String(String::from("person"))],
|
||||
};
|
||||
|
||||
websocket.send(&json!(request).to_string());
|
||||
}
|
||||
|
||||
/// Function that will execute when participants are recieved
|
||||
fn got_participants(result: SurrealResult) {
|
||||
let participants_context = expect_context::<ParticipantsContext>();
|
||||
|
||||
if let SurrealResult::Participants(mut value) = result {
|
||||
let mut participants = participants_context.read.get();
|
||||
participants.append(&mut value);
|
||||
participants_context.write.set(participants);
|
||||
}
|
||||
}
|
||||
|
||||
/// Function to call when an action is recieved from surrealDB
|
||||
fn handle_action(action: SurrealAction) {
|
||||
let participants_context = expect_context::<ParticipantsContext>();
|
||||
|
||||
let mut participants = participants_context.read.get();
|
||||
participants.push(action.result);
|
||||
participants_context.write.set(participants);
|
||||
}
|
||||
|
71
application/src/util/surrealdb/client.rs
Normal file
71
application/src/util/surrealdb/client.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use crate::util::surrealdb::schemas;
|
||||
use leptos::*;
|
||||
use strsim::normalized_damerau_levenshtein;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Time {
|
||||
pub minutes: u32,
|
||||
pub seconds: u32,
|
||||
pub milliseconds: u32,
|
||||
}
|
||||
|
||||
impl Time {
|
||||
pub fn as_milliseconds(&self) -> u32 {
|
||||
self.minutes * 60 * 1000 + self.seconds * 1000 + self.milliseconds * 10
|
||||
}
|
||||
|
||||
pub fn from_milliseconds_to_string(mut time: u32) -> String {
|
||||
let milliseconds = (time % 1000) / 10;
|
||||
time /= 1000;
|
||||
let seconds = time % 60;
|
||||
time /= 60;
|
||||
let minutes = time;
|
||||
|
||||
format!("{minutes:02}:{seconds:02}:{milliseconds:02}")
|
||||
}
|
||||
|
||||
pub fn from_milliseconds(mut time: u32) -> Self {
|
||||
let milliseconds = (time % 1000) / 10;
|
||||
time /= 1000;
|
||||
let seconds = time % 60;
|
||||
time /= 60;
|
||||
let minutes = time;
|
||||
|
||||
Self {
|
||||
milliseconds,
|
||||
seconds,
|
||||
minutes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_participants(
|
||||
participants: Vec<schemas::ParticipantSignal>,
|
||||
search: String,
|
||||
) -> Vec<schemas::ParticipantSignal> {
|
||||
let mut filtered_sorted_list: Vec<(schemas::ParticipantSignal, f64)> = participants
|
||||
.into_iter()
|
||||
.map(|participant| {
|
||||
(
|
||||
participant.clone(),
|
||||
normalized_damerau_levenshtein(
|
||||
&participant.value.get().name.to_lowercase(),
|
||||
&search.to_lowercase(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
filtered_sorted_list.sort_by(|a, b| {
|
||||
let (_, sim_score_a) = a;
|
||||
let (_, sim_score_b) = b;
|
||||
sim_score_b
|
||||
.partial_cmp(sim_score_a)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
filtered_sorted_list
|
||||
.into_iter()
|
||||
.map(|(item, _)| item)
|
||||
.collect()
|
||||
}
|
63
application/src/util/surrealdb/schemas.rs
Normal file
63
application/src/util/surrealdb/schemas.rs
Normal file
@ -0,0 +1,63 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use surrealdb::sql::Thing;
|
||||
}
|
||||
}
|
||||
|
||||
use leptos::RwSignal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Hash, Eq)]
|
||||
pub struct Events {
|
||||
pub lifesaver: Option<u32>,
|
||||
pub hindernis: Option<u32>,
|
||||
pub popduiken: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ParticipantRecord {
|
||||
pub id: Thing,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
pub events: Option<Events>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct Participant {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
pub events: Option<Events>,
|
||||
}
|
||||
|
||||
impl Participant {
|
||||
pub fn as_update(&self) -> ParticipantUpdate {
|
||||
ParticipantUpdate {
|
||||
name: self.name.clone(),
|
||||
group: self.group.clone(),
|
||||
events: self.events.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ParticipantUpdate {
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
pub events: Option<Events>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ParticipantSignal {
|
||||
pub id: String,
|
||||
pub value: RwSignal<Participant>,
|
||||
}
|
||||
|
||||
pub type ParticipantsContext = RwSignal<Vec<ParticipantSignal>>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct NewParticipant {
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
use leptos::*;
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NotificationsContext {
|
||||
pub notifications: ReadSignal<Vec<ToastNotification>>,
|
||||
pub set_notifications: WriteSignal<Vec<ToastNotification>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastNotification {
|
||||
pub text: String,
|
||||
pub option: String, // error, warning, info, success
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub fn init_toast() {
|
||||
let (notifications, set_notifications) = create_signal::<Vec<ToastNotification>>(vec![]);
|
||||
provide_context(NotificationsContext {
|
||||
notifications,
|
||||
set_notifications,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn add_toast(text: String, option: String) {
|
||||
let context = expect_context::<NotificationsContext>();
|
||||
|
||||
let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 4);
|
||||
|
||||
let mut vec = context.notifications.get();
|
||||
vec.push(ToastNotification {
|
||||
text,
|
||||
option,
|
||||
id: id.clone(),
|
||||
});
|
||||
context.set_notifications.set(vec);
|
||||
|
||||
spawn_local(async {
|
||||
TimeoutFuture::new(5000).await;
|
||||
remove_toast(id);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn remove_toast(id: String) {
|
||||
let context = expect_context::<NotificationsContext>();
|
||||
|
||||
let mut vec = context.notifications.get_untracked();
|
||||
vec.retain(|x| x.id != id);
|
||||
context.set_notifications.set(vec);
|
||||
}
|
36
application/src/util/websocket.rs
Normal file
36
application/src/util/websocket.rs
Normal file
@ -0,0 +1,36 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod server;
|
||||
|
||||
pub mod client;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::util::surrealdb::schemas::Participant;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum ParticipantsAction {
|
||||
Add { participant: Participant },
|
||||
Replace { participant: Participant },
|
||||
Remove { id: String },
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn apply_participants_patch(mut participants: Vec<Participant>, action: ParticipantsAction) {
|
||||
match action {
|
||||
ParticipantsAction::Add { participant } => {
|
||||
participants.push(participant);
|
||||
}
|
||||
ParticipantsAction::Remove { id } => {
|
||||
participants.retain(|participant| participant.id != id);
|
||||
}
|
||||
ParticipantsAction::Replace { participant } => {
|
||||
if let Some(index) = participants
|
||||
.iter()
|
||||
.position(|item| item.id == participant.id)
|
||||
{
|
||||
let _ = std::mem::replace(&mut participants[index], participant);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
55
application/src/util/websocket/client.rs
Normal file
55
application/src/util/websocket/client.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use crate::util::surrealdb::schemas;
|
||||
use leptos::*;
|
||||
use leptos_use::*;
|
||||
|
||||
use super::ParticipantsAction;
|
||||
|
||||
pub fn init_websocket() {
|
||||
let UseWebsocketReturn {
|
||||
ready_state,
|
||||
message,
|
||||
..
|
||||
} = use_websocket("ws://192.168.0.150:3000/ws");
|
||||
|
||||
provide_context(ready_state);
|
||||
|
||||
let participants_context = use_context::<schemas::ParticipantsContext>().unwrap();
|
||||
let owner = Owner::current().unwrap();
|
||||
|
||||
create_effect(move |_| {
|
||||
if let Some(m) = message.get() {
|
||||
with_owner(owner, || handle_message(&participants_context, &m));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_message(&participants_context: &schemas::ParticipantsContext, message: &str) {
|
||||
let action: ParticipantsAction = match serde_json::from_str(message) {
|
||||
Ok(res) => res,
|
||||
Err(_err) => {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
logging::log!("Recieved action: {:?}", action);
|
||||
|
||||
match action {
|
||||
ParticipantsAction::Add { participant } => participants_context.update(|participants| {
|
||||
participants.push(schemas::ParticipantSignal {
|
||||
id: participant.id.clone(),
|
||||
value: create_rw_signal(participant),
|
||||
})
|
||||
}),
|
||||
ParticipantsAction::Remove { id } => participants_context
|
||||
.update(|participants| participants.retain(|participant| participant.id != id)),
|
||||
ParticipantsAction::Replace { participant } => {
|
||||
let participants = participants_context.get();
|
||||
for participant_signal in participants {
|
||||
if participant_signal.id == participant.id {
|
||||
participant_signal.value.set(participant);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
92
application/src/util/websocket/server.rs
Normal file
92
application/src/util/websocket/server.rs
Normal file
@ -0,0 +1,92 @@
|
||||
use crate::util::websocket;
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::{sink::SinkExt, stream::StreamExt};
|
||||
use leptos::LeptosOptions;
|
||||
use leptos_router::RouteListing;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
|
||||
pub static WEBSOCKET_STATE: Lazy<WebSocketState> = Lazy::new(|| {
|
||||
let client_set = Arc::new(Mutex::new(HashSet::<uuid::Uuid>::new()));
|
||||
let (tx, _rx) = broadcast::channel(100);
|
||||
|
||||
WebSocketState { client_set, tx }
|
||||
});
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebSocketState {
|
||||
pub client_set: Arc<Mutex<HashSet<uuid::Uuid>>>,
|
||||
pub tx: broadcast::Sender<String>,
|
||||
}
|
||||
|
||||
impl WebSocketState {
|
||||
pub fn apply(&self, action: websocket::ParticipantsAction) -> Result<(), serde_json::Error> {
|
||||
let message = serde_json::to_string(&action)?;
|
||||
let _ = self.tx.send(message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, axum::extract::FromRef)]
|
||||
pub struct AppState {
|
||||
pub leptos_options: LeptosOptions,
|
||||
pub websocket_state: Arc<WebSocketState>,
|
||||
pub routes: Vec<RouteListing>,
|
||||
}
|
||||
|
||||
pub async fn websocket_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| websocket(socket))
|
||||
}
|
||||
|
||||
async fn websocket(stream: WebSocket) {
|
||||
let state = &WEBSOCKET_STATE;
|
||||
|
||||
let (mut sender, mut receiver) = stream.split();
|
||||
|
||||
let mut client_set = state.client_set.lock().await;
|
||||
|
||||
let uuid = uuid::Uuid::new_v4();
|
||||
|
||||
client_set.insert(uuid);
|
||||
drop(client_set);
|
||||
|
||||
let mut rx = state.tx.subscribe();
|
||||
|
||||
let msg = format!("{uuid} joined");
|
||||
println!("{uuid} joined");
|
||||
let _ = state.tx.send(msg);
|
||||
|
||||
let mut send_task = tokio::spawn(async move {
|
||||
while let Ok(msg) = rx.recv().await {
|
||||
if sender.send(Message::Text(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tx = state.tx.clone();
|
||||
let uuid_clone = uuid.clone();
|
||||
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(Message::Text(text))) = receiver.next().await {
|
||||
let _ = tx.send(format!("{uuid_clone}: {text}"));
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = (&mut send_task) => recv_task.abort(),
|
||||
_ = (&mut recv_task) => send_task.abort(),
|
||||
};
|
||||
|
||||
let msg = format!("{uuid} left");
|
||||
println!("{uuid} left");
|
||||
let _ = state.tx.send(msg);
|
||||
|
||||
state.client_set.lock().await.remove(&uuid);
|
||||
}
|
72
application/style/forms.scss
Normal file
72
application/style/forms.scss
Normal file
@ -0,0 +1,72 @@
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
margin: 40px auto 0 auto;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
form label {
|
||||
text-align: left;
|
||||
margin-left: 5px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.autocomplete ul {
|
||||
background-color: $secondary-bg-color-lighter;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
padding: 5px;
|
||||
margin-top: -15px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.autocomplete ul .selected {
|
||||
background-color: $secondary-color;
|
||||
}
|
||||
|
||||
.autocomplete ul li {
|
||||
padding: 3px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
input,select {
|
||||
background-color: $secondary-bg-color-light;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.9em;
|
||||
color: $text-color;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input[type=submit]:hover {
|
||||
background-color: $secondary-bg-color-lighter;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
form .time {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
form .time input {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
form .time input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
37
application/style/groups.scss
Normal file
37
application/style/groups.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.groups-select-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.groups-select-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.groups-select-row div {
|
||||
background-color: $secondary-bg-color-light;
|
||||
border-radius: 10px;
|
||||
width: 50px;
|
||||
padding: 2px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.groups-select-row div:hover {
|
||||
cursor: pointer;
|
||||
background-color: $secondary-color;
|
||||
}
|
||||
|
||||
.groups-select-row .selected {
|
||||
background-color: $primary-color;
|
||||
}
|
||||
|
||||
.events-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.events-container div {
|
||||
width: 100%;
|
||||
}
|
27
application/style/header.scss
Normal file
27
application/style/header.scss
Normal file
@ -0,0 +1,27 @@
|
||||
header {
|
||||
width: 100%;
|
||||
background-color: $secondary-bg-color;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
margin: 10px 20px;
|
||||
}
|
||||
|
||||
.header-container a {
|
||||
margin-right: auto;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: $text-color;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.header-container a:hover {
|
||||
margin: 0;
|
||||
margin-right: auto;
|
||||
}
|
57
application/style/index.scss
Normal file
57
application/style/index.scss
Normal file
@ -0,0 +1,57 @@
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actions-container a {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
background-color: $secondary-bg-color;
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
border: dashed $secondary-bg-color-lighter;
|
||||
}
|
||||
|
||||
.actions-container a:hover {
|
||||
background-color: $secondary-bg-color-light;
|
||||
}
|
||||
|
||||
.participants-search {
|
||||
margin-top: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.participants-table {
|
||||
margin-top: 0px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.participants-table th {
|
||||
position: sticky;
|
||||
background-color: $secondary-bg-color-lighter;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.participants-table th,td {
|
||||
text-align: left;
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.participants-table tr:hover {
|
||||
background-color: $secondary-color !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participants-table tr:nth-child(odd) {
|
||||
background-color: $secondary-bg-color-light;
|
||||
}
|
41
application/style/main.scss
Normal file
41
application/style/main.scss
Normal file
@ -0,0 +1,41 @@
|
||||
$primary-color: #eb6330;
|
||||
$primary-color-light: hsl(16.36, 82.38%, 55.49%, 0.8);
|
||||
$secondary-color: #465651;
|
||||
$accent-color: #89969f;
|
||||
$primary-bg-color: #0d0b0b;
|
||||
$secondary-bg-color: #151719;
|
||||
$secondary-bg-color-light: hsl(204, 11%, 12%, 1);
|
||||
$secondary-bg-color-lighter: hsl(204, 11%, 15%, 1);
|
||||
$accent-bg-color: #181a19;
|
||||
$text-color: #f3efef;
|
||||
|
||||
@import "forms";
|
||||
@import "header";
|
||||
@import "index";
|
||||
@import "groups";
|
||||
@import "modal";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: $primary-bg-color;
|
||||
color: $text-color;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 30px;
|
||||
}
|
28
application/style/modal.scss
Normal file
28
application/style/modal.scss
Normal file
@ -0,0 +1,28 @@
|
||||
.modal-background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 200;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 800px;
|
||||
overflow-y: auto;
|
||||
background-color: $secondary-bg-color;
|
||||
border-radius: 5px;
|
||||
padding: 0px 20px;
|
||||
}
|
||||
|
||||
.modal form {
|
||||
width: 100%;
|
||||
}
|
18
flake.lock
18
flake.lock
@ -38,11 +38,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1710951922,
|
||||
"narHash": "sha256-FOOBJ3DQenLpTNdxMHR2CpGZmYuctb92gF0lpiirZ30=",
|
||||
"lastModified": 1713344939,
|
||||
"narHash": "sha256-jpHkAt0sG2/J7ueKnG7VvLLkBYUMQbXQ2L8OBpVG53s=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f091af045dff8347d66d186a62d42aceff159456",
|
||||
"rev": "e402c3eb6d88384ca6c52ef1c53e61bdc9b84ddd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -54,11 +54,11 @@
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1711001935,
|
||||
"narHash": "sha256-URtGpHue7HHZK0mrHnSf8wJ6OmMKYSsoLmJybrOLFSQ=",
|
||||
"lastModified": 1713297878,
|
||||
"narHash": "sha256-hOkzkhLT59wR8VaMbh1ESjtZLbGi+XNaBN6h49SPqEc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "20f77aa09916374aa3141cbc605c955626762c9a",
|
||||
"rev": "66adc1e47f8784803f2deb6cacd5e07264ec2d5c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -98,11 +98,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1711073443,
|
||||
"narHash": "sha256-PpNb4xq7U5Q/DdX40qe7CijUsqhVVM3VZrhN0+c6Lcw=",
|
||||
"lastModified": 1713492869,
|
||||
"narHash": "sha256-Zv+ZQq3X+EH6oogkXaJ8dGN8t1v26kPZgC5bki04GnM=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "eec55ba9fcde6be4c63942827247e42afef7fafe",
|
||||
"rev": "1e9264d1214d3db00c795b41f75d55b5e153758e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
21
flake.nix
21
flake.nix
@ -25,23 +25,26 @@
|
||||
{
|
||||
devShells.default = mkShell {
|
||||
buildInputs = [
|
||||
trunk
|
||||
openssh
|
||||
pkg-config
|
||||
cargo-insta
|
||||
llvmPackages_latest.llvm
|
||||
llvmPackages_latest.bintools
|
||||
# llvmPackages_17.clangNoLibc
|
||||
zlib.out
|
||||
dart-sass
|
||||
unstable.rust-analyzer
|
||||
llvmPackages.clangNoLibc
|
||||
llvmPackages.lld
|
||||
dap
|
||||
(rust-bin.stable.latest.default.override {
|
||||
llvmPackages_17.lld
|
||||
(rust-bin.selectLatestNightlyWith ( toolchain: toolchain.default.override {
|
||||
extensions= [ "rust-src" "rust-analyzer" ];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
})
|
||||
}))
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
alias grep=ripgrep
|
||||
export PATH=$PATH:''${CARGO_HOME:-~/.cargo}/bin
|
||||
export CC=clang
|
||||
export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER=lld
|
||||
# export CC=clang
|
||||
# export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER=lld
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user