project initiate.
74
.editorconfig
Normal file
|
@ -0,0 +1,74 @@
|
|||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
max_line_length = 100
|
||||
|
||||
[*.java]
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.{yml, yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.sql]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.go]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.sh]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
1033
.gitignore
vendored
Normal file
3
.prettierignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
**/*.md
|
||||
.env
|
||||
.env.*
|
12
.prettierrc
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"printWidth": 100
|
||||
}
|
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
7
README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
25
eslint.config.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import js from '@eslint/js';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
);
|
14
index.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
44
package.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "estim",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^5.2.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.2.0",
|
||||
"@tauri-apps/plugin-notification": "~2.2.1",
|
||||
"@tauri-apps/plugin-os": "~2.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"jotai": "^2.12.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"sanitize.css": "^13.0.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.19.0",
|
||||
"lightningcss": "^1.29.1",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.3",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
6
public/tauri.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.5 KiB |
1
public/vite.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
7
src-tauri/.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
40
src-tauri/Cargo.toml
Normal file
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "estim"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "estim_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
btleplug = { version = "0.11.6", features = ["serde"] }
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
sled = "0.34.7"
|
||||
anyhow = "1.0.89"
|
||||
thiserror = "2.0.11"
|
||||
serde_repr = "0.1.19"
|
||||
bincode = "1.3.3"
|
||||
uuid = { version = "1.10.0", features = ["serde"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
futures = "0.3.31"
|
||||
tauri-plugin-devtools = "2.0.0"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
3
src-tauri/build.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
16
src-tauri/capabilities/default.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"os:default",
|
||||
"notification:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
BIN
src-tauri/icons/128x128.png
Normal file
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
134
src-tauri/src/bluetooth.rs
Normal file
|
@ -0,0 +1,134 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use btleplug::{
|
||||
api::{Central, CentralState, Manager as _, Peripheral as _, ScanFilter},
|
||||
platform::{Adapter, Manager, Peripheral},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use tauri::{
|
||||
async_runtime::{self, JoinHandle, RwLock},
|
||||
AppHandle, Emitter, State,
|
||||
};
|
||||
|
||||
use crate::{errors, state::AppState};
|
||||
|
||||
pub async fn handle_bluetooth_events(
|
||||
app_handle: Arc<AppHandle>,
|
||||
app_state: Arc<RwLock<AppState>>,
|
||||
) -> Result<JoinHandle<()>, errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
let adapter = state.central_adapter.read().await;
|
||||
let app = Arc::clone(&app_handle);
|
||||
if let Some(adapter) = &*adapter {
|
||||
let adapter = adapter.clone();
|
||||
let app_state = Arc::clone(&app_state);
|
||||
Ok(async_runtime::spawn(async move {
|
||||
let mut event_stream = adapter.events().await.unwrap();
|
||||
while let Some(event) = event_stream.next().await {
|
||||
match event {
|
||||
btleplug::api::CentralEvent::DeviceDiscovered(_id) => {
|
||||
app.emit("app_state_updated", ()).unwrap();
|
||||
}
|
||||
btleplug::api::CentralEvent::DeviceConnected(_id) => {
|
||||
let state = app_state.write().await;
|
||||
let peripherals = adapter.peripherals().await;
|
||||
if let Ok(peripherals) = peripherals {
|
||||
for peripheral in peripherals {
|
||||
if peripheral.id() == _id {
|
||||
state.set_connected_peripheral(peripheral).await;
|
||||
app.emit("app_state_updated", ()).unwrap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
btleplug::api::CentralEvent::DeviceDisconnected(_id) => {
|
||||
let state = app_state.write().await;
|
||||
state.clear_connected_peripheral().await;
|
||||
app.emit("app_state_updated", ()).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
app.emit("app_state_updated", ()).unwrap();
|
||||
Err(errors::AppError::BluetoothNotReady)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_central_adapter() -> Result<Adapter, errors::AppError> {
|
||||
let manager = Manager::new()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::BluetoothNotReady)?;
|
||||
let adapters = manager
|
||||
.adapters()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::BluetoothAdapterNotFound)?;
|
||||
|
||||
let mut found_adapter = None;
|
||||
for adpater in adapters {
|
||||
let state = adpater.adapter_state().await;
|
||||
if let Ok(state) = state {
|
||||
if state == CentralState::PoweredOn {
|
||||
found_adapter = Some(adpater);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found_adapter.ok_or(errors::AppError::NoAvailableBluetoothAdapter)
|
||||
}
|
||||
|
||||
pub async fn start_scan(
|
||||
app_handle: Arc<AppHandle>,
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
let adapter = state.get_central_adapter().await;
|
||||
if let Some(adapter) = adapter {
|
||||
adapter
|
||||
.start_scan(ScanFilter::default())
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToStartScan)?;
|
||||
app_handle.emit("app_state_updated", ()).unwrap();
|
||||
state.set_scanning(true).await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors::AppError::NoAvailableBluetoothAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_scan(
|
||||
app_handle: Arc<AppHandle>,
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
let adapter = state.get_central_adapter().await;
|
||||
if let Some(adapter) = adapter {
|
||||
adapter
|
||||
.stop_scan()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToStopScan)?;
|
||||
app_handle.emit("app_state_updated", ()).unwrap();
|
||||
state.set_scanning(false).await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors::AppError::NoAvailableBluetoothAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_peripherals(
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<Vec<Peripheral>, errors::AppError> {
|
||||
let state = app_state.read().await; // Change from lock() to read()
|
||||
let adapter = state.get_central_adapter().await;
|
||||
if let Some(adapter) = adapter {
|
||||
Ok(adapter
|
||||
.peripherals()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?)
|
||||
} else {
|
||||
Err(errors::AppError::NoAvailableBluetoothAdapter)
|
||||
}
|
||||
}
|
122
src-tauri/src/cmd/mod.rs
Normal file
|
@ -0,0 +1,122 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use btleplug::api::{Central as _, Peripheral as _};
|
||||
use state::{ApplicationState, CentralState, ChannelState, PeripheralItem};
|
||||
use tauri::{async_runtime::RwLock, AppHandle, Emitter, State};
|
||||
|
||||
use crate::{bluetooth, errors, state::AppState};
|
||||
|
||||
mod state;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn activate_central_adapter(
|
||||
app_handle: AppHandle,
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), errors::AppError> {
|
||||
let app = Arc::new(app_handle);
|
||||
let adapter = bluetooth::get_central_adapter().await?;
|
||||
{
|
||||
let state = app_state.read().await;
|
||||
state.set_central_adapter(adapter).await;
|
||||
state.clear_central_event_handler().await;
|
||||
}
|
||||
let handle =
|
||||
bluetooth::handle_bluetooth_events(Arc::clone(&app), Arc::clone(&app_state)).await?;
|
||||
{
|
||||
let state = app_state.write().await; // Changed from lock() to write()
|
||||
state.set_central_event_handler(handle).await;
|
||||
}
|
||||
app.emit("app_state_updated", ()).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_application_state(
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<ApplicationState, errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
let mut peripherals: Vec<PeripheralItem> = vec![];
|
||||
let mut connected_peripheral = None;
|
||||
|
||||
let central = state.get_central_adapter().await;
|
||||
let central_state = if let Some(central) = central {
|
||||
let central_device_state = central
|
||||
.adapter_state()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::BluetoothNotReady)?;
|
||||
|
||||
let found_peripherals = central
|
||||
.peripherals()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToRetrievePeripherals)?;
|
||||
for peripheral in found_peripherals {
|
||||
let properties = peripheral
|
||||
.properties()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?;
|
||||
if let Some(properties) = properties {
|
||||
let represent = properties
|
||||
.local_name
|
||||
.unwrap_or_else(|| properties.address.to_string());
|
||||
let item = PeripheralItem {
|
||||
id: peripheral.id(),
|
||||
address: properties.address.to_string(),
|
||||
represent,
|
||||
is_connected: peripheral
|
||||
.is_connected()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToRetrievePeripheralProperties)?,
|
||||
rssi: properties.rssi,
|
||||
battery: properties.tx_power_level,
|
||||
};
|
||||
if peripheral
|
||||
.is_connected()
|
||||
.await
|
||||
.map_err(|_| errors::AppError::UnableToRetrievePeripheralState)?
|
||||
{
|
||||
connected_peripheral = Some(item.clone());
|
||||
}
|
||||
peripherals.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
CentralState {
|
||||
is_ready: central_device_state == btleplug::api::CentralState::PoweredOn,
|
||||
is_scanning: *state.scanning.lock().await,
|
||||
connected: state
|
||||
.connected_peripheral
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.map(|p| p.id()),
|
||||
}
|
||||
} else {
|
||||
CentralState::default()
|
||||
};
|
||||
|
||||
Ok(ApplicationState {
|
||||
central: central_state,
|
||||
peripherals,
|
||||
connected_peripheral,
|
||||
channel_a: ChannelState::default(),
|
||||
channel_b: ChannelState::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_scan_devices(
|
||||
app_handle: AppHandle,
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), errors::AppError> {
|
||||
bluetooth::start_scan(Arc::new(app_handle), app_state).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_scan_devices(
|
||||
app_handle: AppHandle,
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
) -> Result<(), errors::AppError> {
|
||||
bluetooth::stop_scan(Arc::new(app_handle), app_state).await?;
|
||||
Ok(())
|
||||
}
|
41
src-tauri/src/cmd/state.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use btleplug::platform::PeripheralId;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::playlist::PlayMode;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct CentralState {
|
||||
pub is_ready: bool,
|
||||
pub is_scanning: bool,
|
||||
pub connected: Option<PeripheralId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PeripheralItem {
|
||||
pub id: PeripheralId,
|
||||
pub address: String,
|
||||
pub represent: String,
|
||||
pub is_connected: bool,
|
||||
pub rssi: Option<i16>,
|
||||
pub battery: Option<i16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ChannelState {
|
||||
pub is_playing: bool,
|
||||
pub strength: u32,
|
||||
pub strength_limit: u32,
|
||||
pub is_boosting: bool,
|
||||
pub boost_level: u32,
|
||||
pub boost_limit: u32,
|
||||
pub play_mode: PlayMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApplicationState {
|
||||
pub central: CentralState,
|
||||
pub peripherals: Vec<PeripheralItem>,
|
||||
pub connected_peripheral: Option<PeripheralItem>,
|
||||
pub channel_a: ChannelState,
|
||||
pub channel_b: ChannelState,
|
||||
}
|
23
src-tauri/src/config_db.rs
Normal file
|
@ -0,0 +1,23 @@
|
|||
use std::{fs::DirBuilder, path::PathBuf};
|
||||
|
||||
use tauri::{path::PathResolver, AppHandle, Manager, Runtime};
|
||||
|
||||
fn ensure_dir<R: Runtime>(resolver: &PathResolver<R>) -> anyhow::Result<PathBuf> {
|
||||
let config_dir = resolver
|
||||
.local_data_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Unable to get local data directory: {}", e))?;
|
||||
if !config_dir.exists() {
|
||||
let mut dir_creator = DirBuilder::new();
|
||||
dir_creator
|
||||
.recursive(true)
|
||||
.create(&config_dir)
|
||||
.map_err(|e| anyhow::anyhow!("Unable to create config directory: {}", e))?;
|
||||
}
|
||||
Ok(config_dir)
|
||||
}
|
||||
|
||||
pub fn open_config_db<R: Runtime>(app_handle: &AppHandle<R>) -> anyhow::Result<sled::Db> {
|
||||
let config_dir = ensure_dir(app_handle.path())?;
|
||||
let db_path = config_dir.join("conf.db");
|
||||
sled::open(&db_path).map_err(|e| anyhow::anyhow!("Unable to open config database: {}", e))
|
||||
}
|
22
src-tauri/src/errors.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum AppError {
|
||||
#[error("Bluetooth not ready")]
|
||||
BluetoothNotReady,
|
||||
#[error("No Bluetooth adapter found")]
|
||||
BluetoothAdapterNotFound,
|
||||
#[error("No available Bluetooth adapter, maybe not powered on")]
|
||||
NoAvailableBluetoothAdapter,
|
||||
#[error("Unable to start scan devices")]
|
||||
UnableToStartScan,
|
||||
#[error("Unable to stop scan devices")]
|
||||
UnableToStopScan,
|
||||
#[error("Unable to retrieve peripherals")]
|
||||
UnableToRetrievePeripherals,
|
||||
#[error("Unable to retrieve peripheral properties")]
|
||||
UnableToRetrievePeripheralProperties,
|
||||
#[error("Unable to retrieve peripheral state")]
|
||||
UnableToRetrievePeripheralState,
|
||||
}
|
63
src-tauri/src/lib.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
#![allow(dead_code)]
|
||||
#![feature(stmt_expr_attributes)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{
|
||||
async_runtime::{self, RwLock},
|
||||
generate_handler, Manager,
|
||||
};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
mod bluetooth;
|
||||
mod cmd;
|
||||
mod config_db;
|
||||
mod errors;
|
||||
mod playlist;
|
||||
mod state;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[cfg(debug_assertions)]
|
||||
let devtools = tauri_plugin_devtools::init();
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
builder = builder.plugin(devtools);
|
||||
|
||||
builder
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
if let Err(e) = async_runtime::block_on(async {
|
||||
let state = state::AppState::new(app.handle()).await?;
|
||||
app.manage(Arc::new(RwLock::new(state)));
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}) {
|
||||
app.dialog()
|
||||
.message(e.to_string())
|
||||
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
|
||||
.title("Initialization error")
|
||||
.blocking_show();
|
||||
return Err(e.into());
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let window = app.get_webview_window("main").unwrap();
|
||||
window.open_devtools();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(generate_handler![
|
||||
cmd::refresh_application_state,
|
||||
cmd::activate_central_adapter,
|
||||
cmd::start_scan_devices,
|
||||
cmd::stop_scan_devices
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
6
src-tauri/src/main.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
estim_lib::run()
|
||||
}
|
17
src-tauri/src/playlist.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlayMode {
|
||||
#[serde(rename = "repeat")]
|
||||
Repeat,
|
||||
#[serde(rename = "repeat-one")]
|
||||
RepeatOne,
|
||||
#[serde(rename = "shuffle")]
|
||||
Shuffle,
|
||||
}
|
||||
|
||||
impl Default for PlayMode {
|
||||
fn default() -> Self {
|
||||
PlayMode::Repeat
|
||||
}
|
||||
}
|
86
src-tauri/src/state.rs
Normal file
|
@ -0,0 +1,86 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use btleplug::platform::{Adapter, Peripheral};
|
||||
use tauri::{
|
||||
async_runtime::{JoinHandle, Mutex, RwLock},
|
||||
AppHandle, Runtime,
|
||||
};
|
||||
|
||||
use crate::config_db;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Mutex<sled::Db>>,
|
||||
pub central_adapter: Arc<RwLock<Option<Adapter>>>,
|
||||
pub central_event_handler: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
pub scanning: Arc<Mutex<bool>>,
|
||||
pub connected_peripheral: Arc<Mutex<Option<Arc<Peripheral>>>>,
|
||||
}
|
||||
|
||||
unsafe impl Send for AppState {}
|
||||
unsafe impl Sync for AppState {}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new<R: Runtime>(app: &AppHandle<R>) -> anyhow::Result<Self> {
|
||||
let db = config_db::open_config_db(app)?;
|
||||
|
||||
Ok(Self {
|
||||
db: Arc::new(Mutex::new(db)),
|
||||
central_adapter: Arc::new(RwLock::new(None)),
|
||||
central_event_handler: Arc::new(Mutex::new(None)),
|
||||
scanning: Arc::new(Mutex::new(false)),
|
||||
connected_peripheral: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_central_adapter(&self, adapter: Adapter) {
|
||||
let mut central_adapter = self.central_adapter.write().await;
|
||||
*central_adapter = Some(adapter);
|
||||
}
|
||||
|
||||
pub async fn clear_central_adapter(&self) {
|
||||
let mut central_adapter = self.central_adapter.write().await;
|
||||
*central_adapter = None;
|
||||
}
|
||||
|
||||
pub async fn get_central_adapter(&self) -> Option<Adapter> {
|
||||
let central_adapter = self.central_adapter.read().await;
|
||||
central_adapter.clone()
|
||||
}
|
||||
|
||||
pub async fn set_central_event_handler(&self, handler: JoinHandle<()>) {
|
||||
let mut central_event_handler = self.central_event_handler.lock().await;
|
||||
*central_event_handler = Some(handler);
|
||||
}
|
||||
|
||||
pub async fn clear_central_event_handler(&self) {
|
||||
let mut central_event_handler = self.central_event_handler.lock().await;
|
||||
if let Some(handler) = central_event_handler.take() {
|
||||
handler.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_scanning(&self, scanning_state: bool) {
|
||||
let mut scanning = self.scanning.lock().await;
|
||||
*scanning = scanning_state;
|
||||
}
|
||||
|
||||
pub async fn set_connected_peripheral(&self, peripheral: Peripheral) {
|
||||
let mut connected_peripheral = self.connected_peripheral.lock().await;
|
||||
*connected_peripheral = Some(Arc::new(peripheral));
|
||||
}
|
||||
|
||||
pub async fn clear_connected_peripheral(&self) {
|
||||
let mut connected_peripheral = self.connected_peripheral.lock().await;
|
||||
*connected_peripheral = None;
|
||||
}
|
||||
|
||||
pub async fn get_connected_peripheral(&self) -> Option<Arc<Peripheral>> {
|
||||
let connected_peripheral = self.connected_peripheral.lock().await;
|
||||
|
||||
if let Some(peripheral) = connected_peripheral.clone() {
|
||||
Some(Arc::clone(&peripheral))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
39
src-tauri/tauri.conf.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "estim",
|
||||
"version": "0.1.0",
|
||||
"identifier": "net.archgrid.app.estim",
|
||||
"build": {
|
||||
"beforeDevCommand": "deno task dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "deno task build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "estim",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"resizable": false,
|
||||
"hiddenTitle": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"theme": "Dark"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
81
src/Layout.module.css
Normal file
|
@ -0,0 +1,81 @@
|
|||
@layer pages {
|
||||
.layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding-block-start: calc(var(--spacing));
|
||||
padding-block-end: calc(var(--spacing) * 4);
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
height: calc(var(--spacing) * 6);
|
||||
z-index: 10;
|
||||
&.mac_titlebar {
|
||||
padding-left: calc(var(--spacing) * 14);
|
||||
}
|
||||
h1 {
|
||||
line-height: 1em;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
menu {
|
||||
flex: 1;
|
||||
padding: calc(var(--spacing) * 4) 0;
|
||||
max-width: calc(var(--spacing) * 12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.main_content {
|
||||
flex: 1;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.window_move_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(var(--spacing) * 8);
|
||||
z-index: 300;
|
||||
}
|
||||
.route_link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing));
|
||||
div {
|
||||
text-align: center;
|
||||
&.filled {
|
||||
color: var(--color-dark-on-surface);
|
||||
background-color: transparent;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding-block: calc(var(--spacing));
|
||||
}
|
||||
}
|
||||
&.inactive {
|
||||
color: var(--color-dark-on-surface-variant);
|
||||
}
|
||||
&.active {
|
||||
color: var(--color-dark-on-surface);
|
||||
div.filled {
|
||||
color: var(--color-dark-on-secondary-container);
|
||||
background-color: var(--color-dark-secondary-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
70
src/Layout.tsx
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import cx from 'clsx';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import styles from './Layout.module.css';
|
||||
import { defaultIconProps } from './icons/shared-props';
|
||||
import StateBar from './page-components/state-bar/StateBar';
|
||||
|
||||
type FunctionLinkProps = {
|
||||
name: string;
|
||||
url: string;
|
||||
end?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const FunctionLink: FC<FunctionLinkProps> = ({ name, url, end, children }) => {
|
||||
return (
|
||||
<NavLink
|
||||
to={url}
|
||||
className={({ isActive }) =>
|
||||
cx(styles.route_link, isActive ? styles.active : styles.inactive)
|
||||
}
|
||||
end={end}>
|
||||
<div className={styles.filled}>{children}</div>
|
||||
<div>{name}</div>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
const Layout: FC = () => {
|
||||
const os = platform();
|
||||
|
||||
return (
|
||||
<main className={styles.layout}>
|
||||
<header className={cx({ [styles.mac_titlebar]: os === 'macos' })}>
|
||||
<h1>ESTIM Remote</h1>
|
||||
<StateBar />
|
||||
</header>
|
||||
<section>
|
||||
<menu>
|
||||
<FunctionLink name="Device" url="/" end>
|
||||
<Icon icon="material-symbols-light:device-unknown" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Play" url="/play">
|
||||
<Icon icon="material-symbols-light:motion-play" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Pattern Library" url="/library">
|
||||
<Icon icon="material-symbols-light:menu-book" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Pattern Editor" url="/pattern-editor">
|
||||
<Icon icon="material-symbols-light:movie-edit" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
<FunctionLink name="Settings" url="/settings">
|
||||
<Icon icon="material-symbols-light:settings" {...defaultIconProps} />
|
||||
</FunctionLink>
|
||||
</menu>
|
||||
<div className={styles.main_content}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</section>
|
||||
{createPortal(
|
||||
<div data-tauri-drag-region className={styles.window_move_handler} />,
|
||||
document.body,
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
203
src/components.css
Normal file
|
@ -0,0 +1,203 @@
|
|||
@layer base {
|
||||
:root {
|
||||
--button-text: var(--color-dark-on-primary);
|
||||
--button-surface: var(--color-dark-primary);
|
||||
--button-outline: var(--color-dark-outline);
|
||||
}
|
||||
|
||||
:where(ul, menu) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:where(a) {
|
||||
text-decoration: none;
|
||||
color: var(--color-dark-on-surface);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-dark-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-dark-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
font-weight: bold;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.6em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 6);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
&.veritcal {
|
||||
flex-direction: column;
|
||||
}
|
||||
&.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
:where(button, .button) {
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacing) * 1) calc(var(--spacing) * 3);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
font-size: calc(var(--font-size) * 1.2);
|
||||
line-height: 1.3em;
|
||||
color: var(--button-text);
|
||||
background-color: var(--button-surface);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
&.smaller {
|
||||
font-size: calc(var(--font-size) * 0.8);
|
||||
}
|
||||
&.small {
|
||||
font-size: calc(var(--font-size) * 1);
|
||||
}
|
||||
&.large {
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
}
|
||||
&.larger {
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-primary);
|
||||
box-shadow: var(--elevation-dark-1-ambient), var(--elevation-dark-1-umbra);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
color: color-mix(in oklch, var(--button-text) 12%, transparent);
|
||||
}
|
||||
&.tonal:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-primary-container);
|
||||
--button-surface: var(--color-dark-primary-container);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-error);
|
||||
--button-surface: var(--color-dark-error);
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-warning);
|
||||
--button-surface: var(--color-dark-warning);
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-success);
|
||||
--button-surface: var(--color-dark-success);
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-on-info);
|
||||
--button-surface: var(--color-dark-info);
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
|
||||
--button-outline: color-mix(in oklch, var(--color-dark-on-surface) 12%, transparent);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
&.outline {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: transparent;
|
||||
border: 1px solid var(--button-outline);
|
||||
&:hover:not(:disabled) {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
--button-text: var(--color-dark-primary);
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 10%, transparent);
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-error);
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-warning);
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-success);
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-info);
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
}
|
||||
}
|
||||
&.text {
|
||||
--button-text: --color-dark-primary;
|
||||
--button-surface: transparent;
|
||||
border: none;
|
||||
&:hover:not(:disabled) {
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-primary) 8%, transparent);
|
||||
}
|
||||
&:active:not(:disabled) {
|
||||
--button-surface: color-mix(in oklch, var(--color-dark-primary) 10%, transparent);
|
||||
}
|
||||
&.danger:not(:disabled) {
|
||||
--button-text: var(--color-dark-error);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.warn:not(:disabled) {
|
||||
--button-text: var(--color-dark-warning);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.success:not(:disabled) {
|
||||
--button-text: var(--color-dark-success);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&.info:not(:disabled) {
|
||||
--button-text: var(--color-dark-info);
|
||||
--button-surface: transparent;
|
||||
}
|
||||
&:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-dark-on-surface) 38%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
43
src/components/ScrollArea.module.css
Normal file
|
@ -0,0 +1,43 @@
|
|||
@layer components {
|
||||
.scroll_area {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
grid-template-columns: auto calc(var(--spacing) * 3);
|
||||
grid-template-rows: auto calc(var(--spacing) * 3);
|
||||
}
|
||||
.content {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v_scrollbar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.v_thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 3;
|
||||
position: absolute;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.h_scrollbar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
.h_thumb {
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 1;
|
||||
position: absolute;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: oklch(from var(--color-dark-primary) l c h / 70%);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
195
src/components/ScrollArea.tsx
Normal file
|
@ -0,0 +1,195 @@
|
|||
import { clamp } from 'lodash-es';
|
||||
import { MouseEvent, RefObject, useEffect, useRef, useState, WheelEvent } from 'react';
|
||||
import styles from './ScrollArea.module.css';
|
||||
|
||||
type ScrollBarProps = {
|
||||
containerRef: RefObject<HTMLDivElement> | null;
|
||||
};
|
||||
|
||||
function VerticalScrollBar({ containerRef }: ScrollBarProps) {
|
||||
const [thumbPos, setThumbPos] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleMouseDown = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const handleMouseMove = (evt: MouseEvent<HTMLDivElement>) => {
|
||||
evt.preventDefault();
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const thumb = thumbRef.current;
|
||||
|
||||
if (container && scrollbar && thumb) {
|
||||
const trackRect = scrollbar.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const offsetY = evt.clientY - trackRect.top - thumbRect.height / 2;
|
||||
const thumbPosition = clamp(offsetY, 0, trackRect.height - thumbRect.height);
|
||||
setThumbPos(thumbPosition);
|
||||
|
||||
const scrollPercentage = thumbPosition / (trackRect.height - thumbRect.height);
|
||||
container.scrollTop = scrollPercentage * (container.scrollHeight - container.clientHeight);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const updateThumbPosition = () => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const scrollPercentage =
|
||||
container.scrollTop / (container.scrollHeight - container.clientHeight);
|
||||
const thumbPosition =
|
||||
scrollPercentage * (scrollbar.clientHeight - thumbRef.current!.clientHeight);
|
||||
setThumbPos(thumbPosition);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
if (container && scrollbar) {
|
||||
container.addEventListener('scroll', updateThumbPosition);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateThumbPosition);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.v_scrollbar} ref={trackRef}>
|
||||
<div
|
||||
className={styles.v_thumb}
|
||||
ref={thumbRef}
|
||||
style={{ top: thumbPos }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalScrollBar({ containerRef }: ScrollBarProps) {
|
||||
const [thumbPos, setThumbPos] = useState(0);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const thumbRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleMouseDown = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
const handleMouseMove = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const thumb = thumbRef.current;
|
||||
|
||||
if (container && scrollbar && thumb) {
|
||||
const trackRect = scrollbar.getBoundingClientRect();
|
||||
const thumbRect = thumb.getBoundingClientRect();
|
||||
const offsetX = evt.clientX - trackRect.left - thumbRect.width / 2;
|
||||
const thumbPosition = clamp(offsetX, 0, trackRect.width - thumbRect.width);
|
||||
setThumbPos(thumbPosition);
|
||||
|
||||
const scrollPercentage = thumbPosition / (trackRect.width - thumbRect.width);
|
||||
container.scrollLeft = scrollPercentage * (container.scrollWidth - container.clientWidth);
|
||||
}
|
||||
};
|
||||
const handleMouseUp = (evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
//@ts-expect-error TS2769
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const updateThumbPosition = () => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
const scrollPercentage = container.scrollLeft / (container.scrollWidth - container.clientWidth);
|
||||
const thumbPosition =
|
||||
scrollPercentage * (scrollbar.clientWidth - thumbRef.current!.clientWidth);
|
||||
setThumbPos(thumbPosition);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
const scrollbar = trackRef.current;
|
||||
if (container && scrollbar) {
|
||||
container.addEventListener('scroll', updateThumbPosition);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateThumbPosition);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.h_scrollbar} ref={trackRef}>
|
||||
<div
|
||||
className={styles.h_thumb}
|
||||
ref={thumbRef}
|
||||
style={{ left: thumbPos }}
|
||||
onMouseDown={(e) => handleMouseDown(e)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ScrollAreaProps = {
|
||||
children?: React.ReactNode;
|
||||
enableX?: boolean;
|
||||
enableY?: boolean;
|
||||
normalizedScroll?: boolean;
|
||||
};
|
||||
|
||||
export function ScrollArea({
|
||||
children,
|
||||
enableX = false,
|
||||
enableY = false,
|
||||
normalizedScroll = false,
|
||||
}: ScrollAreaProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [xScrollNeeded, setXScrollNeeded] = useState(false);
|
||||
const [yScrollNeeded, setYScrollNeeded] = useState(false);
|
||||
const handleWheel = (evt: WheelEvent<HTMLDivElement>) => {
|
||||
const container = scrollContainerRef?.current;
|
||||
if (enableY && container) {
|
||||
const delta = evt.deltaY;
|
||||
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
|
||||
const newScrollTop = container.scrollTop + normalizedDelta;
|
||||
container.scrollTop = clamp(newScrollTop, 0, container.scrollHeight - container.clientHeight);
|
||||
}
|
||||
if (enableX && container) {
|
||||
const delta = evt.deltaX;
|
||||
const normalizedDelta = normalizedScroll ? clamp(delta, -1, 1) * 30 : delta;
|
||||
const newScrollLeft = container.scrollLeft + normalizedDelta;
|
||||
container.scrollLeft = clamp(newScrollLeft, 0, container.scrollWidth - container.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
setXScrollNeeded(container.scrollWidth > container.clientWidth);
|
||||
setYScrollNeeded(container.scrollHeight > container.clientHeight);
|
||||
}
|
||||
}, [children]);
|
||||
|
||||
return (
|
||||
<div className={styles.scroll_area}>
|
||||
<div className={styles.content} ref={scrollContainerRef} onWheel={(e) => handleWheel(e)}>
|
||||
{children}
|
||||
</div>
|
||||
{enableY && yScrollNeeded && <VerticalScrollBar containerRef={scrollContainerRef} />}
|
||||
{enableX && xScrollNeeded && <HorizontalScrollBar containerRef={scrollContainerRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
96
src/context/EstimContext.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Event, listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { atom, PrimitiveAtom, useSetAtom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { FC, ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export type Channels = 'a' | 'b';
|
||||
type ChannelState = {
|
||||
playing: boolean;
|
||||
playMode: 'shuffle' | 'repeat' | 'repeat-one';
|
||||
strength: number;
|
||||
maxStrength: number;
|
||||
boosting: boolean;
|
||||
boostLevel: number;
|
||||
maxBoostLevel: number;
|
||||
};
|
||||
type DeviceState = {
|
||||
rssi: number | null;
|
||||
battery: number | null;
|
||||
};
|
||||
type BluetoothState = {
|
||||
ready: boolean | null;
|
||||
searching: boolean | null;
|
||||
connected: string | null;
|
||||
};
|
||||
|
||||
export const BleState = atom<BluetoothState>({
|
||||
ready: null,
|
||||
searching: null,
|
||||
connected: null,
|
||||
});
|
||||
export const DeviceState = atom<DeviceState>({
|
||||
rssi: null,
|
||||
battery: null,
|
||||
});
|
||||
const Channels: Record<Channels, PrimitiveAtom<ChannelState>> = {
|
||||
a: atom<ChannelState>({
|
||||
playing: false,
|
||||
playMode: 'repeat-one',
|
||||
strength: 0,
|
||||
maxStrength: 100,
|
||||
boosting: false,
|
||||
boostLevel: 0,
|
||||
maxBoostLevel: 100,
|
||||
}),
|
||||
b: atom<ChannelState>({
|
||||
playing: false,
|
||||
playMode: 'repeat-one',
|
||||
strength: 0,
|
||||
maxStrength: 100,
|
||||
boosting: false,
|
||||
boostLevel: 0,
|
||||
maxBoostLevel: 100,
|
||||
}),
|
||||
};
|
||||
export const ChannelState = atomFamily((channel: Channels) => Channels[channel]);
|
||||
|
||||
const EstimWatchProvider: FC<{ children?: ReactNode }> = ({ children }) => {
|
||||
const unlisten = useRef<UnlistenFn | null>(null);
|
||||
const setBleState = useSetAtom(BleState);
|
||||
const handleAppStateRefresh = useCallback(async (event: Event<unknown>) => {
|
||||
try {
|
||||
const newState = await invoke('refresh_application_state');
|
||||
setBleState({
|
||||
ready: newState.central.is_ready,
|
||||
searching: newState.central.is_scanning,
|
||||
connected: newState.central.connected,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Answer refresh state]', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
try {
|
||||
unlisten.current = await listen('app_state_updated', handleAppStateRefresh);
|
||||
await invoke('activate_central_adapter');
|
||||
} catch (e) {
|
||||
console.error('[Activate Adapter]', e);
|
||||
await message('Fail to activate Bluetooth adapter.', {
|
||||
title: 'Bluetooth Error',
|
||||
kind: 'error',
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unlisten.current?.();
|
||||
};
|
||||
}, []);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default EstimWatchProvider;
|
42
src/icons/IconBattery.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconBatteryProps = SharedIconProps & {
|
||||
level?: number | null;
|
||||
};
|
||||
|
||||
const IconBattery: FC<IconBatteryProps> = ({
|
||||
height = 24,
|
||||
aspect = 1,
|
||||
stroke = 0.5,
|
||||
level = null,
|
||||
}) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const batteryIcon = useMemo(() => {
|
||||
if (level !== null && level >= 90) {
|
||||
return 'material-symbols-light:battery-full';
|
||||
} else if (level !== null && level >= 80) {
|
||||
return 'material-symbols-light:battery-6-bar';
|
||||
} else if (level !== null && level >= 70) {
|
||||
return 'material-symbols-light:battery-5-bar';
|
||||
} else if (level !== null && level >= 50) {
|
||||
return 'material-symbols-light:battery-4-bar';
|
||||
} else if (level !== null && level >= 30) {
|
||||
return 'material-symbols-light:battery-3-bar';
|
||||
} else if (level !== null && level >= 20) {
|
||||
return 'material-symbols-light:battery-2-bar';
|
||||
} else if (level !== null && level >= 10) {
|
||||
return 'material-symbols-light:battery-1-bar';
|
||||
} else if (level !== null && level >= 0) {
|
||||
return 'material-symbols-light:battery-0-bar';
|
||||
} else {
|
||||
return 'material-symbols-light:battery-error';
|
||||
}
|
||||
}, [level]);
|
||||
console.debug('[icon battery]', level, batteryIcon);
|
||||
|
||||
return <Icon icon={batteryIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconBattery;
|
35
src/icons/IconBluetooth.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconBluetoothProps = SharedIconProps & {
|
||||
ready?: boolean | null;
|
||||
searching?: boolean | null;
|
||||
connected?: boolean | null;
|
||||
};
|
||||
|
||||
const IconBluetooth: FC<IconBluetoothProps> = ({
|
||||
height = 24,
|
||||
aspect = 1,
|
||||
stroke = 0.5,
|
||||
ready = false,
|
||||
searching = false,
|
||||
connected = false,
|
||||
}) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const bleIcon = useMemo(() => {
|
||||
if (ready && !searching && !connected) {
|
||||
return 'material-symbols-light:bluetooth';
|
||||
} else if (ready && searching) {
|
||||
return 'material-symbols-light:bluetooth-searching';
|
||||
} else if (ready && !searching && connected) {
|
||||
return 'material-symbols-light:bluetooth-connected';
|
||||
} else {
|
||||
return 'material-symbols-light:bluetooth-disabled';
|
||||
}
|
||||
}, [ready, searching, connected]);
|
||||
|
||||
return <Icon icon={bleIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconBluetooth;
|
30
src/icons/IconRssi.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { SharedIconProps } from './shared-props';
|
||||
|
||||
type IconRssiProps = SharedIconProps & {
|
||||
level?: number | null;
|
||||
};
|
||||
|
||||
const IconRssi: FC<IconRssiProps> = ({ height = 24, aspect = 1, stroke = 0.5, level = null }) => {
|
||||
const width = useMemo(() => height * aspect, [height, aspect]);
|
||||
const rssiIcon = useMemo(() => {
|
||||
if (level === null) {
|
||||
return 'material-symbols-light:signal-cellular-nodata';
|
||||
} else if (level <= -80) {
|
||||
return 'material-symbols-light:signal-cellular-4-bar';
|
||||
} else if (level <= -70) {
|
||||
return 'material-symbols-light:signal-cellular-3-bar';
|
||||
} else if (level <= -60) {
|
||||
return 'material-symbols-light:signal-cellular-2-bar';
|
||||
} else if (level <= -50) {
|
||||
return 'material-symbols-light:signal-cellular-1-bar';
|
||||
} else {
|
||||
return 'material-symbols-light:signal-cellular-null';
|
||||
}
|
||||
}, [level]);
|
||||
|
||||
return <Icon icon={rssiIcon} width={width} height={height} stroke={stroke} />;
|
||||
};
|
||||
|
||||
export default IconRssi;
|
17
src/icons/shared-props.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { IconProps } from '@iconify/react/dist/iconify.js';
|
||||
|
||||
export type SharedIconProps = {
|
||||
height?: number | null;
|
||||
aspect?: number | null;
|
||||
stroke?: number | null;
|
||||
};
|
||||
|
||||
export const defaultIconProps: Partial<IconProps> = {
|
||||
height: 24,
|
||||
stroke: 0.5,
|
||||
};
|
||||
|
||||
export const smallIconProps: Partial<IconProps> = {
|
||||
height: 16,
|
||||
stroke: 0.5,
|
||||
};
|
5
src/index.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
@layer theme, base, components, utilities, pages;
|
||||
@import 'sanitize.css' layer(base);
|
||||
@import 'sanitize.css/forms.css' layer(base);
|
||||
@import './theme.css';
|
||||
@import './components.css';
|
31
src/main.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Load global styles
|
||||
import './index.css';
|
||||
// Load foundations
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import EstimWatchProvider from './context/EstimContext';
|
||||
import Device from './pages/Device';
|
||||
import PatternEditor from './pages/PatternEditor';
|
||||
import PatternLibrary from './pages/PatternLibrary';
|
||||
import PlayControl from './pages/Play';
|
||||
import Settings from './pages/Settings';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<EstimWatchProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Device />} />
|
||||
<Route path="/play" element={<PlayControl />} />
|
||||
<Route path="/library" element={<PatternLibrary />} />
|
||||
<Route path="/pattern-editor" element={<PatternEditor />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</EstimWatchProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
11
src/page-components/device/BleControl.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
@layer pages {
|
||||
.ble_control {
|
||||
height: calc(var(--spacing) * 12);
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
17
src/page-components/device/BleControl.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { BleState } from '../../context/EstimContext';
|
||||
import styles from './BleControl.module.css';
|
||||
|
||||
const BleControl: FC = () => {
|
||||
const bleState = useAtomValue(BleState);
|
||||
|
||||
return (
|
||||
<div className={styles.ble_control}>
|
||||
<button disabled={!bleState.ready || bleState.searching}>Scan</button>
|
||||
<button disabled={!bleState.ready || !bleState.connected}>Disconnect</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BleControl;
|
8
src/page-components/device/DeviceDetail.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
@layer pages {
|
||||
.device_detail {
|
||||
flex: 2;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacing) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
}
|
||||
}
|
8
src/page-components/device/DeviceDetail.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { FC } from 'react';
|
||||
import styles from './DeviceDetail.module.css';
|
||||
|
||||
const DeviceDetail: FC = () => {
|
||||
return <div className={styles.device_detail}></div>;
|
||||
};
|
||||
|
||||
export default DeviceDetail;
|
8
src/page-components/device/DeviceList.module.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
@layer pages {
|
||||
.devices {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
13
src/page-components/device/DeviceList.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { FC } from 'react';
|
||||
import { ScrollArea } from '../../components/ScrollArea';
|
||||
import styles from './DeviceList.module.css';
|
||||
|
||||
const DeviceList: FC = () => {
|
||||
return (
|
||||
<div className={styles.devices}>
|
||||
<ScrollArea enableY></ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceList;
|
11
src/page-components/play-control/ChannelHost.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
@layer pages {
|
||||
.channel_host {
|
||||
flex: 1;
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing));
|
||||
}
|
||||
}
|
17
src/page-components/play-control/ChannelHost.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { FC } from 'react';
|
||||
import { Channels } from '../../context/EstimContext';
|
||||
import styles from './ChannelHost.module.css';
|
||||
|
||||
type ChannelHostProps = {
|
||||
channel: Channels;
|
||||
};
|
||||
|
||||
const ChannelHost: FC<ChannelHostProps> = ({ channel }) => {
|
||||
return (
|
||||
<div className={styles.channel_host}>
|
||||
<h3>Channel {channel.toUpperCase()}</h3>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelHost;
|
30
src/page-components/state-bar/BleState.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { BleState } from '../../context/EstimContext';
|
||||
import IconBluetooth from '../../icons/IconBluetooth';
|
||||
|
||||
const BleStates: FC = () => {
|
||||
const ble = useAtomValue(BleState);
|
||||
const bleIcon = useMemo(() => {
|
||||
if (ble.ready && !ble.searching && !ble.connected) {
|
||||
return 'material-symbols-light:bluetooth';
|
||||
} else if (ble.ready && ble.searching) {
|
||||
return 'material-symbols-light:bluetooth-searching';
|
||||
} else if (ble.ready && !ble.searching && ble.connected) {
|
||||
return 'material-symbols-light:bluetooth-connected';
|
||||
} else {
|
||||
return 'material-symbols-light:bluetooth-disabled';
|
||||
}
|
||||
}, [ble]);
|
||||
|
||||
return (
|
||||
<IconBluetooth
|
||||
height={16}
|
||||
ready={ble.ready}
|
||||
searching={ble.searching}
|
||||
connected={ble.connected?.length > 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BleStates;
|
10
src/page-components/state-bar/ChannelStates.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
@layer pages {
|
||||
.channel_state {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
}
|
||||
}
|
35
src/page-components/state-bar/ChannelStates.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { Channels, ChannelState } from '../../context/EstimContext';
|
||||
import { smallIconProps } from '../../icons/shared-props';
|
||||
import styles from './ChannelStates.module.css';
|
||||
|
||||
const ChannelStates: FC<{ channel: Channels }> = ({ channel }) => {
|
||||
const chState = useAtomValue(ChannelState(channel));
|
||||
|
||||
return (
|
||||
<div className={styles.channel_state}>
|
||||
<span>Ch {channel.toUpperCase()}</span>
|
||||
{chState.playing ? (
|
||||
<Icon icon="material-symbols-light:electric-bolt" {...smallIconProps} />
|
||||
) : (
|
||||
<Icon icon="material-symbols-light:stop" {...smallIconProps} />
|
||||
)}
|
||||
<span>{chState.strength}</span>
|
||||
<Icon icon="material-symbols-light:arrow-upload-progress" {...smallIconProps} />
|
||||
<span>{chState.boostLevel}</span>
|
||||
{chState.playMode === 'shuffle' && (
|
||||
<Icon icon="material-symbols-light:shuffle" {...smallIconProps} />
|
||||
)}
|
||||
{chState.playMode === 'repeat' && (
|
||||
<Icon icon="material-symbols-light:repeat" {...smallIconProps} />
|
||||
)}
|
||||
{chState.playMode === 'repeat-one' && (
|
||||
<Icon icon="material-symbols-light:repeat-one" {...smallIconProps} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelStates;
|
18
src/page-components/state-bar/DeviceStates.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { DeviceState } from '../../context/EstimContext';
|
||||
import IconBattery from '../../icons/IconBattery';
|
||||
import IconRssi from '../../icons/IconRssi';
|
||||
|
||||
const DeviceStates: FC = () => {
|
||||
const deviceState = useAtomValue(DeviceState);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconRssi height={16} level={deviceState.rssi} />
|
||||
<IconBattery height={16} level={deviceState.battery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceStates;
|
13
src/page-components/state-bar/StateBar.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
@layer pages {
|
||||
.state_bar {
|
||||
flex: 1;
|
||||
padding-block-start: calc(var(--spacing));
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
}
|
||||
}
|
18
src/page-components/state-bar/StateBar.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { FC } from 'react';
|
||||
import BleStates from './BleState';
|
||||
import ChannelStates from './ChannelStates';
|
||||
import DeviceStates from './DeviceStates';
|
||||
import styles from './StateBar.module.css';
|
||||
|
||||
const StateBar: FC = () => {
|
||||
return (
|
||||
<div className={styles.state_bar}>
|
||||
<BleStates />
|
||||
<ChannelStates channel="a" />
|
||||
<ChannelStates channel="b" />
|
||||
<DeviceStates />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StateBar;
|
11
src/pages/Device.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
@layer pages {
|
||||
.device_list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-dark-surface-container);
|
||||
}
|
||||
}
|
19
src/pages/Device.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { FC } from 'react';
|
||||
import BleControl from '../page-components/device/BleControl';
|
||||
import DeviceDetail from '../page-components/device/DeviceDetail';
|
||||
import DeviceList from '../page-components/device/DeviceList';
|
||||
import styles from './Device.module.css';
|
||||
|
||||
const Device: FC = () => {
|
||||
return (
|
||||
<div className="workspace horizontal">
|
||||
<div className={styles.device_list}>
|
||||
<BleControl />
|
||||
<DeviceList />
|
||||
</div>
|
||||
<DeviceDetail />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Device;
|
7
src/pages/PatternEditor.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
const PatternEditor: FC = () => {
|
||||
return <div className="workspace"></div>;
|
||||
};
|
||||
|
||||
export default PatternEditor;
|
7
src/pages/PatternLibrary.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
const PatternLibrary: FC = () => {
|
||||
return <div className="workspace"></div>;
|
||||
};
|
||||
|
||||
export default PatternLibrary;
|
10
src/pages/Play.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
@layer pages {
|
||||
.play_control {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
13
src/pages/Play.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { FC } from 'react';
|
||||
import ChannelHost from '../page-components/play-control/ChannelHost';
|
||||
|
||||
const PlayControl: FC = () => {
|
||||
return (
|
||||
<div className="workspace horizontal">
|
||||
<ChannelHost channel="a" />
|
||||
<ChannelHost channel="b" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayControl;
|
7
src/pages/Settings.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
const Settings: FC = () => {
|
||||
return <div className="workspace vertical"></div>;
|
||||
};
|
||||
|
||||
export default Settings;
|
75
src/theme.css
Normal file
|
@ -0,0 +1,75 @@
|
|||
@import './variables.css' layer(theme);
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size);
|
||||
line-height: var(--line-height);
|
||||
font-weight: 400;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
:where(html, body) {
|
||||
color-scheme: dark;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
color: var(--color-dark-on-surface);
|
||||
background-color: var(--color-dark-surface);
|
||||
}
|
||||
|
||||
:where(h1, h2, h3, h4, h5, h6, p, div, span) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:where(menu) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.evelation-0 {
|
||||
box-shadow: var(--elevation-dark-0);
|
||||
}
|
||||
|
||||
.evelation-1 {
|
||||
box-shadow: var(--elevation-dark-1-ambient), --var(--elevation-dark-1-umbra);
|
||||
}
|
||||
|
||||
.evelation-2 {
|
||||
box-shadow: var(--elevation-dark-2-ambient), var(--elevation-dark-2-umbra);
|
||||
}
|
||||
|
||||
.evelation-3 {
|
||||
box-shadow: var(--elevation-dark-3-ambient), var(--elevation-dark-3-umbra);
|
||||
}
|
||||
|
||||
.evelation-4 {
|
||||
box-shadow: var(--elevation-dark-4-ambient), var(--elevation-dark-4-umbra);
|
||||
}
|
||||
|
||||
.evelation-5 {
|
||||
box-shadow: var(--elevation-dark-5-ambient), var(--elevation-dark-5-umbra);
|
||||
}
|
||||
}
|
271
src/variables.css
Normal file
|
@ -0,0 +1,271 @@
|
|||
@layer theme {
|
||||
:root {
|
||||
--font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-light-primary: #744c1f;
|
||||
--color-light-on-primary: #fff5c0;
|
||||
--color-light-primary-container: #95693b;
|
||||
--color-light-on-primary-container: #fff5c0;
|
||||
--color-light-primary-fixed: #95693b;
|
||||
--color-light-primary-fixed-dim: #7a5124;
|
||||
--color-light-on-primary-fixed: #fff5c0;
|
||||
--color-light-on-primary-fixed-variant: #fff5c0;
|
||||
--color-light-inverse-primary: #6f481b;
|
||||
--color-light-secondary: #67513b;
|
||||
--color-light-on-secondary: #fffae0;
|
||||
--color-light-secondary-container: #866e57;
|
||||
--color-light-on-secondary-container: #fffae0;
|
||||
--color-light-secondary-fixed: #866e57;
|
||||
--color-light-secondary-fixed-dim: #6c5540;
|
||||
--color-light-on-secondary-fixed: #fffae0;
|
||||
--color-light-on-secondary-fixed-variant: #fffae0;
|
||||
--color-light-inverse-secondary: #dec1a8;
|
||||
--color-light-tertiary: #5c561a;
|
||||
--color-light-on-tertiary: #ffffbb;
|
||||
--color-light-tertiary-container: #7b7436;
|
||||
--color-light-on-tertiary-container: #ffffbb;
|
||||
--color-light-tertiary-fixed: #7b7436;
|
||||
--color-light-tertiary-fixed-dim: #615b1f;
|
||||
--color-light-on-tertiary-fixed: #ffffbb;
|
||||
--color-light-on-tertiary-fixed-variant: #ffffbb;
|
||||
--color-light-inverse-tertiary: #d3c886;
|
||||
--color-light-error: #b20000;
|
||||
--color-light-on-error: #ffc89b;
|
||||
--color-light-error-container: #d92f18;
|
||||
--color-light-on-error-container: #ffc89b;
|
||||
--color-light-error-fixed: #ffab80;
|
||||
--color-light-error-fixed-dim: #ff8e66;
|
||||
--color-light-on-error-fixed: #640000;
|
||||
--color-light-on-error-fixed-variant: #9f0000;
|
||||
--color-light-inverse-error: #ff8e66;
|
||||
--color-light-surface: #fff8f1;
|
||||
--color-light-surface-dim: #bfb7b1;
|
||||
--color-light-surface-bright: #fff8f1;
|
||||
--color-light-surface-variant: #efe0d4;
|
||||
--color-light-surface-container: #e9e1db;
|
||||
--color-light-surface-container-lowest: #fffef7;
|
||||
--color-light-surface-container-low: #f7efe9;
|
||||
--color-light-surface-container-high: #dbd3cd;
|
||||
--color-light-surface-container-highest: #cdc5bf;
|
||||
--color-light-on-surface: #090000;
|
||||
--color-light-on-surface-variant: #41362d;
|
||||
--color-light-inverse-surface: #f7efe9;
|
||||
--color-light-inverse-on-surface: #090000;
|
||||
--color-light-outline: #5f5349;
|
||||
--color-light-outline-variant: #7d7065;
|
||||
--color-light-scrim: #090000;
|
||||
--color-light-shadow: #090000;
|
||||
--color-light-success: #1c6300;
|
||||
--color-light-on-success: #d6ff6a;
|
||||
--color-light-success-container: #3f8200;
|
||||
--color-light-on-success-container: #d6ff6a;
|
||||
--color-light-success-fixed: #3f8200;
|
||||
--color-light-success-fixed-dim: #226800;
|
||||
--color-light-on-success-fixed: #d6ff6a;
|
||||
--color-light-on-success-fixed-variant: #d6ff6a;
|
||||
--color-light-inverse-success: #053f00;
|
||||
--color-light-defensive: #7131b4;
|
||||
--color-light-on-defensive: #ffdeff;
|
||||
--color-light-defensive-container: #9350d6;
|
||||
--color-light-on-defensive-container: #ffdeff;
|
||||
--color-light-defensive-fixed: #9350d6;
|
||||
--color-light-defensive-fixed-dim: #7737ba;
|
||||
--color-light-on-defensive-fixed: #ffdeff;
|
||||
--color-light-on-defensive-fixed-variant: #ffdeff;
|
||||
--color-light-inverse-defensive: #47038c;
|
||||
--color-light-gentle: #7c4b00;
|
||||
--color-light-on-gentle: #fff25f;
|
||||
--color-light-gentle-container: #9e6700;
|
||||
--color-light-on-gentle-container: #fff25f;
|
||||
--color-light-gentle-fixed: #9e6700;
|
||||
--color-light-gentle-fixed-dim: #814f00;
|
||||
--color-light-on-gentle-fixed: #fff25f;
|
||||
--color-light-on-gentle-fixed-variant: #fff25f;
|
||||
--color-light-inverse-gentle: #562900;
|
||||
--color-light-aggressive: #b6002d;
|
||||
--color-light-on-aggressive: #ffc3cc;
|
||||
--color-light-aggressive-container: #dc2247;
|
||||
--color-light-on-aggressive-container: #ffc3cc;
|
||||
--color-light-aggressive-fixed: #dc2247;
|
||||
--color-light-aggressive-fixed-dim: #bd0031;
|
||||
--color-light-on-aggressive-fixed: #ffc3cc;
|
||||
--color-light-on-aggressive-fixed-variant: #ffc3cc;
|
||||
--color-light-inverse-aggressive: #88000e;
|
||||
--color-light-info: #225694;
|
||||
--color-light-on-info: #ddffff;
|
||||
--color-light-info-container: #4973b4;
|
||||
--color-light-on-info-container: #ddffff;
|
||||
--color-light-info-fixed: #4973b4;
|
||||
--color-light-info-fixed-dim: #2a5a99;
|
||||
--color-light-on-info-fixed: #ddffff;
|
||||
--color-light-on-info-fixed-variant: #ddffff;
|
||||
--color-light-inverse-info: #00346e;
|
||||
--color-light-warning: #834600;
|
||||
--color-light-on-warning: #ffed93;
|
||||
--color-light-warning-container: #a66204;
|
||||
--color-light-on-warning-container: #ffed93;
|
||||
--color-light-warning-fixed: #a66204;
|
||||
--color-light-warning-fixed-dim: #884a00;
|
||||
--color-light-on-warning-fixed: #ffed93;
|
||||
--color-light-on-warning-fixed-variant: #ffed93;
|
||||
--color-light-inverse-warning: #5b2300;
|
||||
--color-dark-primary: #ffe6b1;
|
||||
--color-dark-on-primary: #280000;
|
||||
--color-dark-primary-container: #ecb986;
|
||||
--color-dark-on-primary-container: #2b0000;
|
||||
--color-dark-primary-fixed: #ffd8a5;
|
||||
--color-dark-primary-fixed-dim: #f1bd8a;
|
||||
--color-dark-on-primary-fixed: #280000;
|
||||
--color-dark-on-primary-fixed-variant: #2d0600;
|
||||
--color-dark-inverse-primary: #e8b482;
|
||||
--color-dark-secondary: #ffebd1;
|
||||
--color-dark-on-secondary: #1a0000;
|
||||
--color-dark-secondary-container: #dabea5;
|
||||
--color-dark-on-secondary-container: #1f0500;
|
||||
--color-dark-secondary-fixed: #fbddc4;
|
||||
--color-dark-secondary-fixed-dim: #dec1a8;
|
||||
--color-dark-on-secondary-fixed: #1a0000;
|
||||
--color-dark-on-secondary-fixed-variant: #220c00;
|
||||
--color-dark-inverse-secondary: #715a44;
|
||||
--color-dark-tertiary: #fef1ad;
|
||||
--color-dark-on-tertiary: #150100;
|
||||
--color-dark-tertiary-container: #cfc482;
|
||||
--color-dark-on-tertiary-container: #1b0c00;
|
||||
--color-dark-tertiary-fixed: #f0e4a0;
|
||||
--color-dark-tertiary-fixed-dim: #d3c886;
|
||||
--color-dark-on-tertiary-fixed: #150100;
|
||||
--color-dark-on-tertiary-fixed-variant: #1e1200;
|
||||
--color-dark-inverse-tertiary: #666023;
|
||||
--color-dark-error: #ffb88d;
|
||||
--color-dark-on-error: #4e0000;
|
||||
--color-dark-error-container: #ff8a63;
|
||||
--color-dark-on-error-container: #540000;
|
||||
--color-dark-error-fixed: #ffab80;
|
||||
--color-dark-error-fixed-dim: #ff8e66;
|
||||
--color-dark-on-error-fixed: #640000;
|
||||
--color-dark-on-error-fixed-variant: #9f0000;
|
||||
--color-dark-inverse-error: #bf0902;
|
||||
--color-dark-surface: #18120c;
|
||||
--color-dark-surface-dim: #18120c;
|
||||
--color-dark-surface-bright: #554f4a;
|
||||
--color-dark-surface-variant: #50453b;
|
||||
--color-dark-surface-container: #352f2a;
|
||||
--color-dark-surface-container-lowest: #090000;
|
||||
--color-dark-surface-container-low: #241f1a;
|
||||
--color-dark-surface-container-high: #403a35;
|
||||
--color-dark-surface-container-highest: #4c4640;
|
||||
--color-dark-on-surface: #fffef7;
|
||||
--color-dark-on-surface-variant: #fffdf0;
|
||||
--color-dark-inverse-surface: #352f2a;
|
||||
--color-dark-inverse-on-surface: #fffef7;
|
||||
--color-dark-outline: #fcede1;
|
||||
--color-dark-outline-variant: #cec0b4;
|
||||
--color-dark-scrim: #090000;
|
||||
--color-dark-shadow: #090000;
|
||||
--color-dark-defensive: #ffcfff;
|
||||
--color-dark-on-defensive: #050055;
|
||||
--color-dark-defensive-container: #eca2ff;
|
||||
--color-dark-on-defensive-container: #0d005d;
|
||||
--color-dark-defensive-fixed: #ffc1ff;
|
||||
--color-dark-defensive-fixed-dim: #f0a5ff;
|
||||
--color-dark-on-defensive-fixed: #050055;
|
||||
--color-dark-on-defensive-fixed-variant: #140062;
|
||||
--color-dark-inverse-defensive: #ffcbff;
|
||||
--color-dark-info: #cef0ff;
|
||||
--color-dark-on-info: #00013a;
|
||||
--color-dark-info-container: #9fc3ff;
|
||||
--color-dark-on-info-container: #000d41;
|
||||
--color-dark-info-fixed: #c0e2ff;
|
||||
--color-dark-info-fixed-dim: #a3c6ff;
|
||||
--color-dark-on-info-fixed: #00013a;
|
||||
--color-dark-on-info-fixed-variant: #001346;
|
||||
--color-dark-inverse-info: #caecff;
|
||||
--color-dark-gentle: #ffe350;
|
||||
--color-dark-on-gentle: #370000;
|
||||
--color-dark-gentle-container: #fcb61a;
|
||||
--color-dark-on-gentle-container: #390000;
|
||||
--color-dark-gentle-fixed: #ffd642;
|
||||
--color-dark-gentle-fixed-dim: #ffba20;
|
||||
--color-dark-on-gentle-fixed: #370000;
|
||||
--color-dark-on-gentle-fixed-variant: #3b0000;
|
||||
--color-dark-inverse-gentle: #ffdf4c;
|
||||
--color-dark-success: #c7ff5b;
|
||||
--color-dark-on-success: #001600;
|
||||
--color-dark-success-container: #97d529;
|
||||
--color-dark-on-success-container: #001c00;
|
||||
--color-dark-success-fixed: #b9f54d;
|
||||
--color-dark-success-fixed-dim: #9cd92e;
|
||||
--color-dark-on-success-fixed: #001600;
|
||||
--color-dark-on-success-fixed-variant: #001f00;
|
||||
--color-dark-inverse-success: #c2ff57;
|
||||
--color-dark-warning: #ffde85;
|
||||
--color-dark-on-warning: #350000;
|
||||
--color-dark-warning-container: #ffb15b;
|
||||
--color-dark-on-warning-container: #380000;
|
||||
--color-dark-warning-fixed: #ffd179;
|
||||
--color-dark-warning-fixed-dim: #ffb55e;
|
||||
--color-dark-on-warning-fixed: #350000;
|
||||
--color-dark-on-warning-fixed-variant: #3a0000;
|
||||
--color-dark-inverse-warning: #ffda82;
|
||||
--color-dark-aggressive: #ffb3bd;
|
||||
--color-dark-on-aggressive: #4d0000;
|
||||
--color-dark-aggressive-container: #ff8492;
|
||||
--color-dark-on-aggressive-container: #540000;
|
||||
--color-dark-aggressive-fixed: #ffa6b0;
|
||||
--color-dark-aggressive-fixed-dim: #ff8896;
|
||||
--color-dark-on-aggressive-fixed: #4d0000;
|
||||
--color-dark-on-aggressive-fixed-variant: #590000;
|
||||
--color-dark-inverse-aggressive: #ffafba;
|
||||
|
||||
--spacing: 4px;
|
||||
--border-radius: 2px;
|
||||
--font-size: 10px;
|
||||
--line-height: 1.2em;
|
||||
|
||||
--elevation-light-0: none;
|
||||
--elevation-light-1-ambient: 0 1px 3px 1px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-1-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-2-ambient: 0 2px 6px 2px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-2-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-3-ambient: 0 4px 8px 3px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-3-umbra: 0 1px 3px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-4-ambient: 0 6px 10px 4px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-4-umbra: 0 2px 3px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
--elevation-light-5-ambient: 0 8px 12px 6px
|
||||
color-mix(in oklch, var(--color-light-shadow) 15%, transparent);
|
||||
--elevation-light-5-umbra: 0 4px 4px 0px
|
||||
color-mix(in oklch, var(--color-light-shadow) 30%, transparent);
|
||||
|
||||
--elevation-dark-0: none;
|
||||
--elevation-dark-1-ambient: 0 1px 3px 1px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-1-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-2-ambient: 0 2px 6px 2px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-2-umbra: 0 1px 2px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-3-ambient: 0 4px 8px 3px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-3-umbra: 0 1px 3px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-4-ambient: 0 6px 10px 4px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-4-umbra: 0 2px 3px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
--elevation-dark-5-ambient: 0 8px 12px 6px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 15%, transparent);
|
||||
--elevation-dark-5-umbra: 0 4px 4px 0px
|
||||
color-mix(in oklch, var(--color-dark-shadow) 30%, transparent);
|
||||
}
|
||||
}
|
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
25
tsconfig.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
tsconfig.node.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
38
vite.config.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
lightningcss: {
|
||||
cssModules: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ['**/src-tauri/**'],
|
||||
},
|
||||
},
|
||||
}));
|