Compare commits

..

No commits in common. "main" and "cargo-leptos" have entirely different histories.

53 changed files with 2407 additions and 1245 deletions

View File

@ -1,2 +1,13 @@
/target # Generated by Cargo
/dist # 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/

View File

@ -2,46 +2,119 @@
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
edition = "2021" 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] [dependencies]
leptos = { version = "0.6", features = ["csr"] } axum = { version = "0.7", optional = true, features = [ "ws", "macros" ] }
leptos_meta = { version = "0.6", features = ["csr"] }
leptos_router = { version = "0.6", features = ["csr"] }
console_log = "1"
log = "0.4"
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
leptos-use = "0.10.2" leptos = { version = "0.6", features = [] }
serde = "1.0.196" leptos_axum = { version = "0.6", optional = true }
serde_json = "1.0.113" leptos_meta = { version = "0.6", features = [] }
rand = "0.8.5" leptos_router = { version = "0.6", features = [] }
gloo-timers = "0.3.0" tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
strsim = "0.11.0" 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 [features]
# strum = { version = "0.25", features = ["derive", "strum_macros"] } hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate", "leptos_toaster/hydrate"]
# strum_macros = "0.25" ssr = [
"dep:surrealdb",
[dependencies.web-sys] "dep:axum",
version = "0.3" "dep:tokio",
features = [ "dep:tower",
"Document", "dep:tower-http",
"Window", "dep:leptos_axum",
"Element", "leptos/ssr",
"ScrollIntoViewOptions", "leptos-use/ssr",
"ScrollLogicalPosition", "leptos_meta/ssr",
"ScrollBehavior", "leptos_router/ssr",
"dep:tracing",
"leptos_toaster/ssr"
] ]
[dev-dependencies] # Defines a size-optimized profile for the WASM bundle in release mode
wasm-bindgen = "0.2" [profile.wasm-release]
wasm-bindgen-test = "0.3" inherits = "release"
[profile.release]
opt-level = 'z' opt-level = 'z'
lto = true lto = true
codegen-units = 1 codegen-units = 1
panic = "abort" 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
View 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
View 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>

View File

