Compare commits

...

4 Commits

Author SHA1 Message Date
Mathias Wagner
e39a583e95 Add UI components 2025-09-09 13:07:35 +02:00
Mathias Wagner
12f9eebfad Create me route 2025-09-09 12:39:16 +02:00
Mathias Wagner
0ce3751d08 Create test design 2025-09-09 12:39:03 +02:00
Mathias Wagner
0eb7e9d4ca Create base webui 2025-09-09 12:00:12 +02:00
29 changed files with 1647 additions and 28 deletions

View File

@@ -2,13 +2,19 @@ mod controllers;
mod routes; mod routes;
mod utils; mod utils;
use utils::init_database;
use anyhow::Result; use anyhow::Result;
use axum::{routing::{delete, get, post, put}, Router}; use axum::{
use routes::{admin, auth as auth_routes, machines, setup}; routing::{delete, get, post, put},
use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}}; Router,
};
use routes::{accounts, admin, auth as auth_routes, machines, setup};
use std::path::Path; use std::path::Path;
use tokio::signal; use tokio::signal;
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
};
use utils::init_database;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -16,21 +22,17 @@ async fn main() -> Result<()> {
let api_routes = Router::new() let api_routes = Router::new()
.route("/setup/status", get(setup::get_setup_status)) .route("/setup/status", get(setup::get_setup_status))
.route("/setup/init", post(setup::init_setup)) .route("/setup/init", post(setup::init_setup))
.route("/auth/login", post(auth_routes::login)) .route("/auth/login", post(auth_routes::login))
.route("/auth/logout", post(auth_routes::logout)) .route("/auth/logout", post(auth_routes::logout))
.route("/accounts/me", get(accounts::me))
.route("/admin/users", get(admin::get_users)) .route("/admin/users", get(admin::get_users))
.route("/admin/users", post(admin::create_user_handler)) .route("/admin/users", post(admin::create_user_handler))
.route("/admin/users/{id}", put(admin::update_user_handler)) .route("/admin/users/{id}", put(admin::update_user_handler))
.route("/admin/users/{id}", delete(admin::delete_user_handler)) .route("/admin/users/{id}", delete(admin::delete_user_handler))
.route("/machines/register", post(machines::register_machine)) .route("/machines/register", post(machines::register_machine))
.route("/machines", get(machines::get_machines)) .route("/machines", get(machines::get_machines))
.route("/machines/{id}", delete(machines::delete_machine)) .route("/machines/{id}", delete(machines::delete_machine))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(pool); .with_state(pool);
let dist_path = "./dist"; let dist_path = "./dist";
@@ -48,7 +50,9 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind("0.0.0.0:8379").await?; let listener = tokio::net::TcpListener::bind("0.0.0.0:8379").await?;
println!("Server running on http://0.0.0.0:8379"); println!("Server running on http://0.0.0.0:8379");
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()).await?; axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,6 @@
use crate::utils::{auth::AuthUser, error::*, models::User};
use axum::response::Json;
pub async fn me(auth_user: AuthUser) -> Result<Json<User>, AppError> {
Ok(success_response(auth_user.user))
}

View File

@@ -2,3 +2,4 @@ pub mod admin;
pub mod auth; pub mod auth;
pub mod machines; pub mod machines;
pub mod setup; pub mod setup;
pub mod accounts;

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title> <title>Arkendro</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

8
webui/jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -10,8 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@fontsource/plus-jakarta-sans": "^5.2.6",
"@phosphor-icons/react": "^2.1.10",
"classnames": "^2.5.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"react-router-dom": "^7.8.2",
"sass-embedded": "^1.92.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",

585
webui/pnpm-lock.yaml generated
View File

