Compare commits
45 Commits
32d6e0c875
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
0aba9bb15e | ||
|
cc3b762880 | ||
|
346566b147 | ||
|
4cdc73ca90 | ||
|
9222e60b58 | ||
|
770efe269a | ||
|
65d2e739ef | ||
|
3253b8b98e | ||
|
c6f0b2a8fc | ||
|
440caf4de0 | ||
|
5b30d4c2bc | ||
|
fe62564f6e | ||
|
cbaf999692 | ||
|
b3ddf3710e | ||
|
cc91868c63 | ||
|
3296eba4b2 | ||
|
13d32fb0e0 | ||
|
abee609e43 | ||
|
03fe09d1ce | ||
|
3c7b3c76b9 | ||
|
2ec95eb590 | ||
|
8b0ddcecec | ||
|
1c48bb36d3 | ||
|
2bb0cc35f9 | ||
|
57ba0e3d49 | ||
|
579e7265fc | ||
|
0dac64f1e2 | ||
|
adc1bba9e0 | ||
|
c0b3648fd2 | ||
|
2abc6bfb38 | ||
|
ef39cb7ea8 | ||
|
d0c1ee2adb | ||
|
add11be2b7 | ||
|
a3b0d9071a | ||
|
d4e1884d8e | ||
|
d2a854490f | ||
|
d465951f31 | ||
|
55b10f5a5c | ||
|
edf4163e38 | ||
|
09cba205a4 | ||
|
eea7446346 | ||
|
429d6451c4 | ||
|
128a45ad77 | ||
|
66a85f29f9 | ||
|
d1d8def602 |
96
deno.lock
generated
96
deno.lock
generated
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"npm:@dnd-kit/core@^6.3.1": "6.3.1_react@19.0.0_react-dom@19.0.0__react@19.0.0",
|
||||
"npm:@dnd-kit/modifiers@9": "9.0.0_@dnd-kit+core@6.3.1__react@19.0.0__react-dom@19.0.0___react@19.0.0_react@19.0.0_react-dom@19.0.0__react@19.0.0",
|
||||
"npm:@dnd-kit/sortable@10": "10.0.0_@dnd-kit+core@6.3.1__react@19.0.0__react-dom@19.0.0___react@19.0.0_react@19.0.0_react-dom@19.0.0__react@19.0.0",
|
||||
"npm:@eslint/js@^9.19.0": "9.21.0",
|
||||
"npm:@iconify/react@^5.2.0": "5.2.0_react@19.0.0",
|
||||
"npm:@tauri-apps/api@2": "2.2.0",
|
||||
@@ -11,6 +14,7 @@
|
||||
"npm:@tauri-apps/plugin-os@2.2": "2.2.0",
|
||||
"npm:@types/lodash-es@^4.17.12": "4.17.12",
|
||||
"npm:@types/react-dom@19.0.4": "19.0.4_@types+react@19.0.10",
|
||||
"npm:@types/react-transition-group@^4.4.12": "4.4.12_@types+react@19.0.10",
|
||||
"npm:@types/react@19.0.10": "19.0.10",
|
||||
"npm:@types/uuid@10": "10.0.0",
|
||||
"npm:@vitejs/plugin-react@^4.3.4": "4.3.4_vite@6.2.0__lightningcss@1.29.1_@babel+core@7.26.9_lightningcss@1.29.1",
|
||||
@@ -25,6 +29,7 @@
|
||||
"npm:lodash-es@^4.17.21": "4.17.21",
|
||||
"npm:react-dom@19.0.0": "19.0.0_react@19.0.0",
|
||||
"npm:react-router-dom@^7.2.0": "7.2.0_react@19.0.0_react-dom@19.0.0__react@19.0.0",
|
||||
"npm:react-transition-group@^4.4.5": "4.4.5_react@19.0.0_react-dom@19.0.0__react@19.0.0",
|
||||
"npm:react-use@^17.6.0": "17.6.0_react@19.0.0_react-dom@19.0.0__react@19.0.0_tslib@2.8.1",
|
||||
"npm:react@19.0.0": "19.0.0",
|
||||
"npm:sanitize.css@13": "13.0.0",
|
||||
@@ -180,6 +185,48 @@
|
||||
"@babel/helper-validator-identifier"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/accessibility@3.1.1_react@19.0.0": {
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"dependencies": [
|
||||
"react",
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/core@6.3.1_react@19.0.0_react-dom@19.0.0__react@19.0.0": {
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"dependencies": [
|
||||
"@dnd-kit/accessibility",
|
||||
"@dnd-kit/utilities",
|
||||
"react",
|
||||
"react-dom",
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/modifiers@9.0.0_@dnd-kit+core@6.3.1__react@19.0.0__react-dom@19.0.0___react@19.0.0_react@19.0.0_react-dom@19.0.0__react@19.0.0": {
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"dependencies": [
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/utilities",
|
||||
"react",
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/sortable@10.0.0_@dnd-kit+core@6.3.1__react@19.0.0__react-dom@19.0.0___react@19.0.0_react@19.0.0_react-dom@19.0.0__react@19.0.0": {
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"dependencies": [
|
||||
"@dnd-kit/core",
|
||||
"@dnd-kit/utilities",
|
||||
"react",
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@dnd-kit/utilities@3.2.2_react@19.0.0": {
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"dependencies": [
|
||||
"react",
|
||||
"tslib"
|
||||
]
|
||||
},
|
||||
"@esbuild/aix-ppc64@0.25.0": {
|
||||
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ=="
|
||||
},
|
||||
@@ -561,6 +608,12 @@
|
||||
"@types/react"
|
||||
]
|
||||
},
|
||||
"@types/react-transition-group@4.4.12_@types+react@19.0.10": {
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"dependencies": [
|
||||
"@types/react"
|
||||
]
|
||||
},
|
||||
"@types/react@19.0.10": {
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"dependencies": [
|
||||
@@ -803,6 +856,13 @@
|
||||
"detect-libc@1.0.3": {
|
||||
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="
|
||||
},
|
||||
"dom-helpers@5.2.1": {
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": [
|
||||
"@babel/runtime",
|
||||
"csstype"
|
||||
]
|
||||
},
|
||||
"electron-to-chromium@1.5.104": {
|
||||
"integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g=="
|
||||
},
|
||||
@@ -1173,6 +1233,12 @@
|
||||
"lodash.merge@4.6.2": {
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||
},
|
||||
"loose-envify@1.4.0": {
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dependencies": [
|
||||
"js-tokens"
|
||||
]
|
||||
},
|
||||
"lru-cache@5.1.1": {
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dependencies": [
|
||||
@@ -1231,6 +1297,9 @@
|
||||
"node-releases@2.0.19": {
|
||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="
|
||||
},
|
||||
"object-assign@4.1.1": {
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
|
||||
},
|
||||
"optionator@0.9.4": {
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"dependencies": [
|
||||
@@ -1283,6 +1352,14 @@
|
||||
"prelude-ls@1.2.1": {
|
||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
|
||||
},
|
||||
"prop-types@15.8.1": {
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dependencies": [
|
||||
"loose-envify",
|
||||
"object-assign",
|
||||
"react-is"
|
||||
]
|
||||
},
|
||||
"punycode@2.3.1": {
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
|
||||
},
|
||||
@@ -1296,6 +1373,9 @@
|
||||
"scheduler"
|
||||
]
|
||||
},
|
||||
"react-is@16.13.1": {
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"react-refresh@0.14.2": {
|
||||
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="
|
||||
},
|
||||
@@ -1318,6 +1398,17 @@
|
||||
"turbo-stream"
|
||||
]
|
||||
},
|
||||
"react-transition-group@4.4.5_react@19.0.0_react-dom@19.0.0__react@19.0.0": {
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": [
|
||||
"@babel/runtime",
|
||||
"dom-helpers",
|
||||
"loose-envify",
|
||||
"prop-types",
|
||||
"react",
|
||||
"react-dom"
|
||||
]
|
||||
},
|
||||
"react-universal-interface@0.6.2_react@19.0.0_tslib@2.8.1": {
|
||||
"integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
|
||||
"dependencies": [
|
||||
@@ -1566,6 +1657,9 @@
|
||||
"workspace": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@dnd-kit/core@^6.3.1",
|
||||
"npm:@dnd-kit/modifiers@9",
|
||||
"npm:@dnd-kit/sortable@10",
|
||||
"npm:@eslint/js@^9.19.0",
|
||||
"npm:@iconify/react@^5.2.0",
|
||||
"npm:@tauri-apps/api@2",
|
||||
@@ -1576,6 +1670,7 @@
|
||||
"npm:@tauri-apps/plugin-os@2.2",
|
||||
"npm:@types/lodash-es@^4.17.12",
|
||||
"npm:@types/react-dom@19.0.4",
|
||||
"npm:@types/react-transition-group@^4.4.12",
|
||||
"npm:@types/react@19.0.10",
|
||||
"npm:@types/uuid@10",
|
||||
"npm:@vitejs/plugin-react@^4.3.4",
|
||||
@@ -1590,6 +1685,7 @@
|
||||
"npm:lodash-es@^4.17.21",
|
||||
"npm:react-dom@19.0.0",
|
||||
"npm:react-router-dom@^7.2.0",
|
||||
"npm:react-transition-group@^4.4.5",
|
||||
"npm:react-use@^17.6.0",
|
||||
"npm:react@19.0.0",
|
||||
"npm:sanitize.css@13",
|
||||
|
@@ -11,6 +11,9 @@
|
||||
"tauri:dev": "tauri dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@iconify/react": "^5.2.0",
|
||||
"@tauri-apps/plugin-dialog": "~2.2.0",
|
||||
"@tauri-apps/plugin-notification": "~2.2.1",
|
||||
@@ -22,6 +25,7 @@
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-router-dom": "^7.2.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-use": "^17.6.0",
|
||||
"sanitize.css": "^13.0.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
@@ -31,6 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
@@ -6,6 +6,7 @@ use btleplug::{
|
||||
};
|
||||
pub use state::{CentralState, ChannelState, PeripheralItem};
|
||||
use tauri::{async_runtime::RwLock, AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
bluetooth, errors,
|
||||
@@ -143,3 +144,45 @@ pub async fn list_patterns(
|
||||
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
|
||||
Ok(patterns)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_pattern(
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
pattern: Pattern,
|
||||
) -> Result<(), errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
state
|
||||
.db
|
||||
.store_pattern(&pattern)
|
||||
.await
|
||||
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pattern(
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
pattern_id: Uuid,
|
||||
) -> Result<Option<Pattern>, errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
let pattern = state
|
||||
.db
|
||||
.get_pattern(&pattern_id)
|
||||
.await
|
||||
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
|
||||
Ok(pattern)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_pattern(
|
||||
app_state: State<'_, Arc<RwLock<AppState>>>,
|
||||
pattern_id: Uuid,
|
||||
) -> Result<(), errors::AppError> {
|
||||
let state = app_state.read().await;
|
||||
state
|
||||
.db
|
||||
.remove_pattern(&pattern_id)
|
||||
.await
|
||||
.map_err(|e| errors::AppError::StorageFailure(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
@@ -94,6 +94,7 @@ impl ConfigDb {
|
||||
.collect::<Vec<u8>>();
|
||||
db.remove(key)
|
||||
.map_err(|e| anyhow::anyhow!("Unable to remove pattern: {}", e))?;
|
||||
// todo: need to remove requested pattern in all playlists.
|
||||
db.flush_async()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Unable to save db: {}", e))?;
|
||||
|
@@ -65,7 +65,10 @@ pub fn run() {
|
||||
cmd::activate_central_adapter,
|
||||
cmd::start_scan_devices,
|
||||
cmd::stop_scan_devices,
|
||||
cmd::list_patterns
|
||||
cmd::list_patterns,
|
||||
cmd::save_pattern,
|
||||
cmd::get_pattern,
|
||||
cmd::remove_pattern
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
@@ -1,9 +1,11 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use serde_repr::{Deserialize_repr, Serialize_repr};
|
||||
|
||||
use crate::fraction::Fraction;
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr)]
|
||||
#[repr(u32)]
|
||||
pub enum FrequencyShifting {
|
||||
Linear,
|
||||
Quadratic,
|
||||
|
@@ -69,6 +69,11 @@
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding-block: calc(var(--spacing));
|
||||
}
|
||||
&.name {
|
||||
font-size: var(--label-small-font-size);
|
||||
line-height: var(--label-small-line-height);
|
||||
font-weight: var(--label-small-font-weight);
|
||||
}
|
||||
}
|
||||
&.inactive {
|
||||
color: var(--color-on-surface-variant);
|
||||
|
@@ -24,7 +24,7 @@ const FunctionLink: FC<FunctionLinkProps> = ({ name, url, end, children }) => {
|
||||
}
|
||||
end={end}>
|
||||
<div className={styles.filled}>{children}</div>
|
||||
<div>{name}</div>
|
||||
<div className={styles.name}>{name}</div>
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
@@ -23,33 +23,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
font-weight: bold;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.6em;
|
||||
font-size: 2rem;
|
||||
line-height: 2.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.25rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.8em;
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.5em;
|
||||
font-size: 1.375rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.2em;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1em;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.center {
|
||||
@@ -80,6 +87,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
border-top-width: 1px;
|
||||
border-color: var(--color-outline-variant);
|
||||
border-style: solid;
|
||||
width: 100%;
|
||||
align-self: stretch;
|
||||
&.dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
&.dotted {
|
||||
border-style: dotted;
|
||||
}
|
||||
&.vertical {
|
||||
border-top-width: 0;
|
||||
border-left-width: 1px;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:where(button, .button) {
|
||||
border: none;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
@@ -89,23 +119,22 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
font-size: calc(var(--font-size) * 1.2);
|
||||
line-height: 1.3em;
|
||||
font-size: var(--label-medium-font-size);
|
||||
line-height: var(--label-medium-line-height);
|
||||
font-weight: var(--label-medium-font-weight);
|
||||
color: var(--button-text);
|
||||
background-color: var(--button-surface);
|
||||
box-shadow: var(--elevation-0);
|
||||
cursor: pointer;
|
||||
&.smaller {
|
||||
font-size: calc(var(--font-size) * 0.8);
|
||||
}
|
||||
&.small {
|
||||
font-size: calc(var(--font-size) * 1);
|
||||
font-size: var(--label-small-font-size);
|
||||
line-height: var(--label-small-line-height);
|
||||
font-weight: var(--label-small-font-weight);
|
||||
}
|
||||
&.large {
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
}
|
||||
&.larger {
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
font-size: var(--label-large-font-size);
|
||||
line-height: var(--label-large-line-height);
|
||||
font-weight: var(--label-large-font-weight);
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--button-surface);
|
||||
@@ -227,10 +256,12 @@
|
||||
--button-text: var(--color-info);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-0);
|
||||
}
|
||||
&:active {
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
}
|
||||
}
|
||||
@@ -260,11 +291,13 @@
|
||||
--button-text: var(--color-info);
|
||||
}
|
||||
&:hover {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-0);
|
||||
}
|
||||
&:active {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
}
|
||||
}
|
||||
&.text:disabled {
|
||||
@@ -274,6 +307,7 @@
|
||||
&.icon:not(:disabled) {
|
||||
--button-text: var(--color-on-surface-variant);
|
||||
--button-surface: transparent;
|
||||
padding: calc(var(--spacing) * 1);
|
||||
&.selected {
|
||||
--button-text: var(--color-primary);
|
||||
&.secondary {
|
||||
@@ -296,20 +330,24 @@
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-0);
|
||||
}
|
||||
&:active {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
}
|
||||
}
|
||||
&.icon:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-on-surface) 38%, transparent);
|
||||
--button-surface: transparent;
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
&.filled_icon:not(:disabled) {
|
||||
--button-text: var(--color-on-primary);
|
||||
--button-surface: var(--color-primary);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
&.secondary {
|
||||
--button-text: var(--color-on-secondary);
|
||||
--button-surface: var(--color-secondary);
|
||||
@@ -342,10 +380,12 @@
|
||||
&.filled_icon:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-on-surface) 38%, transparent);
|
||||
--button-surface: color-mix(in oklch, var(--color-on-surface) 12%, transparent);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
&.tonal_icon:not(:disabled) {
|
||||
--button-text: var(--color-on-primary-container);
|
||||
--button-surface: var(--color-primary-container);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
&.secondary {
|
||||
--button-text: var(--color-on-secondary-container);
|
||||
--button-surface: var(--color-secondary-container);
|
||||
@@ -360,52 +400,59 @@
|
||||
}
|
||||
&.warn {
|
||||
--button-text: var(--color-on-warning-container);
|
||||
--button-surface: var(--color-warning);
|
||||
--button-surface: var(--color-warning-container);
|
||||
}
|
||||
&.success {
|
||||
--button-text: var(--color-on-success-container);
|
||||
--button-surface: var(--color-success-container);
|
||||
}
|
||||
&.info {
|
||||
--button-text: var(--color-on-info);
|
||||
--button-surface: var(--color-info);
|
||||
--button-text: var(--color-on-info-container);
|
||||
--button-surface: var(--color-info-container);
|
||||
}
|
||||
&.unselected {
|
||||
--button-text: var(--color-on-surface-variant);
|
||||
--button-surface: var(--color-surface-container-highest);
|
||||
}
|
||||
&:hover {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
color: var(--button-surface);
|
||||
background-color: color-mix(in oklch, var(--button-surface) 8%, transparent);
|
||||
box-shadow: var(--elevation-0);
|
||||
}
|
||||
&:active {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
color: var(--button-surface);
|
||||
background-color: color-mix(in oklch, var(--button-surface) 18%, transparent);
|
||||
}
|
||||
}
|
||||
&.tonal_icon:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-on-surface) 38%, transparent);
|
||||
--button-surface: color-mix(in oklch, var(--color-on-surface) 12%, transparent);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
&.outlined_icon:not(:disabled) {
|
||||
--button-text: var(--color-on-surface-variant);
|
||||
--button-surface: transparent;
|
||||
border: 1px solid var(--button-outline);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
&.selected {
|
||||
--button-text: var(--color-inverse-on-surface);
|
||||
--button-surface: var(--color-inverse-surface);
|
||||
}
|
||||
&:hover {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 8%, transparent);
|
||||
box-shadow: var(--elevation-0);
|
||||
}
|
||||
&:active {
|
||||
--button-surface: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
color: var(--button-text);
|
||||
background-color: color-mix(in oklch, var(--button-text) 18%, transparent);
|
||||
}
|
||||
}
|
||||
&.outlined_icon:disabled {
|
||||
--button-text: color-mix(in oklch, var(--color-on-surface) 38%, transparent);
|
||||
--button-surface: color-mix(in oklch, var(--color-on-surface) 12%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--color-on-surface) 12%, transparent);
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,9 +462,14 @@
|
||||
border-top-left-radius: calc(var(--border-radius) * 2);
|
||||
border-top-right-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacing)) calc(var(--spacing) * 2);
|
||||
line-height: 1.5em;
|
||||
font-size: var(--body-small-font-size);
|
||||
line-height: var(--body-small-line-height);
|
||||
font-weight: var(--body-small-font-weight);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface-container-highest);
|
||||
&.error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
.input_wrapper & {
|
||||
padding: 0;
|
||||
border: none;
|
||||
@@ -449,6 +501,12 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
border-bottom: 1px solid var(--color-on-surface-variant);
|
||||
border-top-left-radius: calc(var(--border-radius) * 2);
|
||||
border-top-right-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-surface-container-highest);
|
||||
&.error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
132
src/components/Notifications.module.css
Normal file
132
src/components/Notifications.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
@layer components {
|
||||
.notification_positioner {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 25vw;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
z-index: 600;
|
||||
}
|
||||
.message_box_positioner {
|
||||
position: absolute;
|
||||
}
|
||||
.toast_positioner {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 20vh;
|
||||
z-index: 700;
|
||||
}
|
||||
.notification {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 2px;
|
||||
.notification_icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.notificaiton_content {
|
||||
flex-grow: 1;
|
||||
.notification_title {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.notification_close {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
align-self: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.toast {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 2px;
|
||||
width: 35vw;
|
||||
.toast_icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.toast_content {
|
||||
flex-grow: 1;
|
||||
display: inline-block;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.toast_close {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.kind_info {
|
||||
color: var(--color-on-info-container);
|
||||
background-color: var(--color-info-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_promption {
|
||||
color: var(--color-on-tertiary-container);
|
||||
background-color: var(--color-tertiary-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_success {
|
||||
color: var(--color-on-success-container);
|
||||
background-color: var(--color-success-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_warning {
|
||||
color: var(--color-on-warning-container);
|
||||
background-color: var(--color-warning-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_error {
|
||||
color: var(--color-on-error-container);
|
||||
background-color: var(--color-error-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.kind_custom {
|
||||
color: var(--color-on-defensive-container);
|
||||
background-color: var(--color-defensive-container);
|
||||
box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra);
|
||||
}
|
||||
.slide_in_enter {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
.slide_in_enter_active {
|
||||
transition: all 500ms ease-in-out;
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.slide_out_exit_active {
|
||||
transition: all 500ms ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
.fade_in_enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade_in_enter_active {
|
||||
transition: all 500ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
.fade_out_exit_active {
|
||||
transition: all 500ms ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
296
src/components/Notifications.tsx
Normal file
296
src/components/Notifications.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Icon, IconifyIconProps } from '@iconify/react/dist/iconify.js';
|
||||
import cx from 'clsx';
|
||||
import {
|
||||
createContext,
|
||||
createRef,
|
||||
FC,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { CSSTransition, TransitionGroup } from 'react-transition-group';
|
||||
import { v4 } from 'uuid';
|
||||
import styles from './Notifications.module.css';
|
||||
|
||||
export enum NotificationType {
|
||||
INFO,
|
||||
PROMPTION,
|
||||
SUCCESS,
|
||||
WARNING,
|
||||
ERROR,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
export enum ToastDuration {
|
||||
MANUAL = 0,
|
||||
SHORT = 1500,
|
||||
MEDIUM = 3000,
|
||||
LONG = 5000,
|
||||
}
|
||||
|
||||
interface NotificationFunctions {
|
||||
addNotification(
|
||||
kind: NotificationType,
|
||||
title?: string,
|
||||
message?: ReactNode,
|
||||
icon?: IconifyIconProps['icon'],
|
||||
duration?: number,
|
||||
): string;
|
||||
removeNotification(id: string): void;
|
||||
showToast(
|
||||
kind: NotificationType,
|
||||
message: string,
|
||||
icon?: IconifyIconProps['icon'],
|
||||
duration?: ToastDuration,
|
||||
): string;
|
||||
}
|
||||
|
||||
const NotificationStyleMap = {
|
||||
[NotificationType.INFO]: styles.kind_info,
|
||||
[NotificationType.PROMPTION]: styles.kind_promption,
|
||||
[NotificationType.SUCCESS]: styles.kind_success,
|
||||
[NotificationType.WARNING]: styles.kind_warning,
|
||||
[NotificationType.ERROR]: styles.kind_error,
|
||||
[NotificationType.CUSTOM]: styles.kind_custom,
|
||||
};
|
||||
|
||||
const NotificationHostContext = createContext<NotificationFunctions>({
|
||||
addNotification: () => '',
|
||||
removeNotification: () => {},
|
||||
showToast: () => '',
|
||||
});
|
||||
|
||||
type NotificationProps = {
|
||||
kind?: NotificationType;
|
||||
nid: string;
|
||||
icon?: IconifyIconProps['icon'];
|
||||
title?: string;
|
||||
message?: ReactNode;
|
||||
duration?: number;
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
closeAction?: (id: string) => void;
|
||||
};
|
||||
|
||||
const Notification: FC<NotificationProps> = ({
|
||||
kind = NotificationType.INFO,
|
||||
nid,
|
||||
icon,
|
||||
title,
|
||||
message,
|
||||
duration = 3000,
|
||||
ref,
|
||||
closeAction,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
closeAction?.(nid);
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [nid, duration, closeAction]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.notification, NotificationStyleMap[kind])} ref={ref}>
|
||||
{icon && <Icon icon={icon} className={cx(styles.notification_icon)} />}
|
||||
<div className={cx(styles.notificaiton_content)}>
|
||||
{title && <div className={cx(styles.notification_title)}>{title}</div>}
|
||||
{message && <div>{message}</div>}
|
||||
</div>
|
||||
{duration === 0 && (
|
||||
<Icon
|
||||
icon="material-symbols-light:close"
|
||||
className={styles.notification_close}
|
||||
onClick={() => closeAction?.(nid)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToastProps = {
|
||||
kind: NotificationType;
|
||||
tid: string;
|
||||
message?: string;
|
||||
icon?: string;
|
||||
duration?: ToastDuration;
|
||||
ref: RefObject<HTMLDivElement>;
|
||||
closeAction: (tid?: string) => void;
|
||||
};
|
||||
|
||||
const Toast: FC<ToastProps> = ({
|
||||
kind,
|
||||
tid,
|
||||
message,
|
||||
icon,
|
||||
duration = ToastDuration.MEDIUM,
|
||||
ref,
|
||||
closeAction,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (duration !== ToastDuration.MANUAL) {
|
||||
const timer = setTimeout(() => {
|
||||
closeAction?.(tid);
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [tid, duration, closeAction]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.toast, NotificationStyleMap[kind])} ref={ref}>
|
||||
{icon && <Icon icon={icon} className={styles.toast_icon} />}
|
||||
<div className={styles.toast_content}>{message}</div>
|
||||
{duration === ToastDuration.MANUAL && (
|
||||
<Icon
|
||||
icon="material-symbols-light:close"
|
||||
className={styles.toast_close}
|
||||
onClick={() => closeAction?.(tid)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function useNotification() {
|
||||
const functions = useContext<NotificationFunctions>(NotificationHostContext);
|
||||
return functions;
|
||||
}
|
||||
|
||||
type NotificationElement = {
|
||||
id: string;
|
||||
element: ReactNode;
|
||||
ref: RefObject<ReactNode | HTMLDivElement>;
|
||||
};
|
||||
|
||||
type NotificationsProps = {
|
||||
defaultDuration?: number;
|
||||
maxNotifications?: number;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const Notifications: FC<NotificationsProps> = ({
|
||||
defaultDuration = 3000,
|
||||
maxNotifications = 5,
|
||||
children,
|
||||
}) => {
|
||||
const [notifications, setNotifications] = useState<NotificationElement[]>([]);
|
||||
const [toasts, setToasts] = useState<NotificationElement[]>([]);
|
||||
|
||||
const removeNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
const addNotification = useCallback(
|
||||
(
|
||||
kind: NotificationType,
|
||||
title?: string,
|
||||
message?: ReactNode,
|
||||
icon?: IconifyIconProps['icon'],
|
||||
duration?: number,
|
||||
) => {
|
||||
const id = v4();
|
||||
const ref = createRef<ReactNode | HTMLDivElement>();
|
||||
const newNotify = (
|
||||
<Notification
|
||||
kind={kind}
|
||||
nid={id}
|
||||
icon={icon}
|
||||
title={title}
|
||||
message={message}
|
||||
duration={duration ?? defaultDuration}
|
||||
closeAction={removeNotification}
|
||||
//@ts-expect-error TS2322
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
setNotifications((prev) => [...prev, { id, element: newNotify, ref } as NotificationElement]);
|
||||
|
||||
return id;
|
||||
},
|
||||
[removeNotification, defaultDuration],
|
||||
);
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
const showToast = useCallback(
|
||||
(kind: NotificationType, message?: string, icon?: string, duration?: ToastDuration) => {
|
||||
const id = v4();
|
||||
const ref = createRef<HTMLDivElement>();
|
||||
const newToast = (
|
||||
<Toast
|
||||
kind={kind}
|
||||
tid={id}
|
||||
message={message ?? ''}
|
||||
icon={icon}
|
||||
duration={duration ?? ToastDuration.MEDIUM}
|
||||
ref={ref}
|
||||
closeAction={removeToast}
|
||||
/>
|
||||
);
|
||||
setToasts((prev) => [...prev, { id, element: newToast, ref } as NotificationElement]);
|
||||
|
||||
return id;
|
||||
},
|
||||
[removeToast],
|
||||
);
|
||||
|
||||
return (
|
||||
<NotificationHostContext
|
||||
value={{
|
||||
addNotification,
|
||||
removeNotification,
|
||||
showToast,
|
||||
}}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className={cx(styles.notification_positioner)}>
|
||||
<TransitionGroup component={null}>
|
||||
{notifications.slice(0, maxNotifications).map(({ id, element, ref }) => (
|
||||
<CSSTransition
|
||||
key={id}
|
||||
//@ts-expect-error TS2322
|
||||
nodeRef={ref}
|
||||
unmountOnExit
|
||||
timeout={500}
|
||||
classNames={{
|
||||
enter: styles.slide_in_enter,
|
||||
enterActive: styles.slide_in_enter_active,
|
||||
exitActive: styles.slide_out_exit_active,
|
||||
}}>
|
||||
{element}
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{createPortal(
|
||||
<div className={cx(styles.toast_positioner)}>
|
||||
<TransitionGroup component={null}>
|
||||
{toasts.slice(0, 1).map(({ id, element, ref }) => (
|
||||
<CSSTransition
|
||||
key={id}
|
||||
//@ts-expect-error TS2322
|
||||
nodeRef={ref}
|
||||
unmountOnExit
|
||||
timeout={500}
|
||||
classNames={{
|
||||
enter: styles.fade_in_enter,
|
||||
enterActive: styles.fade_in_enter_active,
|
||||
exitActive: styles.fade_out_exit_active,
|
||||
}}>
|
||||
{element}
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</NotificationHostContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
@@ -1,15 +1,11 @@
|
||||
@layer components {
|
||||
.pattern_preview {
|
||||
flex-basis: 140px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: calc(var(--spacing)) calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
.canvas_wrapper {
|
||||
flex: 1 0;
|
||||
canvas {
|
||||
|
@@ -1,17 +1,72 @@
|
||||
import { FC } from 'react';
|
||||
import { getDefaultStore } from 'jotai';
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { ErrorColorAtom, PrimaryColorAtom } from '../context/ThemeColors';
|
||||
import styles from './PatternPreview.module.css';
|
||||
|
||||
interface RawPusle {
|
||||
tickOrder: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
frequencyLevel: number;
|
||||
}
|
||||
|
||||
const canvasSafeBound = {
|
||||
coordinate: { pt: 5.5, pr: 5.5, pb: 5.5, pl: 5.5 },
|
||||
chart: { pt: 5.5, pr: 5.5, pb: 5.5, pl: 5.5 },
|
||||
};
|
||||
|
||||
function drawCoordinates(ctx: CanvasRenderingContext2D, width: number, height: width) {
|
||||
const colorStore = getDefaultStore();
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
ctx.beginPath();
|
||||
// Draw x axis
|
||||
ctx.moveTo(canvasSafeBound.coordinate.pl, height - canvasSafeBound.coordinate.pb);
|
||||
ctx.lineTo(width - canvasSafeBound.coordinate.pr, height - canvasSafeBound.coordinate.pt);
|
||||
|
||||
ctx.strokeStyle = colorStore.get(PrimaryColorAtom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
// Draw aggressive mark line
|
||||
const tickUnit = (height - canvasSafeBound.coordinate.pt - canvasSafeBound.coordinate.pb) / 31;
|
||||
const highlightY = height - canvasSafeBound.coordinate.pb - 20 * tickUnit;
|
||||
ctx.moveTo(canvasSafeBound.coordinate.pl, highlightY);
|
||||
ctx.lineTo(width - canvasSafeBound.coordinate.pr, highlightY);
|
||||
ctx.strokeStyle = colorStore.get(ErrorColorAtom);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const PatternPreview: FC = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [conatienrRef, { width, height }] = useMeasure();
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio ?? 1;
|
||||
setCanvasSize({ width: (width ?? 0) * dpr, height: (height ?? 0) * dpr });
|
||||
canvas.width = (width ?? 0) * dpr;
|
||||
canvas.height = (height ?? 0) * dpr;
|
||||
}
|
||||
}, [width, height]);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
drawCoordinates(ctx, canvasSize.width, canvasSize.height);
|
||||
}
|
||||
}, [canvasSize]);
|
||||
|
||||
return (
|
||||
<div className={styles.pattern_preview}>
|
||||
<h4>Pattern Preview</h4>
|
||||
<div>
|
||||
<canvas />
|
||||
<h5>Pattern Preview</h5>
|
||||
<div className={styles.canvas_wrapper} ref={conatienrRef}>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
47
src/components/Switch.module.css
Normal file
47
src/components/Switch.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@layer components {
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
cursor: pointer;
|
||||
&[aria-disabled='true'] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
.switch_handle {
|
||||
position: relative;
|
||||
width: calc(var(--spacing) * 9);
|
||||
height: calc(var(--spacing) * 5);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: color-mix(in oklch, var(--color-surface-container-high) 38%, transparent);
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
transform: translate(2px, 3px);
|
||||
width: calc(var(--spacing) * 3);
|
||||
height: calc(var(--spacing) * 3);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-primary);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
&.checked {
|
||||
background-color: color-mix(in oklch, var(--color-primary) 38%, transparent);
|
||||
&::before {
|
||||
background-color: var(--color-primary);
|
||||
transform: translate(calc(var(--spacing) * 5 - 2px), 3px);
|
||||
}
|
||||
}
|
||||
[aria-disabled='true'] & {
|
||||
--disabled-color: var(--color-on-surface);
|
||||
background-color: color-mix(in oklch, var(--disabled-color) 8%, transparent);
|
||||
border-color: var(--disabled-color);
|
||||
&.checked {
|
||||
background-color: color-mix(in oklch, var(--disabled-color) 20%, transparent);
|
||||
}
|
||||
&::before {
|
||||
background-color: color-mix(in oklch, var(--disabled-color) 38%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
40
src/components/Switch.tsx
Normal file
40
src/components/Switch.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import cx from 'clsx';
|
||||
import { FC, useCallback, useEffect, useState } from 'react';
|
||||
import styles from './Switch.module.css';
|
||||
|
||||
type SwitchProps = {
|
||||
name?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const Switch: FC<SwitchProps> = ({ name, checked = false, disabled = false, onChange }) => {
|
||||
const [isChecked, setChecked] = useState(checked);
|
||||
const handleChange = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setChecked((prev) => !prev);
|
||||
onChange?.(!isChecked);
|
||||
}
|
||||
}, [disabled, onChange, isChecked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checked !== isChecked) {
|
||||
setChecked(checked);
|
||||
}
|
||||
}, [checked]);
|
||||
|
||||
return (
|
||||
<div aria-disabled={disabled} className={styles.switch}>
|
||||
<div
|
||||
className={cx(styles.switch_handle, isChecked && styles.checked)}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
{name !== undefined && (
|
||||
<input type="hidden" name={name} value={isChecked ? 'true' : 'false'} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Switch;
|
@@ -1,157 +1,72 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily } from 'jotai/utils';
|
||||
import { get, reduce } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { atomWithRefresh } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
|
||||
import { Pattern, Pulse, totalDuration } from './pattern-model';
|
||||
|
||||
export enum FrequencyShifting {
|
||||
/**
|
||||
* Change frequency undergoes a linear transformation from previous pulse to current one.
|
||||
*/
|
||||
Linear,
|
||||
/**
|
||||
* Change frequency undergoes a quadratic transformation from previous pulse to current one.
|
||||
*/
|
||||
Quadratic,
|
||||
/**
|
||||
* Change frequency undergoes a cubic transformation from previous pulse to current one.
|
||||
*/
|
||||
Cubic,
|
||||
/**
|
||||
* Change frequency with quick fade in and fade out.
|
||||
*/
|
||||
Ease,
|
||||
/**
|
||||
* Change frequency with spiking within range from previous pulse and current one.
|
||||
*/
|
||||
Pulsating,
|
||||
/**
|
||||
* Based on frequency of previous pulse, take the twice of frequency of current pulse as the peak frequency. Follow the maximum frequency limitation.
|
||||
*/
|
||||
Spiking,
|
||||
/**
|
||||
* Randomize frequency within range from previous frequency and current one.
|
||||
*/
|
||||
Randomize,
|
||||
/**
|
||||
* Randomize frequency within minium and maximum frequency.
|
||||
*/
|
||||
Maniac,
|
||||
/**
|
||||
* Synchronize changes of frequency with pulse width changes.
|
||||
*/
|
||||
Synchronized,
|
||||
}
|
||||
|
||||
export interface ControlPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class Pulse {
|
||||
order: number;
|
||||
id: number;
|
||||
offset: number;
|
||||
width: number;
|
||||
maniac: boolean;
|
||||
frequency: number;
|
||||
frequencyShifting: FrequencyShifting;
|
||||
controlPoint1: ControlPoint;
|
||||
controlPoint2: ControlPoint;
|
||||
|
||||
constructor(order: number, width: number, frequency: number) {
|
||||
this.id = v4();
|
||||
this.order = order;
|
||||
this.offset = 0;
|
||||
this.width = width;
|
||||
this.maniac = false;
|
||||
this.frequency = frequency;
|
||||
this.frequencyShifting = FrequencyShifting.Linear;
|
||||
this.controlPoint1 = { x: 0, y: 0 };
|
||||
this.controlPoint2 = { x: 0, y: 0 };
|
||||
export const SearchKeywordAtom = atom<string | null>(null);
|
||||
export const PatternsAtom = atomWithRefresh(async (get) => {
|
||||
try {
|
||||
const keyword = get(SearchKeywordAtom);
|
||||
const patterns = await invoke<Pattern[]>('list_patterns', { keyword });
|
||||
return patterns;
|
||||
} catch (e) {
|
||||
console.error('[retrieving pattern list]', e);
|
||||
}
|
||||
|
||||
equals(other: Pulse): boolean {
|
||||
return this.id === other.id;
|
||||
}
|
||||
}
|
||||
|
||||
export class Pattern {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastModifiedAt: number | null;
|
||||
smoothRepeat: boolean;
|
||||
pulses: Pulse[];
|
||||
|
||||
constructor() {
|
||||
this.id = v4();
|
||||
this.name = '';
|
||||
this.createdAt = dayjs().valueOf();
|
||||
this.lastModifiedAt = null;
|
||||
this.smoothRepeat = true;
|
||||
this.Pulses = [];
|
||||
}
|
||||
}
|
||||
|
||||
export function createNewPulse(pattern: Pattern): Pulse {
|
||||
const maxOrder = reduce(pattern.pulses, (former, pulse) => Math.max(former, pulse.order), 0);
|
||||
if (pattern.smoothRepeat) {
|
||||
return new Pulse(
|
||||
maxOrder + 1,
|
||||
get(pattern.pulses, '[0].width', 0),
|
||||
get(pattern.pulses, '[0].frequency', 1),
|
||||
);
|
||||
} else {
|
||||
return new Pulse(
|
||||
maxOrder + 1,
|
||||
get(pattern.pulses, '[-1].width', 0),
|
||||
get(pattern.pulses, '[-1].frequency', 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function totalDuration(pattern: Pattern): number {
|
||||
return reduce(
|
||||
pattern.pulses,
|
||||
(former, pulse) => former + pulse.offset,
|
||||
pattern.smoothRepeat ? 100 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
export const PatternsAtom = atomFamily((keyword: string) =>
|
||||
atom(async () => {
|
||||
try {
|
||||
const patterns = await invoke<Pattern[]>('list_patterns', { keyword });
|
||||
return patterns;
|
||||
} catch (e) {
|
||||
console.error('[retrieving pattern list]', e);
|
||||
return [];
|
||||
});
|
||||
export const SelectedPatternIdAtom = atom<string | null>(null);
|
||||
export const CurrentPatternAtom = atomWithRefresh<Pattern | null>(async (get) => {
|
||||
try {
|
||||
const patternId = get(SelectedPatternIdAtom);
|
||||
if (patternId === null) {
|
||||
return null;
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
);
|
||||
export const CurrentPatternAtom = atom<Pattern | null>(null);
|
||||
export const PulsesInCurrentPatternAtom = atom(
|
||||
(get) => get(CurrentPatternAtom)?.pulses ?? [],
|
||||
(get, set, pulse: Pulse) => {
|
||||
const currentPulses = get(CurrentPatternAtom)?.pulses ?? [];
|
||||
const newPulses = currentPulses.map((p) => (p.id === pulse.id ? pulse : p));
|
||||
if (!newPulses.some((p) => p.id === pulse.id)) {
|
||||
newPulses.push(pulse);
|
||||
}
|
||||
newPulses.sort((a, b) => a.order - b.order);
|
||||
const currentPattern = get(CurrentPatternAtom);
|
||||
if (currentPattern) {
|
||||
set(CurrentPatternAtom, {
|
||||
...currentPattern,
|
||||
pulses: newPulses,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
const pattern = await invoke('get_pattern', { patternId });
|
||||
return pattern;
|
||||
} catch (e) {
|
||||
console.error('[retrieving pattern]', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
export const CurrentPatternDuration = atom((get) => {
|
||||
const currentPattern = get(CurrentPatternAtom);
|
||||
if (!currentPattern) return 0;
|
||||
return totalDuration(currentPattern);
|
||||
});
|
||||
export const SelectedPulseIdAtom = atom<string | null>(null);
|
||||
export const SelectedPulseAtom = atom<Pulse | null>(async (get) => {
|
||||
const pattern = await get(CurrentPatternAtom);
|
||||
const selectedPulseId = get(SelectedPulseIdAtom);
|
||||
console.debug('[refresh selected pulse]', selectedPulseId, pattern);
|
||||
return pattern?.pulses?.find((pulse) => pulse.id === selectedPulseId) ?? null;
|
||||
});
|
||||
|
||||
export function useSavePattern() {
|
||||
const refreshPatterns = useSetAtom(PatternsAtom);
|
||||
const selectedPatternId = useAtomValue(SelectedPatternIdAtom);
|
||||
const { showToast } = useNotification();
|
||||
|
||||
const savePattern = useCallback(
|
||||
async (pattern: Pattern) => {
|
||||
try {
|
||||
await invoke('save_pattern', { pattern });
|
||||
refreshPatterns();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[save pattern]', error);
|
||||
showToast(
|
||||
NotificationType.ERROR,
|
||||
'Failed to save pattern. Please try again.',
|
||||
'material-symbols-light:error-outline',
|
||||
ToastDuration.MEDIUM,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[selectedPatternId],
|
||||
);
|
||||
|
||||
return savePattern;
|
||||
}
|
||||
|
23
src/context/ThemeColors.ts
Normal file
23
src/context/ThemeColors.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { atomWithRefresh } from 'jotai/utils';
|
||||
|
||||
export const PrimaryColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim(),
|
||||
);
|
||||
export const SecondaryColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-secondary').trim(),
|
||||
);
|
||||
export const TeritaryColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-tertiary').trim(),
|
||||
);
|
||||
export const ErrorColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-error').trim(),
|
||||
);
|
||||
export const GentleColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-gentle').trim(),
|
||||
);
|
||||
export const AggressiveColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-aggressive').trim(),
|
||||
);
|
||||
export const DefensiveColorAtom = atomWithRefresh(() =>
|
||||
window.getComputedStyle(document.documentElement).getPropertyValue('--color-defensive').trim(),
|
||||
);
|
190
src/context/pattern-model.ts
Normal file
190
src/context/pattern-model.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { get, reduce } from 'lodash-es';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export enum FrequencyShifting {
|
||||
/**
|
||||
* Change frequency undergoes a linear transformation from previous pulse to current one.
|
||||
*/
|
||||
Linear = 0,
|
||||
/**
|
||||
* Change frequency undergoes a quadratic transformation from previous pulse to current one.
|
||||
*/
|
||||
Quadratic = 1,
|
||||
/**
|
||||
* Change frequency undergoes a cubic transformation from previous pulse to current one.
|
||||
*/
|
||||
Cubic = 2,
|
||||
/**
|
||||
* Change frequency with quick fade in and fade out.
|
||||
*/
|
||||
Ease = 3,
|
||||
/**
|
||||
* Change frequency with spiking within range from previous pulse and current one.
|
||||
*/
|
||||
Pulsating = 4,
|
||||
/**
|
||||
* Based on frequency of previous pulse, take the twice of frequency of current pulse as the peak frequency. Follow the maximum frequency limitation.
|
||||
*/
|
||||
Spiking = 5,
|
||||
/**
|
||||
* Randomize frequency within range from previous frequency and current one.
|
||||
*/
|
||||
Randomize = 6,
|
||||
/**
|
||||
* Randomize frequency within minium and maximum frequency.
|
||||
*/
|
||||
Maniac = 7,
|
||||
/**
|
||||
* Synchronize changes of frequency with pulse width changes.
|
||||
*/
|
||||
Synchronized = 8,
|
||||
}
|
||||
|
||||
export interface ControlPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Pulse {
|
||||
order: number;
|
||||
id: number;
|
||||
offset: number;
|
||||
width: number;
|
||||
maniac: boolean;
|
||||
frequency: number;
|
||||
frequencyShifting: FrequencyShifting;
|
||||
controlPoint1: ControlPoint;
|
||||
controlPoint2: ControlPoint;
|
||||
}
|
||||
export function createPulse(order: number, width: number, frequency: number) {
|
||||
return {
|
||||
id: v4(),
|
||||
order,
|
||||
offset: 0,
|
||||
width,
|
||||
maniac: false,
|
||||
frequency,
|
||||
frequencyShifting: FrequencyShifting.Linear,
|
||||
controlPoint1: { x: 0, y: 0 },
|
||||
controlPoint2: { x: 0, y: 0 },
|
||||
} as Pulse;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastModifiedAt: number | null;
|
||||
smoothRepeat: boolean;
|
||||
pulses: Pulse[];
|
||||
}
|
||||
export function createPattern() {
|
||||
return {
|
||||
id: v4(),
|
||||
name: '',
|
||||
createdAt: dayjs().valueOf(),
|
||||
lastModifiedAt: null,
|
||||
smoothRepeat: true,
|
||||
pulses: [],
|
||||
} as Pattern;
|
||||
}
|
||||
|
||||
export function movePulseUp(pattern: Pattern, pulseId: string, step: number) {
|
||||
const index = pattern.pulses.findIndex((pulse) => pulse.id === pulseId);
|
||||
if (index === -1 || index - step < 0) return;
|
||||
|
||||
const targetIndex = index - step;
|
||||
const targetPulse = pattern.pulses[targetIndex];
|
||||
const currentPulse = pattern.pulses[index];
|
||||
|
||||
// Swap the pulses
|
||||
pattern.pulses[targetIndex] = currentPulse;
|
||||
pattern.pulses[index] = targetPulse;
|
||||
|
||||
// If the target pulse's order is 1, swap their offsets
|
||||
if (targetPulse.order === 1) {
|
||||
const tempOffset = currentPulse.offset;
|
||||
currentPulse.offset = targetPulse.offset;
|
||||
targetPulse.offset = tempOffset;
|
||||
}
|
||||
|
||||
// Swap their order
|
||||
const tempOrder = currentPulse.order;
|
||||
currentPulse.order = targetPulse.order;
|
||||
targetPulse.order = tempOrder;
|
||||
|
||||
// Sort pulses by order
|
||||
pattern.pulses.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export function movePulseDown(pattern: Pattern, pulseId: string, step: number) {
|
||||
const index = pattern.pulses.findIndex((pulse) => pulse.id === pulseId);
|
||||
if (index === -1 || index + step >= pattern.pulses.length) return;
|
||||
|
||||
const targetIndex = index + step;
|
||||
const targetPulse = pattern.pulses[targetIndex];
|
||||
const currentPulse = pattern.pulses[index];
|
||||
|
||||
// Swap the pulses
|
||||
pattern.pulses[targetIndex] = currentPulse;
|
||||
pattern.pulses[index] = targetPulse;
|
||||
|
||||
// If the current pulse's order is 1, swap their offsets
|
||||
if (currentPulse.order === 1) {
|
||||
const tempOffset = currentPulse.offset;
|
||||
currentPulse.offset = targetPulse.offset;
|
||||
targetPulse.offset = tempOffset;
|
||||
}
|
||||
|
||||
// Swap their order
|
||||
const tempOrder = currentPulse.order;
|
||||
currentPulse.order = targetPulse.order;
|
||||
targetPulse.order = tempOrder;
|
||||
|
||||
// Sort pulses by order
|
||||
pattern.pulses.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
export function addPulse(pattern: Pattern): Pulse {
|
||||
const maxOrder = reduce(pattern.pulses, (former, pulse) => Math.max(former, pulse.order), 0);
|
||||
const newPulse = createPulse(
|
||||
maxOrder + 1,
|
||||
pattern.smoothRepeat
|
||||
? get(pattern.pulses, '[0].width', 0)
|
||||
: get(pattern.pulses, '[-1].width', 0),
|
||||
pattern.smoothRepeat
|
||||
? get(pattern.pulses, '[0].frequency', 1)
|
||||
: get(pattern.pulses, '[-1].frequency', 1),
|
||||
);
|
||||
pattern.pulses.push(newPulse);
|
||||
return newPulse;
|
||||
}
|
||||
|
||||
export function updatePulse(pattern: Pattern, pulseId: string, pulse: Pulse) {
|
||||
const index = pattern.pulses.findIndex((p) => p.id === pulseId);
|
||||
if (index !== -1) {
|
||||
const { id, order, ...rest } = pulse;
|
||||
pattern.pulses[index] = { ...pattern.pulses[index], ...rest };
|
||||
}
|
||||
}
|
||||
|
||||
export function deletePulse(pattern: Pattern, pulseId: string) {
|
||||
pattern.pulses = pattern.pulses.filter((pulse) => pulse.id !== pulseId);
|
||||
pattern.pulses.sort((a, b) => a.order - b.order);
|
||||
pattern.pulses.forEach((pulse, index) => {
|
||||
pulse.order = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
export function durationRemains(pattern: Pattern): number {
|
||||
return 20 * 1000 - totalDuration(pattern) - (pattern.smoothRepeat ? 100 : 0);
|
||||
}
|
||||
|
||||
export function totalDuration(pattern: Pattern): number {
|
||||
return reduce(
|
||||
pattern.pulses,
|
||||
(former, pulse) => former + pulse.offset,
|
||||
pattern.smoothRepeat && pattern.pulses.length > 1 ? 100 : 0,
|
||||
);
|
||||
}
|
@@ -12,6 +12,6 @@ export const defaultIconProps: Partial<IconProps> = {
|
||||
};
|
||||
|
||||
export const smallIconProps: Partial<IconProps> = {
|
||||
height: 16,
|
||||
height: 14,
|
||||
stroke: 0.5,
|
||||
};
|
||||
|
@@ -3,3 +3,4 @@
|
||||
@import 'sanitize.css/forms.css' layer(base);
|
||||
@import './theme.css';
|
||||
@import './components.css';
|
||||
@import './utilities.css';
|
||||
|
35
src/main.tsx
35
src/main.tsx
@@ -5,27 +5,36 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import Layout from './Layout';
|
||||
import Notifications from './components/Notifications';
|
||||
import EstimWatchProvider from './context/EstimContext';
|
||||
import CreatePattern from './pages/CreatePattern';
|
||||
import Device from './pages/Device';
|
||||
import PatternEditor from './pages/PatternEditor';
|
||||
import PatternLibrary from './pages/PatternLibrary';
|
||||
import PatternNavigator from './pages/PatternNavigator';
|
||||
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>
|
||||
<Notifications>
|
||||
<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">
|
||||
<Route index element={<PatternNavigator />} />
|
||||
<Route path="new" element={<CreatePattern />} />
|
||||
<Route path="edit" element={<PatternEditor />} />
|
||||
</Route>
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</EstimWatchProvider>
|
||||
</Notifications>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
@@ -31,9 +31,11 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 1);
|
||||
font-size: var(--body-medium-font-size);
|
||||
line-height: var(--body-medium-line-height);
|
||||
font-weight: var(--body-medium-font-weight);
|
||||
.device_name {
|
||||
flex: 1;
|
||||
font-size: calc(var(--font-size) * 1.3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -41,11 +43,5 @@
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 8%, transparent);
|
||||
}
|
||||
}
|
||||
.empty_prompt {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
text-align: center;
|
||||
font-size: calc(var(--font-size) * 0.8);
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -10,7 +10,7 @@ const AvailableDevices: FC = () => {
|
||||
|
||||
return (
|
||||
<div className={styles.devices_list}>
|
||||
{devices.length === 0 && <div className={styles.empty_prompt}>No available devices.</div>}
|
||||
{devices.length === 0 && <div className="empty_prompt">No available devices.</div>}
|
||||
{devices.map((device, index) => (
|
||||
<div key={index} className={styles.device_card}>
|
||||
<div className={styles.device_name}>{device.represent}</div>
|
||||
|
@@ -9,5 +9,8 @@
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
color: var(--color-light-on-surface);
|
||||
background-color: var(--color-surface-container);
|
||||
.content {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
35
src/page-components/pattern-editor/PatternHeader.tsx
Normal file
35
src/page-components/pattern-editor/PatternHeader.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { FC, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import EditableContent from '../../components/EditableContent';
|
||||
import { CurrentPatternAtom, SelectedPatternIdAtom } from '../../context/Patterns';
|
||||
import styles from './PatternHeader.module.css';
|
||||
|
||||
const PatternHeader: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const currentPattern = useAtomValue(CurrentPatternAtom);
|
||||
const setActivePattern = useSetAtom(SelectedPatternIdAtom);
|
||||
const handleClosePattern = useCallback(() => {
|
||||
setActivePattern(null);
|
||||
navigate('/library');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.pattern_header}>
|
||||
<EditableContent
|
||||
as="h4"
|
||||
placeholder="Pattern Name"
|
||||
value={currentPattern?.name ?? null}
|
||||
additionalClassName={styles.content}
|
||||
/>
|
||||
<div className="spacer" />
|
||||
<button className="tonal" onClick={handleClosePattern}>
|
||||
<Icon icon="material-symbols-light:close" />
|
||||
Close Edit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatternHeader;
|
@@ -0,0 +1,22 @@
|
||||
@layer pages {
|
||||
.pattern_overview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing));
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface-container);
|
||||
}
|
||||
.attribute_row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
font-size: var(--body-small-font-size);
|
||||
line-height: var(--body-small-line-height);
|
||||
font-weight: var(--body-small-font-weight);
|
||||
}
|
||||
}
|
18
src/page-components/pattern-editor/PatternOverview.tsx
Normal file
18
src/page-components/pattern-editor/PatternOverview.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FC } from 'react';
|
||||
import Switch from '../../components/Switch';
|
||||
import styles from './PatternOverview.module.css';
|
||||
import PulseList from './PulseList';
|
||||
|
||||
const PatternOverview: FC = () => {
|
||||
return (
|
||||
<div className={styles.pattern_overview}>
|
||||
<div className={styles.attribute_row}>
|
||||
<label>Smooth Repeat</label>
|
||||
<Switch />
|
||||
</div>
|
||||
<PulseList />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatternOverview;
|
@@ -0,0 +1,13 @@
|
||||
@layer pages {
|
||||
.pulse_attributes {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing));
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface-container);
|
||||
}
|
||||
}
|
8
src/page-components/pattern-editor/PulseAttributes.tsx
Normal file
8
src/page-components/pattern-editor/PulseAttributes.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FC } from 'react';
|
||||
import styles from './PulseAttributes.module.css';
|
||||
|
||||
const PulseAttributes: FC = () => {
|
||||
return <div className={styles.pulse_attributes}></div>;
|
||||
};
|
||||
|
||||
export default PulseAttributes;
|
53
src/page-components/pattern-editor/PulseCard.module.css
Normal file
53
src/page-components/pattern-editor/PulseCard.module.css
Normal file
@@ -0,0 +1,53 @@
|
||||
@layer pages {
|
||||
.pulse_card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: 0;
|
||||
.order {
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
background-color: var(--color-secondary-container);
|
||||
color: var(--color-on-secondary-container);
|
||||
border-top-left-radius: calc(var(--border-radius) * 2);
|
||||
border-bottom-left-radius: calc(var(--border-radius) * 2);
|
||||
}
|
||||
.offset {
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 3);
|
||||
}
|
||||
&.selected {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
|
||||
.order {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-secondary-container) 88%,
|
||||
var(--color-white)
|
||||
);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 8%, transparent);
|
||||
box-shadow: var(--elevation-2-ambient), var(--elevation-2-umbra);
|
||||
.order {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-secondary-container) 60%,
|
||||
var(--color-white)
|
||||
);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
|
||||
.order {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-secondary-container) 68%,
|
||||
var(--color-white)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
34
src/page-components/pattern-editor/PulseCard.tsx
Normal file
34
src/page-components/pattern-editor/PulseCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import cx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { FC, useCallback } from 'react';
|
||||
import { Pulse } from '../../context/pattern-model';
|
||||
import { SelectedPulseIdAtom } from '../../context/Patterns';
|
||||
import styles from './PulseCard.module.css';
|
||||
|
||||
type PulseCardProps = {
|
||||
pulse: Pulse;
|
||||
};
|
||||
|
||||
const PulseCard: FC<PulseCardProps> = ({ pulse }) => {
|
||||
const [selected, setSelected] = useAtom(SelectedPulseIdAtom);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (selected === pulse.id) {
|
||||
setSelected(null);
|
||||
} else {
|
||||
setSelected(pulse.id);
|
||||
}
|
||||
}, [pulse, selected]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.pulse_card, selected === pulse.id && styles.selected)}
|
||||
onClick={handleSelect}>
|
||||
<div className={styles.order}>{pulse.order.toString().padStart(3, '0')}</div>
|
||||
<div className="spacer" />
|
||||
<div className={styles.offset}>+ {pulse.offset} ms</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PulseCard;
|
38
src/page-components/pattern-editor/PulseList.module.css
Normal file
38
src/page-components/pattern-editor/PulseList.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@layer page {
|
||||
.pulse_tools {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.attribute_unit {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
font-size: var(--body-small-font-size);
|
||||
line-height: var(--body-small-line-height);
|
||||
font-weight: var(--body-small-font-weight);
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
.pulses {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
padding-block-end: calc(var(--spacing));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pulse_cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
94
src/page-components/pattern-editor/PulseList.tsx
Normal file
94
src/page-components/pattern-editor/PulseList.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { max } from 'lodash-es';
|
||||
import { FC, useCallback, useMemo } from 'react';
|
||||
import { ScrollArea } from '../../components/ScrollArea';
|
||||
import { addPulse, deletePulse } from '../../context/pattern-model';
|
||||
import {
|
||||
CurrentPatternAtom,
|
||||
CurrentPatternDuration,
|
||||
SelectedPulseAtom,
|
||||
useSavePattern,
|
||||
} from '../../context/Patterns';
|
||||
import PulseCard from './PulseCard';
|
||||
import styles from './PulseList.module.css';
|
||||
|
||||
const PulseList: FC = () => {
|
||||
const [pattern, refreshPattern] = useAtom(CurrentPatternAtom);
|
||||
const duration = useAtomValue(CurrentPatternDuration);
|
||||
const selectedPulse = useAtomValue(SelectedPulseAtom);
|
||||
const maxPulseOrder = useMemo(
|
||||
() => max(pattern?.pulses.map((pulse) => pulse.order) ?? []),
|
||||
[pattern],
|
||||
);
|
||||
const savePattern = useSavePattern();
|
||||
|
||||
const handleAddPulseAction = useCallback(async () => {
|
||||
if (!pattern) return;
|
||||
addPulse(pattern);
|
||||
await savePattern(pattern);
|
||||
refreshPattern();
|
||||
}, [pattern]);
|
||||
const handleDeletePulseAction = useCallback(async () => {
|
||||
if (!pattern || !selectedPulse) return;
|
||||
deletePulse(pattern, selectedPulse.id);
|
||||
await savePattern(pattern);
|
||||
refreshPattern();
|
||||
}, [pattern, selectedPulse]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.pulse_tools}>
|
||||
<div className={styles.attribute_unit}>
|
||||
<span>{pattern?.pulses.length ?? 0}</span>
|
||||
<label>Key Pulses</label>
|
||||
</div>
|
||||
<div className={styles.attribute_unit}>
|
||||
<label>Total Duration</label>
|
||||
<span>{duration.toFixed(2)} s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pulse_tools}>
|
||||
<button className="text" disabled>
|
||||
<Icon icon="material-symbols-light:play-arrow-outline" />
|
||||
<span>Test Run</span>
|
||||
</button>
|
||||
<button className="text" onClick={handleAddPulseAction}>
|
||||
<Icon icon="material-symbols-light:add" />
|
||||
<span>Add Pulse</span>
|
||||
</button>
|
||||
<button className="text" onClick={handleDeletePulseAction} disabled={!selectedPulse}>
|
||||
<Icon icon="material-symbols-light:delete-forever-outline" />
|
||||
<span>Delete Selected</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.pulse_tools}>
|
||||
<button className="text" disabled={!selectedPulse || selectedPulse.order === 1}>
|
||||
<Icon icon="material-symbols-light:arrow-upward" />
|
||||
<span>Move Up</span>
|
||||
</button>
|
||||
<button className="text" disabled={!selectedPulse || selectedPulse.order === maxPulseOrder}>
|
||||
<Icon icon="material-symbols-light:arrow-downward" />
|
||||
<span>Move Down</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.pulses}>
|
||||
<ScrollArea enableY>
|
||||
<div className={styles.pulse_cards}>
|
||||
{pattern?.pulses.length === 0 ? (
|
||||
<div className="empty_prompt">No key pulses.</div>
|
||||
) : (
|
||||
<>
|
||||
{pattern?.pulses.map((pulse) => (
|
||||
<PulseCard key={pulse.id} pulse={pulse} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PulseList;
|
@@ -3,5 +3,70 @@
|
||||
flex: 2;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.control_panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.button_row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
}
|
||||
.detail_panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.detail_row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.detail_unit {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
label {
|
||||
min-width: 8em;
|
||||
flex: 0 0 8em;
|
||||
text-align: right;
|
||||
}
|
||||
.content {
|
||||
flex: 1 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.preview_panel {
|
||||
min-height: 140px;
|
||||
flex: 0 0;
|
||||
}
|
||||
}
|
||||
.empty_promption {
|
||||
flex: 2;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
.promption {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.spacer {
|
||||
flex: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,112 @@
|
||||
import { FC } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { FC, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NotificationType, useNotification } from '../../components/Notifications';
|
||||
import PatternPreview from '../../components/PatternPreview';
|
||||
import { CurrentPatternAtom, PatternsAtom, SelectedPatternIdAtom } from '../../context/Patterns';
|
||||
import { Pattern, totalDuration } from '../../context/pattern-model';
|
||||
import styles from './PatternDetail.module.css';
|
||||
|
||||
const EmptyPromption: FC = () => {
|
||||
return (
|
||||
<div className={styles.empty_promption}>
|
||||
<div className={styles.promption}>
|
||||
<span className="empty_prompt">Select a pattern from left first.</span>
|
||||
</div>
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Detail: FC<{ pattern: Pattern }> = ({ pattern }) => {
|
||||
const { showToast } = useNotification();
|
||||
const navigate = useNavigate();
|
||||
const refreshPatterns = useSetAtom(PatternsAtom);
|
||||
const resetSelected = useSetAtom(SelectedPatternIdAtom);
|
||||
const patternDuration = useMemo(() => totalDuration(pattern), [pattern]);
|
||||
const createTime = useMemo(
|
||||
() => dayjs(pattern.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
[pattern],
|
||||
);
|
||||
|
||||
const handleDeleteAction = useCallback(async () => {
|
||||
try {
|
||||
const answer = await ask(
|
||||
`The pattern ${pattern.name} will be deleted, and cannot be revoked. Are you sure?`,
|
||||
{
|
||||
title: 'Confirm action',
|
||||
kind: 'warning',
|
||||
},
|
||||
);
|
||||
if (answer) {
|
||||
await invoke('remove_pattern', { patternId: pattern.id });
|
||||
showToast(NotificationType.SUCCESS, 'Pattern deleted.');
|
||||
refreshPatterns();
|
||||
resetSelected(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[delete pattern]', e);
|
||||
showToast(NotificationType.ERROR, 'Failed to delete pattern. Please try again.');
|
||||
}
|
||||
}, [pattern]);
|
||||
|
||||
return (
|
||||
<div className={styles.pattern_detail}>
|
||||
<div className={styles.control_panel}>
|
||||
<div className={styles.button_row}>
|
||||
<button className="tonal" onClick={() => navigate('/pattern-editor/edit')}>
|
||||
Edit Pattern
|
||||
</button>
|
||||
<button className="tonal danger" onClick={handleDeleteAction}>
|
||||
Delete Pattern
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.button_row}>
|
||||
<button className="tonal warn">Test Run</button>
|
||||
</div>
|
||||
<div className={styles.button_row}>
|
||||
<button className="tonal secondary">Add to Channel A Playlist</button>
|
||||
<button className="tonal secondary">Add to Channel B Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="dotted" />
|
||||
<div className={styles.detail_panel}>
|
||||
<div className={styles.detail_row}>
|
||||
<div className={styles.detail_unit}>
|
||||
<label>Created At</label>
|
||||
<div className={styles.content}>{createTime}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detail_row}>
|
||||
<div className={styles.detail_unit}>
|
||||
<label>Duration</label>
|
||||
<div className={styles.content}>{(patternDuration / 1000).toFixed(2)} s</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detail_row}>
|
||||
<div className={styles.detail_unit}>
|
||||
<label> </label>
|
||||
<div className={styles.content}>
|
||||
{pattern.pulses.length} key frame{pattern.pulses.length > 1 && 's'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="dotted" />
|
||||
<div className={styles.preview_panel}>
|
||||
<PatternPreview />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PatternDetail: FC = () => {
|
||||
return <div className={styles.pattern_detail}></div>;
|
||||
const currentPattern = useAtomValue(CurrentPatternAtom);
|
||||
|
||||
return !currentPattern ? <EmptyPromption /> : <Detail pattern={currentPattern} />;
|
||||
};
|
||||
|
||||
export default PatternDetail;
|
||||
|
@@ -1,10 +1,51 @@
|
||||
@layer pages {
|
||||
.pattern_list {
|
||||
.patterns {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: calc(var(--spacing) * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
.search_row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
}
|
||||
}
|
||||
.pattern_list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.pattern_card {
|
||||
padding: calc(var(--spacing) * 3) calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 2);
|
||||
box-shadow: var(--elevation-0);
|
||||
.name {
|
||||
flex: 1;
|
||||
}
|
||||
&.selected {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
|
||||
}
|
||||
&:hover {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 8%, transparent);
|
||||
box-shadow: var(--elevation-2-ambient), var(--elevation-2-umbra);
|
||||
}
|
||||
&:active {
|
||||
background-color: color-mix(in oklch, var(--color-on-surface) 18%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,92 @@
|
||||
import { FC } from 'react';
|
||||
import { Icon } from '@iconify/react/dist/iconify.js';
|
||||
import cx from 'clsx';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { ScrollArea } from '../../components/ScrollArea';
|
||||
import { PatternsAtom, SearchKeywordAtom, SelectedPatternIdAtom } from '../../context/Patterns';
|
||||
import { Pattern, totalDuration } from '../../context/pattern-model';
|
||||
import styles from './Patterns.module.css';
|
||||
|
||||
const Patterns: FC = () => {
|
||||
const PatternCard: FC<{ pattern: Pattern }> = ({ pattern }) => {
|
||||
const [selectedId, setSelectedId] = useAtom(SelectedPatternIdAtom);
|
||||
const navigate = useNavigate();
|
||||
const duration = useMemo(() => {
|
||||
return totalDuration(pattern) / 1000;
|
||||
}, [pattern]);
|
||||
const selected = useMemo(() => selectedId === pattern.id, [selectedId, pattern]);
|
||||
const handleSingleClick = useCallback(() => {
|
||||
if (selectedId === pattern.id) {
|
||||
setSelectedId(null);
|
||||
} else {
|
||||
setSelectedId(pattern.id);
|
||||
}
|
||||
}, [pattern, selectedId]);
|
||||
const handleDblClick = useCallback(() => {
|
||||
setSelectedId(pattern.id);
|
||||
navigate('/pattern-editor/edit');
|
||||
}, [pattern]);
|
||||
|
||||
return (
|
||||
<div className={styles.pattern_list}>
|
||||
<ScrollArea enableY></ScrollArea>
|
||||
<div
|
||||
className={cx(styles.pattern_card, selected && styles.selected)}
|
||||
onClick={handleSingleClick}
|
||||
onDoubleClick={handleDblClick}>
|
||||
<h5 className={styles.name}>{pattern.name}</h5>
|
||||
<span>{duration.toFixed(2)} s</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Patterns: FC = () => {
|
||||
const [rawKeyword, setRawKeyword] = useState<string>('');
|
||||
const [keyword, setKeyword] = useAtom(SearchKeywordAtom);
|
||||
const patterns = useAtomValue(PatternsAtom);
|
||||
const setPatternSelection = useSetAtom(SelectedPatternIdAtom);
|
||||
const navigate = useNavigate();
|
||||
const createNewAction = useCallback(() => {
|
||||
setPatternSelection(null);
|
||||
navigate('/pattern-editor/new');
|
||||
}, []);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (rawKeyword.trim().length > 0) {
|
||||
setKeyword(rawKeyword.trim());
|
||||
} else {
|
||||
setKeyword(null);
|
||||
}
|
||||
},
|
||||
1500,
|
||||
[rawKeyword],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.patterns}>
|
||||
<div className={styles.search_row}>
|
||||
<div className={cx('input_wrapper', 'extendable')}>
|
||||
<Icon icon="material-symbols-light:search" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search patterns..."
|
||||
value={rawKeyword}
|
||||
onChange={(evt) => setRawKeyword(evt.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="tonal secondary" onClick={createNewAction}>
|
||||
<Icon icon="material-symbols-light:add" />
|
||||
New Pattern
|
||||
</button>
|
||||
</div>
|
||||
<ScrollArea enableY>
|
||||
<div className={styles.pattern_list}>
|
||||
{patterns.length === 0 && <div className="empty_prompt">No pattern found.</div>}
|
||||
{patterns.map((p) => (
|
||||
<PatternCard key={p.id} pattern={p} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { FC, useMemo } from 'react';
|
||||
import { BleState } from '../../context/EstimContext';
|
||||
import IconBluetooth from '../../icons/IconBluetooth';
|
||||
import { smallIconProps } from '../../icons/shared-props';
|
||||
|
||||
const BleStates: FC = () => {
|
||||
const ble = useAtomValue(BleState);
|
||||
@@ -19,7 +20,7 @@ const BleStates: FC = () => {
|
||||
|
||||
return (
|
||||
<IconBluetooth
|
||||
height={16}
|
||||
{...smallIconProps}
|
||||
ready={ble.ready}
|
||||
searching={ble.searching}
|
||||
connected={ble.connected?.length > 0}
|
||||
|
@@ -5,6 +5,8 @@
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing));
|
||||
font-size: calc(var(--font-size) * 1.4);
|
||||
font-size: var(--label-medium-font-size);
|
||||
line-height: var(--label-medium-line-height);
|
||||
font-weight: var(--label-medium-font-weight);
|
||||
}
|
||||
}
|
||||
|
@@ -3,14 +3,15 @@ import { FC } from 'react';
|
||||
import { DeviceState } from '../../context/EstimContext';
|
||||
import IconBattery from '../../icons/IconBattery';
|
||||
import IconRssi from '../../icons/IconRssi';
|
||||
import { smallIconProps } from '../../icons/shared-props';
|
||||
|
||||
const DeviceStates: FC = () => {
|
||||
const deviceState = useAtomValue(DeviceState);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconRssi height={16} level={deviceState?.rssi} />
|
||||
<IconBattery height={16} level={deviceState?.battery} />
|
||||
<IconRssi {...smallIconProps} level={deviceState?.rssi} />
|
||||
<IconBattery {...smallIconProps} level={deviceState?.battery} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -8,6 +8,8 @@
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
font-size: calc(var(--font-size) * 1.6);
|
||||
font-size: var(--label-small-font-size);
|
||||
line-height: var(--label-small-line-height);
|
||||
font-weight: var(--label-small-font-weight);
|
||||
}
|
||||
}
|
||||
|
15
src/pages/CreatePattern.module.css
Normal file
15
src/pages/CreatePattern.module.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@layer pages {
|
||||
.create_form {
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
background-color: var(--color-surface-container);
|
||||
}
|
||||
.form_row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing) * 3);
|
||||
.pattern_name_input {
|
||||
min-width: 30em;
|
||||
}
|
||||
}
|
||||
}
|
79
src/pages/CreatePattern.tsx
Normal file
79
src/pages/CreatePattern.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import cx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { FC, useActionState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { NotificationType, ToastDuration, useNotification } from '../components/Notifications';
|
||||
import { SelectedPatternIdAtom, useSavePattern } from '../context/Patterns';
|
||||
import styles from './CreatePattern.module.css';
|
||||
|
||||
const CreatePattern: FC = () => {
|
||||
const { showToast } = useNotification();
|
||||
const navigate = useNavigate();
|
||||
const loadPattern = useSetAtom(SelectedPatternIdAtom);
|
||||
const savePattern = useSavePattern();
|
||||
|
||||
const [errState, handleFormSubmit] = useActionState(async (state, formData) => {
|
||||
const patternName = formData.get('pattern_name') as string | null;
|
||||
|
||||
if (patternName === null || patternName.length === 0) {
|
||||
showToast(
|
||||
NotificationType.ERROR,
|
||||
'Please enter a pattern name.',
|
||||
'material-symbols-light:error-outline',
|
||||
ToastDuration.MEDIUM,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const newPattern = new Pattern();
|
||||
newPattern.name = patternName;
|
||||
|
||||
try {
|
||||
const updated = await savePattern(newPattern);
|
||||
if (!updated) {
|
||||
showToast(
|
||||
NotificationType.ERROR,
|
||||
'Failed to reload the created pattern. Please try again.',
|
||||
'material-symbols-light:error-outline',
|
||||
ToastDuration.MEDIUM,
|
||||
);
|
||||
loadPattern(null);
|
||||
navigate('/library');
|
||||
return true;
|
||||
}
|
||||
loadPattern(newPattern.id);
|
||||
navigate('/pattern-editor/edit');
|
||||
} catch (e) {
|
||||
console.error('[save pattern]', e);
|
||||
loadPattern(null);
|
||||
showToast(
|
||||
NotificationType.ERROR,
|
||||
'Failed to create pattern. Please try again.',
|
||||
'material-symbols-light:error-outline',
|
||||
ToastDuration.MEDIUM,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, false);
|
||||
|
||||
return (
|
||||
<div className={cx('workspace', 'vertical', styles.create_form)}>
|
||||
<div className="center">
|
||||
<form action={handleFormSubmit} className={styles.form_row}>
|
||||
<input
|
||||
type="text"
|
||||
name="pattern_name"
|
||||
placeholder="pattern name"
|
||||
className={cx(styles.pattern_name_input, errState && 'error')}
|
||||
/>
|
||||
<button type="submit" className="filled">
|
||||
Create New Pattern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreatePattern;
|
@@ -7,4 +7,11 @@
|
||||
align-items: stretch;
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pattern_preview {
|
||||
flex-basis: 140px;
|
||||
color: var(--color-on-surface);
|
||||
background-color: var(--color-surface-container);
|
||||
padding: calc(var(--spacing)) calc(var(--spacing) * 2);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
}
|
||||
}
|
||||
|
@@ -13,7 +13,9 @@ const PatternEditor: FC = () => {
|
||||
<PatternOverview />
|
||||
<PulseAttributes />
|
||||
</div>
|
||||
<PatternPreview />
|
||||
<div className={styles.pattern_preview}>
|
||||
<PatternPreview />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
12
src/pages/PatternNavigator.tsx
Normal file
12
src/pages/PatternNavigator.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { FC } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { SelectedPatternIdAtom } from '../context/Patterns';
|
||||
|
||||
const PatternNavigator: FC = () => {
|
||||
const selected = useAtomValue(SelectedPatternIdAtom);
|
||||
|
||||
return selected === null ? <Navigate to="new" /> : <Navigate to="edit" />;
|
||||
};
|
||||
|
||||
export default PatternNavigator;
|
@@ -3,9 +3,9 @@
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size);
|
||||
line-height: var(--line-height);
|
||||
font-weight: 400;
|
||||
font-size: var(--root-font-size);
|
||||
line-height: var(--root-line-height);
|
||||
font-weight: var(--root-font-weight);
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
22
src/utilities.css
Normal file
22
src/utilities.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@layer utilities {
|
||||
.extendable {
|
||||
flex: 1 1;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.empty_prompt {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
text-align: center;
|
||||
font-size: var(--body-extra-small-font-size);
|
||||
line-height: var(--body-extra-small-line-height);
|
||||
font-weight: var(--body-extra-small-font-weight);
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
}
|
@@ -1,7 +1,5 @@
|
||||
@layer theme {
|
||||
:root {
|
||||
--font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-primary: light-dark(#744c1f, #ffe6b1);
|
||||
@@ -333,6 +331,79 @@
|
||||
--font-size: 10px;
|
||||
--line-height: 1.2em;
|
||||
|
||||
--font-family: 'Inter', system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
--root-font-size: 16px;
|
||||
--root-line-height: 1.2;
|
||||
--root-font-weight: 400;
|
||||
|
||||
--display-large-font-size: 3.5625rem;
|
||||
--display-large-line-height: 1.1228;
|
||||
--display-large-font-weight: 400;
|
||||
|
||||
--display-medium-font-size: 2.8125rem;
|
||||
--display-medium-line-height: 1.1556;
|
||||
--display-medium-font-weight: 400;
|
||||
|
||||
--display-small-font-size: 2.25rem;
|
||||
--display-small-line-height: 1.2222;
|
||||
--display-small-font-weight: 400;
|
||||
|
||||
--headline-large-font-size: 2rem;
|
||||
--headline-large-line-height: 1.25;
|
||||
--headline-large-font-weight: 400;
|
||||
|
||||
--headline-medium-font-size: 1.75rem;
|
||||
--headline-medium-line-height: 1.2857;
|
||||
--headline-medium-font-weight: 400;
|
||||
|
||||
--headline-small-font-size: 1.5rem;
|
||||
--headline-small-line-height: 1.3333;
|
||||
--headline-small-font-weight: 400;
|
||||
|
||||
--title-large-font-size: 1.375rem;
|
||||
--title-large-line-height: 1.2727;
|
||||
--title-large-font-weight: 400;
|
||||
|
||||
--title-medium-font-size: 1rem;
|
||||
--title-medium-line-height: 1.5;
|
||||
--title-medium-font-weight: 400;
|
||||
|
||||
--title-small-font-size: 0.875rem;
|
||||
--title-small-line-height: 1.4286;
|
||||
--title-small-font-weight: 500;
|
||||
|
||||
--body-large-font-size: 1rem;
|
||||
--body-large-line-height: 1.5;
|
||||
--body-large-font-weight: 400;
|
||||
|
||||
--body-medium-font-size: 0.875rem;
|
||||
--body-medium-line-height: 1.4286;
|
||||
--body-medium-font-weight: 400;
|
||||
|
||||
--body-small-font-size: 0.75rem;
|
||||
--body-small-line-height: 1.3333;
|
||||
--body-small-font-weight: 400;
|
||||
|
||||
--body-extra-small-font-size: 0.625rem;
|
||||
--body-extra-small-line-height: 1.2;
|
||||
--body-extra-small-font-weight: 400;
|
||||
|
||||
--body-extrime-small-font-size: 0.5rem;
|
||||
--body-extrime-small-line-height: 1;
|
||||
--body-extrime-small-font-weight: 400;
|
||||
|
||||
--label-large-font-size: 0.875rem;
|
||||
--label-large-line-height: 1.4286;
|
||||
--label-large-font-weight: 500;
|
||||
|
||||
--label-medium-font-size: 0.75rem;
|
||||
--label-medium-line-height: 1.3333;
|
||||
--label-medium-font-weight: 500;
|
||||
|
||||
--label-small-font-size: 0.6875rem;
|
||||
--label-small-line-height: 1.4545;
|
||||
--label-small-font-weight: 400;
|
||||
|
||||
--elevation-0: none;
|
||||
--elevation-1-ambient: 0 1px 3px 1px color-mix(in oklch, var(--color-shadow) 15%, transparent);
|
||||
--elevation-1-umbra: 0 1px 2px 0px color-mix(in oklch, var(--color-shadow) 30%, transparent);
|
||||
|
Reference in New Issue
Block a user