Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c57e8d8254 | |||
| 81d9624c9e | |||
| f029a322c4 | |||
| b0676d3834 | |||
| 6c66b1cd7d | |||
| 5af3ddb972 | |||
| d21dab57b5 | |||
| 7a3a016683 | |||
| 4ff3e30348 |
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "frangipane-client",
|
"name": "frangipane-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"backendVersion": "1.0.3",
|
"backendVersion": "1.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
"@tauri-apps/plugin-fs": "~2",
|
"@tauri-apps/plugin-fs": "~2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
|
"@tauri-apps/plugin-notification": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
"@tauri-apps/plugin-upload": "~2",
|
"@tauri-apps/plugin-upload": "~2",
|
||||||
"@tauri-apps/plugin-websocket": "~2",
|
"@tauri-apps/plugin-websocket": "~2",
|
||||||
|
|||||||
332
src-tauri/Cargo.lock
generated
332
src-tauri/Cargo.lock
generated
@@ -32,6 +32,28 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -551,6 +573,51 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||||
|
dependencies = [
|
||||||
|
"alsa",
|
||||||
|
"coreaudio-rs",
|
||||||
|
"dasp_sample",
|
||||||
|
"jack",
|
||||||
|
"jni",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2",
|
||||||
|
"ndk",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -666,6 +733,12 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -1020,6 +1093,7 @@ dependencies = [
|
|||||||
name = "frangipane"
|
name = "frangipane"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cpal",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -1027,10 +1101,13 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-upload",
|
"tauri-plugin-upload",
|
||||||
"tauri-plugin-websocket",
|
"tauri-plugin-websocket",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1263,6 +1340,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gethostname"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
|
dependencies = [
|
||||||
|
"rustix",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.1.16"
|
version = "0.1.16"
|
||||||
@@ -1841,6 +1928,33 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jack"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73213dab741ae0a4623824289625611d11bb8254ad1fdefc84e480f7632aa528"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"jack-sys",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jack-sys"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6013b7619b95a22b576dfb43296faa4ecbe40abbdb97dfd22ead520775fc86ab"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"lazy_static",
|
||||||
|
"libc",
|
||||||
|
"libloading",
|
||||||
|
"log",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "javascriptcore-rs"
|
name = "javascriptcore-rs"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -2042,6 +2156,27 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -2191,12 +2326,37 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.11.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"serde",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -2259,6 +2419,31 @@ dependencies = [
|
|||||||
"objc2-quartz-core",
|
"objc2-quartz-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"libc",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2270,6 +2455,29 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"objc2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2288,7 +2496,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
"dispatch2",
|
"dispatch2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2315,6 +2525,16 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-location"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-text"
|
name = "objc2-core-text"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2419,8 +2639,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image",
|
||||||
|
"objc2-core-location",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-quartz-core",
|
||||||
|
"objc2-user-notifications",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-user-notifications"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2474,6 +2713,22 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "os_info"
|
||||||
|
version = "3.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"log",
|
||||||
|
"nix",
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-ui-kit",
|
||||||
|
"serde",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2711,7 +2966,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.12.1",
|
"indexmap 2.12.1",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -2857,6 +3112,15 @@ dependencies = [
|
|||||||
"psl-types",
|
"psl-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -3782,6 +4046,15 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sys-locale"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-configuration"
|
name = "system-configuration"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -4069,6 +4342,25 @@ dependencies = [
|
|||||||
"urlpattern",
|
"urlpattern",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-notification"
|
||||||
|
version = "2.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"notify-rust",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"time",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.2"
|
version = "2.5.2"
|
||||||
@@ -4091,6 +4383,24 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-os"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
|
||||||
|
dependencies = [
|
||||||
|
"gethostname",
|
||||||
|
"log",
|
||||||
|
"os_info",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serialize-to-javascript",
|
||||||
|
"sys-locale",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-store"
|
name = "tauri-plugin-store"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -4246,6 +4556,18 @@ dependencies = [
|
|||||||
"toml 0.9.8",
|
"toml 0.9.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"windows",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.23.0"
|
version = "3.23.0"
|
||||||
@@ -4574,9 +4896,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.43"
|
version = "0.1.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
@@ -4596,9 +4918,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.35"
|
version = "0.1.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ tauri-plugin-websocket = "2"
|
|||||||
tauri-plugin-upload = "2"
|
tauri-plugin-upload = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
cpal = { version = "0.17.1", features = ["jack"] }
|
||||||
|
tracing = "0.1.44"
|
||||||
|
|
||||||
|
|||||||
8
src-tauri/Info.plist
Normal file
8
src-tauri/Info.plist
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Request microphone access for WebRTC</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -7,6 +7,12 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"store:default",
|
"store:default",
|
||||||
{
|
{
|
||||||
@@ -47,7 +53,9 @@
|
|||||||
"allow": [
|
"allow": [
|
||||||
"*"
|
"*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"os:default",
|
||||||
|
"notification:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|||||||
169
src-tauri/src/audio.rs
Normal file
169
src-tauri/src/audio.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
use cpal::{Host, HostId, SampleFormat};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
|
pub struct AudioState {
|
||||||
|
stream: Arc<Mutex<Option<cpal::Stream>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
stream: Arc::new(Mutex::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct MicConfig {
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to resolve host string to cpal::Host
|
||||||
|
fn get_host_by_name(name: &str) -> Result<Host, String> {
|
||||||
|
let host_id = cpal::available_hosts()
|
||||||
|
.into_iter()
|
||||||
|
.find(|id| id.name() == name)
|
||||||
|
.ok_or_else(|| format!("Host '{}' not found", name))?;
|
||||||
|
|
||||||
|
cpal::host_from_id(host_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_audio_hosts() -> Vec<String> {
|
||||||
|
cpal::available_hosts()
|
||||||
|
.into_iter()
|
||||||
|
.map(|id| id.name().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_input_devices(host_name: Option<String>) -> Result<Vec<(String, String)>, String> {
|
||||||
|
// If no host provided, use default
|
||||||
|
let host = if let Some(name) = host_name {
|
||||||
|
get_host_by_name(&name)?
|
||||||
|
} else {
|
||||||
|
cpal::default_host()
|
||||||
|
};
|
||||||
|
|
||||||
|
let devices = host.input_devices().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let device_names: Vec<(String, String)> = devices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|device| {
|
||||||
|
Some((
|
||||||
|
device.id().ok()?.to_string(),
|
||||||
|
device.description().ok()?.to_string(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(device_names)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn start_microphone(
|
||||||
|
app: AppHandle,
|
||||||
|
state: tauri::State<AudioState>,
|
||||||
|
host_name: Option<String>,
|
||||||
|
device_id: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let host = if let Some(ref h_name) = host_name {
|
||||||
|
get_host_by_name(h_name)?
|
||||||
|
} else {
|
||||||
|
cpal::default_host()
|
||||||
|
};
|
||||||
|
|
||||||
|
let device = if let Some(ref id) = device_id {
|
||||||
|
host.input_devices()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.find(|d| d.id().map(|i| i.to_string()) == Ok(id.to_string()))
|
||||||
|
.ok_or_else(|| format!("Device '{}' not found on host '{:?}'", id, host.id()))?
|
||||||
|
} else {
|
||||||
|
host.default_input_device()
|
||||||
|
.ok_or("No input device available")?
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = device
|
||||||
|
.default_input_config()
|
||||||
|
.map_err(|e| format!("Failed to get config: {}", e))?;
|
||||||
|
|
||||||
|
let sample_rate = config.sample_rate();
|
||||||
|
let device_channels = config.channels() as usize;
|
||||||
|
let channels = 1;
|
||||||
|
|
||||||
|
let buffer_threshold = (sample_rate as usize / 10) * 2;
|
||||||
|
|
||||||
|
app.emit(
|
||||||
|
"microphone-config",
|
||||||
|
MicConfig {
|
||||||
|
sample_rate: sample_rate,
|
||||||
|
channels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let sample_format = config.sample_format();
|
||||||
|
let stream_config: cpal::StreamConfig = config.into();
|
||||||
|
let err_fn = |err| eprintln!("Stream error: {}", err);
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
|
let stream = match sample_format {
|
||||||
|
cpal::SampleFormat::F32 => {
|
||||||
|
let mut local_buffer = Vec::with_capacity(buffer_threshold * 2);
|
||||||
|
device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data: &[f32], _| {
|
||||||
|
for frame in data.chunks(device_channels) {
|
||||||
|
let sample = frame[0].clamp(-1.0, 1.0);
|
||||||
|
let v = if sample >= 0.0 {
|
||||||
|
(sample * 32767.0) as i16
|
||||||
|
} else {
|
||||||
|
(sample * 32768.0) as i16
|
||||||
|
};
|
||||||
|
local_buffer.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
if local_buffer.len() >= buffer_threshold {
|
||||||
|
let _ = app_handle.emit("microphone-data", &local_buffer);
|
||||||
|
local_buffer.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cpal::SampleFormat::I16 => {
|
||||||
|
let mut local_buffer = Vec::with_capacity(buffer_threshold * 2);
|
||||||
|
device.build_input_stream(
|
||||||
|
&stream_config,
|
||||||
|
move |data: &[i16], _| {
|
||||||
|
for frame in data.chunks(device_channels) {
|
||||||
|
local_buffer.extend_from_slice(&frame[0].to_le_bytes());
|
||||||
|
}
|
||||||
|
if local_buffer.len() >= buffer_threshold {
|
||||||
|
let _ = app_handle.emit("microphone-data", &local_buffer);
|
||||||
|
local_buffer.clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
err_fn,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return Err("Unsupported sample format".to_string()),
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
stream.play().map_err(|e| e.to_string())?;
|
||||||
|
*state.stream.lock().unwrap() = Some(stream);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn stop_microphone(state: tauri::State<AudioState>) -> Result<(), String> {
|
||||||
|
let mut stream_guard = state.stream.lock().unwrap();
|
||||||
|
*stream_guard = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
mod audio;
|
||||||
|
use audio::{get_audio_hosts, get_input_devices, start_microphone, stop_microphone};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn log(message: &str) {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
println!("[JS] {}", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.manage(audio::AudioState::new())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_upload::init())
|
.plugin(tauri_plugin_upload::init())
|
||||||
@@ -14,7 +19,13 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
start_microphone,
|
||||||
|
stop_microphone,
|
||||||
|
log,
|
||||||
|
get_input_devices,
|
||||||
|
get_audio_hosts
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod audio;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
frangipane_lib::run()
|
frangipane_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
7
src-tauri/tauri.android.conf.json
Normal file
7
src-tauri/tauri.android.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 26
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "frangipane",
|
"productName": "frangipane",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"identifier": "com.strawberries.frangipane",
|
"identifier": "com.strawberries.frangipane",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "yarn dev",
|
"beforeDevCommand": "yarn dev",
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"title": "frangipane",
|
"title": "frangipane",
|
||||||
"width": 800,
|
"width": 800,
|
||||||
"height": 600,
|
"height": 600,
|
||||||
"decorations": true,
|
"decorations": false,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"center": true,
|
"center": true,
|
||||||
|
|||||||
85
src/App.vue
85
src/App.vue
@@ -1,5 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page">
|
<div id="page" :class="{ 'is-mobile': currentPlatform === 'android' || currentPlatform === 'ios' }">
|
||||||
|
<div v-if="currentPlatform != 'android' && currentPlatform != 'ios'" data-tauri-drag-region class="titlebar">
|
||||||
|
<div class="titlebar-button" @click="minimize">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<rect fill="currentColor" width="10" height="1" x="1" y="6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-button" @click="toggleMaximize">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<rect fill="none" stroke="currentColor" stroke-width="1" width="9" height="9" x="1.5" y="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="titlebar-button" id="close-btn" @click="close">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M11 1.57L10.43 1 6 5.43 1.57 1 1 1.57 5.43 6 1 10.43 1.57 11 6 6.57 10.43 11 11 10.43 6.57 6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main id="content">
|
<main id="content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
@@ -7,6 +26,8 @@
|
|||||||
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
||||||
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
||||||
|
|
||||||
|
<VoiceControl />
|
||||||
|
|
||||||
<footer v-if="!$route.meta.hideNavbar">
|
<footer v-if="!$route.meta.hideNavbar">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</footer>
|
</footer>
|
||||||
@@ -19,6 +40,13 @@ import Navbar from './components/Navbar.vue'
|
|||||||
import VersionWarningModal from './components/VersionWarningModal.vue'
|
import VersionWarningModal from './components/VersionWarningModal.vue'
|
||||||
import { apiFetch } from './api/client'
|
import { apiFetch } from './api/client'
|
||||||
import { VersionResponse } from './types'
|
import { VersionResponse } from './types'
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { platform } from '@tauri-apps/plugin-os';
|
||||||
|
import VoiceControl from './components/VoiceControl.vue'
|
||||||
|
|
||||||
|
const currentPlatform = ref('')
|
||||||
|
|
||||||
|
const appWindow = getCurrentWindow()
|
||||||
|
|
||||||
const showVersionWarningModal = ref(false)
|
const showVersionWarningModal = ref(false)
|
||||||
const backendVersion = ref('')
|
const backendVersion = ref('')
|
||||||
@@ -26,13 +54,24 @@ const backendVersion = ref('')
|
|||||||
const appVersion = __APP_VERSION__
|
const appVersion = __APP_VERSION__
|
||||||
const expectedBackendVersion = __BACKEND_VERSION__
|
const expectedBackendVersion = __BACKEND_VERSION__
|
||||||
|
|
||||||
|
const isFullScreen = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
backendVersion.value = (await getBackendVersion()).version
|
backendVersion.value = (await getBackendVersion()).version
|
||||||
if (backendVersion.value !== expectedBackendVersion) {
|
if (backendVersion.value !== expectedBackendVersion) {
|
||||||
showVersionWarningModal.value = true
|
showVersionWarningModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPlatform.value = platform()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const minimize = () => appWindow.minimize()
|
||||||
|
const toggleMaximize = () => {
|
||||||
|
appWindow.setFullscreen(!isFullScreen.value)
|
||||||
|
isFullScreen.value = !isFullScreen.value
|
||||||
|
}
|
||||||
|
const close = () => appWindow.close()
|
||||||
|
|
||||||
async function getBackendVersion() {
|
async function getBackendVersion() {
|
||||||
return await apiFetch<VersionResponse>('/version')
|
return await apiFetch<VersionResponse>('/version')
|
||||||
}
|
}
|
||||||
@@ -50,27 +89,65 @@ async function getBackendVersion() {
|
|||||||
#content {
|
#content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
padding: 2rem;
|
/* padding: 2rem; */
|
||||||
|
padding: calc(20px + 1.8rem) 1.8rem calc(1.8rem - 10px) 1.8rem;
|
||||||
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.titlebar {
|
||||||
|
height: 30px;
|
||||||
|
background: var(--panel);
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 45px;
|
||||||
|
height: 30px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-button:hover {
|
||||||
|
background: var(--panel-accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-btn:hover {
|
||||||
|
background: #e81123;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-bottom: 24px;
|
padding-bottom: calc(1.8rem - 10px);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
#content {
|
#content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
padding-top: calc(30px + 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-mobile #content {
|
||||||
padding-top: 30px;
|
padding-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
.is-mobile footer {
|
||||||
padding-bottom: 56px;
|
padding-bottom: 56px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { UpdateUserResponse } from '../types';
|
import { UpdateUserResponse } from '../types';
|
||||||
import { apiFetch } from './client'
|
import { apiFetch, ApiError } from './client'
|
||||||
// import { upload } from '@tauri-apps/plugin-upload';
|
|
||||||
import { getAuthData } from '../store';
|
import { getAuthData } from '../store';
|
||||||
import { API } from '../main.ts';
|
import { API } from '../main.ts';
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ export async function uploadAvatar(
|
|||||||
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`);
|
xhr.setRequestHeader('Authorization', `Bearer ${auth.token}`);
|
||||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||||
|
|
||||||
// Handle Progress
|
|
||||||
if (xhr.upload && onProgress) {
|
if (xhr.upload && onProgress) {
|
||||||
xhr.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
@@ -39,11 +37,32 @@ export async function uploadAvatar(
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
resolve(xhr.response);
|
resolve(xhr.response);
|
||||||
} else {
|
} else {
|
||||||
|
if (xhr.status === 413) {
|
||||||
|
reject(new ApiError('FILE_TOO_LARGE', 'File too large'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = JSON.parse(xhr.responseText);
|
||||||
|
if (res && res.code && res.message) {
|
||||||
|
reject(new ApiError(res.code, res.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
|
||||||
reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`));
|
reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => reject(new Error('Network error during upload'));
|
xhr.onerror = () => {
|
||||||
|
if (xhr.status === 413) {
|
||||||
|
reject(new ApiError('FILE_TOO_LARGE', 'File too large'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new ApiError("UPLOAD_FAILED", "Failed uploading file."));
|
||||||
|
};
|
||||||
|
|
||||||
xhr.send(fileData);
|
xhr.send(fileData);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { getAuthData, clearAuthData } from '../store'
|
|||||||
import { API } from '../main.ts'
|
import { API } from '../main.ts'
|
||||||
import router from '../router'
|
import router from '../router'
|
||||||
|
|
||||||
|
// Custom Error class to hold the backend code
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
constructor(code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
@@ -15,26 +26,36 @@ export async function apiFetch<T>(
|
|||||||
...options,
|
...options,
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
// Only add json header if it's not formdata
|
|
||||||
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
|
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
...(auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 401 && auth.token) {
|
// Invalid token?
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (auth.token) {
|
||||||
await clearAuthData()
|
await clearAuthData()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
throw new Error("Session expired")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle error responses
|
// Handle error responses
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (json && json.code && json.message) {
|
||||||
|
throw new ApiError(json.code, json.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) throw e;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(text || res.statusText)
|
throw new Error(text || res.statusText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the response as text first
|
|
||||||
const responseText = await res.text()
|
const responseText = await res.text()
|
||||||
|
|
||||||
if (!responseText) {
|
if (!responseText) {
|
||||||
|
|||||||
@@ -29,3 +29,14 @@ export function declineFriendRequest(senderUuid: string) {
|
|||||||
body: JSON.stringify({ sender_uuid: senderUuid }),
|
body: JSON.stringify({ sender_uuid: senderUuid }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkIsFriend(targetUuid: string) {
|
||||||
|
return apiFetch<boolean>(`/friends/check/${targetUuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFriend(friendUuid: string) {
|
||||||
|
return apiFetch<void>('/friends/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ friend_uuid: friendUuid }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,6 @@ export function sendMessage(roomUuid: string, content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getWsToken(): Promise<string> {
|
export async function getWsToken(): Promise<string> {
|
||||||
const res = await apiFetch<{ token: string }>(`/ws/messages/issue-token`);
|
const res = await apiFetch<{ token: string }>(`/ws/issue-token`);
|
||||||
return res.token;
|
return res.token;
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/base.css
22
src/base.css
@@ -16,6 +16,7 @@ body,
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* This is overwritten by the theme configuration */
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1116;
|
--bg: #0f1116;
|
||||||
--panel: #171922;
|
--panel: #171922;
|
||||||
@@ -23,6 +24,7 @@ body,
|
|||||||
--text: #e6e6eb;
|
--text: #e6e6eb;
|
||||||
--muted: #9aa0aa;
|
--muted: #9aa0aa;
|
||||||
--accent: #f27aa3;
|
--accent: #f27aa3;
|
||||||
|
--accent-rgb: 242, 122, 163;
|
||||||
--accent-hover: #ff91b3;
|
--accent-hover: #ff91b3;
|
||||||
--accent-second: #96CDFB;
|
--accent-second: #96CDFB;
|
||||||
--border: #2a2f3b;
|
--border: #2a2f3b;
|
||||||
@@ -30,6 +32,10 @@ body,
|
|||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont,
|
font-family: system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", sans-serif;
|
"Segoe UI", sans-serif;
|
||||||
@@ -59,7 +65,8 @@ input:focus, textarea:focus {
|
|||||||
button, .button {
|
button, .button {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #0b0d12;
|
color: var(--bg);
|
||||||
|
/* color: var(--btn-text); */
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
@@ -74,6 +81,19 @@ button:hover, .button:hover {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
@close="showInviteModal = false" @room-changed="handleRoomChanged" />
|
@close="showInviteModal = false" @room-changed="handleRoomChanged" />
|
||||||
|
|
||||||
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
|
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
|
||||||
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false" :ownerUuid="currentRoom?.owner_uuid || ''"
|
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false"
|
||||||
@close="showDetailsModal = false" @room-changed="handleRoomChanged" />
|
:ownerUuid="currentRoom?.owner_uuid || ''" @close="showDetailsModal = false"
|
||||||
|
@room-changed="handleRoomChanged" />
|
||||||
|
|
||||||
|
<VoiceDeviceModal v-if="showVoiceModal" @close="showVoiceModal = false" @select="handleVoiceSelect" />
|
||||||
|
|
||||||
<div v-if="uuid === 'none'" class="no-room">
|
<div v-if="uuid === 'none'" class="no-room">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
@@ -43,6 +46,10 @@
|
|||||||
<i class="fa-solid fa-users"></i>
|
<i class="fa-solid fa-users"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="invite-btn" @click="toggleVoice" :class="{ 'active-voice': isCurrentRoomVoice }">
|
||||||
|
<i class="fa-solid" :class="isCurrentRoomVoice ? 'fa-phone-slash' : 'fa-phone'"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div v-if="connectionError" class="connection-error">
|
<div v-if="connectionError" class="connection-error">
|
||||||
<p>{{ connectionError }}</p>
|
<p>{{ connectionError }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,10 +69,13 @@ import MessageList from "./MessageList.vue";
|
|||||||
import MessageInput from "./MessageInput.vue";
|
import MessageInput from "./MessageInput.vue";
|
||||||
import InvitePeopleModal from './InvitePeopleModal.vue';
|
import InvitePeopleModal from './InvitePeopleModal.vue';
|
||||||
import RoomDetailsModal from "./RoomDetailsModal.vue";
|
import RoomDetailsModal from "./RoomDetailsModal.vue";
|
||||||
|
import VoiceDeviceModal from "./VoiceDeviceModal.vue";
|
||||||
import WebSocket from '@tauri-apps/plugin-websocket';
|
import WebSocket from '@tauri-apps/plugin-websocket';
|
||||||
import { getAuthData } from "../store.ts";
|
import { getAuthData } from "../store.ts";
|
||||||
import { fetchRoomInfo } from "../api/rooms.ts";
|
import { fetchRoomInfo } from "../api/rooms.ts";
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { sendNotification } from '@tauri-apps/plugin-notification';
|
||||||
|
import { voiceActions, voiceState } from "../voice";
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
|
||||||
@@ -88,9 +98,11 @@ const connectionError = ref<string | null>(null);
|
|||||||
// Pagination State
|
// Pagination State
|
||||||
const isLoadingMore = ref(false);
|
const isLoadingMore = ref(false);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
|
const isInitialLoad = ref(false);
|
||||||
|
|
||||||
const showInviteModal = ref(false);
|
const showInviteModal = ref(false);
|
||||||
const showDetailsModal = ref(false);
|
const showDetailsModal = ref(false);
|
||||||
const isInitialLoad = ref(false);
|
const showVoiceModal = ref(false);
|
||||||
|
|
||||||
// WebSocket State
|
// WebSocket State
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
@@ -107,6 +119,10 @@ const handleRoomChanged = () => {
|
|||||||
emit('room-action');
|
emit('room-action');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCurrentRoomVoice = computed(() => {
|
||||||
|
return voiceState.currentRoomUuid === props.uuid && voiceState.status === 'connected';
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await connectGlobalWebSocket();
|
await connectGlobalWebSocket();
|
||||||
|
|
||||||
@@ -170,7 +186,7 @@ async function connectGlobalWebSocket() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get a one-time token for the connection
|
// Get a one-time token for the connection
|
||||||
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
const res = await apiFetch<{ token: string }>('/ws/issue-token');
|
||||||
const wsToken = res.token;
|
const wsToken = res.token;
|
||||||
|
|
||||||
const url = `${API_WS}/messages?token=${wsToken}`;
|
const url = `${API_WS}/messages?token=${wsToken}`;
|
||||||
@@ -194,6 +210,12 @@ async function connectGlobalWebSocket() {
|
|||||||
} else {
|
} else {
|
||||||
// Notifications for other rooms
|
// Notifications for other rooms
|
||||||
emit('notification', data.room_uuid);
|
emit('notification', data.room_uuid);
|
||||||
|
|
||||||
|
sendNotification({
|
||||||
|
title: $t('notifications-message-title', { messageType: data.message_type, senderUsername: data.sender }),
|
||||||
|
body: data.content,
|
||||||
|
// channelId: 'messages',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -301,6 +323,26 @@ async function onSend(content: string) {
|
|||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
await sendMessage(props.uuid, content);
|
await sendMessage(props.uuid, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleVoice() {
|
||||||
|
if (isCurrentRoomVoice.value) {
|
||||||
|
await voiceActions.leaveRoom();
|
||||||
|
} else {
|
||||||
|
showVoiceModal.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVoiceSelect(selection: { host: string | null, device: string | null }) {
|
||||||
|
showVoiceModal.value = false;
|
||||||
|
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await voiceActions.joinRoom(props.uuid, selection.host, selection.device);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to join voice", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -327,7 +369,7 @@ async function onSend(content: string) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
/* pointer-events: none; */
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +389,7 @@ async function onSend(content: string) {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: white;
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
@@ -386,7 +428,7 @@ async function onSend(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:hover {
|
.retry-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--panel-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-room {
|
.no-room {
|
||||||
@@ -416,7 +458,7 @@ async function onSend(content: string) {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
/* pointer-events: none; */
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -449,4 +491,8 @@ async function onSend(content: string) {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-voice {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,15 +76,6 @@ const emit = defineEmits(['yes', 'no']);
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
border-color: var(--error);
|
border-color: var(--error);
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<form class="modal" @submit.prevent="submit">
|
<form class="modal" @submit.prevent="submit">
|
||||||
<h2>{{ $t('chat-create-title') }}</h2>
|
<h2>{{ $t('chat-create-title') }}</h2>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>{{ $t('chat-create-name') }}</label>
|
<label>{{ $t('chat-create-name') }}</label>
|
||||||
<input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus />
|
<input v-model="name" :placeholder="$t('chat-create-name-placeholder')" autofocus />
|
||||||
@@ -32,7 +34,11 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { createRoom } from '../api/rooms'
|
import { createRoom } from '../api/rooms'
|
||||||
import type { Room } from '../types'
|
import type { Room } from '../types'
|
||||||
|
import { useErrorTranslator } from '../errors';
|
||||||
|
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
|
const errorMessage = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const global = ref(false)
|
const global = ref(false)
|
||||||
|
|
||||||
@@ -42,12 +48,17 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
try {
|
||||||
const room = await createRoom(name.value, global.value)
|
const room = await createRoom(name.value, global.value)
|
||||||
emit('created', room)
|
emit('created', room)
|
||||||
emit('close')
|
emit('close')
|
||||||
name.value = ''
|
name.value = ''
|
||||||
global.value = false
|
global.value = false
|
||||||
}
|
}
|
||||||
|
catch (err) {
|
||||||
|
errorMessage.value = translateError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -92,14 +103,4 @@ async function submit() {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ import { ref } from 'vue'
|
|||||||
import { sendRoomInvite } from '../api/rooms'
|
import { sendRoomInvite } from '../api/rooms'
|
||||||
import { sendFriendRequest } from '../api/friends';
|
import { sendFriendRequest } from '../api/friends';
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from '../errors';
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
const props = defineProps<{ room_uuid: string }>();
|
const props = defineProps<{ room_uuid: string }>();
|
||||||
|
|
||||||
@@ -50,10 +52,7 @@ async function submit() {
|
|||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
const username = receiverUsername.value.trim()
|
const username = receiverUsername.value.trim()
|
||||||
|
|
||||||
if (!username) {
|
if (!username) return
|
||||||
// errorMessage.value = 'Username is required.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendRoomInvite(username, props.room_uuid)
|
await sendRoomInvite(username, props.room_uuid)
|
||||||
@@ -67,7 +66,7 @@ async function submit() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err?.message || err || $t('shared-error');
|
errorMessage.value = translateError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -120,14 +119,4 @@ async function submit() {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea ref="textareaRef" v-model="content" @input="resize" @keydown="handleKeydown" rows="1"
|
<div class="container-div">
|
||||||
|
<textarea ref="textareaRef" v-model="content" @input="handleInput" @keydown="handleKeydown" rows="1"
|
||||||
:placeholder="$t('chat-input-placeholder')"></textarea>
|
:placeholder="$t('chat-input-placeholder')"></textarea>
|
||||||
|
|
||||||
|
<button class="send-btn" @click="submit" :disabled="!content.trim()">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, nextTick, onMounted } from 'vue'
|
||||||
|
import { saveMessageDraft, getMessageDraft } from '../store'
|
||||||
|
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
@@ -17,10 +24,24 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const saved = await getMessageDraft()
|
||||||
|
if (saved) {
|
||||||
|
content.value = saved
|
||||||
|
resize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
resize()
|
||||||
|
saveMessageDraft(content.value)
|
||||||
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!content.value.trim()) return
|
if (!content.value.trim()) return
|
||||||
emit('send', content.value)
|
emit('send', content.value)
|
||||||
content.value = ''
|
content.value = ''
|
||||||
|
saveMessageDraft('')
|
||||||
resize()
|
resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +64,9 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
textarea.selectionStart = textarea.selectionEnd = start + 1
|
textarea.selectionStart = textarea.selectionEnd = start + 1
|
||||||
})
|
})
|
||||||
resize()
|
resize()
|
||||||
|
|
||||||
|
saveMessageDraft(content.value)
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
} else if (!e.shiftKey && !e.altKey && e.key === 'Enter') {
|
} else if (!e.shiftKey && !e.altKey && e.key === 'Enter') {
|
||||||
submit()
|
submit()
|
||||||
@@ -52,6 +76,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.container-div {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -64,5 +95,26 @@ textarea {
|
|||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
margin-right: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:hover:not(:disabled) {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,38 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<ul>
|
<ul :class="{ 'is-compact': isCompact }">
|
||||||
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
|
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
|
||||||
<div class="sender-info">
|
<div class="sender-info">
|
||||||
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="avatar" />
|
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="avatar clickable"
|
||||||
<div class="sender">{{ m.sender }}</div>
|
@click.stop="openUserProfile(m)" />
|
||||||
|
<div class="sender clickable" @click.stop="openUserProfile(m)">
|
||||||
|
{{ m.sender }}
|
||||||
|
</div>
|
||||||
<span class="timestamp">{{ m.sent_at }}</span>
|
<span class="timestamp">{{ m.sent_at }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-content">{{ m.content }}</div>
|
<div class="message-content">{{ m.content }}</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<UserProfileModal v-if="selectedUser" :username="selectedUser.name" :user-uuid="selectedUser.uuid"
|
||||||
|
@close="selectedUser = null" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import type { Message } from '../types'
|
import type { Message } from '../types'
|
||||||
import { getAvatarUrl } from '../store.ts'
|
import { getAvatarUrl, getCompactLayoutPreference } from '../store.ts'
|
||||||
import defaultAvatar from '../assets/default-avatar.png'
|
import defaultAvatar from '../assets/default-avatar.png'
|
||||||
import { getAuthData } from '../store.ts';
|
import { getAuthData } from '../store.ts';
|
||||||
|
import UserProfileModal from './UserProfileModal.vue';
|
||||||
|
|
||||||
defineProps<{ messages: Message[] }>()
|
defineProps<{ messages: Message[] }>()
|
||||||
|
|
||||||
const currentUserUuid = ref<string | null>(null)
|
const currentUserUuid = ref<string | null>(null)
|
||||||
|
const isCompact = ref(false)
|
||||||
|
|
||||||
|
const selectedUser = ref<{ name: string, uuid: string } | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const auth = await getAuthData()
|
const auth = await getAuthData()
|
||||||
if (auth.user) {
|
if (auth.user) {
|
||||||
currentUserUuid.value = auth.user.uuid
|
currentUserUuid.value = auth.user.uuid
|
||||||
}
|
}
|
||||||
|
isCompact.value = await getCompactLayoutPreference()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleAvatarError = (event: Event) => {
|
const handleAvatarError = (event: Event) => {
|
||||||
const img = event.target as HTMLImageElement;
|
const img = event.target as HTMLImageElement;
|
||||||
img.src = defaultAvatar;
|
img.src = defaultAvatar;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openUserProfile = (message: Message) => {
|
||||||
|
selectedUser.value = {
|
||||||
|
name: message.sender,
|
||||||
|
uuid: message.sender_uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -50,14 +68,12 @@ ul {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
/* gap: 0.5rem; */
|
|
||||||
background: var(--panel-accent);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
/* border: 1px solid var(--border); */
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-info {
|
.sender-info {
|
||||||
@@ -66,14 +82,41 @@ ul {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
/* border: 1px solid var(--border); */
|
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
/* border-radius: var(--radius) var(--radius) 0 0; */
|
|
||||||
/* background-color: rgba(255, 255, 255, 0.02); */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 18px;
|
padding: 5px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender.clickable:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
padding: 10px;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.message.is-me {
|
.message.is-me {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -87,13 +130,31 @@ ul {
|
|||||||
.message.is-me .message-content {
|
.message.is-me .message-content {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
padding-left: 10px;
|
}
|
||||||
|
|
||||||
|
ul.is-compact .message {
|
||||||
|
background: transparent;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.is-compact .sender-info {
|
||||||
|
border-bottom: none;
|
||||||
|
padding: 5px 18px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.is-compact .message-content {
|
||||||
|
padding: 5px 10px 10px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.is-compact .message.is-me .message-content {
|
||||||
|
padding-right: 50px;
|
||||||
|
padding-left: 0;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender {
|
.sender {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
/* flex: 1; */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
@@ -101,13 +162,4 @@ ul {
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
|
||||||
padding: 10px;
|
|
||||||
padding-left: 1rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
max-width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ onMounted(() => {
|
|||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: black;
|
color: var(--bg);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
@@ -92,7 +92,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--panel-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:not(.router-link-active):hover i {
|
.nav-item:not(.router-link-active):hover i {
|
||||||
|
|||||||
@@ -205,15 +205,6 @@ const handleAvatarError = (event: Event) => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list {
|
.member-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:hover {
|
.retry-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--panel-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-content-wrapper {
|
.room-content-wrapper {
|
||||||
@@ -150,7 +150,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: black;
|
color: var(--bg);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
@@ -193,7 +193,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.room-item:hover {
|
.room-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--panel-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +236,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-btn:hover {
|
.create-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--panel-hover);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ import { updateSettings } from '../api/account'
|
|||||||
import { updateLocalUser } from '../store'
|
import { updateLocalUser } from '../store'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from '../errors';
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
const props = defineProps<{ user: User | null }>()
|
const props = defineProps<{ user: User | null }>()
|
||||||
const emit = defineEmits(['close', 'updated'])
|
const emit = defineEmits(['close', 'updated'])
|
||||||
@@ -84,7 +86,7 @@ async function submit() {
|
|||||||
emit('updated')
|
emit('updated')
|
||||||
emit('close')
|
emit('close')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err?.message || $t('settings-error-failed')
|
errorMessage.value = translateError(err);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -112,7 +114,6 @@ async function submit() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.2rem;
|
gap: 1.2rem;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -154,10 +155,4 @@ button:disabled {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ import { refreshLocalUser } from '../store.ts';
|
|||||||
import { getAuthData } from '../store.ts';
|
import { getAuthData } from '../store.ts';
|
||||||
import { refreshAvatar } from '../store.ts';
|
import { refreshAvatar } from '../store.ts';
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from '../errors.ts';
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'updated']);
|
const emit = defineEmits(['close', 'updated']);
|
||||||
|
|
||||||
@@ -166,7 +168,8 @@ async function handleUpload() {
|
|||||||
emit('close');
|
emit('close');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Upload failed:", err);
|
console.error("Upload failed:", err);
|
||||||
errorMessage.value = $t('settings-error-upload-avatar-failed-upload');
|
const msg = translateError(err);
|
||||||
|
errorMessage.value = msg !== 'An error occurred' ? msg : $t('settings-error-upload-avatar-failed-upload');
|
||||||
isSubmitting.value = false;
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +205,7 @@ async function handleUpload() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--panel-accent);
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -221,7 +224,7 @@ async function handleUpload() {
|
|||||||
|
|
||||||
.drop-zone:hover {
|
.drop-zone:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--panel-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-content i {
|
.drop-content i {
|
||||||
@@ -299,10 +302,4 @@ async function handleUpload() {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
256
src/components/UserProfileModal.vue
Normal file
256
src/components/UserProfileModal.vue
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="backdrop" @click.self="emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
<!-- <h2>User Profile</h2> -->
|
||||||
|
|
||||||
|
<div class="profile-content">
|
||||||
|
<img :src="getAvatarUrl(userUuid)" @error="handleAvatarError" class="avatar-large" />
|
||||||
|
|
||||||
|
<div class="info-group">
|
||||||
|
<label>{{ $t('profile-username') }}</label>
|
||||||
|
<div class="info-value">{{ username }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-group">
|
||||||
|
<label>{{ $t('profile-userid') }}</label>
|
||||||
|
<div class="info-value uuid">{{ userUuid }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<!-- Friend Button -->
|
||||||
|
<div v-if="!isMe && !isLoading" class="friend-actions">
|
||||||
|
<button v-if="isFriend" class="btn-danger" @click="requestRemoveFriend" :disabled="isActionLoading">
|
||||||
|
{{ $t('profile-remove-friend') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-else class="btn-primary" @click="handleAddFriend" :disabled="isActionLoading || requestSent">
|
||||||
|
{{ requestSent ? $t('profile-request-sent') : $t('profile-add-friend') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" @click="emit('close')" class="secondary">
|
||||||
|
{{ $t('shared-close') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal v-if="modalState.visible" :title="modalState.title" :message="modalState.message"
|
||||||
|
:confirm-label="modalState.confirmLabel" :confirm-button-class="modalState.isDanger ? 'btn-danger' : ''"
|
||||||
|
@yes="handleConfirmRemove" @no="closeConfirmModal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, reactive } from 'vue'
|
||||||
|
import { getAvatarUrl, getAuthData } from '../store.ts'
|
||||||
|
import { checkIsFriend, removeFriend, sendFriendRequest } from '../api/friends'
|
||||||
|
import defaultAvatar from '../assets/default-avatar.png'
|
||||||
|
import ConfirmModal from './ConfirmModal.vue'
|
||||||
|
import { useFluent } from 'fluent-vue'
|
||||||
|
|
||||||
|
const { $t } = useFluent()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
username: string
|
||||||
|
userUuid: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentUserUuid = ref<string | null>(null)
|
||||||
|
const isFriend = ref(false)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const isActionLoading = ref(false)
|
||||||
|
const requestSent = ref(false)
|
||||||
|
|
||||||
|
// Confirm Modal State
|
||||||
|
const modalState = reactive({
|
||||||
|
visible: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: '',
|
||||||
|
isDanger: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMe = computed(() => currentUserUuid.value === props.userUuid)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const auth = await getAuthData()
|
||||||
|
if (auth.user) {
|
||||||
|
currentUserUuid.value = auth.user.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check if not self
|
||||||
|
if (currentUserUuid.value && currentUserUuid.value !== props.userUuid) {
|
||||||
|
isFriend.value = await checkIsFriend(props.userUuid)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check friend status", error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAddFriend = async () => {
|
||||||
|
isActionLoading.value = true
|
||||||
|
try {
|
||||||
|
await sendFriendRequest(props.username)
|
||||||
|
requestSent.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send friend request", error)
|
||||||
|
} finally {
|
||||||
|
isActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRemoveFriend = () => {
|
||||||
|
modalState.title = $t('profile-remove-friend');
|
||||||
|
modalState.message = $t('profile-remove-friend-confirm', { user: props.username });
|
||||||
|
modalState.confirmLabel = $t('shared-delete'); // or 'shared-confirm'
|
||||||
|
modalState.isDanger = true;
|
||||||
|
modalState.visible = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConfirmModal = () => {
|
||||||
|
modalState.visible = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRemove = async () => {
|
||||||
|
closeConfirmModal();
|
||||||
|
isActionLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeFriend(props.userUuid)
|
||||||
|
isFriend.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to remove friend", error)
|
||||||
|
} finally {
|
||||||
|
isActionLoading.value = false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarError = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.src = defaultAvatar;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-large {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--panel-accent);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.uuid {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background-color: var(--error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -49,10 +49,4 @@ const emit = defineEmits(['close']);
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
154
src/components/VoiceControl.vue
Normal file
154
src/components/VoiceControl.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="voiceState.status !== 'disconnected'" class="voice-bar">
|
||||||
|
<div class="voice-info">
|
||||||
|
<div class="status-indicator" :class="voiceState.status"></div>
|
||||||
|
<div class="details">
|
||||||
|
<span class="room-label">Voice Connected</span>
|
||||||
|
<span class="speaking-label" v-if="speakersList.length > 0">
|
||||||
|
Speaking: {{ speakersList.length }}
|
||||||
|
</span>
|
||||||
|
<span class="speaking-label" v-else>Room Quiet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="voice-controls">
|
||||||
|
<button class="control-btn" :class="{ 'active': voiceState.isMuted }" @click="voiceActions.toggleMute()">
|
||||||
|
<i class="fa-solid" :class="voiceState.isMuted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="control-btn hangup" @click="voiceActions.leaveRoom()">
|
||||||
|
<i class="fa-solid fa-phone-slash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { voiceState, voiceActions } from '../voice'; // Ensure this path points to your voice.ts
|
||||||
|
|
||||||
|
const speakersList = computed(() => Array.from(voiceState.speakingUsers));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-select {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 150px;
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-select:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-bar {
|
||||||
|
background-color: var(--panel-accent);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 50px;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connected {
|
||||||
|
background-color: #4ade80;
|
||||||
|
/* Green */
|
||||||
|
box-shadow: 0 0 5px #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.connecting {
|
||||||
|
background-color: #facc15;
|
||||||
|
/* Yellow */
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.error {
|
||||||
|
background-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaking-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.active {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup {
|
||||||
|
color: #f87171;
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn.hangup:hover {
|
||||||
|
background: #f87171;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
src/components/VoiceDeviceModal.vue
Normal file
198
src/components/VoiceDeviceModal.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="backdrop" @click.self="$emit('close')">
|
||||||
|
<div class="modal">
|
||||||
|
|
||||||
|
<div v-if="step === 1" class="step-container">
|
||||||
|
<h3>{{ $t('chat-voice-select-host') || 'Select Audio System' }}</h3>
|
||||||
|
<div v-if="loadingHosts" class="loading">Loading systems...</div>
|
||||||
|
|
||||||
|
<div v-else class="list">
|
||||||
|
<button @click="selectHost(null)" class="item default">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles"></i>
|
||||||
|
<span>Automatic (Default)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-for="host in hosts" :key="host" @click="selectHost(host)" class="item">
|
||||||
|
<i class="fa-solid fa-server"></i>
|
||||||
|
<span>{{ host }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="step-container">
|
||||||
|
<div class="header">
|
||||||
|
<button class="back-btn" @click="step = 1">
|
||||||
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
|
</button>
|
||||||
|
<h3>{{ selectedHost }} Devices</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingDevices" class="loading">Scanning devices...</div>
|
||||||
|
|
||||||
|
<div v-else class="list">
|
||||||
|
<button @click="selectDevice(null)" class="item default">
|
||||||
|
<i class="fa-solid fa-star"></i>
|
||||||
|
<span>Default Device</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-for="device in devices" :key="device[0]" @click="selectDevice(device[0])" class="item">
|
||||||
|
<i class="fa-solid fa-microphone"></i>
|
||||||
|
<div class="device-details">
|
||||||
|
<span class="device-name">{{ device[1] }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="secondary" @click="$emit('close')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { voiceActions } from '../voice';
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'select']);
|
||||||
|
|
||||||
|
const step = ref(1);
|
||||||
|
const hosts = ref<string[]>([]);
|
||||||
|
const devices = ref<string[]>([]); // Array of [id, name] tuples
|
||||||
|
const selectedHost = ref<string | null>(null);
|
||||||
|
|
||||||
|
const loadingHosts = ref(false);
|
||||||
|
const loadingDevices = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadingHosts.value = true;
|
||||||
|
hosts.value = await voiceActions.getHosts();
|
||||||
|
loadingHosts.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectHost(host: string | null) {
|
||||||
|
selectedHost.value = host;
|
||||||
|
|
||||||
|
// Default
|
||||||
|
if (host === null) {
|
||||||
|
emit('select', { host: null, device: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
step.value = 2;
|
||||||
|
loadingDevices.value = true;
|
||||||
|
devices.value = await voiceActions.getDevices(host);
|
||||||
|
loadingDevices.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDevice(deviceId: string | null) {
|
||||||
|
emit('select', { host: selectedHost.value, device: deviceId });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: none;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.8rem;
|
||||||
|
gap: 0.8rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
30
src/errors.ts
Normal file
30
src/errors.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
|
|
||||||
|
export function useErrorTranslator() {
|
||||||
|
const { $t } = useFluent();
|
||||||
|
|
||||||
|
function translateError(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
// Convert "AUTH_INVALID_CREDENTIALS" -> "error-auth-invalid-credentials"
|
||||||
|
const key = `error-${err.code.toLowerCase().replace(/_/g, '-')}`;
|
||||||
|
|
||||||
|
const translated = $t(key);
|
||||||
|
|
||||||
|
// Fallback to the message provided by backend.
|
||||||
|
if (translated === key) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $t('shared-error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { translateError };
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ auth-login-btn = Login
|
|||||||
auth-register-btn = Create Account
|
auth-register-btn = Create Account
|
||||||
auth-no-account = Don't have an account?
|
auth-no-account = Don't have an account?
|
||||||
auth-has-account = Already have an account?
|
auth-has-account = Already have an account?
|
||||||
auth-error-unknown = An unknown error occurred
|
|
||||||
auth-error-password-match = Passwords do not match
|
auth-error-password-match = Passwords do not match
|
||||||
auth-error-password-length = Password must be at least 8 characters long
|
auth-error-password-length = Password must be at least 8 characters long
|
||||||
auth-error-email-invalid = Please enter a valid email address
|
auth-error-email-invalid = Please enter a valid email address
|
||||||
@@ -51,6 +50,16 @@ chat-create-global = Global room
|
|||||||
chat-create-submit = Create
|
chat-create-submit = Create
|
||||||
chat-connecting = Connecting to room...
|
chat-connecting = Connecting to room...
|
||||||
chat-connecting-failed = Could not connect. Check internet connection.
|
chat-connecting-failed = Could not connect. Check internet connection.
|
||||||
|
chat-voice-select-input = Select input device.
|
||||||
|
|
||||||
|
## User profile
|
||||||
|
profile-title = User profile
|
||||||
|
profile-add-friend = Add Friend
|
||||||
|
profile-remove-friend = Remove Friend
|
||||||
|
profile-remove-friend-confirm = Are you sure you want to remove this friend?
|
||||||
|
profile-request-sent = Request sent
|
||||||
|
profile-username = Username
|
||||||
|
profile-userid = User ID
|
||||||
|
|
||||||
## Friends page
|
## Friends page
|
||||||
friends-title = Your friends
|
friends-title = Your friends
|
||||||
@@ -58,6 +67,8 @@ friends-add-title = Add Friend
|
|||||||
friends-send-request = Send Request
|
friends-send-request = Send Request
|
||||||
friends-list-header = Your Friends
|
friends-list-header = Your Friends
|
||||||
friends-error-required = Username is required.
|
friends-error-required = Username is required.
|
||||||
|
friends-connectiong = Loading friends...
|
||||||
|
friends-list-empty = No friends found
|
||||||
|
|
||||||
## Notifications page
|
## Notifications page
|
||||||
notifications-title = Notifications
|
notifications-title = Notifications
|
||||||
@@ -73,12 +84,16 @@ notifications-error-friend-accept = An error occurred while accepting the reques
|
|||||||
notifications-error-friend-decline = An error occurred while declining the request.
|
notifications-error-friend-decline = An error occurred while declining the request.
|
||||||
notifications-error-room-accept = An error occurred while accepting the invite.
|
notifications-error-room-accept = An error occurred while accepting the invite.
|
||||||
notifications-error-room-decline = An error occurred while declining the invite.
|
notifications-error-room-decline = An error occurred while declining the invite.
|
||||||
|
notifications-connectiong = Loading notifications...
|
||||||
|
notifications-empty = No notifications found
|
||||||
|
|
||||||
## Settings page
|
## Settings page
|
||||||
settings-loading = Loading settings...
|
settings-loading = Loading settings...
|
||||||
settings-title = Settings
|
settings-title = Settings
|
||||||
settings-account = Account
|
settings-account = Account
|
||||||
settings-language = Language
|
settings-language = Language
|
||||||
|
settings-appearance = Appearance
|
||||||
|
settings-compact-layout = Use compact layout
|
||||||
settings-label-username = Username:
|
settings-label-username = Username:
|
||||||
settings-label-email = Email:
|
settings-label-email = Email:
|
||||||
settings-update-btn = Update
|
settings-update-btn = Update
|
||||||
@@ -100,7 +115,7 @@ settings-error-upload-avatar-failed-upload = Failed to upload image
|
|||||||
|
|
||||||
## Warning
|
## Warning
|
||||||
warning-wrongversion-title = Wrong app version
|
warning-wrongversion-title = Wrong app version
|
||||||
warning-wrongversion-message = The backend expects version {$expectedBackendVersion} while your version of the app ({$appVersion}) supports backend version {$backendVersion}. Please update to avoid potential issues.
|
warning-wrongversion-message = The backend expects version {$backendVersion} while your version of the app ({$appVersion}) supports backend version {$expectedBackendVersion}. Please update to avoid potential issues.
|
||||||
warning-wrongversion-dismiss = I know what I'm doing
|
warning-wrongversion-dismiss = I know what I'm doing
|
||||||
|
|
||||||
## Shared
|
## Shared
|
||||||
@@ -112,3 +127,37 @@ shared-updating = Updating
|
|||||||
shared-delete = Delete
|
shared-delete = Delete
|
||||||
shared-leave = Leave
|
shared-leave = Leave
|
||||||
shared-confirm = Confirm
|
shared-confirm = Confirm
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
notifications-message-title = New {$messageType} message from {$senderUsername}
|
||||||
|
|
||||||
|
## Errors (backend)
|
||||||
|
error-auth-invalid-credentials = Invalid email or password.
|
||||||
|
error-auth-missing-token = Authentication missing.
|
||||||
|
error-auth-invalid-token = Session expired or invalid.
|
||||||
|
error-user-not-found = User not found.
|
||||||
|
error-user-email-taken = Email is already in use.
|
||||||
|
error-user-username-taken = Username is already taken.
|
||||||
|
error-user-username-length = Username must be 1-35 characters long.
|
||||||
|
error-user-invalid-email = Invalid email format.
|
||||||
|
error-user-password-too-short = Password must be at least 8 characters.
|
||||||
|
error-user-empty-fields = Required fields are empty.
|
||||||
|
error-avatar-not-found = Avatar not found.
|
||||||
|
error-room-not-found = Room not found.
|
||||||
|
error-room-name-length = Room name must be 1-35 characters long.
|
||||||
|
error-room-not-member = You are not a member of this room.
|
||||||
|
error-room-already-member = This person is already a member.
|
||||||
|
error-room-owner-cannot-leave = Owner cannot leave the room without transferring ownership.
|
||||||
|
error-room-global-no-members = Cannot list members for global rooms.
|
||||||
|
error-invite-self = You cannot invite yourself.
|
||||||
|
error-invite-already-sent = Invite already sent.
|
||||||
|
error-invite-not-found = Invite not found.
|
||||||
|
error-friend-request-self = You cannot friend request yourself.
|
||||||
|
error-friend-already-exists = You are already friends.
|
||||||
|
error-friend-request-already-sent = Friend request already pending.
|
||||||
|
error-friend-request-not-found = Friend request not found.
|
||||||
|
error-friend-not-found = User is not in your friends list.
|
||||||
|
error-internal-server-error = An error occured.
|
||||||
|
error-internal-db-error = An error occured.
|
||||||
|
error-file-too-large = The file is too large (max 2MB).
|
||||||
|
error-upload-failed = Failed to upload the file.
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ auth-login-btn = Se connecter
|
|||||||
auth-register-btn = Créer un compte
|
auth-register-btn = Créer un compte
|
||||||
auth-no-account = Pas encore de compte ?
|
auth-no-account = Pas encore de compte ?
|
||||||
auth-has-account = Déjà un compte ?
|
auth-has-account = Déjà un compte ?
|
||||||
auth-error-unknown = Une erreur inconnue est survenue
|
|
||||||
auth-error-password-match = Les mots de passe ne correspondent pas
|
auth-error-password-match = Les mots de passe ne correspondent pas
|
||||||
auth-error-password-length = Le mot de passe doit faire au moins 8 caractères
|
auth-error-password-length = Le mot de passe doit faire au moins 8 caractères
|
||||||
auth-error-email-invalid = Veuillez entrer une adresse email valide
|
auth-error-email-invalid = Veuillez entrer une adresse email valide
|
||||||
@@ -51,6 +50,16 @@ chat-create-global = Salon public
|
|||||||
chat-create-submit = Créer
|
chat-create-submit = Créer
|
||||||
chat-connecting = Connexion au salon...
|
chat-connecting = Connexion au salon...
|
||||||
chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet.
|
chat-connecting-failed = Impossible d'établir la connexion. Vérifiez votre internet.
|
||||||
|
chat-voice-select-input = Sélectionnez un périphérique d'entrée.
|
||||||
|
|
||||||
|
## User profile
|
||||||
|
profile-title = Profil d'utilisateur
|
||||||
|
profile-add-friend = Ajouter en ami
|
||||||
|
profile-remove-friend = Retirer l'ami
|
||||||
|
profile-remove-friend-confirm = Etes-vous sûr de vouloir retirer cet ami ?
|
||||||
|
profile-request-sent = Requête envoyée
|
||||||
|
profile-username = Nom d'utilisateur
|
||||||
|
profile-userid = ID d'utilisateur
|
||||||
|
|
||||||
## Friends page
|
## Friends page
|
||||||
friends-title = Vos amis
|
friends-title = Vos amis
|
||||||
@@ -58,6 +67,8 @@ friends-add-title = Ajouter un ami
|
|||||||
friends-send-request = Envoyer
|
friends-send-request = Envoyer
|
||||||
friends-list-header = Vos Amis
|
friends-list-header = Vos Amis
|
||||||
friends-error-required = Le nom d'utilisateur est requis.
|
friends-error-required = Le nom d'utilisateur est requis.
|
||||||
|
friends-connectiong = Chargement des amis...
|
||||||
|
friends-list-empty = Pas d'amis trouvés
|
||||||
|
|
||||||
## Notifications page
|
## Notifications page
|
||||||
notifications-title = Notifications
|
notifications-title = Notifications
|
||||||
@@ -73,12 +84,16 @@ notifications-error-friend-accept = Erreur lors de l'acceptation de la demande.
|
|||||||
notifications-error-friend-decline = Erreur lors du refus de la demande.
|
notifications-error-friend-decline = Erreur lors du refus de la demande.
|
||||||
notifications-error-room-accept = Erreur lors de l'acceptation de l'invitation.
|
notifications-error-room-accept = Erreur lors de l'acceptation de l'invitation.
|
||||||
notifications-error-room-decline = Erreur lors du refus de l'invitation.
|
notifications-error-room-decline = Erreur lors du refus de l'invitation.
|
||||||
|
notifications-connectiong = Chargement des notifications...
|
||||||
|
notifications-empty = Pas de notifications trouvées
|
||||||
|
|
||||||
## Settings page
|
## Settings page
|
||||||
settings-loading = Chargement des paramètres...
|
settings-loading = Chargement des paramètres...
|
||||||
settings-title = Paramètres
|
settings-title = Paramètres
|
||||||
settings-account = Compte
|
settings-account = Compte
|
||||||
settings-language = Langue
|
settings-language = Langue
|
||||||
|
settings-appearance = Apparence
|
||||||
|
settings-compact-layout = Utiliser la disposition compacte
|
||||||
settings-label-username = Nom d'utilisateur :
|
settings-label-username = Nom d'utilisateur :
|
||||||
settings-label-email = Email :
|
settings-label-email = Email :
|
||||||
settings-update-btn = Modifier
|
settings-update-btn = Modifier
|
||||||
@@ -98,7 +113,7 @@ settings-error-upload-avatar-failed-upload = Erreur d'envoi de l'image
|
|||||||
|
|
||||||
## Warning
|
## Warning
|
||||||
warning-wrongversion-title = Mauvaise version de l'application
|
warning-wrongversion-title = Mauvaise version de l'application
|
||||||
warning-wrongversion-message = Le backend attend la version {$expectedBackendVersion} alors que votre version de l'application ({$appVersion}) prend en charge la version {$backendVersion} du backend. Veuillez mettre à jour pour éviter d'éventuels problèmes.
|
warning-wrongversion-message = Le backend attend la version {$backendVersion} alors que votre version de l'application ({$appVersion}) prend en charge la version {$expectedBackendVersion} du backend. Veuillez mettre à jour pour éviter d'éventuels problèmes.
|
||||||
warning-wrongversion-dismiss = Je sais ce que je fais
|
warning-wrongversion-dismiss = Je sais ce que je fais
|
||||||
|
|
||||||
## Shared
|
## Shared
|
||||||
@@ -110,3 +125,37 @@ shared-updating = Mise à jour...
|
|||||||
shared-delete = Supprimer
|
shared-delete = Supprimer
|
||||||
shared-leave = Quitter
|
shared-leave = Quitter
|
||||||
shared-confirm = Confirmer
|
shared-confirm = Confirmer
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
notifications-message-title = Nouveau message {$messageType} de {$senderUsername}
|
||||||
|
|
||||||
|
## Errors (backend)
|
||||||
|
error-auth-invalid-credentials = Email ou mot de passe incorrect.
|
||||||
|
error-auth-missing-token = Authentification manquante.
|
||||||
|
error-auth-invalid-token = Session expirée ou invalide.
|
||||||
|
error-user-not-found = Utilisateur introuvable.
|
||||||
|
error-user-email-taken = L'adresse email est déjà utilisée.
|
||||||
|
error-user-username-taken = Ce nom d'utilisateur est déjà pris.
|
||||||
|
error-user-username-length = Le nom d'utilisateur doit faire 1-35 caractères.
|
||||||
|
error-user-invalid-email = Format d'email invalide.
|
||||||
|
error-user-password-too-short = Le mot de passe doit faire au moins 8 caractères.
|
||||||
|
error-user-empty-fields = Des champs requis sont vides.
|
||||||
|
error-avatar-not-found = Avatar introuvable.
|
||||||
|
error-room-not-found = Salon introuvable.
|
||||||
|
error-room-name-length = Le nom de la salle doit faire 1-35 caractères.
|
||||||
|
error-room-not-member = Vous n'êtes pas membre de ce salon.
|
||||||
|
error-room-already-member = Cette personne est déjà membre.
|
||||||
|
error-room-owner-cannot-leave = Le propriétaire ne peut pas quitter le salon sans transférer la propriété.
|
||||||
|
error-room-global-no-members = Impossible de lister les membres d'un salon global.
|
||||||
|
error-invite-self = Vous ne pouvez pas vous inviter vous-même.
|
||||||
|
error-invite-already-sent = Invitation déjà envoyée.
|
||||||
|
error-invite-not-found = Invitation introuvable.
|
||||||
|
error-friend-request-self = Vous ne pouvez pas vous ajouter en ami.
|
||||||
|
error-friend-already-exists = Vous êtes déjà amis.
|
||||||
|
error-friend-request-already-sent = Demande d'ami déjà en attente.
|
||||||
|
error-friend-request-not-found = Demande d'ami introuvable.
|
||||||
|
error-friend-not-found = L'utilisateur n'est pas dans votre liste d'amis.
|
||||||
|
error-internal-server-error = Une erreur est survenue.
|
||||||
|
error-internal-db-error = Une erreur est survenue.
|
||||||
|
error-file-too-large = Le fichier est trop volumineux (max 2Mo).
|
||||||
|
error-upload-failed = Erreur lors de l'envoi du fichier.
|
||||||
|
|||||||
38
src/main.ts
38
src/main.ts
@@ -1,11 +1,15 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import router from './router.ts'
|
import router from './router.ts'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { validateToken } from './store.ts'
|
import { validateToken, initTheme } from './store.ts'
|
||||||
import { fluent, setLanguage } from './i18n'
|
import { fluent, setLanguage } from './i18n'
|
||||||
|
|
||||||
import './base.css'
|
import './base.css'
|
||||||
import { getLocalePreference } from './store.ts'
|
import { getLocalePreference } from './store.ts'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
export function logJS(msg: any) {
|
||||||
|
invoke('log', { message: typeof msg === 'string' ? msg : JSON.stringify(msg) }).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
await validateToken()
|
await validateToken()
|
||||||
@@ -17,20 +21,42 @@ async function init() {
|
|||||||
const savedLocale = await getLocalePreference();
|
const savedLocale = await getLocalePreference();
|
||||||
const osLocale = navigator.language;
|
const osLocale = navigator.language;
|
||||||
|
|
||||||
|
await initTheme();
|
||||||
|
|
||||||
if (savedLocale) {
|
if (savedLocale) {
|
||||||
setLanguage(savedLocale);
|
setLanguage(savedLocale);
|
||||||
} else {
|
} else {
|
||||||
setLanguage(osLocale);
|
setLanguage(osLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// await createChannel({
|
||||||
|
// id: 'messages',
|
||||||
|
// name: 'Messages',
|
||||||
|
// description: 'Notifications for new messages',
|
||||||
|
// importance: Importance.High,
|
||||||
|
// visibility: Visibility.Private,
|
||||||
|
// lights: true,
|
||||||
|
// lightColor: '#ff0000',
|
||||||
|
// vibration: true,
|
||||||
|
// sound: 'notification_sound',
|
||||||
|
// });
|
||||||
|
|
||||||
|
// window.addEventListener("error", (event) => {
|
||||||
|
// logJS(`Uncaught JS Error: ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`);
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
// logJS(`Unhandled Promise Rejection: ${JSON.stringify(event.reason)}`);
|
||||||
|
// });
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
export const API = 'http://127.0.0.1:8080'
|
// export const API = 'http://127.0.0.1:8080'
|
||||||
// export const API = 'http://192.168.1.183:8080'
|
export const API = 'http://192.168.1.183:8080'
|
||||||
// export const API = 'https://alatreon.org/frangipane'
|
// export const API = 'https://alatreon.org/frangipane'
|
||||||
export const API_WS = 'ws://127.0.0.1:8080/ws'
|
// export const API_WS = 'ws://127.0.0.1:8080/ws'
|
||||||
// export const API_WS = 'ws://192.168.1.183:8080/ws'
|
export const API_WS = 'ws://192.168.1.183:8080/ws'
|
||||||
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
|
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ const handleNotification = (roomUuid: string) => {
|
|||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
/* background: rgba(0, 0, 0, 0.3); */
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,31 @@
|
|||||||
|
|
||||||
<div class="friends-list">
|
<div class="friends-list">
|
||||||
<h2>{{ $t('friends-list-header') }}</h2>
|
<h2>{{ $t('friends-list-header') }}</h2>
|
||||||
<ul>
|
|
||||||
<li v-for="friend in friends" :key="friend.uuid">
|
<!-- Loading State -->
|
||||||
{{ friend.username }}
|
<div class="wait-container" v-if="isLoading">
|
||||||
</li>
|
<p class="wait-msg">{{ $t('friends-connecting') }}</p>
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty / Error / Retry State -->
|
||||||
|
<div class="wait-container" v-else-if="!friends || friends.length === 0">
|
||||||
|
<p class="wait-msg">{{ $t('friends-list-empty') || 'No friends yet' }}</p>
|
||||||
|
<button class="retry-btn" @click="loadFriends()">
|
||||||
|
<i class="fa-solid fa-rotate-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content State -->
|
||||||
|
<div class="friends" v-else>
|
||||||
|
<button class="friend" v-for="friend in friends" :key="friend.uuid" @click="openProfile(friend)">
|
||||||
|
<img :src="getAvatarUrl(friend.uuid)" @error="handleAvatarError" class="avatar" />
|
||||||
|
<p>{{ friend.username }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserProfileModal v-if="selectedFriend" :username="selectedFriend.username" :user-uuid="selectedFriend.uuid"
|
||||||
|
@close="selectedFriend = null" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,28 +47,54 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { fetchFriends, sendFriendRequest } from '../api/friends'
|
import { fetchFriends, sendFriendRequest } from '../api/friends'
|
||||||
import type { Friend } from '../types'
|
import type { Friend } from '../types'
|
||||||
|
import { getAvatarUrl } from '../store'
|
||||||
|
import defaultAvatar from '../assets/default-avatar.png'
|
||||||
|
import UserProfileModal from '../components/UserProfileModal.vue'
|
||||||
|
import { useErrorTranslator } from '../errors'
|
||||||
|
|
||||||
const friends = ref<Friend[]>([])
|
const friends = ref<Friend[]>([])
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
|
const selectedFriend = ref<Friend | null>(null)
|
||||||
|
|
||||||
|
async function loadFriends() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
friends.value = await fetchFriends()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch friends", err)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
friends.value = await fetchFriends()
|
await loadFriends()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function send() {
|
const openProfile = (friend: Friend) => {
|
||||||
if (!username.value) {
|
selectedFriend.value = friend
|
||||||
// errorMessage.value = 'Username is required.'
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAvatarError = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.src = defaultAvatar;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!username.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendFriendRequest(username.value)
|
await sendFriendRequest(username.value)
|
||||||
username.value = ''
|
username.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
friends.value = await fetchFriends()
|
await loadFriends()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err
|
errorMessage.value = translateError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -99,6 +144,7 @@ async function send() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.friends-list {
|
.friends-list {
|
||||||
|
position: relative;
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -106,21 +152,61 @@ async function send() {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friends-list ul {
|
.wait-container {
|
||||||
list-style: none;
|
display: flex;
|
||||||
padding: 0;
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friends-list li {
|
.wait-msg {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
background: var(--panel-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
background: var(--panel-light);
|
background: var(--panel-light);
|
||||||
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend:hover p {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.friends-list h2 {
|
.friends-list h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
height: 42px;
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ref } from "vue";
|
|||||||
import { login } from '../store.ts'
|
import { login } from '../store.ts'
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from "../errors.ts";
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
@@ -32,6 +33,7 @@ const errorMessage = ref("");
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
@@ -39,7 +41,8 @@ async function submit() {
|
|||||||
await login(email.value, "", password.value);
|
await login(email.value, "", password.value);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err?.message || $t('auth-error-unknown');
|
errorMessage.value = translateError(err);
|
||||||
|
// errorMessage.value = err?.message || $t('auth-error-unknown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ import { fetchFriendRequests, acceptFriendRequest, declineFriendRequest } from '
|
|||||||
import { fetchRoomInvites, acceptRoomInvite, declineRoomInvite } from '../api/rooms.ts'
|
import { fetchRoomInvites, acceptRoomInvite, declineRoomInvite } from '../api/rooms.ts'
|
||||||
import { useNotifications } from '../store'
|
import { useNotifications } from '../store'
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from '../errors.ts';
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const { requests, invites, refreshNotifications } = useNotifications()
|
const { requests, invites, refreshNotifications } = useNotifications()
|
||||||
@@ -65,7 +67,8 @@ async function acceptFriend(senderUuid: string) {
|
|||||||
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
|
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
|
||||||
// fetchFriends().then(f => (friends.value = f))
|
// fetchFriends().then(f => (friends.value = f))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value = $t('notifications-error-friend-accept')
|
const msg = translateError(err);
|
||||||
|
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-accept')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +78,8 @@ async function declineFriend(senderUuid: string) {
|
|||||||
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
|
requests.value = requests.value.filter(r => r.sender_uuid !== senderUuid)
|
||||||
// fetchFriends().then(f => (friends.value = f))
|
// fetchFriends().then(f => (friends.value = f))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value = $t('notifications-error-friend-decline')
|
const msg = translateError(err);
|
||||||
|
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-friend-decline')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +88,8 @@ async function acceptRoom(senderUuid: string, roomUuid: string) {
|
|||||||
await acceptRoomInvite(senderUuid, roomUuid)
|
await acceptRoomInvite(senderUuid, roomUuid)
|
||||||
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
|
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value = $t('notifications-error-room-accept')
|
const msg = translateError(err);
|
||||||
throw err
|
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-accept')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +98,8 @@ async function declineRoom(senderUuid: string, roomUuid: string) {
|
|||||||
await declineRoomInvite(senderUuid, roomUuid)
|
await declineRoomInvite(senderUuid, roomUuid)
|
||||||
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
|
invites.value = invites.value.filter(r => r.room_uuid !== roomUuid)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorMessage.value = $t('notifications-error-room-decline')
|
const msg = translateError(err);
|
||||||
throw err
|
errorMessage.value = msg !== $t('shared-error') ? msg : $t('notifications-error-room-decline')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -182,7 +186,7 @@ async function declineRoom(senderUuid: string, roomUuid: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.decline-btn:hover {
|
.decline-btn:hover {
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
background-color: var(--panel-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<button type="submit">{{ $t('auth-register-btn') }}</button>
|
<button type="submit">{{ $t('auth-register-btn') }}</button>
|
||||||
|
|
||||||
<p class="login-link">
|
<p class="login-link">
|
||||||
{{ $t('auth-has-settings') }} <router-link to="/login">{{ $t('auth-login-title') }}</router-link>
|
{{ $t('auth-has-account') }} <router-link to="/login">{{ $t('auth-login-title') }}</router-link>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
|
||||||
@@ -35,8 +35,10 @@ import { ref } from "vue";
|
|||||||
import { register } from '../store.ts'
|
import { register } from '../store.ts'
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useFluent } from 'fluent-vue';
|
import { useFluent } from 'fluent-vue';
|
||||||
|
import { useErrorTranslator } from '../errors';
|
||||||
|
|
||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
const { translateError } = useErrorTranslator();
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
const username = ref("");
|
const username = ref("");
|
||||||
@@ -46,22 +48,18 @@ const errorMessage = ref("");
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function submit(event: Event) {
|
async function submit() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
|
||||||
const form = event.target as HTMLFormElement;
|
// if (!form.checkValidity()) {
|
||||||
|
// if (password.value.length < 8) {
|
||||||
|
// errorMessage.value = $t('auth-error-password-length');
|
||||||
|
// } else {
|
||||||
|
// errorMessage.value = $t('auth-error-email-invalid');
|
||||||
|
// }
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
// Check password length and email
|
|
||||||
if (!form.checkValidity()) {
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
errorMessage.value = $t('auth-error-password-length');
|
|
||||||
} else {
|
|
||||||
errorMessage.value = $t('auth-error-email-invalid');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check password match
|
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
errorMessage.value = $t('auth-error-password-match');
|
errorMessage.value = $t('auth-error-password-match');
|
||||||
return;
|
return;
|
||||||
@@ -71,7 +69,7 @@ async function submit(event: Event) {
|
|||||||
await register(email.value, username.value, password.value);
|
await register(email.value, username.value, password.value);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errorMessage.value = err?.message || "An unknown error occurred";
|
errorMessage.value = translateError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -38,6 +38,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2>{{ $t('settings-appearance') || 'Appearance' }}</h2>
|
||||||
|
<div class="theme-grid">
|
||||||
|
<button v-for="theme in availableThemes" :key="theme.id" class="theme-btn"
|
||||||
|
:class="{ active: currentTheme === theme.id }" @click="changeTheme(theme.id)">
|
||||||
|
{{ theme.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-checkbox">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" v-model="useCompactLayout" @change="toggleCompactLayout" />
|
||||||
|
<span>{{ $t('settings-compact-layout') || 'Use Compact Layout' }} (WIP)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="logout-btn" @click="logout">
|
<button class="logout-btn" @click="logout">
|
||||||
<i class="fa-solid fa-right-from-bracket"></i>
|
<i class="fa-solid fa-right-from-bracket"></i>
|
||||||
<span>{{ $t('settings-logout-btn') }}</span>
|
<span>{{ $t('settings-logout-btn') }}</span>
|
||||||
@@ -49,7 +64,9 @@
|
|||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { logout as authLogout } from '../store.ts'
|
import { logout as authLogout } from '../store.ts'
|
||||||
|
import { saveThemePreference, getThemePreference } from "../store.ts"
|
||||||
import { getAuthData } from "../store.ts"
|
import { getAuthData } from "../store.ts"
|
||||||
|
import { saveCompactLayoutPreference, getCompactLayoutPreference } from "../store.ts"
|
||||||
import type { User } from "../types"
|
import type { User } from "../types"
|
||||||
import UpdateAccountModal from '../components/UpdateAccountModal.vue'
|
import UpdateAccountModal from '../components/UpdateAccountModal.vue'
|
||||||
import { useFluent } from 'fluent-vue'
|
import { useFluent } from 'fluent-vue'
|
||||||
@@ -58,22 +75,28 @@ import { getSupportedLanguagesMetadata, setLanguage } from '../i18n'
|
|||||||
import UploadAvatarModal from '../components/UploadAvatarModal.vue'
|
import UploadAvatarModal from '../components/UploadAvatarModal.vue'
|
||||||
import defaultAvatar from '../assets/default-avatar.png'
|
import defaultAvatar from '../assets/default-avatar.png'
|
||||||
import { getAvatarUrl } from '../store.ts'
|
import { getAvatarUrl } from '../store.ts'
|
||||||
|
import { getAvailableThemes } from '../themeLoader';
|
||||||
|
|
||||||
|
const { $t } = useFluent()
|
||||||
|
|
||||||
|
const showAvatarModal = ref(false)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const availableThemes = getAvailableThemes();
|
||||||
|
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const showUpdateModal = ref(false)
|
||||||
|
const currentLang = ref('')
|
||||||
|
|
||||||
|
const languages = computed(() => getSupportedLanguagesMetadata())
|
||||||
|
const currentTheme = ref('default');
|
||||||
|
const useCompactLayout = ref(false)
|
||||||
|
|
||||||
const handleAvatarError = (event: Event) => {
|
const handleAvatarError = (event: Event) => {
|
||||||
const img = event.target as HTMLImageElement;
|
const img = event.target as HTMLImageElement;
|
||||||
img.src = defaultAvatar;
|
img.src = defaultAvatar;
|
||||||
};
|
};
|
||||||
|
|
||||||
const showAvatarModal = ref(false)
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const user = ref<User | null>(null)
|
|
||||||
const showUpdateModal = ref(false)
|
|
||||||
const { $t } = useFluent()
|
|
||||||
const currentLang = ref('')
|
|
||||||
|
|
||||||
const languages = computed(() => getSupportedLanguagesMetadata())
|
|
||||||
|
|
||||||
async function fetchUserData() {
|
async function fetchUserData() {
|
||||||
try {
|
try {
|
||||||
const auth = await getAuthData()
|
const auth = await getAuthData()
|
||||||
@@ -85,8 +108,9 @@ async function fetchUserData() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const pref = await getLocalePreference()
|
const pref = await getLocalePreference()
|
||||||
// Synchronize the UI state with the actual active language
|
|
||||||
currentLang.value = pref || (navigator.language.split('-')[0])
|
currentLang.value = pref || (navigator.language.split('-')[0])
|
||||||
|
currentTheme.value = await getThemePreference()
|
||||||
|
useCompactLayout.value = await getCompactLayoutPreference()
|
||||||
|
|
||||||
fetchUserData()
|
fetchUserData()
|
||||||
})
|
})
|
||||||
@@ -97,6 +121,15 @@ async function changeLanguage(code: string) {
|
|||||||
await saveLocalePreference(actual)
|
await saveLocalePreference(actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeTheme(theme: string) {
|
||||||
|
currentTheme.value = theme
|
||||||
|
await saveThemePreference(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCompactLayout() {
|
||||||
|
await saveCompactLayoutPreference(useCompactLayout.value)
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authLogout()
|
authLogout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -171,12 +204,67 @@ h2 {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover:not(.active) {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.lang-btn.active {
|
.lang-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #000;
|
color: #000;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn:hover:not(.active) {
|
||||||
|
background: rgba(var(--accent-rgb), 0.1);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-checkbox {
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -193,11 +281,11 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
color: rgba(255, 80, 80, 0.8);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover i {
|
.logout-btn:hover i {
|
||||||
color: rgba(255, 80, 80, 0.8);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn i {
|
.logout-btn i {
|
||||||
|
|||||||
52
src/store.ts
52
src/store.ts
@@ -8,6 +8,7 @@ import { load, Store } from '@tauri-apps/plugin-store'
|
|||||||
import { UpdateUserResponse } from './types'
|
import { UpdateUserResponse } from './types'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { API } from './main'
|
import { API } from './main'
|
||||||
|
import { applyTheme } from './themeLoader.ts';
|
||||||
|
|
||||||
let store: Store | null = null
|
let store: Store | null = null
|
||||||
export const initAuth = getAuthData
|
export const initAuth = getAuthData
|
||||||
@@ -184,3 +185,54 @@ export function getAvatarUrl(uuid: string | undefined | null) {
|
|||||||
export function refreshAvatar(uuid: string) {
|
export function refreshAvatar(uuid: string) {
|
||||||
avatarTimestamps[uuid] = Date.now()
|
avatarTimestamps[uuid] = Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==== Color themes ====
|
||||||
|
|
||||||
|
export async function saveThemePreference(themeId: string) {
|
||||||
|
const s = await getStore();
|
||||||
|
await s.set('theme', themeId);
|
||||||
|
await s.save();
|
||||||
|
applyTheme(themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initTheme() {
|
||||||
|
const s = await getStore();
|
||||||
|
const themeId = (await s.get<string>('theme')) || 'default';
|
||||||
|
applyTheme(themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThemePreference(): Promise<string> {
|
||||||
|
const s = await getStore()
|
||||||
|
return (await s.get<string>('theme')) ?? 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyStoredTheme() {
|
||||||
|
const theme = await getThemePreference();
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== Layout ====
|
||||||
|
|
||||||
|
export async function saveCompactLayoutPreference(enabled: boolean) {
|
||||||
|
const s = await getStore();
|
||||||
|
await s.set('compact_layout', enabled);
|
||||||
|
await s.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCompactLayoutPreference(): Promise<boolean> {
|
||||||
|
const s = await getStore();
|
||||||
|
return (await s.get<boolean>('compact_layout')) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== Message draft ====
|
||||||
|
|
||||||
|
export async function saveMessageDraft(text: string) {
|
||||||
|
const s = await getStore()
|
||||||
|
await s.set('message_draft', text)
|
||||||
|
await s.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessageDraft(): Promise<string> {
|
||||||
|
const s = await getStore()
|
||||||
|
return (await s.get<string>('message_draft')) ?? ''
|
||||||
|
}
|
||||||
|
|||||||
26
src/themeLoader.ts
Normal file
26
src/themeLoader.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import themes from './themes.json';
|
||||||
|
|
||||||
|
export type ThemeKey = keyof typeof themes;
|
||||||
|
|
||||||
|
export function applyTheme(themeKey: string) {
|
||||||
|
const theme = (themes as any)[themeKey] || themes.default;
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Convert json keys to css Variables
|
||||||
|
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||||
|
root.style.setProperty(`--${key}`, value as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// if (themeKey.includes('light')) {
|
||||||
|
// root.style.setProperty('--btn-text', theme.colors.bg);
|
||||||
|
// } else {
|
||||||
|
// root.style.setProperty('--btn-text', '#0b0d12');
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableThemes() {
|
||||||
|
return Object.entries(themes).map(([id, data]) => ({
|
||||||
|
id,
|
||||||
|
name: data.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
172
src/themes.json
Normal file
172
src/themes.json
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
{
|
||||||
|
"default": {
|
||||||
|
"name": "Frangipane Dark",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#0f1116",
|
||||||
|
"panel": "#171922",
|
||||||
|
"panel-accent": "#12141B",
|
||||||
|
"panel-hover": "#22242D",
|
||||||
|
"text": "#e6e6eb",
|
||||||
|
"muted": "#9aa0aa",
|
||||||
|
"accent": "#f27aa3",
|
||||||
|
"accent-rgb": "242, 122, 163",
|
||||||
|
"accent-hover": "#ff91b3",
|
||||||
|
"accent-second": "#96CDFB",
|
||||||
|
"border": "#2a2f3b",
|
||||||
|
"error": "#ff5050"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catppuccin-latte": {
|
||||||
|
"name": "Catppuccin Latte",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#eff1f5",
|
||||||
|
"panel": "#e6e9ef",
|
||||||
|
"panel-hover": "#bcc0cc",
|
||||||
|
"panel-accent": "#ccd0da",
|
||||||
|
"text": "#4c4f69",
|
||||||
|
"muted": "#7c7f93",
|
||||||
|
"accent": "#ea76cb",
|
||||||
|
"accent-rgb": "234, 118, 203",
|
||||||
|
"accent-hover": "#d20f39",
|
||||||
|
"accent-second": "#1e66f5",
|
||||||
|
"border": "#dce0e8",
|
||||||
|
"error": "#d20f39"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catppuccin-frappe": {
|
||||||
|
"name": "Catppuccin Frappé",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#303446",
|
||||||
|
"panel": "#292c3c",
|
||||||
|
"panel-accent": "#232634",
|
||||||
|
"panel-hover": "#414559",
|
||||||
|
"text": "#c6d0f5",
|
||||||
|
"muted": "#838ba7",
|
||||||
|
"accent": "#8caaee",
|
||||||
|
"accent-rgb": "140, 170, 238",
|
||||||
|
"accent-hover": "#85c1dc",
|
||||||
|
"accent-second": "#f2d5cf",
|
||||||
|
"border": "#414559",
|
||||||
|
"error": "#e78284"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catppuccin-macchiato": {
|
||||||
|
"name": "Catppuccin Macchiato",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#24273a",
|
||||||
|
"panel": "#1e2030",
|
||||||
|
"panel-accent": "#181926",
|
||||||
|
"panel-hover": "#363a4f",
|
||||||
|
"text": "#cad3f5",
|
||||||
|
"muted": "#8087a2",
|
||||||
|
"accent": "#c6a0f6",
|
||||||
|
"accent-rgb": "198, 160, 246",
|
||||||
|
"accent-hover": "#f5bde6",
|
||||||
|
"accent-second": "#8aadf4",
|
||||||
|
"border": "#494d64",
|
||||||
|
"error": "#ed8796"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"catppuccin-mocha": {
|
||||||
|
"name": "Catppuccin Mocha",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#1e1e2e",
|
||||||
|
"panel": "#181825",
|
||||||
|
"panel-accent": "#11111b",
|
||||||
|
"panel-hover": "#313244",
|
||||||
|
"text": "#cdd6f4",
|
||||||
|
"muted": "#7f849c",
|
||||||
|
"accent": "#a6e3a1",
|
||||||
|
"accent-rgb": "166, 227, 161",
|
||||||
|
"accent-hover": "#94e2d5",
|
||||||
|
"accent-second": "#f5c2e7",
|
||||||
|
"border": "#313244",
|
||||||
|
"error": "#f38ba8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nord": {
|
||||||
|
"name": "Nordic",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#2e3440",
|
||||||
|
"panel": "#3b4252",
|
||||||
|
"panel-accent": "#242933",
|
||||||
|
"panel-hover": "#434c5e",
|
||||||
|
"text": "#eceff4",
|
||||||
|
"muted": "#d8dee9",
|
||||||
|
"accent": "#88c0d0",
|
||||||
|
"accent-rgb": "136, 192, 208",
|
||||||
|
"accent-hover": "#8fbcbb",
|
||||||
|
"accent-second": "#81a1c1",
|
||||||
|
"border": "#4c566a",
|
||||||
|
"error": "#bf616a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tokyo-night": {
|
||||||
|
"name": "Tokyo Night",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#1a1b26",
|
||||||
|
"panel": "#16161e",
|
||||||
|
"panel-accent": "#1f2335",
|
||||||
|
"panel-hover": "#292e42",
|
||||||
|
"text": "#a9b1d6",
|
||||||
|
"muted": "#565f89",
|
||||||
|
"accent": "#7aa2f7",
|
||||||
|
"accent-rgb": "122, 162, 247",
|
||||||
|
"accent-hover": "#7dcfff",
|
||||||
|
"accent-second": "#bb9af7",
|
||||||
|
"border": "#24283b",
|
||||||
|
"error": "#f7768e"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gruvbox-dark": {
|
||||||
|
"name": "Gruvbox Dark",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#282828",
|
||||||
|
"panel": "#3c3836",
|
||||||
|
"panel-accent": "#282828",
|
||||||
|
"panel-hover": "#504945",
|
||||||
|
"text": "#ebdbb2",
|
||||||
|
"muted": "#a89984",
|
||||||
|
"accent": "#fabd2f",
|
||||||
|
"accent-rgb": "250, 189, 47",
|
||||||
|
"accent-hover": "#fe8019",
|
||||||
|
"accent-second": "#b8bb26",
|
||||||
|
"border": "#504945",
|
||||||
|
"error": "#fb4934"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gruvbox-light": {
|
||||||
|
"name": "Gruvbox Light",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#fbf1c7",
|
||||||
|
"panel": "#f2e5bc",
|
||||||
|
"panel-accent": "#d5c4a1",
|
||||||
|
"panel-hover": "#ebdbb2",
|
||||||
|
"text": "#3c3836",
|
||||||
|
"muted": "#7c6f64",
|
||||||
|
"accent": "#d65d0e",
|
||||||
|
"accent-rgb": "214, 93, 14",
|
||||||
|
"accent-hover": "#9d0006",
|
||||||
|
"accent-second": "#458588",
|
||||||
|
"border": "#bdae93",
|
||||||
|
"error": "#cc241d"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"solarized-dark": {
|
||||||
|
"name": "Solarized Dark",
|
||||||
|
"colors": {
|
||||||
|
"bg": "#002b36",
|
||||||
|
"panel": "#073642",
|
||||||
|
"panel-accent": "#00212b",
|
||||||
|
"panel-hover": "#586e75",
|
||||||
|
"text": "#839496",
|
||||||
|
"muted": "#657b83",
|
||||||
|
"accent": "#268bd2",
|
||||||
|
"accent-rgb": "38, 139, 210",
|
||||||
|
"accent-hover": "#2aa198",
|
||||||
|
"accent-second": "#859900",
|
||||||
|
"border": "#073642",
|
||||||
|
"error": "#dc322f"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/voice.ts
Normal file
199
src/voice.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
import { API_WS } from './main';
|
||||||
|
import { apiFetch } from './api/client';
|
||||||
|
import WebSocket from '@tauri-apps/plugin-websocket';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
interface VoiceState {
|
||||||
|
status: 'disconnected' | 'connecting' | 'connected';
|
||||||
|
currentRoomUuid: string | null;
|
||||||
|
isMuted: boolean;
|
||||||
|
speakingUsers: Set<string>;
|
||||||
|
captureSampleRate: number;
|
||||||
|
captureChannels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceState = reactive<VoiceState>({
|
||||||
|
status: 'disconnected',
|
||||||
|
currentRoomUuid: null,
|
||||||
|
isMuted: false,
|
||||||
|
speakingUsers: new Set(),
|
||||||
|
captureSampleRate: 48000,
|
||||||
|
captureChannels: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let unlistenMic: UnlistenFn | null = null;
|
||||||
|
let unlistenConfig: UnlistenFn | null = null;
|
||||||
|
let unlistenError: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
let nextStartTime = 0;
|
||||||
|
|
||||||
|
async function ensureMicrophonePermission() {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
// Stop it immediately so it releases the mic
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Microphone permission denied:", err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup global error listener
|
||||||
|
export async function initVoiceListeners() {
|
||||||
|
if (unlistenError) return;
|
||||||
|
unlistenError = await listen('microphone-error', (event) => {
|
||||||
|
console.error("RUST AUDIO ERROR:", event.payload);
|
||||||
|
voiceActions.leaveRoom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voiceActions = {
|
||||||
|
async getHosts(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>('get_audio_hosts');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getDevices(hostName: string | null): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await invoke('get_input_devices', { hostName });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async joinRoom(roomUuid: string, hostName: string | null, deviceName: string | null) {
|
||||||
|
if (voiceState.status === 'connected') return;
|
||||||
|
|
||||||
|
nextStartTime = 0;
|
||||||
|
voiceState.status = 'connecting';
|
||||||
|
voiceState.currentRoomUuid = roomUuid;
|
||||||
|
|
||||||
|
await initVoiceListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ token: string }>('/ws/issue-token');
|
||||||
|
const url = `${API_WS}/voice/${roomUuid}?token=${res.token}`;
|
||||||
|
socket = await WebSocket.connect(url);
|
||||||
|
|
||||||
|
socket.addListener((msg) => {
|
||||||
|
if (msg.type === 'Binary') handleIncomingAudio(msg.data);
|
||||||
|
else if (msg.type === 'Close') voiceActions.leaveRoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.startAudioCapture(hostName, deviceName);
|
||||||
|
voiceState.status = 'connected';
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Voice join failed", e);
|
||||||
|
voiceActions.leaveRoom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async leaveRoom() {
|
||||||
|
nextStartTime = 0;
|
||||||
|
voiceState.status = 'disconnected';
|
||||||
|
voiceState.currentRoomUuid = null;
|
||||||
|
voiceState.speakingUsers.clear();
|
||||||
|
|
||||||
|
await this.stopAudioCapture();
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// WebSocket plugin disconnect is async
|
||||||
|
await socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
voiceState.isMuted = !voiceState.isMuted;
|
||||||
|
},
|
||||||
|
|
||||||
|
async startAudioCapture(hostName: string | null, deviceName: string | null) {
|
||||||
|
const hasPermission = await ensureMicrophonePermission();
|
||||||
|
if (!hasPermission) throw new Error("Microphone permission denied");
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
|
||||||
|
unlistenConfig = await listen<{ sample_rate: number, channels: number }>('microphone-config', (event) => {
|
||||||
|
voiceState.captureSampleRate = event.payload.sample_rate;
|
||||||
|
voiceState.captureChannels = event.payload.channels;
|
||||||
|
});
|
||||||
|
|
||||||
|
unlistenMic = await listen<number[]>('microphone-data', (event) => {
|
||||||
|
if (voiceState.isMuted || !socket) return;
|
||||||
|
socket.send(event.payload).catch(console.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke('start_microphone', { hostName, deviceName });
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopAudioCapture() {
|
||||||
|
try {
|
||||||
|
await invoke('stop_microphone');
|
||||||
|
} catch (e) { console.warn(e); }
|
||||||
|
|
||||||
|
if (unlistenMic) { unlistenMic(); unlistenMic = null; }
|
||||||
|
if (unlistenConfig) { unlistenConfig(); unlistenConfig = null; }
|
||||||
|
|
||||||
|
if (audioContext) {
|
||||||
|
await audioContext.close();
|
||||||
|
audioContext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleIncomingAudio(data: number[]) {
|
||||||
|
if (!audioContext) return;
|
||||||
|
|
||||||
|
const arrayBuffer = new Uint8Array(data).buffer;
|
||||||
|
const audioDataOffset = 16; // Skip UUID
|
||||||
|
|
||||||
|
if (arrayBuffer.byteLength <= audioDataOffset) return;
|
||||||
|
const audioByteLength = arrayBuffer.byteLength - audioDataOffset;
|
||||||
|
|
||||||
|
const pcmData = new Int16Array(arrayBuffer, audioDataOffset, audioByteLength / 2);
|
||||||
|
const float32Data = new Float32Array(pcmData.length);
|
||||||
|
for (let i = 0; i < pcmData.length; i++) {
|
||||||
|
float32Data[i] = pcmData[i] / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = audioContext.createBuffer(
|
||||||
|
1,
|
||||||
|
float32Data.length,
|
||||||
|
voiceState.captureSampleRate
|
||||||
|
);
|
||||||
|
buffer.getChannelData(0).set(float32Data);
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
|
||||||
|
const now = audioContext.currentTime;
|
||||||
|
|
||||||
|
if (nextStartTime === 0) nextStartTime = now + 0.05;
|
||||||
|
|
||||||
|
// If drifted too far behind (lag > 0.5s), jump ahead
|
||||||
|
if (nextStartTime < now) {
|
||||||
|
nextStartTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStartTime > now + 5.0) {
|
||||||
|
nextStartTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
source.start(nextStartTime);
|
||||||
|
nextStartTime += buffer.duration;
|
||||||
|
|
||||||
|
}
|
||||||
14
yarn.lock
14
yarn.lock
@@ -423,6 +423,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.8.0"
|
"@tauri-apps/api" "^2.8.0"
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-notification@~2":
|
||||||
|
version "2.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz#01581e3abd3bd18200121b213d3e6ebff2967fe2"
|
||||||
|
integrity sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==
|
||||||
|
dependencies:
|
||||||
|
"@tauri-apps/api" "^2.8.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-opener@^2":
|
"@tauri-apps/plugin-opener@^2":
|
||||||
version "2.5.2"
|
version "2.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz#6e2127d0ad7627a16103215ed596e4fa42bda199"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz#6e2127d0ad7627a16103215ed596e4fa42bda199"
|
||||||
@@ -430,6 +437,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@tauri-apps/api" "^2.8.0"
|
"@tauri-apps/api" "^2.8.0"
|
||||||
|
|
||||||
|
"@tauri-apps/plugin-os@~2":
|
||||||
|
version "2.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz#de916a82d8d955bba59a2ffdba7f7aaa29397246"
|
||||||
|
integrity sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==
|
||||||
|
dependencies:
|
||||||
|
"@tauri-apps/api" "^2.8.0"
|
||||||
|
|
||||||
"@tauri-apps/plugin-store@~2":
|
"@tauri-apps/plugin-store@~2":
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz#5e2d3362e41861d2fa79a3f1a78c091e12963236"
|
resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz#5e2d3362e41861d2fa79a3f1a78c091e12963236"
|
||||||
|
|||||||
Reference in New Issue
Block a user