@@ -8,12 +8,27 @@ importers:
.: .:
dependencies: dependencies:
'@fontsource/plus-jakarta-sans':
specifier: ^5.2.6
version: 5.2.6
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
classnames:
specifier: ^2.5.1
version: 2.5.1
react: react:
specifier: ^19.1.1 specifier: ^19.1.1
version: 19.1.1 version: 19.1.1
react-dom: react-dom:
specifier: ^19.1.1 specifier: ^19.1.1
version: 19.1.1(react@19.1.1) version: 19.1.1(react@19.1.1)
react-router-dom:
specifier: ^7.8.2
version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
sass-embedded:
specifier: ^1.92.1
version: 1.92.1
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.33.0 specifier: ^9.33.0
@@ -26,7 +41,7 @@ importers:
version: 19.1.9(@types/react@19.1.12) version: 19.1.9(@types/react@19.1.12)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.2(vite@7.1.5) version: 5.0.2(vite@7.1.5(sass-embedded@1.92.1)(sass@1.92.1))
eslint: eslint:
specifier: ^9.33.0 specifier: ^9.33.0
version: 9.35.0 version: 9.35.0
@@ -41,7 +56,7 @@ importers:
version: 16.3.0 version: 16.3.0
vite: vite:
specifier: ^7.1.2 specifier: ^7.1.2
version: 7.1.5 version: 7.1.5(sass-embedded@1.92.1)(sass@1.92.1)
packages: packages:
@@ -128,6 +143,9 @@ packages:
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@bufbuild/protobuf@2.7.0':
resolution: {integrity: sha512-qn6tAIZEw5i/wiESBF4nQxZkl86aY4KoO0IkUa2Lh+rya64oTOdJQFlZuMwI1Qz9VBJQrQC4QlSA2DNek5gCOA==}
'@esbuild/aix-ppc64@0.25.9': '@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -322,6 +340,9 @@ packages:
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fontsource/plus-jakarta-sans@5.2.6':
resolution: {integrity: sha512-mvUiz1ta3bCVhP/DPmAOmuzhHQi6ddOo1GgaW58rpojr510Rx9BkqXqcnMhGEOMZZB3+84frvfFmw/jKCctHLw==}
'@humanfs/core@0.19.1': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@@ -354,6 +375,95 @@ packages:
'@jridgewell/trace-mapping@0.3.30': '@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.1':
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.1':
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.1':
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.1':
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.1':
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.1':
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.1':
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
engines: {node: '>=10'}
peerDependencies:
react: '>= 16.8'
react-dom: '>= 16.8'
'@rolldown/pluginutils@1.0.0-beta.34': '@rolldown/pluginutils@1.0.0-beta.34':
resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==}
@@ -520,11 +630,18 @@ packages:
brace-expansion@1.1.12: brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.25.4: browserslist@4.25.4:
resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==}
callsites@3.1.0: callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -536,6 +653,13 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -543,12 +667,19 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -568,6 +699,11 @@ packages:
deep-is@0.1.4: deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
electron-to-chromium@1.5.215: electron-to-chromium@1.5.215:
resolution: {integrity: sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==} resolution: {integrity: sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==}
@@ -659,6 +795,10 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@5.0.0: find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -699,6 +839,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immutable@5.1.3:
resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==}
import-fresh@3.3.1: import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -715,6 +858,10 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -761,6 +908,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -775,6 +926,9 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.20: node-releases@2.0.20:
resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==} resolution: {integrity: sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==}
@@ -805,6 +959,10 @@ packages:
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.3: picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -830,10 +988,31 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-router-dom@7.8.2:
resolution: {integrity: sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.8.2:
resolution: {integrity: sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.1.1: react@19.1.1:
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -843,6 +1022,123 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
sass-embedded-all-unknown@1.92.1:
resolution: {integrity: sha512-5t6/YZf+vhO3OY/49h8RCL6Cwo78luva0M+TnTM9gu9ASffRXAuOVLNKciSXa3loptyemDDS6IU5/dVH5w0KmA==}
cpu: ['!arm', '!arm64', '!riscv64', '!x64']
sass-embedded-android-arm64@1.92.1:
resolution: {integrity: sha512-Q+UruGb7yKawHagVmVDRRKsnc4mJZvWMBnuRCu2coJo2FofyqBmXohVGXbxko97sYceA9TJTrUEx3WVKQUNCbQ==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.92.1:
resolution: {integrity: sha512-4EjpVVzuksERdgAd4BqeSXFnWtWN3DSRyEIUPJ7BhcS9sfDh2Gf6miI2kNTvIQLJ2XIJynDDcEQ8a1U9KwKUTQ==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-riscv64@1.92.1:
resolution: {integrity: sha512-nCY5btLlX7W7Jc6cCL6D2Yklpiu540EJ2G08YVGu12DrAMCBzqM347CSRf2ojp1H8jyhvmLkaFwnrJWzh+6S+w==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.92.1:
resolution: {integrity: sha512-qYWR3bftJ77aLYwYDFuzDI4dcwVVixxqQxlIQWNGkHRCexj614qGSSHemr18C2eVj3mjXAQxTQxU68U7pkGPAA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.92.1:
resolution: {integrity: sha512-g2yQ3txjMYLKMjL2cW1xRO9nnV3ijf95NbX/QShtV6tiVUETZNWDsRMDEwBNGYY6PTE/UZerjJL1R/2xpQg6WA==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.92.1:
resolution: {integrity: sha512-eH+fgxLQhTEPjZPCgPAVuX5e514Qp/4DMAUMtlNShv4cr4TD5qOp1XlsPYR/b7uE7p2cKFkUpUn/bHNqJ2ay4A==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.92.1:
resolution: {integrity: sha512-dNmlpGeZkry1BofhAdGFBXrpM69y9LlYuNnncf+HfsOOUtj8j0q1RwS+zb5asknhKFUOAG8GCGRY1df7Rwu35g==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.92.1:
resolution: {integrity: sha512-cT3w8yoQTqrtZvWLJeutEGmawITDTY4J6oSVQjeDcPnnoPt0gOFxem8YMznraACXvahw/2+KJDH33BTNgiPo0A==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-arm64@1.92.1:
resolution: {integrity: sha512-TfiEBkCyNzVoOhjHXUT+vZ6+p0ueDbvRw6f4jHdkvljZzXdXMby4wh7BU1odl69rgRTkSvYKhgbErRLDR/F7pQ==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.92.1:
resolution: {integrity: sha512-nPBos6lI31ef2zQhqTZhFOU7ar4impJbLIax0XsqS269YsiCwjhk11VmUloJTpFlJuKMiVXNo7dPx+katxhD/Q==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-riscv64@1.92.1:
resolution: {integrity: sha512-R+RcJA4EYpJDE9JM1GgPYgZo7x94FlxZ6jPodOQkEaZ1S9kvXVCuP5X/0PXRPhu08KJOfeMsAElzfdAjUf7KJg==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.92.1:
resolution: {integrity: sha512-/HolYRGXJjx8nLw6oj5ZrkR7PFM7X/5kE4MYZaFMpDIPIcw3bqB2fUXLo/MYlRLsw7gBAT6hJAMBrNdKuTphfw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.92.1:
resolution: {integrity: sha512-b9bxe0CMsbSsLx3nrR0cq8xpIkoAC6X36o4DGMITF3m2v3KsojC7ru9X0Gz+zUFr6rwpq/0lTNzFLNu6sPNo3w==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.92.1:
resolution: {integrity: sha512-xuiK5Jp5NldW4bvlC7AuX1Wf7o0gLZ3md/hNg+bkTvxtCDgnUHtfdo8Q+xWP11bD9QX31xXFWpmUB8UDLi6XQQ==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-unknown-all@1.92.1:
resolution: {integrity: sha512-AT9oXvtNY4N+Nd0wvoWqq9A5HjdH/X3aUH4boQUtXyaJ/9DUwnQmBpP5Gtn028ZS8exOGBdobmmWAuigv0k/OA==}
os: ['!android', '!darwin', '!linux', '!win32']
sass-embedded-win32-arm64@1.92.1:
resolution: {integrity: sha512-KvmpQjY9yTBMtTYz4WBqetlv9bGaDW1aStcu7MSTbH7YiSybX/9fnxlCAEQv1WlIidQhcJAiyk0Eae+LGK7cIQ==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-x64@1.92.1:
resolution: {integrity: sha512-B6Nz/GbH7Vkpb2TkQHsGcczWM5t+70VWopWF1x5V5yxLpA8ZzVQ7NTKKi+jDoVY2Efu6ZyzgT9n5KgG2kWliXA==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.92.1:
resolution: {integrity: sha512-28YwLnF5atAhogt3E4hXzz/NB9dwKffyw08a7DEasLh94P7+aELkG3ENSHYCWB9QFN14hYNLfwr9ozUsPDhcDQ==}
engines: {node: '>=16.0.0'}
hasBin: true
sass@1.92.1:
resolution: {integrity: sha512-ffmsdbwqb3XeyR8jJR6KelIXARM9bFQe8A6Q3W4Klmwy5Ckd5gz7jgUNHo4UOqutU5Sk1DtKLbpDP0nLCg1xqQ==}
engines: {node: '>=14.0.0'}
hasBin: true
scheduler@0.26.0: scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
@@ -850,6 +1146,9 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -870,10 +1169,29 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
sync-child-process@1.0.2:
resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==}
engines: {node: '>=16.0.0'}
sync-message-port@1.1.3:
resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==}
engines: {node: '>=16.0.0'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -887,6 +1205,9 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==}
vite@7.1.5: vite@7.1.5:
resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1057,6 +1378,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1 '@babel/helper-validator-identifier': 7.27.1
'@bufbuild/protobuf@2.7.0': {}
'@esbuild/aix-ppc64@0.25.9': '@esbuild/aix-ppc64@0.25.9':
optional: true optional: true
@@ -1179,6 +1502,8 @@ snapshots:
'@eslint/core': 0.15.2 '@eslint/core': 0.15.2
levn: 0.4.1 levn: 0.4.1
'@fontsource/plus-jakarta-sans@5.2.6': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7': '@humanfs/node@0.16.7':
@@ -1209,6 +1534,72 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@parcel/watcher-android-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-x64@2.5.1':
optional: true
'@parcel/watcher-freebsd-x64@2.5.1':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.1':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.1':
optional: true
'@parcel/watcher-win32-arm64@2.5.1':
optional: true
'@parcel/watcher-win32-ia32@2.5.1':
optional: true
'@parcel/watcher-win32-x64@2.5.1':
optional: true
'@parcel/watcher@2.5.1':
dependencies:
detect-libc: 1.0.3
is-glob: 4.0.3
micromatch: 4.0.8
node-addon-api: 7.1.1
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.1
'@parcel/watcher-darwin-arm64': 2.5.1
'@parcel/watcher-darwin-x64': 2.5.1
'@parcel/watcher-freebsd-x64': 2.5.1
'@parcel/watcher-linux-arm-glibc': 2.5.1
'@parcel/watcher-linux-arm-musl': 2.5.1
'@parcel/watcher-linux-arm64-glibc': 2.5.1
'@parcel/watcher-linux-arm64-musl': 2.5.1
'@parcel/watcher-linux-x64-glibc': 2.5.1
'@parcel/watcher-linux-x64-musl': 2.5.1
'@parcel/watcher-win32-arm64': 2.5.1
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
optional: true
'@phosphor-icons/react@2.1.10(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
'@rolldown/pluginutils@1.0.0-beta.34': {} '@rolldown/pluginutils@1.0.0-beta.34': {}
'@rollup/rollup-android-arm-eabi@4.50.1': '@rollup/rollup-android-arm-eabi@4.50.1':
@@ -1307,7 +1698,7 @@ snapshots:
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
'@vitejs/plugin-react@5.0.2(vite@7.1.5)': '@vitejs/plugin-react@5.0.2(vite@7.1.5(sass-embedded@1.92.1)(sass@1.92.1))':
dependencies: dependencies:
'@babel/core': 7.28.4 '@babel/core': 7.28.4
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4)
@@ -1315,7 +1706,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.34 '@rolldown/pluginutils': 1.0.0-beta.34
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 7.1.5 vite: 7.1.5(sass-embedded@1.92.1)(sass@1.92.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -1345,6 +1736,11 @@ snapshots:
balanced-match: 1.0.2 balanced-match: 1.0.2
concat-map: 0.0.1 concat-map: 0.0.1
braces@3.0.3:
dependencies:
fill-range: 7.1.1
optional: true
browserslist@4.25.4: browserslist@4.25.4:
dependencies: dependencies:
caniuse-lite: 1.0.30001741 caniuse-lite: 1.0.30001741
@@ -1352,6 +1748,8 @@ snapshots:
node-releases: 2.0.20 node-releases: 2.0.20
update-browserslist-db: 1.1.3(browserslist@4.25.4) update-browserslist-db: 1.1.3(browserslist@4.25.4)
buffer-builder@0.2.0: {}
callsites@3.1.0: {} callsites@3.1.0: {}
caniuse-lite@1.0.30001741: {} caniuse-lite@1.0.30001741: {}
@@ -1361,16 +1759,27 @@ snapshots:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
supports-color: 7.2.0 supports-color: 7.2.0
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
optional: true
classnames@2.5.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-name@1.1.4: {} color-name@1.1.4: {}
colorjs.io@0.5.2: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie@1.0.2: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -1385,6 +1794,9 @@ snapshots:
deep-is@0.1.4: {} deep-is@0.1.4: {}
detect-libc@1.0.3:
optional: true
electron-to-chromium@1.5.215: {} electron-to-chromium@1.5.215: {}
esbuild@0.25.9: esbuild@0.25.9:
@@ -1509,6 +1921,11 @@ snapshots:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
optional: true
find-up@5.0.0: find-up@5.0.0:
dependencies: dependencies:
locate-path: 6.0.0 locate-path: 6.0.0
@@ -1538,6 +1955,8 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immutable@5.1.3: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
parent-module: 1.0.1 parent-module: 1.0.1
@@ -1551,6 +1970,9 @@ snapshots:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
is-number@7.0.0:
optional: true
isexe@2.0.0: {} isexe@2.0.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -1588,6 +2010,12 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
optional: true
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
@@ -1598,6 +2026,9 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
node-addon-api@7.1.1:
optional: true
node-releases@2.0.20: {} node-releases@2.0.20: {}
optionator@0.9.4: optionator@0.9.4:
@@ -1627,6 +2058,9 @@ snapshots:
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1:
optional: true
picomatch@4.0.3: {} picomatch@4.0.3: {}
postcss@8.5.6: postcss@8.5.6:
@@ -1646,8 +2080,25 @@ snapshots:
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-router-dom@7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-router: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-router@7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
cookie: 1.0.2
react: 19.1.1
set-cookie-parser: 2.7.1
optionalDependencies:
react-dom: 19.1.1(react@19.1.1)
react@19.1.1: {} react@19.1.1: {}
readdirp@4.1.2:
optional: true
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
rollup@4.50.1: rollup@4.50.1:
@@ -1677,10 +2128,113 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.50.1 '@rollup/rollup-win32-x64-msvc': 4.50.1
fsevents: 2.3.3 fsevents: 2.3.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
sass-embedded-all-unknown@1.92.1:
dependencies:
sass: 1.92.1
optional: true
sass-embedded-android-arm64@1.92.1:
optional: true
sass-embedded-android-arm@1.92.1:
optional: true
sass-embedded-android-riscv64@1.92.1:
optional: true
sass-embedded-android-x64@1.92.1:
optional: true
sass-embedded-darwin-arm64@1.92.1:
optional: true
sass-embedded-darwin-x64@1.92.1:
optional: true
sass-embedded-linux-arm64@1.92.1:
optional: true
sass-embedded-linux-arm@1.92.1:
optional: true
sass-embedded-linux-musl-arm64@1.92.1:
optional: true
sass-embedded-linux-musl-arm@1.92.1:
optional: true
sass-embedded-linux-musl-riscv64@1.92.1:
optional: true
sass-embedded-linux-musl-x64@1.92.1:
optional: true
sass-embedded-linux-riscv64@1.92.1:
optional: true
sass-embedded-linux-x64@1.92.1:
optional: true
sass-embedded-unknown-all@1.92.1:
dependencies:
sass: 1.92.1
optional: true
sass-embedded-win32-arm64@1.92.1:
optional: true
sass-embedded-win32-x64@1.92.1:
optional: true
sass-embedded@1.92.1:
dependencies:
'@bufbuild/protobuf': 2.7.0
buffer-builder: 0.2.0
colorjs.io: 0.5.2
immutable: 5.1.3
rxjs: 7.8.2
supports-color: 8.1.1
sync-child-process: 1.0.2
varint: 6.0.0
optionalDependencies:
sass-embedded-all-unknown: 1.92.1
sass-embedded-android-arm: 1.92.1
sass-embedded-android-arm64: 1.92.1
sass-embedded-android-riscv64: 1.92.1
sass-embedded-android-x64: 1.92.1
sass-embedded-darwin-arm64: 1.92.1
sass-embedded-darwin-x64: 1.92.1
sass-embedded-linux-arm: 1.92.1
sass-embedded-linux-arm64: 1.92.1
sass-embedded-linux-musl-arm: 1.92.1
sass-embedded-linux-musl-arm64: 1.92.1
sass-embedded-linux-musl-riscv64: 1.92.1
sass-embedded-linux-musl-x64: 1.92.1
sass-embedded-linux-riscv64: 1.92.1
sass-embedded-linux-x64: 1.92.1
sass-embedded-unknown-all: 1.92.1
sass-embedded-win32-arm64: 1.92.1
sass-embedded-win32-x64: 1.92.1
sass@1.92.1:
dependencies:
chokidar: 4.0.3
immutable: 5.1.3
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.1
optional: true
scheduler@0.26.0: {} scheduler@0.26.0: {}
semver@6.3.1: {} semver@6.3.1: {}
set-cookie-parser@2.7.1: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@@ -1695,11 +2249,28 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
sync-child-process@1.0.2:
dependencies:
sync-message-port: 1.1.3
sync-message-port@1.1.3: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
optional: true
tslib@2.8.1: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -1714,7 +2285,9 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
vite@7.1.5: varint@6.0.0: {}
vite@7.1.5(sass-embedded@1.92.1)(sass@1.92.1):
dependencies: dependencies:
esbuild: 0.25.9 esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -1724,6 +2297,8 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
sass: 1.92.1
sass-embedded: 1.92.1
which@2.0.2: which@2.0.2:
dependencies: dependencies:

View File

@@ -1,10 +1,31 @@
const App = () => { import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom";
import { UserProvider } from '@/common/contexts/UserContext.jsx';
import "@/common/styles/main.sass";
import Root from "@/common/layouts/Root.jsx";
import "@fontsource/plus-jakarta-sans/300.css";
import "@fontsource/plus-jakarta-sans/400.css";
import "@fontsource/plus-jakarta-sans/600.css";
import "@fontsource/plus-jakarta-sans/700.css";
import "@fontsource/plus-jakarta-sans/800.css";
return ( const Placeholder = ({title}) => <div className="content"><h2 style={{fontSize:'1rem'}}>{title}</h2><p className="muted">Content coming soon.</p></div>;
<>
<h1>vite init</h1> const App = () => {
</> const router = createBrowserRouter([
) {
} path: "/",
element: <Root />,
children: [
{ path: "/", element: <Navigate to="/dashboard" /> },
{ path: "/dashboard", element: <Placeholder title="Dashboard" /> },
{ path: "/servers", element: <Placeholder title="Servers" /> },
{ path: "/settings", element: <Placeholder title="Settings" /> },
{ path: "/admin/users", element: <Placeholder title="User Management" /> },
],
},
]);
return <UserProvider><RouterProvider router={router}/></UserProvider>;
};
export default App; export default App;

View File

@@ -0,0 +1,38 @@
import React from "react";
import cn from "classnames";
import "./styles.sass";
export const Button = ({
as: Component = "button",
variant = "primary",
size = "md",
full = false,
icon,
iconRight,
loading = false,
disabled,
className,
children,
...rest
}) => {
const isDisabled = disabled || loading;
return (
<Component
className={cn(
"btn",
`btn--${variant}`,
`btn--${size}`,
full && "btn--full",
loading && "is-loading",
className
)}
disabled={isDisabled}
{...rest}
>
{loading && <span className="btn-spinner" aria-hidden />}
{icon && <span className="btn-icon btn-icon--left">{icon}</span>}
<span className="btn-label">{children}</span>
{iconRight && <span className="btn-icon btn-icon--right">{iconRight}</span>}
</Component>
);
};

View File

@@ -0,0 +1 @@
export { Button as default } from "./Button.jsx";

View File

@@ -0,0 +1,91 @@
.btn
--c-bg: #ffffff
--c-bg-hover: #f2f5f8
--c-bg-active: #e6ebf0
--c-border: #dfe3e8
--c-border-hover: #c7ced6
--c-text: #1f2429
--c-accent: #0f62fe
--c-danger: #d93025
position: relative
display: inline-flex
align-items: center
justify-content: center
gap: .6rem
font-family: inherit
font-weight: 600
line-height: 1.2
cursor: pointer
border: 1px solid var(--c-border)
background: var(--c-bg)
color: var(--c-text)
border-radius: 12px
transition: all .2s ease
user-select: none
text-decoration: none
&:hover:not(:disabled)
background: var(--c-bg-hover)
border-color: var(--c-border-hover)
&:active:not(:disabled)
background: var(--c-bg-active)
transform: translateY(1px)
&:focus-visible
outline: 2px solid var(--c-accent)
outline-offset: 2px
&:disabled
opacity: .55
cursor: not-allowed
&.btn--full
width: 100%
&.btn--sm
font-size: .85rem
padding: .7rem 1rem
&.btn--md
font-size: .95rem
padding: .85rem 1.25rem
&.btn--lg
font-size: 1.05rem
padding: 1rem 1.5rem
&.btn--primary
--c-bg: #1f2429
--c-bg-hover: #374048
--c-bg-active: #2a3038
--c-border: #1f2429
--c-text: #ffffff
background: var(--c-bg)
border-color: var(--c-border)
&:hover:not(:disabled)
background: var(--c-bg-hover)
&.btn--subtle
--c-bg: #f0f3f6
--c-bg-hover: #e6ebf0
--c-bg-active: #dfe3e8
--c-border: #dfe3e8
&.btn--danger
--c-bg: #d93025
--c-bg-hover: #c22b21
--c-bg-active: #a9241b
--c-border: #d93025
--c-text: #ffffff
background: var(--c-bg)
border-color: var(--c-border)
.btn-icon
display: inline-flex
align-items: center
&--left
margin-right: .25rem
&--right
margin-left: .25rem
.btn-spinner
width: 14px
height: 14px
border: 2px solid rgba(0,0,0,.15)
border-top-color: var(--c-text)
border-radius: 50%
animation: spin .7s linear infinite
@keyframes spin
to
transform: rotate(360deg)

View File

@@ -0,0 +1,23 @@
import React from "react";
import cn from "classnames";
import "./styles.sass";
export const Input = ({
label,
error,
icon,
className,
containerClassName,
type = "text",
...rest
}) => {
return (
<div className={cn("field", containerClassName)}>
{label && <label className="field-label">{label}</label>}
<div className={cn("field-control", error && "has-error", icon && "has-icon", className)}>
{icon && <span className="field-icon">{icon}</span>}
<input type={type} className="field-input" {...rest} />
</div>
{error && <div className="field-error">{error}</div>}
</div>
);
};

View File

@@ -0,0 +1 @@
export { Input as default } from "./Input.jsx";

View File

@@ -0,0 +1,63 @@
.field
display: flex
flex-direction: column
gap: .5rem
font-size: .9rem
font-weight: 600
color: #374048
.field-label
letter-spacing: .3px
margin-bottom: .2rem
.field-control
position: relative
display: flex
align-items: center
background: #ffffff
border: 2px solid #e1e8f0
border-radius: 16px
padding: 1rem 1.2rem
transition: all .2s ease
&.has-icon .field-input
padding-left: 2.2rem
&.has-error
border-color: #d93025
box-shadow: 0 0 0 4px rgba(217, 48, 37, 0.1)
&:focus-within
border-color: #0f62fe
box-shadow: 0 0 0 4px rgba(15, 98, 254, 0.1)
transform: translateY(-1px)
.field-icon
position: absolute
left: 1rem
top: 50%
transform: translateY(-50%)
display: inline-flex
font-size: 1.1rem
color: #6b7781
pointer-events: none
.field-input
appearance: none
outline: none
background: transparent
border: 0
color: #1f2429
font: inherit
font-size: 1rem
font-weight: 500
width: 100%
line-height: 1.3
&::placeholder
color: #a0abb4
font-weight: 400
&:focus
outline: none
.field-error
font-size: .65rem
font-weight: 600
color: #d93025
letter-spacing: .5px

View File

@@ -0,0 +1,95 @@
import React, {useState, useRef, useEffect, useContext} from 'react';
import {UserCircleIcon, SignOutIcon, CaretDownIcon} from '@phosphor-icons/react';
import {UserContext} from '@/common/contexts/UserContext.jsx';
import './styles.sass';
export const ProfileMenu = () => {
const {logout} = useContext(UserContext);
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Close menu on escape key
useEffect(() => {
const handleEscapeKey = (event) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscapeKey);
return () => {
document.removeEventListener('keydown', handleEscapeKey);
};
}
}, [isOpen]);
const handleLogout = () => {
setIsOpen(false);
logout();
};
const toggleMenu = () => {
setIsOpen(!isOpen);
};
return (
<div className="profile-menu" ref={menuRef}>
<button
className={`profile-menu-trigger ${isOpen ? 'active' : ''}`}
onClick={toggleMenu}
aria-label="User menu"
aria-expanded={isOpen}
>
<div className="profile-menu-avatar">
<UserCircleIcon size={16} weight="fill"/>
</div>
<span className="profile-menu-name">Admin</span>
<CaretDownIcon
size={14}
className={`profile-menu-caret ${isOpen ? 'rotated' : ''}`}
/>
</button>
{isOpen && (
<div className="profile-menu-dropdown">
<div className="profile-menu-header">
<div className="profile-menu-avatar profile-menu-avatar--large">
<UserCircleIcon size={20} weight="fill"/>
</div>
<div className="profile-menu-info">
<div className="profile-menu-name-large">Admin User</div>
<div className="profile-menu-role">Administrator</div>
</div>
</div>
<div className="profile-menu-divider"></div>
<div className="profile-menu-actions">
<button
className="profile-menu-item"
onClick={handleLogout}
>
<SignOutIcon size={16}/>
<span>Sign Out</span>
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export { ProfileMenu as default } from './ProfileMenu';

View File

@@ -0,0 +1,123 @@
.profile-menu
position: relative
display: inline-block
.profile-menu-trigger
display: flex
align-items: center
gap: .5rem
padding: .5rem .75rem
background: transparent
border: 1px solid var(--border)
border-radius: 10px
color: var(--text)
font-size: .85rem
font-weight: 500
cursor: pointer
transition: all .2s ease
&:hover
background: var(--bg-elev)
border-color: var(--border-strong)
&.active
background: var(--bg-elev)
border-color: var(--border-strong)
.profile-menu-caret
transform: rotate(180deg)
.profile-menu-avatar
width: 28px
height: 28px
background: rgba(15, 98, 254, 0.15)
border: 1px solid rgba(15, 98, 254, 0.25)
border-radius: 50%
display: flex
align-items: center
justify-content: center
color: var(--accent)
flex-shrink: 0
backdrop-filter: blur(8px)
&--large
width: 36px
height: 36px
background: rgba(15, 98, 254, 0.2)
border: 1px solid rgba(15, 98, 254, 0.3)
.profile-menu-name
font-weight: 600
white-space: nowrap
.profile-menu-caret
transition: transform .2s ease
color: var(--text-dim)
&.rotated
transform: rotate(180deg)
.profile-menu-dropdown
position: absolute
top: calc(100% + .5rem)
right: 0
min-width: 200px
background: var(--bg-alt)
border: 1px solid var(--border)
border-radius: 12px
box-shadow: 0 8px 32px -8px rgba(31,36,41,.15), 0 4px 16px -4px rgba(31,36,41,.1)
z-index: 1000
animation: dropdownFadeIn .2s ease-out
.profile-menu-header
padding: 1rem
display: flex
align-items: center
gap: .75rem
.profile-menu-info
flex: 1
min-width: 0
.profile-menu-name-large
font-size: .9rem
font-weight: 600
color: var(--text)
margin-bottom: .1rem
.profile-menu-role
font-size: .75rem
color: var(--text-dim)
font-weight: 500
.profile-menu-divider
height: 1px
background: var(--border)
margin: 0 .5rem
.profile-menu-actions
padding: .5rem
.profile-menu-item
display: flex
align-items: center
gap: .75rem
width: 100%
padding: .75rem
background: transparent
border: none
border-radius: 8px
color: var(--text)
font-size: .85rem
font-weight: 500
cursor: pointer
transition: all .15s ease
text-align: left
&:hover
background: var(--bg-elev)
color: var(--danger)
svg
color: var(--danger)
@keyframes dropdownFadeIn
from
opacity: 0
transform: translateY(-4px) scale(0.95)
to
opacity: 1
transform: translateY(0) scale(1)

View File

@@ -0,0 +1,60 @@
import React, { useContext } from 'react';
import { NavLink } from 'react-router-dom';
import { HouseIcon, GearSixIcon, SquaresFourIcon, CubeIcon, UsersIcon } from '@phosphor-icons/react';
import { UserContext } from '@/common/contexts/UserContext.jsx';
import './styles.sass';
const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: <HouseIcon weight="duotone" /> },
{ to: '/servers', label: 'Servers', icon: <SquaresFourIcon weight="duotone" /> },
{ to: '/settings', label: 'Settings', icon: <GearSixIcon weight="duotone" /> },
];
const adminNavItems = [
{ to: '/admin/users', label: 'User Management', icon: <UsersIcon weight="duotone" /> },
];
export const Sidebar = () => {
const { user } = useContext(UserContext);
const isAdmin = user?.role === 'admin' || user?.is_admin === true;
return (
<aside className="sidebar">
<div className="sidebar-brand">
<CubeIcon size={24} weight="duotone" />
<span>Arkendro</span>
</div>
<nav className="sidebar-nav">
<div className="nav-section">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({isActive}) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-item-icon">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</div>
{isAdmin && (
<div className="nav-section">
<div className="nav-section-title">Admin</div>
{adminNavItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({isActive}) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-item-icon">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</div>
)}
</nav>
</aside>
);
};

View File

@@ -0,0 +1 @@
export { Sidebar as default } from './Sidebar';

View File

@@ -0,0 +1,69 @@
.sidebar
width: var(--sidebar-width)
background: var(--bg-alt)
border-right: 1px solid var(--border)
display: flex
flex-direction: column
padding: 1.5rem 1.2rem
gap: 1.8rem
.sidebar-brand
font-size: 1.1rem
font-weight: 700
letter-spacing: .3px
display: flex
align-items: center
gap: .75rem
color: var(--text)
padding-bottom: .5rem
svg
color: var(--accent)
flex-shrink: 0
.sidebar-nav
display: flex
flex-direction: column
gap: 1.2rem
.nav-section
display: flex
flex-direction: column
gap: .4rem
.nav-section-title
font-size: .75rem
font-weight: 700
text-transform: uppercase
letter-spacing: .8px
color: var(--text-dim)
margin-bottom: .3rem
padding: 0 .9rem
.nav-item
display: flex
align-items: center
gap: .75rem
padding: .75rem .9rem
color: var(--text-dim)
font-size: .85rem
font-weight: 600
letter-spacing: .3px
border-radius: 10px
transition: all .2s ease
cursor: pointer
text-decoration: none
&:hover
background: var(--bg-elev)
color: var(--text)
transform: translateX(2px)
&.active
background: var(--text)
color: #ffffff
font-weight: 700
box-shadow: 0 2px 8px -2px rgba(31, 36, 41, 0.2)
.nav-item-icon
font-size: 1.2rem
display: inline-flex
color: inherit
min-width: 1.2rem

View File

@@ -0,0 +1,71 @@
import { createContext, useEffect, useState } from "react";
import { getRequest, postRequest } from "@/common/utils/RequestUtil.js";
import Login from "@/pages/Login/index.js";
export const UserContext = createContext({});
export const UserProvider = ({ children }) => {
const [sessionToken, setSessionToken] = useState(localStorage.getItem("sessionToken"));
const [isSetupCompleted, setIsSetupCompleted] = useState(false);
const [user, setUser] = useState(null);
const updateSessionToken = (sessionToken) => {
setSessionToken(sessionToken);
localStorage.setItem("sessionToken", sessionToken);
login();
};
const checkFirstTimeSetup = async () => {
try {
const response = await getRequest("setup/status");
setIsSetupCompleted(response?.first_user_exists);
} catch (error) {
console.error(error);
}
};
const login = async () => {
try {
const userObj = await getRequest("accounts/me");
setUser(userObj);
} catch (error) {
if (error.message === "Unauthorized") {
setSessionToken(null);
localStorage.removeItem("sessionToken");
}
}
};
const logout = async () => {
try {
await postRequest("auth/logout", { token: sessionToken });
} catch (e) {
// ignore
}
window.location.reload();
};
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const tokenFromUrl = searchParams.get('token');
const error = searchParams.get('error');
if (tokenFromUrl) {
updateSessionToken(tokenFromUrl);
} else if (error) {
console.log(error)
}
}, [location]);
useEffect(() => {
sessionToken ? login() : checkFirstTimeSetup();
}, []);
return (
<UserContext.Provider value={{ updateSessionToken, user, sessionToken, isSetupCompleted, login, logout }}>
{user == null && <Login />}
{user !== null && children}
</UserContext.Provider>
);
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Sidebar from '@/common/components/Sidebar';
import ProfileMenu from '@/common/components/ProfileMenu';
const Root = () => {
return (
<div className="layout">
<Sidebar />
<div className="main">
<header className="topbar">
<h3>Dashboard</h3>
<div className="grow"></div>
<ProfileMenu />
</header>
<Outlet />
</div>
</div>
);
};
export default Root;

View File

@@ -0,0 +1,69 @@
:root
--bg: #f7f9fb
--bg-alt: #ffffff
--bg-elev: #f0f3f6
--border: #dfe3e8
--border-strong: #c7ced6
--text: #1f2429
--text-dim: #5c6a78
--accent: #0f62fe
--danger: #d93025
--radius-sm: 4px
--radius: 6px
--radius-lg: 10px
--sidebar-width: 240px
*, *::before, *::after
box-sizing: border-box
html, body, #root
height: 100%
body
margin: 0
font-family: "Plus Jakarta Sans", sans-serif
background: var(--bg)
color: var(--text)
-webkit-font-smoothing: antialiased
font-size: 14px
h1, h2, h3, h4, h5, h6, p
margin: 0
ul
margin: 0
padding: 0
list-style: none
a
color: var(--accent)
text-decoration: none
.layout
display: flex
min-height: 100vh
.main
flex: 1
display: flex
flex-direction: column
min-width: 0
.topbar
height: 54px
display: flex
align-items: center
padding: 0 1.1rem
border-bottom: 1px solid var(--border)
background: var(--bg-alt)
gap: 1rem
.grow
flex: 1
.content
flex: 1
padding: 1.4rem 1.3rem 2rem
display: flex
flex-direction: column
gap: 1.2rem

View File

@@ -0,0 +1,48 @@
export const request = async (url, method, body, headers) => {
url = url.startsWith("/") ? url.substring(1) : url;
const response = await fetch(`/api/${url}`, {
method: method,
headers: {...headers, "Content-Type": "application/json"},
body: JSON.stringify(body)
});
if (response.status === 401) throw new Error("Unauthorized");
const rawData = await response.text();
const data = rawData ? JSON.parse(rawData) : rawData.toString();
if (data.code >= 300) throw data;
if (!response.ok) throw data;
return data;
}
const getToken = () => {
return localStorage.getItem("sessionToken");
}
export const sessionRequest = (url, method, token, body) => {
return request(url, method, body, {"Authorization": `Bearer ${token}`});
}
export const getRequest = (url) => {
return sessionRequest(url, "GET", getToken());
}
export const postRequest = (url, body) => {
return sessionRequest(url, "POST", getToken(), body);
}
export const putRequest = (url, body) => {
return sessionRequest(url, "PUT", getToken(), body);
}
export const deleteRequest = (url) => {
return sessionRequest(url, "DELETE", getToken());
}
export const patchRequest = (url, body) => {
return sessionRequest(url, "PATCH", getToken(), body);
}

View File

@@ -1,9 +1,12 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import {UserProvider} from "@/common/contexts/UserContext.jsx";
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <UserProvider>
<App />
</UserProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -0,0 +1,75 @@
import React, { useContext, useState } from 'react';
import './styles.sass';
import Input from '@/common/components/Input';
import Button from '@/common/components/Button';
import { UserContext } from '@/common/contexts/UserContext.jsx';
import { request } from '@/common/utils/RequestUtil.js';
import { LockIcon, UserIcon } from '@phosphor-icons/react';
export const Login = () => {
const { updateSessionToken, isSetupCompleted } = useContext(UserContext);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [mode, setMode] = useState(isSetupCompleted ? 'login' : 'setup');
const [isExiting, setIsExiting] = useState(false);
React.useEffect(()=>{
setMode(isSetupCompleted ? 'login' : 'setup');
}, [isSetupCompleted]);
const submit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (mode === 'login') {
const data = await request('/auth/login','POST',{username, password});
// Trigger fade-out animation before updating token
setIsExiting(true);
setTimeout(() => {
updateSessionToken(data.token);
}, 300); // Match the fade-out animation duration
} else { // setup
await request('/setup/init','POST',{username, password});
const data = await request('/auth/login','POST',{username, password});
setIsExiting(true);
setTimeout(() => {
updateSessionToken(data.token);
}, 300);
}
} catch (err) {
setError(err.error || err.message || 'Error');
setLoading(false);
}
};
return (
<div className={`auth-wrapper ${isExiting ? 'fade-out' : ''}`}>
<div className={`auth-box ${isExiting ? 'fade-out' : ''}`}>
<div className="auth-header">
<div className="auth-title">{mode === 'login' ? 'Welcome Back' : 'Initial Setup'}</div>
<div className="auth-subtitle">
{mode === 'login'
? 'Sign in to your account to continue'
: 'Create your admin account to get started'
}
</div>
</div>
<form className="form" onSubmit={submit}>
<div className="form-field form-field--1">
<Input label="Username" placeholder="your name" value={username} onChange={e=>setUsername(e.target.value)} icon={<UserIcon size={16} />} autoFocus required />
</div>
<div className="form-field form-field--2">
<Input type="password" label="Password" placeholder="••••••••" value={password} onChange={e=>setPassword(e.target.value)} icon={<LockIcon size={16} />} required />
</div>
{error && <div className="auth-error form-field form-field--3">{error}</div>}
<div className="form-field form-field--4">
<Button type="submit" loading={loading} variant="primary" full>{mode === 'login' ? 'Login' : 'Create Admin & Continue'}</Button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export {Login as default} from "./Login";

View File

@@ -0,0 +1,130 @@
.auth-wrapper
min-height: 100vh
display: flex
align-items: center
justify-content: center
padding: 2rem 1rem
background: var(--bg)
animation: fadeInWrapper .4s ease-out
.auth-box
width: 100%
max-width: 480px
background: var(--bg-alt)
border: 2px solid var(--border)
border-radius: 24px
padding: 3rem 2.5rem 2.8rem
display: flex
flex-direction: column
gap: 1.8rem
box-shadow: 0 8px 32px -8px rgba(31,36,41,.12), 0 4px 16px -4px rgba(31,36,41,.08)
position: relative
overflow: hidden
animation: fadeInUp .6s ease-out
.auth-title
font-size: 1.75rem
font-weight: 700
letter-spacing: -0.5px
text-align: center
color: var(--text)
margin-bottom: 0.5rem
.auth-header
text-align: center
margin-bottom: 1rem
.auth-subtitle
font-size: 1rem
font-weight: 400
color: var(--text-dim)
text-align: center
line-height: 1.4
.auth-error
background: rgba(217, 48, 37, 0.1)
border: 1px solid rgba(217, 48, 37, 0.2)
border-radius: 12px
padding: 0.75rem 1rem
color: #d93025
font-size: 0.9rem
font-weight: 600
text-align: center
.form
display: flex
flex-direction: column
gap: 1.5rem
@keyframes fadeInWrapper
from
opacity: 0
backdrop-filter: blur(0px)
to
opacity: 1
backdrop-filter: blur(2px)
@keyframes fadeInUp
from
opacity: 0
transform: translateY(40px) scale(0.9)
filter: blur(4px)
50%
opacity: 0.8
transform: translateY(20px) scale(0.95)
filter: blur(2px)
to
opacity: 1
transform: translateY(0) scale(1)
filter: blur(0px)
// Fade out animations for when dialog closes
.auth-wrapper.fade-out
animation: fadeOutWrapper .3s ease-in forwards
.auth-box.fade-out
animation: fadeOutDown .3s ease-in forwards
@keyframes fadeOutWrapper
from
opacity: 1
backdrop-filter: blur(2px)
to
opacity: 0
backdrop-filter: blur(0px)
@keyframes fadeOutDown
from
opacity: 1
transform: translateY(0) scale(1)
filter: blur(0px)
to
opacity: 0
transform: translateY(20px) scale(0.95)
filter: blur(2px)
// Staggered animations for form elements
.form-field
animation: slideInLeft .5s ease-out both
.form-field--1
animation-delay: .1s
.form-field--2
animation-delay: .2s
.form-field--3
animation-delay: .3s
.form-field--4
animation-delay: .4s
@keyframes slideInLeft
from
opacity: 0
transform: translateX(-20px)
filter: blur(2px)
to
opacity: 1
transform: translateX(0)
filter: blur(0px)

View File

@@ -1,6 +1,20 @@
import { defineConfig } from 'vite' import {defineConfig} from "vite"
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react"
import * as path from "path";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:8379"
}
}
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
}
},
}); });