@ -3,77 +3,88 @@
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo"> <img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture> </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 ```bash
cargo install cargo-generate cargo install cargo-leptos
``` ```
Then run
To set up your project with this template, run ```bash
cargo leptos new --git leptos-rs/start-axum
```sh
cargo generate --git https://github.com/leptos-community/start-csr
``` ```
to generate your new project, then to generate a new project template.
```sh ```bash
cd application 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
```bash
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]. cargo leptos watch
If you don't have Rust nightly, you can install it with
```sh
rustup toolchain install nightly --allow-downgrade
``` ```
You can add the `wasm` compilation target to rust using ## Installing Additional Tools
```sh
rustup target add wasm32-unknown-unknown 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 ## Testing Your Project
```bash
To develop your Leptos CSR project, running cargo leptos end-to-end
```sh
trunk serve --port 3000 --open
``` ```
will open your app in your default browser at `http://localhost:3000`. ```bash
cargo leptos end-to-end --release
## Deploying your Leptos CSR project
To build a Leptos CSR app for release, use the command
```sh
trunk build --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 ## Licensing
[Trunk-instructions]: https://trunkrs.dev/assets/
[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
View 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
}
}
}

View 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"
}
}

View 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;

View 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!");
});

View File

@ -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>

View File

@ -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;
}

View File

@ -1,2 +1,3 @@
[toolchain] [toolchain]
channel = "nightly" channel = "nightly"

58
application/src/app.rs Normal file
View 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>
}
}

View File

@ -0,0 +1,3 @@
pub mod header;
pub mod participant;
pub mod participants;

View 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>
}
}

View File

@ -1,2 +0,0 @@
pub mod navbar;
pub mod toast;

View File

@ -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>
}
}

View 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>
}
}

View 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>
}
}

View File

@ -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>
}
}

View 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>
}
}
/>
}
}

View 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}"),
)),
}
}

View File

@ -1,89 +1,15 @@
use leptos::*; pub mod app;
use leptos_meta::*; pub mod error_template;
use leptos_router::*; #[cfg(feature = "ssr")]
pub mod fileserv;
pub mod pages;
pub mod util;
pub mod components;
// Modules #[cfg(feature = "hydrate")]
mod components; #[wasm_bindgen::prelude::wasm_bindgen]
mod pages; pub fn hydrate() {
mod util; use crate::app::*;
console_error_panic_hook::set_once();
// Top-Level pages leptos::mount_to_body(App);
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>
}
} }

View File

@ -1,14 +1,45 @@
use application::App; #![feature(diagnostic_namespace)]
use leptos::*; #[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() { application::util::surrealdb::connect()
// set up logging .await
_ = console_log::init_with_level(log::Level::Debug); .expect("Database connection failed");
console_error_panic_hook::set_once();
mount_to_body(|| { // Setting get_configuration(None) means we'll be using cargo-leptos's env values
view! { // For deployment these variables are:
<App /> // <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
View File

@ -0,0 +1,4 @@
pub mod add_participant;
pub mod add_time;
pub mod groups;
pub mod index;

View 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>
}
}

View 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>
}
}

View 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)
}

View File

@ -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>
}
}

View 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() />
}
}

View File

@ -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>
}
}

View File

@ -1,5 +0,0 @@
pub mod home;
pub mod login;
pub mod not_found;
pub mod participants;
pub mod times;

View File

@ -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> }
}

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -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>
}
}

View File

@ -1,2 +1,2 @@
pub mod surrealdb; pub mod surrealdb;
pub mod toast; pub mod websocket;

View File

@ -1,387 +1,69 @@
use crate::util;
use leptos::*; 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)] pub mod client;
#[serde(untagged)] pub mod schemas;
enum SurrealId {
String(String),
Integer(u32),
}
#[derive(Serialize)] cfg_if::cfg_if! {
struct SurrealRequest { if #[cfg(feature = "ssr")] {
id: SurrealId, use once_cell::sync::Lazy;
method: String, use surrealdb::engine::remote::ws::{Client, Ws};
params: Vec<SurrealParams>, use surrealdb::opt::auth::Root;
} use surrealdb::Surreal;
#[derive(Serialize)] pub static DB: Lazy<Surreal<Client>> = Lazy::new(Surreal::init);
#[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,
}
} }
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[cfg(feature = "ssr")]
pub struct Participant { pub async fn connect() -> Result<(), ServerFnError> {
pub id: String, DB.connect::<Ws>("localhost:80").await?;
pub name: String,
pub group: String, DB.signin(Root {
username: "root",
password: "root",
})
.await?;
DB.use_ns("wrb").use_db("timings").await?;
Ok(())
} }
#[derive(Clone, Debug)] #[server]
pub struct ParticipantsContext { pub async fn get_participants() -> Result<Vec<schemas::Participant>, ServerFnError> {
pub read: ReadSignal<Vec<Participant>>, let participant_records: Vec<schemas::ParticipantRecord> = DB.select("participant").await?;
write: WriteSignal<Vec<Participant>>,
}
#[derive(Clone)] let mut participants: Vec<schemas::Participant> = vec![];
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>,
}
#[derive(Serialize, Deserialize, Clone)] participant_records.iter().for_each(|participant| {
struct SurrealResponse { participants.push(schemas::Participant {
id: Option<u32>, id: participant.id.id.to_string(),
result: SurrealResult, name: participant.name.clone(),
} group: participant.group.clone(),
events: participant.events.clone(),
#[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,
}); });
create_effect(move |prev_value| { Ok(participants)
let status = ready_state.get(); }
if prev_value != Some(status.clone()) && status == ConnectionReadyState::Open { pub fn init_participants() {
let (token, _, _) = use_local_storage::<String, FromToStringCodec>("surrealdb-token"); let participants = create_local_resource(|| (), |_| async move { get_participants().await });
if token.get().is_empty() { create_effect(move |_| {
set_loading.set(false); participants.and_then(|data: &Vec<schemas::Participant>| {
return status; 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 { participants_context.set(participants_new);
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
}); });
} }
/// 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);
}

View 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()
}

View 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,
}

View File

@ -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);
}

View 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);
}
}
}
}
*/

View 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;
}
}
}
}
}

View 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);
}

View 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;
}

View 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%;
}

View 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;
}

View 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;
}

View 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;
}

View 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%;
}

View File

@ -38,11 +38,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1710951922, "lastModified": 1713344939,
"narHash": "sha256-FOOBJ3DQenLpTNdxMHR2CpGZmYuctb92gF0lpiirZ30=", "narHash": "sha256-jpHkAt0sG2/J7ueKnG7VvLLkBYUMQbXQ2L8OBpVG53s=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "f091af045dff8347d66d186a62d42aceff159456", "rev": "e402c3eb6d88384ca6c52ef1c53e61bdc9b84ddd",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -54,11 +54,11 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1711001935, "lastModified": 1713297878,
"narHash": "sha256-URtGpHue7HHZK0mrHnSf8wJ6OmMKYSsoLmJybrOLFSQ=", "narHash": "sha256-hOkzkhLT59wR8VaMbh1ESjtZLbGi+XNaBN6h49SPqEc=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "20f77aa09916374aa3141cbc605c955626762c9a", "rev": "66adc1e47f8784803f2deb6cacd5e07264ec2d5c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -98,11 +98,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1711073443, "lastModified": 1713492869,
"narHash": "sha256-PpNb4xq7U5Q/DdX40qe7CijUsqhVVM3VZrhN0+c6Lcw=", "narHash": "sha256-Zv+ZQq3X+EH6oogkXaJ8dGN8t1v26kPZgC5bki04GnM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "eec55ba9fcde6be4c63942827247e42afef7fafe", "rev": "1e9264d1214d3db00c795b41f75d55b5e153758e",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -25,23 +25,26 @@
{ {
devShells.default = mkShell { devShells.default = mkShell {
buildInputs = [ buildInputs = [
trunk openssh
pkg-config
cargo-insta
llvmPackages_latest.llvm
llvmPackages_latest.bintools
# llvmPackages_17.clangNoLibc
zlib.out
dart-sass dart-sass
unstable.rust-analyzer llvmPackages_17.lld
llvmPackages.clangNoLibc (rust-bin.selectLatestNightlyWith ( toolchain: toolchain.default.override {
llvmPackages.lld
dap
(rust-bin.stable.latest.default.override {
extensions= [ "rust-src" "rust-analyzer" ]; extensions= [ "rust-src" "rust-analyzer" ];
targets = [ "wasm32-unknown-unknown" ]; targets = [ "wasm32-unknown-unknown" ];
}) }))
]; ];
shellHook = '' shellHook = ''
alias grep=ripgrep alias grep=ripgrep
export PATH=$PATH:''${CARGO_HOME:-~/.cargo}/bin export PATH=$PATH:''${CARGO_HOME:-~/.cargo}/bin
export CC=clang # export CC=clang
export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER=lld # export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_LINKER=lld
''; '';
}; };
} }