Compare commits
12 Commits
0e342a54be
...
4ddcd3ee25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ddcd3ee25 | ||
|
|
3476c47be4 | ||
|
|
8da3545451 | ||
|
|
57cacd8918 | ||
|
|
0f6d7c052b | ||
|
|
0d23ffcaec | ||
|
|
ce75694ffb | ||
|
|
e2a7761388 | ||
|
|
653a23f805 | ||
|
|
9cb60f7473 | ||
|
|
ed2ae939a6 | ||
|
|
3f7d58d4fe |
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Xyroscar
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
68
README.md
68
README.md
@@ -1,7 +1,67 @@
|
|||||||
# Tauri + SvelteKit + TypeScript
|
# Resona
|
||||||
|
|
||||||
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
Resona is a desktop API client for testing and debugging HTTP APIs. Built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Features
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
- **Workspaces**: Organize your API requests into workspaces with tags for easy filtering
|
||||||
|
- **Collections**: Group related requests into collections within workspaces
|
||||||
|
- **Variables**: Define variables at global, workspace, collection, or request scope with automatic interpolation
|
||||||
|
- **HTTP Client**: Send HTTP requests with support for various body types (JSON, form-data, URL-encoded, etc.)
|
||||||
|
- **Sync Groups**: Sync variables across multiple workspaces
|
||||||
|
- **Themes**: Multiple built-in themes including light, dark, and Catppuccin variants (Latte, Frappe, Macchiato, Mocha)
|
||||||
|
- **Persistent Storage**: All data is stored locally using an embedded database (redb)
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: SvelteKit, TypeScript, TailwindCSS, shadcn-svelte
|
||||||
|
- **Backend**: Rust, Tauri
|
||||||
|
- **Database**: redb (embedded key-value store)
|
||||||
|
- **HTTP**: reqwest
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v18+)
|
||||||
|
- Rust (latest stable)
|
||||||
|
- Bun (or npm/yarn/pnpm)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
bun run tauri dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
bun run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
resona/
|
||||||
|
├── src/ # Frontend (SvelteKit)
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── components/ # UI components
|
||||||
|
│ │ ├── services/ # API service layer
|
||||||
|
│ │ └── types/ # TypeScript types
|
||||||
|
│ └── routes/ # SvelteKit routes
|
||||||
|
├── src-tauri/ # Backend (Rust/Tauri)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── collections/ # Collections module
|
||||||
|
│ ├── db/ # Database layer
|
||||||
|
│ ├── http/ # HTTP client
|
||||||
|
│ ├── requests/ # Requests module
|
||||||
|
│ ├── settings/ # App settings
|
||||||
|
│ ├── variables/ # Variables module
|
||||||
|
│ └── workspaces/ # Workspaces module
|
||||||
|
└── static/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -13,11 +13,16 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2"
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"svelte-codemirror-editor": "^2.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^0.554.0",
|
"@internationalized/date": "^3.8.1",
|
||||||
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.9.0",
|
"@sveltejs/kit": "^2.9.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
@@ -25,6 +30,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"bits-ui": "^2.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
|||||||
396
src-tauri/Cargo.lock
generated
396
src-tauri/Cargo.lock
generated
@@ -451,8 +451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -491,6 +493,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -514,9 +526,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -527,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -679,6 +691,15 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -806,6 +827,15 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -928,6 +958,15 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -935,7 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -949,6 +988,12 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1359,6 +1404,25 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.12.1",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1457,6 +1521,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1468,6 +1533,38 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -1487,9 +1584,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1948,6 +2047,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -1990,6 +2099,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2364,6 +2490,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2858,6 +3028,15 @@ version = "0.6.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redb"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae323eb086579a3769daa2c753bb96deb95993c534711e0dbe881b5192906a06"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2935,22 +3114,31 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -2966,11 +3154,33 @@ dependencies = [
|
|||||||
name = "resona"
|
name = "resona"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"directories",
|
||||||
|
"redb",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"tokio",
|
||||||
|
"urlencoding",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.16",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2995,6 +3205,39 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -3016,6 +3259,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3073,6 +3325,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -3346,7 +3621,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -3428,6 +3703,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3481,6 +3762,27 @@ dependencies = [
|
|||||||
"syn 2.0.111",
|
"syn 2.0.111",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3502,7 +3804,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -3921,9 +4223,41 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.111",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.17"
|
version = "0.7.17"
|
||||||
@@ -4201,6 +4535,12 @@ dependencies = [
|
|||||||
"unic-common",
|
"unic-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -4213,6 +4553,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.7"
|
version = "2.5.7"
|
||||||
@@ -4225,6 +4571,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urlpattern"
|
name = "urlpattern"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -4261,6 +4613,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4643,6 +5001,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -4688,6 +5057,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
@@ -5150,6 +5528,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|||||||
@@ -23,3 +23,17 @@ tauri-plugin-opener = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
redb = "3.1.0"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
thiserror = "2"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
directories = "6.0.0"
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
urlencoding = "2"
|
||||||
|
|
||||||
|
|||||||
51
src-tauri/src/collections/commands.rs
Normal file
51
src-tauri/src/collections/commands.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
use super::service::CollectionService;
|
||||||
|
use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_collections(db: State<Database>) -> Result<Vec<Collection>, String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.get_all().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_collection(db: State<Database>, id: String) -> Result<Collection, String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.get(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_collections_by_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Vec<Collection>, String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.get_by_workspace(&workspace_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_collection(
|
||||||
|
db: State<Database>,
|
||||||
|
input: CreateCollectionInput,
|
||||||
|
) -> Result<Collection, String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.create(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_collection(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateCollectionInput,
|
||||||
|
) -> Result<Collection, String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.update(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_collection(db: State<Database>, id: String) -> Result<(), String> {
|
||||||
|
let service = CollectionService::new(db.inner().clone());
|
||||||
|
service.delete(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
8
src-tauri/src/collections/mod.rs
Normal file
8
src-tauri/src/collections/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mod commands;
|
||||||
|
mod service;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use commands::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{Collection, CreateCollectionInput, UpdateCollectionInput};
|
||||||
|
pub(crate) use service::CollectionService;
|
||||||
164
src-tauri/src/collections/service.rs
Normal file
164
src-tauri/src/collections/service.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
|
||||||
|
use crate::db::{
|
||||||
|
Database, DbError, DbResult, COLLECTIONS, COLLECTIONS_BY_WORKSPACE,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput};
|
||||||
|
|
||||||
|
pub struct CollectionService {
|
||||||
|
db: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CollectionService {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all(&self) -> DbResult<Vec<Collection>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(COLLECTIONS)?;
|
||||||
|
|
||||||
|
let mut collections = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (_, value) = entry?;
|
||||||
|
let collection: Collection = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
collections.push(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
collections.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(collections)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> DbResult<Collection> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(COLLECTIONS)?;
|
||||||
|
|
||||||
|
let value = table
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("Collection not found: {}", id)))?;
|
||||||
|
|
||||||
|
let collection: Collection = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Collection>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
|
||||||
|
|
||||||
|
let collection_ids: Vec<String> = match idx_table.get(workspace_id)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut collections = Vec::new();
|
||||||
|
for id in collection_ids {
|
||||||
|
if let Ok(collection) = self.get(&id) {
|
||||||
|
collections.push(collection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collections.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(collections)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&self, input: CreateCollectionInput) -> DbResult<Collection> {
|
||||||
|
let collection = Collection::new(input.name, input.description, input.workspace_id.clone());
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&collection)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(COLLECTIONS)?;
|
||||||
|
table.insert(collection.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
|
||||||
|
let ids_json = match idx_table.get(input.workspace_id.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => "[]".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ids.push(collection.id.clone());
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, input: UpdateCollectionInput) -> DbResult<Collection> {
|
||||||
|
let mut collection = self.get(&input.id)?;
|
||||||
|
|
||||||
|
if let Some(name) = input.name {
|
||||||
|
collection.name = name;
|
||||||
|
}
|
||||||
|
if let Some(description) = input.description {
|
||||||
|
collection.description = description;
|
||||||
|
}
|
||||||
|
collection.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&collection)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(COLLECTIONS)?;
|
||||||
|
table.insert(collection.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: &str) -> DbResult<()> {
|
||||||
|
let collection = self.get(id)?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Remove from collections table
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(COLLECTIONS)?;
|
||||||
|
table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(collection.workspace_id.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
ids.retain(|i| i != id);
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(collection.workspace_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src-tauri/src/collections/types.rs
Normal file
43
src-tauri/src/collections/types.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Collection {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Collection {
|
||||||
|
pub fn new(name: String, description: String, workspace_id: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
workspace_id,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateCollectionInput {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateCollectionInput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
130
src-tauri/src/db/database.rs
Normal file
130
src-tauri/src/db/database.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
//! Database initialization and management
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use directories::ProjectDirs;
|
||||||
|
use redb::{Database as RedbDatabase, ReadableDatabase};
|
||||||
|
|
||||||
|
use super::error::{DbError, DbResult};
|
||||||
|
use super::tables::*;
|
||||||
|
|
||||||
|
/// Main database wrapper
|
||||||
|
pub struct Database {
|
||||||
|
db: Arc<RedbDatabase>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
/// Create or open the database at the default application data directory
|
||||||
|
pub fn open() -> DbResult<Self> {
|
||||||
|
let path = Self::get_db_path()?;
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = RedbDatabase::create(&path)?;
|
||||||
|
let database = Self { db: Arc::new(db) };
|
||||||
|
|
||||||
|
// Initialize tables
|
||||||
|
database.init_tables()?;
|
||||||
|
|
||||||
|
Ok(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open database at a specific path (useful for testing)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn open_at(path: PathBuf) -> DbResult<Self> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = RedbDatabase::create(&path)?;
|
||||||
|
let database = Self { db: Arc::new(db) };
|
||||||
|
database.init_tables()?;
|
||||||
|
|
||||||
|
Ok(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the default database path
|
||||||
|
fn get_db_path() -> DbResult<PathBuf> {
|
||||||
|
let proj_dirs = ProjectDirs::from("com", "xyroscar", "resona")
|
||||||
|
.ok_or_else(|| DbError::Io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"Could not determine application data directory",
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
Ok(proj_dirs.data_dir().join("resona.redb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize all tables
|
||||||
|
fn init_tables(&self) -> DbResult<()> {
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Create main tables
|
||||||
|
write_txn.open_table(WORKSPACES)?;
|
||||||
|
write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
write_txn.open_table(COLLECTIONS)?;
|
||||||
|
write_txn.open_table(REQUESTS)?;
|
||||||
|
write_txn.open_table(VARIABLES)?;
|
||||||
|
write_txn.open_table(APP_SETTINGS)?;
|
||||||
|
|
||||||
|
// Create index tables
|
||||||
|
write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?;
|
||||||
|
write_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
|
||||||
|
write_txn.open_table(VARIABLES_BY_SCOPE)?;
|
||||||
|
write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a reference to the underlying redb database
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn inner(&self) -> &RedbDatabase {
|
||||||
|
&self.db
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin a read transaction
|
||||||
|
pub fn begin_read(&self) -> DbResult<redb::ReadTransaction> {
|
||||||
|
Ok(self.db.begin_read()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin a write transaction
|
||||||
|
pub fn begin_write(&self) -> DbResult<redb::WriteTransaction> {
|
||||||
|
Ok(self.db.begin_write()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for Database {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
db: Arc::clone(&self.db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env::temp_dir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_creation() {
|
||||||
|
let path = temp_dir().join("resona_test.redb");
|
||||||
|
let _ = std::fs::remove_file(&path); // Clean up any previous test
|
||||||
|
|
||||||
|
let db = Database::open_at(path.clone()).expect("Failed to create database");
|
||||||
|
|
||||||
|
// Verify tables exist by attempting to read from them
|
||||||
|
let read_txn = db.begin_read().expect("Failed to begin read transaction");
|
||||||
|
let _ = read_txn.open_table(WORKSPACES).expect("Workspaces table should exist");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src-tauri/src/db/error.rs
Normal file
42
src-tauri/src/db/error.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//! Database error types
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum DbError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Database(#[from] redb::DatabaseError),
|
||||||
|
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
Storage(#[from] redb::StorageError),
|
||||||
|
|
||||||
|
#[error("Table error: {0}")]
|
||||||
|
Table(#[from] redb::TableError),
|
||||||
|
|
||||||
|
#[error("Transaction error: {0}")]
|
||||||
|
Transaction(#[from] redb::TransactionError),
|
||||||
|
|
||||||
|
#[error("Commit error: {0}")]
|
||||||
|
Commit(#[from] redb::CommitError),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
Serialization(String),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type DbResult<T> = Result<T, DbError>;
|
||||||
|
|
||||||
|
// Implement conversion to tauri::Error for command returns
|
||||||
|
impl From<DbError> for String {
|
||||||
|
fn from(err: DbError) -> Self {
|
||||||
|
err.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src-tauri/src/db/mod.rs
Normal file
11
src-tauri/src/db/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! Database module for Resona
|
||||||
|
//!
|
||||||
|
//! This module handles all database operations using redb as the storage backend.
|
||||||
|
|
||||||
|
mod database;
|
||||||
|
mod error;
|
||||||
|
mod tables;
|
||||||
|
|
||||||
|
pub use database::Database;
|
||||||
|
pub use error::{DbError, DbResult};
|
||||||
|
pub use tables::*;
|
||||||
47
src-tauri/src/db/tables.rs
Normal file
47
src-tauri/src/db/tables.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//! Table definitions for redb
|
||||||
|
//!
|
||||||
|
//! All tables are defined here as constants for consistent access across the application.
|
||||||
|
|
||||||
|
use redb::TableDefinition;
|
||||||
|
|
||||||
|
/// Workspaces table: workspace_id -> workspace JSON
|
||||||
|
pub const WORKSPACES: TableDefinition<&str, &str> = TableDefinition::new("workspaces");
|
||||||
|
|
||||||
|
/// Workspace sync groups table: sync_group_id -> sync_group JSON
|
||||||
|
pub const WORKSPACE_SYNC_GROUPS: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("workspace_sync_groups");
|
||||||
|
|
||||||
|
/// Collections table: collection_id -> collection JSON
|
||||||
|
pub const COLLECTIONS: TableDefinition<&str, &str> = TableDefinition::new("collections");
|
||||||
|
|
||||||
|
/// Requests table: request_id -> request JSON
|
||||||
|
pub const REQUESTS: TableDefinition<&str, &str> = TableDefinition::new("requests");
|
||||||
|
|
||||||
|
/// Variables table: variable_id -> variable JSON
|
||||||
|
pub const VARIABLES: TableDefinition<&str, &str> = TableDefinition::new("variables");
|
||||||
|
|
||||||
|
/// App settings table: "settings" -> settings JSON (single row)
|
||||||
|
pub const APP_SETTINGS: TableDefinition<&str, &str> = TableDefinition::new("app_settings");
|
||||||
|
|
||||||
|
// Index tables for efficient lookups
|
||||||
|
|
||||||
|
/// Collections by workspace index: workspace_id -> collection_ids JSON array
|
||||||
|
pub const COLLECTIONS_BY_WORKSPACE: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("idx_collections_by_workspace");
|
||||||
|
|
||||||
|
/// Requests by collection index: collection_id -> request_ids JSON array
|
||||||
|
pub const REQUESTS_BY_COLLECTION: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("idx_requests_by_collection");
|
||||||
|
|
||||||
|
/// Requests by workspace (standalone) index: workspace_id -> request_ids JSON array
|
||||||
|
pub const REQUESTS_BY_WORKSPACE: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("idx_requests_by_workspace");
|
||||||
|
|
||||||
|
/// Variables by scope index: scope_key -> variable_ids JSON array
|
||||||
|
/// scope_key format: "global", "workspace:{id}", "collection:{id}", "request:{id}"
|
||||||
|
pub const VARIABLES_BY_SCOPE: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("idx_variables_by_scope");
|
||||||
|
|
||||||
|
/// Workspaces by sync group index: sync_group_id -> workspace_ids JSON array
|
||||||
|
pub const WORKSPACES_BY_SYNC_GROUP: TableDefinition<&str, &str> =
|
||||||
|
TableDefinition::new("idx_workspaces_by_sync_group");
|
||||||
174
src-tauri/src/http/client.rs
Normal file
174
src-tauri/src/http/client.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use reqwest::{header::HeaderMap, Client, Method};
|
||||||
|
|
||||||
|
use super::types::{HttpRequest, HttpResponse, HttpResponseHeader};
|
||||||
|
|
||||||
|
pub async fn execute_request(request: HttpRequest) -> Result<HttpResponse, String> {
|
||||||
|
let client = Client::new();
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
// Parse method
|
||||||
|
let method = match request.method.to_uppercase().as_str() {
|
||||||
|
"GET" => Method::GET,
|
||||||
|
"POST" => Method::POST,
|
||||||
|
"PUT" => Method::PUT,
|
||||||
|
"PATCH" => Method::PATCH,
|
||||||
|
"DELETE" => Method::DELETE,
|
||||||
|
"HEAD" => Method::HEAD,
|
||||||
|
"OPTIONS" => Method::OPTIONS,
|
||||||
|
_ => return Err(format!("Unsupported HTTP method: {}", request.method)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build URL with query params
|
||||||
|
let mut url = request.url.clone();
|
||||||
|
let enabled_params: Vec<_> = request
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.enabled && !p.key.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !enabled_params.is_empty() {
|
||||||
|
let query_string: String = enabled_params
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}={}", urlencoding::encode(&p.key), urlencoding::encode(&p.value)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
if url.contains('?') {
|
||||||
|
url = format!("{}&{}", url, query_string);
|
||||||
|
} else {
|
||||||
|
url = format!("{}?{}", url, query_string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for header in &request.headers {
|
||||||
|
if header.enabled && !header.key.is_empty() {
|
||||||
|
if let (Ok(name), Ok(value)) = (
|
||||||
|
header.key.parse::<reqwest::header::HeaderName>(),
|
||||||
|
header.value.parse::<reqwest::header::HeaderValue>(),
|
||||||
|
) {
|
||||||
|
headers.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
let mut req_builder = client.request(method, &url).headers(headers);
|
||||||
|
|
||||||
|
// Add body based on body type
|
||||||
|
match request.body_type.as_str() {
|
||||||
|
"json" => {
|
||||||
|
req_builder = req_builder
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(request.body.clone());
|
||||||
|
}
|
||||||
|
"xml" => {
|
||||||
|
req_builder = req_builder
|
||||||
|
.header("Content-Type", "application/xml")
|
||||||
|
.body(request.body.clone());
|
||||||
|
}
|
||||||
|
"text" => {
|
||||||
|
req_builder = req_builder
|
||||||
|
.header("Content-Type", "text/plain")
|
||||||
|
.body(request.body.clone());
|
||||||
|
}
|
||||||
|
"html" => {
|
||||||
|
req_builder = req_builder
|
||||||
|
.header("Content-Type", "text/html")
|
||||||
|
.body(request.body.clone());
|
||||||
|
}
|
||||||
|
"x-www-form-urlencoded" => {
|
||||||
|
let enabled_form: Vec<_> = request
|
||||||
|
.form_data
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.enabled && !f.key.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let form_string: String = enabled_form
|
||||||
|
.iter()
|
||||||
|
.map(|f| format!("{}={}", urlencoding::encode(&f.key), urlencoding::encode(&f.value)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
req_builder = req_builder
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body(form_string);
|
||||||
|
}
|
||||||
|
"form-data" => {
|
||||||
|
let mut form = reqwest::multipart::Form::new();
|
||||||
|
for item in &request.form_data {
|
||||||
|
if item.enabled && !item.key.is_empty() {
|
||||||
|
if item.item_type == "file" {
|
||||||
|
// For file uploads, read the file
|
||||||
|
match std::fs::read(&item.value) {
|
||||||
|
Ok(contents) => {
|
||||||
|
let file_name = std::path::Path::new(&item.value)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("file")
|
||||||
|
.to_string();
|
||||||
|
let part = reqwest::multipart::Part::bytes(contents)
|
||||||
|
.file_name(file_name);
|
||||||
|
form = form.part(item.key.clone(), part);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!("Failed to read file {}: {}", item.value, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form = form.text(item.key.clone(), item.value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req_builder = req_builder.multipart(form);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// "none" or unknown - no body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
let response = req_builder
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let status_text = response
|
||||||
|
.status()
|
||||||
|
.canonical_reason()
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Collect headers
|
||||||
|
let response_headers: Vec<HttpResponseHeader> = response
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| HttpResponseHeader {
|
||||||
|
key: k.to_string(),
|
||||||
|
value: v.to_str().unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get body
|
||||||
|
let body_bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||||
|
|
||||||
|
let size_bytes = body_bytes.len();
|
||||||
|
let body = String::from_utf8_lossy(&body_bytes).to_string();
|
||||||
|
|
||||||
|
Ok(HttpResponse {
|
||||||
|
status,
|
||||||
|
status_text,
|
||||||
|
headers: response_headers,
|
||||||
|
body,
|
||||||
|
time_ms: elapsed.as_millis() as u64,
|
||||||
|
size_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
5
src-tauri/src/http/mod.rs
Normal file
5
src-tauri/src/http/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod client;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use client::execute_request;
|
||||||
|
pub use types::{HttpRequest, HttpResponse, HttpResponseHeader};
|
||||||
55
src-tauri/src/http/types.rs
Normal file
55
src-tauri/src/http/types.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpRequest {
|
||||||
|
pub method: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: Vec<HttpRequestHeader>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub params: Vec<HttpRequestParam>,
|
||||||
|
pub body_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub form_data: Vec<HttpFormDataItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpRequestHeader {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpRequestParam {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpFormDataItem {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub status_text: String,
|
||||||
|
pub headers: Vec<HttpResponseHeader>,
|
||||||
|
pub body: String,
|
||||||
|
pub time_ms: u64,
|
||||||
|
pub size_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HttpResponseHeader {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
@@ -1,14 +1,98 @@
|
|||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Resona - API Client Application
|
||||||
|
|
||||||
|
mod collections;
|
||||||
|
mod db;
|
||||||
|
mod http;
|
||||||
|
mod requests;
|
||||||
|
mod settings;
|
||||||
|
mod variables;
|
||||||
|
mod workspaces;
|
||||||
|
|
||||||
|
use db::Database;
|
||||||
|
use http::{HttpRequest, HttpResponse};
|
||||||
|
|
||||||
|
use collections::{
|
||||||
|
create_collection, delete_collection, get_collection, get_collections,
|
||||||
|
get_collections_by_workspace, update_collection,
|
||||||
|
};
|
||||||
|
use requests::{
|
||||||
|
create_request, delete_request, get_all_requests_by_workspace, get_request,
|
||||||
|
get_requests_by_collection, get_standalone_requests_by_workspace, update_request,
|
||||||
|
};
|
||||||
|
use settings::{get_settings, reset_settings, update_settings};
|
||||||
|
use variables::{
|
||||||
|
create_variable, delete_variable, get_collection_variables, get_global_variables,
|
||||||
|
get_request_variables, get_resolved_variables, get_variable, get_workspace_variables,
|
||||||
|
update_variable,
|
||||||
|
};
|
||||||
|
use workspaces::{
|
||||||
|
add_workspace_to_sync_group, create_sync_group, create_workspace, delete_sync_group,
|
||||||
|
delete_workspace, get_sync_group, get_sync_group_for_workspace, get_sync_groups,
|
||||||
|
get_workspace, get_workspaces, get_workspaces_by_sync_group, remove_workspace_from_sync_group,
|
||||||
|
update_sync_group, update_workspace,
|
||||||
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
async fn send_http_request(request: HttpRequest) -> Result<HttpResponse, String> {
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
http::execute_request(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
let db = Database::open().expect("Failed to initialize database");
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.manage(db)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// Workspace commands
|
||||||
|
get_workspaces,
|
||||||
|
get_workspace,
|
||||||
|
create_workspace,
|
||||||
|
update_workspace,
|
||||||
|
delete_workspace,
|
||||||
|
// Sync group commands
|
||||||
|
get_sync_groups,
|
||||||
|
get_sync_group,
|
||||||
|
get_sync_group_for_workspace,
|
||||||
|
create_sync_group,
|
||||||
|
update_sync_group,
|
||||||
|
delete_sync_group,
|
||||||
|
get_workspaces_by_sync_group,
|
||||||
|
add_workspace_to_sync_group,
|
||||||
|
remove_workspace_from_sync_group,
|
||||||
|
// Collection commands
|
||||||
|
get_collections,
|
||||||
|
get_collection,
|
||||||
|
get_collections_by_workspace,
|
||||||
|
create_collection,
|
||||||
|
update_collection,
|
||||||
|
delete_collection,
|
||||||
|
// Request commands
|
||||||
|
get_request,
|
||||||
|
get_requests_by_collection,
|
||||||
|
get_standalone_requests_by_workspace,
|
||||||
|
get_all_requests_by_workspace,
|
||||||
|
create_request,
|
||||||
|
update_request,
|
||||||
|
delete_request,
|
||||||
|
// Variable commands
|
||||||
|
get_variable,
|
||||||
|
get_global_variables,
|
||||||
|
get_workspace_variables,
|
||||||
|
get_collection_variables,
|
||||||
|
get_request_variables,
|
||||||
|
get_resolved_variables,
|
||||||
|
create_variable,
|
||||||
|
update_variable,
|
||||||
|
delete_variable,
|
||||||
|
// HTTP client
|
||||||
|
send_http_request,
|
||||||
|
// Settings commands
|
||||||
|
get_settings,
|
||||||
|
update_settings,
|
||||||
|
reset_settings,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
63
src-tauri/src/requests/commands.rs
Normal file
63
src-tauri/src/requests/commands.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
use super::service::RequestService;
|
||||||
|
use super::types::{CreateRequestInput, Request, UpdateRequestInput};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request(db: State<Database>, id: String) -> Result<Request, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.get(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_requests_by_collection(
|
||||||
|
db: State<Database>,
|
||||||
|
collection_id: String,
|
||||||
|
) -> Result<Vec<Request>, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.get_by_collection(&collection_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_standalone_requests_by_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Vec<Request>, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.get_standalone_by_workspace(&workspace_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_all_requests_by_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Vec<Request>, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.get_all_by_workspace(&workspace_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_request(
|
||||||
|
db: State<Database>,
|
||||||
|
input: CreateRequestInput,
|
||||||
|
) -> Result<Request, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.create(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_request(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateRequestInput,
|
||||||
|
) -> Result<Request, String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.update(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_request(db: State<Database>, id: String) -> Result<(), String> {
|
||||||
|
let service = RequestService::new(db.inner().clone());
|
||||||
|
service.delete(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
11
src-tauri/src/requests/mod.rs
Normal file
11
src-tauri/src/requests/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mod commands;
|
||||||
|
mod service;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use commands::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{
|
||||||
|
BodyType, CreateRequestInput, FormDataItem, HttpMethod, Request, RequestHeader,
|
||||||
|
RequestParam, UpdateRequestInput,
|
||||||
|
};
|
||||||
|
pub(crate) use service::RequestService;
|
||||||
298
src-tauri/src/requests/service.rs
Normal file
298
src-tauri/src/requests/service.rs
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
|
||||||
|
use crate::db::{
|
||||||
|
Database, DbError, DbResult, REQUESTS, REQUESTS_BY_COLLECTION, REQUESTS_BY_WORKSPACE,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::types::{CreateRequestInput, Request, UpdateRequestInput};
|
||||||
|
|
||||||
|
pub struct RequestService {
|
||||||
|
db: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestService {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> DbResult<Request> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(REQUESTS)?;
|
||||||
|
|
||||||
|
let value = table
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("Request not found: {}", id)))?;
|
||||||
|
|
||||||
|
let request: Request = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_collection(&self, collection_id: &str) -> DbResult<Vec<Request>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
|
||||||
|
let request_ids: Vec<String> = match idx_table.get(collection_id)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
for id in request_ids {
|
||||||
|
if let Ok(request) = self.get(&id) {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_standalone_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Request>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?;
|
||||||
|
|
||||||
|
let request_ids: Vec<String> = match idx_table.get(workspace_id)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
for id in request_ids {
|
||||||
|
if let Ok(request) = self.get(&id) {
|
||||||
|
if request.collection_id.is_none() {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Request>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?;
|
||||||
|
|
||||||
|
let request_ids: Vec<String> = match idx_table.get(workspace_id)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut requests = Vec::new();
|
||||||
|
for id in request_ids {
|
||||||
|
if let Ok(request) = self.get(&id) {
|
||||||
|
requests.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&self, input: CreateRequestInput) -> DbResult<Request> {
|
||||||
|
let mut request = Request::new(input.name, input.method, input.workspace_id.clone());
|
||||||
|
request.url = input.url;
|
||||||
|
request.headers = input.headers;
|
||||||
|
request.params = input.params;
|
||||||
|
request.body_type = input.body_type;
|
||||||
|
request.body = input.body;
|
||||||
|
request.form_data = input.form_data;
|
||||||
|
request.collection_id = input.collection_id.clone();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(REQUESTS)?;
|
||||||
|
table.insert(request.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workspace index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
|
||||||
|
let ids_json = match idx_table.get(input.workspace_id.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => "[]".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ids.push(request.id.clone());
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection index if applicable
|
||||||
|
if let Some(ref collection_id) = input.collection_id {
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
let ids_json = match idx_table.get(collection_id.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => "[]".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ids.push(request.id.clone());
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(collection_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, input: UpdateRequestInput) -> DbResult<Request> {
|
||||||
|
let mut request = self.get(&input.id)?;
|
||||||
|
let old_collection_id = request.collection_id.clone();
|
||||||
|
|
||||||
|
if let Some(name) = input.name {
|
||||||
|
request.name = name;
|
||||||
|
}
|
||||||
|
if let Some(method) = input.method {
|
||||||
|
request.method = method;
|
||||||
|
}
|
||||||
|
if let Some(url) = input.url {
|
||||||
|
request.url = url;
|
||||||
|
}
|
||||||
|
if let Some(headers) = input.headers {
|
||||||
|
request.headers = headers;
|
||||||
|
}
|
||||||
|
if let Some(params) = input.params {
|
||||||
|
request.params = params;
|
||||||
|
}
|
||||||
|
if let Some(body_type) = input.body_type {
|
||||||
|
request.body_type = body_type;
|
||||||
|
}
|
||||||
|
if let Some(body) = input.body {
|
||||||
|
request.body = body;
|
||||||
|
}
|
||||||
|
if let Some(form_data) = input.form_data {
|
||||||
|
request.form_data = form_data;
|
||||||
|
}
|
||||||
|
if let Some(collection_id) = input.collection_id {
|
||||||
|
request.collection_id = collection_id;
|
||||||
|
}
|
||||||
|
request.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&request)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(REQUESTS)?;
|
||||||
|
table.insert(request.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection index if collection changed
|
||||||
|
if old_collection_id != request.collection_id {
|
||||||
|
// Remove from old collection index
|
||||||
|
if let Some(ref old_id) = old_collection_id {
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(old_id.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
ids.retain(|i| i != &request.id);
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(old_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to new collection index
|
||||||
|
if let Some(ref new_id) = request.collection_id {
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(new_id.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
if !ids.contains(&request.id) {
|
||||||
|
ids.push(request.id.clone());
|
||||||
|
}
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(new_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: &str) -> DbResult<()> {
|
||||||
|
let request = self.get(id)?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Remove from requests table
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(REQUESTS)?;
|
||||||
|
table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update workspace index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(request.workspace_id.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
ids.retain(|i| i != id);
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(request.workspace_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update collection index if applicable
|
||||||
|
if let Some(ref collection_id) = request.collection_id {
|
||||||
|
let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(collection_id.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
ids.retain(|i| i != id);
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(collection_id.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src-tauri/src/requests/types.rs
Normal file
142
src-tauri/src/requests/types.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub enum HttpMethod {
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Head,
|
||||||
|
Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpMethod {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum BodyType {
|
||||||
|
None,
|
||||||
|
Json,
|
||||||
|
Xml,
|
||||||
|
Text,
|
||||||
|
Html,
|
||||||
|
FormData,
|
||||||
|
#[serde(rename = "x-www-form-urlencoded")]
|
||||||
|
XWwwFormUrlencoded,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BodyType {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RequestHeader {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RequestParam {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FormDataItem {
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: String, // "text" or "file"
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Request {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub method: HttpMethod,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: Vec<RequestHeader>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub params: Vec<RequestParam>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body_type: BodyType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub form_data: Vec<FormDataItem>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
pub fn new(name: String, method: HttpMethod, workspace_id: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
method,
|
||||||
|
url: String::new(),
|
||||||
|
headers: Vec::new(),
|
||||||
|
params: Vec::new(),
|
||||||
|
body_type: BodyType::None,
|
||||||
|
body: String::new(),
|
||||||
|
form_data: Vec::new(),
|
||||||
|
collection_id: None,
|
||||||
|
workspace_id,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateRequestInput {
|
||||||
|
pub name: String,
|
||||||
|
pub method: HttpMethod,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: Vec<RequestHeader>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub params: Vec<RequestParam>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body_type: BodyType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub form_data: Vec<FormDataItem>,
|
||||||
|
pub collection_id: Option<String>,
|
||||||
|
pub workspace_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateRequestInput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub method: Option<HttpMethod>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub headers: Option<Vec<RequestHeader>>,
|
||||||
|
pub params: Option<Vec<RequestParam>>,
|
||||||
|
pub body_type: Option<BodyType>,
|
||||||
|
pub body: Option<String>,
|
||||||
|
pub form_data: Option<Vec<FormDataItem>>,
|
||||||
|
pub collection_id: Option<Option<String>>,
|
||||||
|
}
|
||||||
27
src-tauri/src/settings/commands.rs
Normal file
27
src-tauri/src/settings/commands.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
use super::service::SettingsService;
|
||||||
|
use super::types::{AppSettings, UpdateSettingsInput};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_settings(db: State<Database>) -> Result<AppSettings, String> {
|
||||||
|
let service = SettingsService::new(db.inner().clone());
|
||||||
|
service.get().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_settings(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateSettingsInput,
|
||||||
|
) -> Result<AppSettings, String> {
|
||||||
|
let service = SettingsService::new(db.inner().clone());
|
||||||
|
service.update(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn reset_settings(db: State<Database>) -> Result<AppSettings, String> {
|
||||||
|
let service = SettingsService::new(db.inner().clone());
|
||||||
|
service.reset().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
7
src-tauri/src/settings/mod.rs
Normal file
7
src-tauri/src/settings/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mod commands;
|
||||||
|
mod service;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use commands::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{AppSettings, CustomTheme, Theme, ThemeColors, UpdateSettingsInput};
|
||||||
78
src-tauri/src/settings/service.rs
Normal file
78
src-tauri/src/settings/service.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use crate::db::{Database, DbResult, APP_SETTINGS};
|
||||||
|
|
||||||
|
use super::types::{AppSettings, UpdateSettingsInput};
|
||||||
|
|
||||||
|
const SETTINGS_KEY: &str = "settings";
|
||||||
|
|
||||||
|
pub struct SettingsService {
|
||||||
|
db: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsService {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self) -> DbResult<AppSettings> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(APP_SETTINGS)?;
|
||||||
|
|
||||||
|
match table.get(SETTINGS_KEY)? {
|
||||||
|
Some(value) => {
|
||||||
|
let settings: AppSettings = serde_json::from_str(value.value())?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
None => Ok(AppSettings::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, input: UpdateSettingsInput) -> DbResult<AppSettings> {
|
||||||
|
let mut settings = self.get()?;
|
||||||
|
|
||||||
|
if let Some(theme) = input.theme {
|
||||||
|
settings.theme = theme;
|
||||||
|
}
|
||||||
|
if let Some(custom_themes) = input.custom_themes {
|
||||||
|
settings.custom_themes = custom_themes;
|
||||||
|
}
|
||||||
|
if let Some(default_timeout) = input.default_timeout {
|
||||||
|
settings.default_timeout = default_timeout;
|
||||||
|
}
|
||||||
|
if let Some(follow_redirects) = input.follow_redirects {
|
||||||
|
settings.follow_redirects = follow_redirects;
|
||||||
|
}
|
||||||
|
if let Some(validate_ssl) = input.validate_ssl {
|
||||||
|
settings.validate_ssl = validate_ssl;
|
||||||
|
}
|
||||||
|
if let Some(max_history_items) = input.max_history_items {
|
||||||
|
settings.max_history_items = max_history_items;
|
||||||
|
}
|
||||||
|
if let Some(auto_save_requests) = input.auto_save_requests {
|
||||||
|
settings.auto_save_requests = auto_save_requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(APP_SETTINGS)?;
|
||||||
|
let json = serde_json::to_string(&settings)?;
|
||||||
|
table.insert(SETTINGS_KEY, json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&self) -> DbResult<AppSettings> {
|
||||||
|
let settings = AppSettings::default();
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(APP_SETTINGS)?;
|
||||||
|
let json = serde_json::to_string(&settings)?;
|
||||||
|
table.insert(SETTINGS_KEY, json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src-tauri/src/settings/types.rs
Normal file
95
src-tauri/src/settings/types.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Theme {
|
||||||
|
System,
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
Latte,
|
||||||
|
Frappe,
|
||||||
|
Macchiato,
|
||||||
|
Mocha,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::System
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomTheme {
|
||||||
|
pub name: String,
|
||||||
|
pub colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThemeColors {
|
||||||
|
pub background: String,
|
||||||
|
pub foreground: String,
|
||||||
|
pub primary: String,
|
||||||
|
pub secondary: String,
|
||||||
|
pub accent: String,
|
||||||
|
pub muted: String,
|
||||||
|
pub border: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppSettings {
|
||||||
|
pub theme: Theme,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_themes: Vec<CustomTheme>,
|
||||||
|
#[serde(default = "default_timeout")]
|
||||||
|
pub default_timeout: u32,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub follow_redirects: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub validate_ssl: bool,
|
||||||
|
#[serde(default = "default_max_history")]
|
||||||
|
pub max_history_items: u32,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_save_requests: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_timeout() -> u32 {
|
||||||
|
30000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_history() -> u32 {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme: Theme::System,
|
||||||
|
custom_themes: Vec::new(),
|
||||||
|
default_timeout: 30000,
|
||||||
|
follow_redirects: true,
|
||||||
|
validate_ssl: true,
|
||||||
|
max_history_items: 100,
|
||||||
|
auto_save_requests: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateSettingsInput {
|
||||||
|
pub theme: Option<Theme>,
|
||||||
|
pub custom_themes: Option<Vec<CustomTheme>>,
|
||||||
|
pub default_timeout: Option<u32>,
|
||||||
|
pub follow_redirects: Option<bool>,
|
||||||
|
pub validate_ssl: Option<bool>,
|
||||||
|
pub max_history_items: Option<u32>,
|
||||||
|
pub auto_save_requests: Option<bool>,
|
||||||
|
}
|
||||||
86
src-tauri/src/variables/commands.rs
Normal file
86
src-tauri/src/variables/commands.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
use super::service::VariableService;
|
||||||
|
use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_variable(db: State<Database>, id: String) -> Result<Variable, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.get(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_global_variables(db: State<Database>) -> Result<Vec<Variable>, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.get_global().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_workspace_variables(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Vec<Variable>, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.get_by_workspace(&workspace_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_collection_variables(
|
||||||
|
db: State<Database>,
|
||||||
|
collection_id: String,
|
||||||
|
) -> Result<Vec<Variable>, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.get_by_collection(&collection_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_request_variables(
|
||||||
|
db: State<Database>,
|
||||||
|
request_id: String,
|
||||||
|
) -> Result<Vec<Variable>, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.get_by_request(&request_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_resolved_variables(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
collection_id: Option<String>,
|
||||||
|
request_id: Option<String>,
|
||||||
|
) -> Result<Vec<ResolvedVariable>, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service
|
||||||
|
.get_resolved(
|
||||||
|
workspace_id.as_deref(),
|
||||||
|
collection_id.as_deref(),
|
||||||
|
request_id.as_deref(),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_variable(
|
||||||
|
db: State<Database>,
|
||||||
|
input: CreateVariableInput,
|
||||||
|
) -> Result<Variable, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.create(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_variable(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateVariableInput,
|
||||||
|
) -> Result<Variable, String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.update(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_variable(db: State<Database>, id: String) -> Result<(), String> {
|
||||||
|
let service = VariableService::new(db.inner().clone());
|
||||||
|
service.delete(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
8
src-tauri/src/variables/mod.rs
Normal file
8
src-tauri/src/variables/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mod commands;
|
||||||
|
mod service;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use commands::*;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{CreateVariableInput, UpdateVariableInput, Variable, VariableScope};
|
||||||
|
pub(crate) use service::VariableService;
|
||||||
236
src-tauri/src/variables/service.rs
Normal file
236
src-tauri/src/variables/service.rs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
|
||||||
|
use crate::db::{Database, DbError, DbResult, VARIABLES, VARIABLES_BY_SCOPE};
|
||||||
|
|
||||||
|
use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable, VariableScope};
|
||||||
|
|
||||||
|
pub struct VariableService {
|
||||||
|
db: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VariableService {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> DbResult<Variable> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(VARIABLES)?;
|
||||||
|
|
||||||
|
let value = table
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("Variable not found: {}", id)))?;
|
||||||
|
|
||||||
|
let variable: Variable = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_by_scope_key(&self, scope_key: &str) -> DbResult<Vec<Variable>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(VARIABLES_BY_SCOPE)?;
|
||||||
|
|
||||||
|
let variable_ids: Vec<String> = match idx_table.get(scope_key)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut variables = Vec::new();
|
||||||
|
for id in variable_ids {
|
||||||
|
if let Ok(variable) = self.get(&id) {
|
||||||
|
variables.push(variable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variables.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_global(&self) -> DbResult<Vec<Variable>> {
|
||||||
|
self.get_by_scope_key("global")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult<Vec<Variable>> {
|
||||||
|
self.get_by_scope_key(&format!("workspace:{}", workspace_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_collection(&self, collection_id: &str) -> DbResult<Vec<Variable>> {
|
||||||
|
self.get_by_scope_key(&format!("collection:{}", collection_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_request(&self, request_id: &str) -> DbResult<Vec<Variable>> {
|
||||||
|
self.get_by_scope_key(&format!("request:{}", request_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_resolved(
|
||||||
|
&self,
|
||||||
|
workspace_id: Option<&str>,
|
||||||
|
collection_id: Option<&str>,
|
||||||
|
request_id: Option<&str>,
|
||||||
|
) -> DbResult<Vec<ResolvedVariable>> {
|
||||||
|
let mut resolved_map = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Global variables (lowest priority)
|
||||||
|
for var in self.get_global()? {
|
||||||
|
resolved_map.insert(var.name.clone(), ResolvedVariable {
|
||||||
|
name: var.name,
|
||||||
|
value: var.value,
|
||||||
|
scope: var.scope,
|
||||||
|
is_secret: var.is_secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace variables
|
||||||
|
if let Some(ws_id) = workspace_id {
|
||||||
|
for var in self.get_by_workspace(ws_id)? {
|
||||||
|
resolved_map.insert(var.name.clone(), ResolvedVariable {
|
||||||
|
name: var.name,
|
||||||
|
value: var.value,
|
||||||
|
scope: var.scope,
|
||||||
|
is_secret: var.is_secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection variables
|
||||||
|
if let Some(coll_id) = collection_id {
|
||||||
|
for var in self.get_by_collection(coll_id)? {
|
||||||
|
resolved_map.insert(var.name.clone(), ResolvedVariable {
|
||||||
|
name: var.name,
|
||||||
|
value: var.value,
|
||||||
|
scope: var.scope,
|
||||||
|
is_secret: var.is_secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request variables (highest priority)
|
||||||
|
if let Some(req_id) = request_id {
|
||||||
|
for var in self.get_by_request(req_id)? {
|
||||||
|
resolved_map.insert(var.name.clone(), ResolvedVariable {
|
||||||
|
name: var.name,
|
||||||
|
value: var.value,
|
||||||
|
scope: var.scope,
|
||||||
|
is_secret: var.is_secret,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resolved: Vec<_> = resolved_map.into_values().collect();
|
||||||
|
resolved.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&self, input: CreateVariableInput) -> DbResult<Variable> {
|
||||||
|
let mut variable = Variable::new(
|
||||||
|
input.name,
|
||||||
|
input.value,
|
||||||
|
input.scope,
|
||||||
|
input.scope_id,
|
||||||
|
);
|
||||||
|
variable.is_secret = input.is_secret;
|
||||||
|
variable.description = input.description;
|
||||||
|
|
||||||
|
let scope_key = variable.scope_key();
|
||||||
|
let json = serde_json::to_string(&variable)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(VARIABLES)?;
|
||||||
|
table.insert(variable.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scope index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?;
|
||||||
|
let ids_json = match idx_table.get(scope_key.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => "[]".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ids.push(variable.id.clone());
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(scope_key.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, input: UpdateVariableInput) -> DbResult<Variable> {
|
||||||
|
let mut variable = self.get(&input.id)?;
|
||||||
|
|
||||||
|
if let Some(name) = input.name {
|
||||||
|
variable.name = name;
|
||||||
|
}
|
||||||
|
if let Some(value) = input.value {
|
||||||
|
variable.value = value;
|
||||||
|
}
|
||||||
|
if let Some(is_secret) = input.is_secret {
|
||||||
|
variable.is_secret = is_secret;
|
||||||
|
}
|
||||||
|
if let Some(description) = input.description {
|
||||||
|
variable.description = Some(description);
|
||||||
|
}
|
||||||
|
variable.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&variable)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(VARIABLES)?;
|
||||||
|
table.insert(variable.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: &str) -> DbResult<()> {
|
||||||
|
let variable = self.get(id)?;
|
||||||
|
let scope_key = variable.scope_key();
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Remove from variables table
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(VARIABLES)?;
|
||||||
|
table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scope index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?;
|
||||||
|
let ids_json = idx_table
|
||||||
|
.get(scope_key.as_str())?
|
||||||
|
.map(|v| v.value().to_string())
|
||||||
|
.unwrap_or_else(|| "[]".to_string());
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
ids.retain(|i| i != id);
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(scope_key.as_str(), new_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src-tauri/src/variables/types.rs
Normal file
87
src-tauri/src/variables/types.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum VariableScope {
|
||||||
|
Global,
|
||||||
|
Workspace,
|
||||||
|
Collection,
|
||||||
|
Request,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VariableScope {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Global
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Variable {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub scope: VariableScope,
|
||||||
|
pub scope_id: Option<String>,
|
||||||
|
pub is_secret: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Variable {
|
||||||
|
pub fn new(name: String, value: String, scope: VariableScope, scope_id: Option<String>) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
scope,
|
||||||
|
scope_id,
|
||||||
|
is_secret: false,
|
||||||
|
description: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scope_key(&self) -> String {
|
||||||
|
match self.scope {
|
||||||
|
VariableScope::Global => "global".to_string(),
|
||||||
|
VariableScope::Workspace => format!("workspace:{}", self.scope_id.as_deref().unwrap_or("")),
|
||||||
|
VariableScope::Collection => format!("collection:{}", self.scope_id.as_deref().unwrap_or("")),
|
||||||
|
VariableScope::Request => format!("request:{}", self.scope_id.as_deref().unwrap_or("")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateVariableInput {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub scope: VariableScope,
|
||||||
|
pub scope_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_secret: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateVariableInput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub is_secret: Option<bool>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResolvedVariable {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub scope: VariableScope,
|
||||||
|
pub is_secret: bool,
|
||||||
|
}
|
||||||
143
src-tauri/src/workspaces/commands.rs
Normal file
143
src-tauri/src/workspaces/commands.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//! Tauri commands for operations on workspace
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
|
||||||
|
Workspace, WorkspaceSyncGroup,
|
||||||
|
};
|
||||||
|
use super::workspace::WorkspaceService;
|
||||||
|
|
||||||
|
/// Get all workspaces
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_workspaces(db: State<Database>) -> Result<Vec<Workspace>, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.get_all().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a workspace by ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_workspace(db: State<Database>, id: String) -> Result<Workspace, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.get(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new workspace
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
input: CreateWorkspaceInput,
|
||||||
|
) -> Result<Workspace, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.create(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing workspace
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateWorkspaceInput,
|
||||||
|
) -> Result<Workspace, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.update(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a workspace
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_workspace(db: State<Database>, id: String) -> Result<(), String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.delete(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all sync groups
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_sync_groups(db: State<Database>) -> Result<Vec<WorkspaceSyncGroup>, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.get_all_sync_groups().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a sync group by ID
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_sync_group(db: State<Database>, id: String) -> Result<WorkspaceSyncGroup, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.get_sync_group(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sync group for a workspace
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_sync_group_for_workspace(
|
||||||
|
db: State<Database>,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<Option<WorkspaceSyncGroup>, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service
|
||||||
|
.get_sync_group_for_workspace(&workspace_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_sync_group(
|
||||||
|
db: State<Database>,
|
||||||
|
input: CreateSyncGroupInput,
|
||||||
|
) -> Result<WorkspaceSyncGroup, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.create_sync_group(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_sync_group(
|
||||||
|
db: State<Database>,
|
||||||
|
input: UpdateSyncGroupInput,
|
||||||
|
) -> Result<WorkspaceSyncGroup, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.update_sync_group(input).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_sync_group(db: State<Database>, id: String) -> Result<(), String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service.delete_sync_group(&id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get workspaces by sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_workspaces_by_sync_group(
|
||||||
|
db: State<Database>,
|
||||||
|
sync_group_id: String,
|
||||||
|
) -> Result<Vec<Workspace>, String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service
|
||||||
|
.get_workspaces_by_sync_group(&sync_group_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a workspace to a sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_workspace_to_sync_group(
|
||||||
|
db: State<Database>,
|
||||||
|
sync_group_id: String,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service
|
||||||
|
.add_workspace_to_sync_group(&sync_group_id, &workspace_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a workspace from a sync group
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_workspace_from_sync_group(
|
||||||
|
db: State<Database>,
|
||||||
|
sync_group_id: String,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let service = WorkspaceService::new(db.inner().clone());
|
||||||
|
service
|
||||||
|
.remove_workspace_from_sync_group(&sync_group_id, &workspace_id)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
21
src-tauri/src/workspaces/mod.rs
Normal file
21
src-tauri/src/workspaces/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! Workspaces module
|
||||||
|
//!
|
||||||
|
//! Handles workspace management including CRUD operations and sync groups.
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod types;
|
||||||
|
mod workspace;
|
||||||
|
|
||||||
|
// Re-export commands for use in lib.rs
|
||||||
|
pub use commands::*;
|
||||||
|
|
||||||
|
// Re-export types for external use (frontend bindings)
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use types::{
|
||||||
|
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
|
||||||
|
Workspace, WorkspaceSyncGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
// WorkspaceService is used internally by commands
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub(crate) use workspace::WorkspaceService;
|
||||||
97
src-tauri/src/workspaces/types.rs
Normal file
97
src-tauri/src/workspaces/types.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
pub struct Workspace {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sync_group_id: Option<String>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Workspace {
|
||||||
|
pub fn new(name: String, description: String) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
tags: Vec::new(),
|
||||||
|
sync_group_id: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateWorkspaceInput {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateWorkspaceInput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub sync_group_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WorkspaceSyncGroup {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub workspace_ids: Vec<String>,
|
||||||
|
pub synced_variable_names: Vec<String>,
|
||||||
|
pub sync_secrets: bool,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
#[serde(default = "Utc::now")]
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceSyncGroup {
|
||||||
|
pub fn new(name: String, workspace_ids: Vec<String>) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
name,
|
||||||
|
workspace_ids,
|
||||||
|
synced_variable_names: Vec::new(),
|
||||||
|
sync_secrets: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateSyncGroupInput {
|
||||||
|
pub name: String,
|
||||||
|
pub workspace_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub synced_variable_names: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_secrets: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateSyncGroupInput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub synced_variable_names: Option<Vec<String>>,
|
||||||
|
pub sync_secrets: Option<bool>,
|
||||||
|
}
|
||||||
572
src-tauri/src/workspaces/workspace.rs
Normal file
572
src-tauri/src/workspaces/workspace.rs
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
|
||||||
|
use crate::db::{
|
||||||
|
Database, DbError, DbResult, WORKSPACES, WORKSPACE_SYNC_GROUPS, WORKSPACES_BY_SYNC_GROUP,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::types::{
|
||||||
|
CreateSyncGroupInput, CreateWorkspaceInput, UpdateSyncGroupInput, UpdateWorkspaceInput,
|
||||||
|
Workspace, WorkspaceSyncGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct WorkspaceService {
|
||||||
|
db: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceService {
|
||||||
|
pub fn new(db: Database) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all(&self) -> DbResult<Vec<Workspace>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(WORKSPACES)?;
|
||||||
|
|
||||||
|
let mut workspaces = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (_, value) = entry?;
|
||||||
|
let workspace: Workspace = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
workspaces.push(workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaces.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(workspaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> DbResult<Workspace> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(WORKSPACES)?;
|
||||||
|
|
||||||
|
let value = table
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("Workspace not found: {}", id)))?;
|
||||||
|
|
||||||
|
let workspace: Workspace = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(&self, input: CreateWorkspaceInput) -> DbResult<Workspace> {
|
||||||
|
let mut workspace = Workspace::new(input.name, input.description);
|
||||||
|
workspace.tags = input.tags;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
table.insert(workspace.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&self, input: UpdateWorkspaceInput) -> DbResult<Workspace> {
|
||||||
|
let mut workspace = self.get(&input.id)?;
|
||||||
|
|
||||||
|
if let Some(name) = input.name {
|
||||||
|
workspace.name = name;
|
||||||
|
}
|
||||||
|
if let Some(description) = input.description {
|
||||||
|
workspace.description = description;
|
||||||
|
}
|
||||||
|
if let Some(tags) = input.tags {
|
||||||
|
workspace.tags = tags;
|
||||||
|
}
|
||||||
|
if let Some(sync_group_id) = input.sync_group_id {
|
||||||
|
workspace.sync_group_id = Some(sync_group_id);
|
||||||
|
}
|
||||||
|
workspace.updated_at = Utc::now();
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
let json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
table.insert(workspace.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a workspace
|
||||||
|
pub fn delete(&self, id: &str) -> DbResult<()> {
|
||||||
|
// First get the workspace to check sync_group_id
|
||||||
|
let workspace = self.get(id)?;
|
||||||
|
let sync_group_id = workspace.sync_group_id.clone();
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Remove from workspaces table
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from sync group index if applicable
|
||||||
|
if let Some(group_id) = sync_group_id {
|
||||||
|
self.remove_from_sync_index(&write_txn, &group_id, id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to remove a workspace ID from the sync group index
|
||||||
|
fn remove_from_sync_index(
|
||||||
|
&self,
|
||||||
|
write_txn: &redb::WriteTransaction,
|
||||||
|
group_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
|
||||||
|
// Read current IDs
|
||||||
|
let ids_json = match idx_table.get(group_id)? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ids.retain(|i| i != workspace_id);
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(group_id, new_json.as_str())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to add a workspace ID to the sync group index
|
||||||
|
fn add_to_sync_index(
|
||||||
|
&self,
|
||||||
|
write_txn: &redb::WriteTransaction,
|
||||||
|
group_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
|
||||||
|
// Read current IDs or start with empty
|
||||||
|
let ids_json = match idx_table.get(group_id)? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => "[]".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ids: Vec<String> = serde_json::from_str(&ids_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
if !ids.contains(&workspace_id.to_string()) {
|
||||||
|
ids.push(workspace_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_json = serde_json::to_string(&ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
idx_table.insert(group_id, new_json.as_str())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Sync Group Operations ====================
|
||||||
|
|
||||||
|
/// Get all sync groups
|
||||||
|
pub fn get_all_sync_groups(&self) -> DbResult<Vec<WorkspaceSyncGroup>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (_, value) = entry?;
|
||||||
|
let group: WorkspaceSyncGroup = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
groups.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a sync group by ID
|
||||||
|
pub fn get_sync_group(&self, id: &str) -> DbResult<WorkspaceSyncGroup> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let table = read_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
|
||||||
|
let value = table
|
||||||
|
.get(id)?
|
||||||
|
.ok_or_else(|| DbError::NotFound(format!("Sync group not found: {}", id)))?;
|
||||||
|
|
||||||
|
let group: WorkspaceSyncGroup = serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get sync group for a workspace
|
||||||
|
pub fn get_sync_group_for_workspace(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> DbResult<Option<WorkspaceSyncGroup>> {
|
||||||
|
let workspace = self.get(workspace_id)?;
|
||||||
|
|
||||||
|
match workspace.sync_group_id {
|
||||||
|
Some(group_id) => Ok(Some(self.get_sync_group(&group_id)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new sync group
|
||||||
|
pub fn create_sync_group(&self, input: CreateSyncGroupInput) -> DbResult<WorkspaceSyncGroup> {
|
||||||
|
let mut group = WorkspaceSyncGroup::new(input.name, input.workspace_ids.clone());
|
||||||
|
group.synced_variable_names = input.synced_variable_names;
|
||||||
|
group.sync_secrets = input.sync_secrets;
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&group)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Insert sync group
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
table.insert(group.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
let ids_json = serde_json::to_string(&input.workspace_ids)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
idx_table.insert(group.id.as_str(), ids_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each workspace's sync_group_id
|
||||||
|
{
|
||||||
|
let mut ws_table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
for ws_id in &input.workspace_ids {
|
||||||
|
// Read workspace
|
||||||
|
let ws_json = match ws_table.get(ws_id.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut workspace: Workspace = serde_json::from_str(&ws_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
workspace.sync_group_id = Some(group.id.clone());
|
||||||
|
workspace.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let new_ws_json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a sync group
|
||||||
|
pub fn update_sync_group(&self, input: UpdateSyncGroupInput) -> DbResult<WorkspaceSyncGroup> {
|
||||||
|
// Read existing
|
||||||
|
let mut group = self.get_sync_group(&input.id)?;
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if let Some(name) = input.name {
|
||||||
|
group.name = name;
|
||||||
|
}
|
||||||
|
if let Some(synced_variable_names) = input.synced_variable_names {
|
||||||
|
group.synced_variable_names = synced_variable_names;
|
||||||
|
}
|
||||||
|
if let Some(sync_secrets) = input.sync_secrets {
|
||||||
|
group.sync_secrets = sync_secrets;
|
||||||
|
}
|
||||||
|
group.updated_at = Utc::now();
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
let json = serde_json::to_string(&group)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
table.insert(group.id.as_str(), json.as_str())?;
|
||||||
|
}
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a sync group
|
||||||
|
pub fn delete_sync_group(&self, id: &str) -> DbResult<()> {
|
||||||
|
// Get the sync group to find associated workspaces
|
||||||
|
let group = self.get_sync_group(id)?;
|
||||||
|
let workspace_ids = group.workspace_ids.clone();
|
||||||
|
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
// Remove sync_group_id from all associated workspaces
|
||||||
|
{
|
||||||
|
let mut ws_table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
for ws_id in &workspace_ids {
|
||||||
|
let ws_json = match ws_table.get(ws_id.as_str())? {
|
||||||
|
Some(value) => value.value().to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut workspace: Workspace = serde_json::from_str(&ws_json)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
workspace.sync_group_id = None;
|
||||||
|
workspace.updated_at = Utc::now();
|
||||||
|
|
||||||
|
let new_ws_json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
ws_table.insert(ws_id.as_str(), new_ws_json.as_str())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from sync groups table
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from index
|
||||||
|
{
|
||||||
|
let mut idx_table = write_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
idx_table.remove(id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get workspaces by sync group
|
||||||
|
pub fn get_workspaces_by_sync_group(&self, sync_group_id: &str) -> DbResult<Vec<Workspace>> {
|
||||||
|
let read_txn = self.db.begin_read()?;
|
||||||
|
let idx_table = read_txn.open_table(WORKSPACES_BY_SYNC_GROUP)?;
|
||||||
|
|
||||||
|
let workspace_ids: Vec<String> = match idx_table.get(sync_group_id)? {
|
||||||
|
Some(value) => serde_json::from_str(value.value())
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?,
|
||||||
|
None => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
drop(idx_table);
|
||||||
|
drop(read_txn);
|
||||||
|
|
||||||
|
let mut workspaces = Vec::new();
|
||||||
|
for ws_id in workspace_ids {
|
||||||
|
if let Ok(workspace) = self.get(&ws_id) {
|
||||||
|
workspaces.push(workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(workspaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a workspace to a sync group
|
||||||
|
pub fn add_workspace_to_sync_group(
|
||||||
|
&self,
|
||||||
|
sync_group_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
// Read existing data
|
||||||
|
let mut group = self.get_sync_group(sync_group_id)?;
|
||||||
|
let mut workspace = self.get(workspace_id)?;
|
||||||
|
|
||||||
|
// Update in memory
|
||||||
|
if !group.workspace_ids.contains(&workspace_id.to_string()) {
|
||||||
|
group.workspace_ids.push(workspace_id.to_string());
|
||||||
|
group.updated_at = Utc::now();
|
||||||
|
}
|
||||||
|
workspace.sync_group_id = Some(sync_group_id.to_string());
|
||||||
|
workspace.updated_at = Utc::now();
|
||||||
|
|
||||||
|
// Serialize
|
||||||
|
let group_json = serde_json::to_string(&group)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
let ws_json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
// Write all changes
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
table.insert(sync_group_id, group_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_to_sync_index(&write_txn, sync_group_id, workspace_id)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut ws_table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
ws_table.insert(workspace_id, ws_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a workspace from a sync group
|
||||||
|
pub fn remove_workspace_from_sync_group(
|
||||||
|
&self,
|
||||||
|
sync_group_id: &str,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> DbResult<()> {
|
||||||
|
// Read existing data
|
||||||
|
let mut group = self.get_sync_group(sync_group_id)?;
|
||||||
|
let mut workspace = self.get(workspace_id)?;
|
||||||
|
|
||||||
|
// Update in memory
|
||||||
|
group.workspace_ids.retain(|id| id != workspace_id);
|
||||||
|
group.updated_at = Utc::now();
|
||||||
|
workspace.sync_group_id = None;
|
||||||
|
workspace.updated_at = Utc::now();
|
||||||
|
|
||||||
|
// Serialize
|
||||||
|
let group_json = serde_json::to_string(&group)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
let ws_json = serde_json::to_string(&workspace)
|
||||||
|
.map_err(|e| DbError::Serialization(e.to_string()))?;
|
||||||
|
|
||||||
|
// Write all changes
|
||||||
|
let write_txn = self.db.begin_write()?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut table = write_txn.open_table(WORKSPACE_SYNC_GROUPS)?;
|
||||||
|
table.insert(sync_group_id, group_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.remove_from_sync_index(&write_txn, sync_group_id, workspace_id)?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut ws_table = write_txn.open_table(WORKSPACES)?;
|
||||||
|
ws_table.insert(workspace_id, ws_json.as_str())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_txn.commit()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::env::temp_dir;
|
||||||
|
|
||||||
|
fn create_test_db() -> Database {
|
||||||
|
let path = temp_dir().join(format!("resona_test_{}.redb", uuid::Uuid::new_v4()));
|
||||||
|
Database::open_at(path).expect("Failed to create test database")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_workspace_crud() {
|
||||||
|
let db = create_test_db();
|
||||||
|
let service = WorkspaceService::new(db);
|
||||||
|
|
||||||
|
let workspace = service
|
||||||
|
.create(CreateWorkspaceInput {
|
||||||
|
name: "Test Workspace".to_string(),
|
||||||
|
description: "A test workspace".to_string(),
|
||||||
|
tags: vec!["Development".to_string()],
|
||||||
|
})
|
||||||
|
.expect("Failed to create workspace");
|
||||||
|
|
||||||
|
assert_eq!(workspace.name, "Test Workspace");
|
||||||
|
assert_eq!(workspace.tags, vec!["Development".to_string()]);
|
||||||
|
|
||||||
|
let fetched = service.get(&workspace.id).expect("Failed to get workspace");
|
||||||
|
assert_eq!(fetched.id, workspace.id);
|
||||||
|
|
||||||
|
let updated = service
|
||||||
|
.update(UpdateWorkspaceInput {
|
||||||
|
id: workspace.id.clone(),
|
||||||
|
name: Some("Updated Workspace".to_string()),
|
||||||
|
description: None,
|
||||||
|
tags: None,
|
||||||
|
sync_group_id: None,
|
||||||
|
})
|
||||||
|
.expect("Failed to update workspace");
|
||||||
|
|
||||||
|
assert_eq!(updated.name, "Updated Workspace");
|
||||||
|
assert_eq!(updated.description, "A test workspace");
|
||||||
|
|
||||||
|
let all = service.get_all().expect("Failed to get all workspaces");
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
|
||||||
|
service
|
||||||
|
.delete(&workspace.id)
|
||||||
|
.expect("Failed to delete workspace");
|
||||||
|
let all = service.get_all().expect("Failed to get all workspaces");
|
||||||
|
assert_eq!(all.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sync_groups() {
|
||||||
|
let db = create_test_db();
|
||||||
|
let service = WorkspaceService::new(db);
|
||||||
|
|
||||||
|
let ws1 = service
|
||||||
|
.create(CreateWorkspaceInput {
|
||||||
|
name: "Workspace 1".to_string(),
|
||||||
|
description: "First workspace".to_string(),
|
||||||
|
tags: vec!["Development".to_string()],
|
||||||
|
})
|
||||||
|
.expect("Failed to create workspace 1");
|
||||||
|
|
||||||
|
let ws2 = service
|
||||||
|
.create(CreateWorkspaceInput {
|
||||||
|
name: "Workspace 2".to_string(),
|
||||||
|
description: "Second workspace".to_string(),
|
||||||
|
tags: vec!["Production".to_string()],
|
||||||
|
})
|
||||||
|
.expect("Failed to create workspace 2");
|
||||||
|
|
||||||
|
// Create sync group
|
||||||
|
let group = service
|
||||||
|
.create_sync_group(CreateSyncGroupInput {
|
||||||
|
name: "Test Sync Group".to_string(),
|
||||||
|
workspace_ids: vec![ws1.id.clone(), ws2.id.clone()],
|
||||||
|
synced_variable_names: vec!["API_KEY".to_string()],
|
||||||
|
sync_secrets: false,
|
||||||
|
})
|
||||||
|
.expect("Failed to create sync group");
|
||||||
|
|
||||||
|
// Verify workspaces are linked
|
||||||
|
let ws1_updated = service.get(&ws1.id).expect("Failed to get workspace 1");
|
||||||
|
assert_eq!(ws1_updated.sync_group_id, Some(group.id.clone()));
|
||||||
|
|
||||||
|
// Get workspaces by sync group
|
||||||
|
let grouped = service
|
||||||
|
.get_workspaces_by_sync_group(&group.id)
|
||||||
|
.expect("Failed to get workspaces by sync group");
|
||||||
|
assert_eq!(grouped.len(), 2);
|
||||||
|
|
||||||
|
// Delete sync group
|
||||||
|
service
|
||||||
|
.delete_sync_group(&group.id)
|
||||||
|
.expect("Failed to delete sync group");
|
||||||
|
|
||||||
|
// Verify workspaces are unlinked
|
||||||
|
let ws1_final = service.get(&ws1.id).expect("Failed to get workspace 1");
|
||||||
|
assert_eq!(ws1_final.sync_group_id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/lib/components/code-editor.svelte
Normal file
103
src/lib/components/code-editor.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CodeMirror from "svelte-codemirror-editor";
|
||||||
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { xml } from "@codemirror/lang-xml";
|
||||||
|
import { html } from "@codemirror/lang-html";
|
||||||
|
import type { BodyType } from "$lib/types/request";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
language: BodyType;
|
||||||
|
readonly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
class?: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = "",
|
||||||
|
language = "json",
|
||||||
|
readonly = false,
|
||||||
|
placeholder = "",
|
||||||
|
class: className = "",
|
||||||
|
onchange,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function getLanguageSupport(lang: BodyType) {
|
||||||
|
switch (lang) {
|
||||||
|
case "json":
|
||||||
|
return json();
|
||||||
|
case "xml":
|
||||||
|
return xml();
|
||||||
|
case "html":
|
||||||
|
return html();
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(newValue: string) {
|
||||||
|
if (onchange) {
|
||||||
|
onchange(newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang = $derived(getLanguageSupport(language));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="code-editor-wrapper {className}">
|
||||||
|
<CodeMirror
|
||||||
|
bind:value
|
||||||
|
{lang}
|
||||||
|
{readonly}
|
||||||
|
{placeholder}
|
||||||
|
lineNumbers={true}
|
||||||
|
tabSize={2}
|
||||||
|
lineWrapping={true}
|
||||||
|
onchange={handleChange}
|
||||||
|
styles={{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
fontSize: "13px",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "hsl(var(--muted))",
|
||||||
|
borderRight: "1px solid hsl(var(--border))",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: "hsl(var(--accent))",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
backgroundColor: "hsl(var(--accent) / 0.3)",
|
||||||
|
},
|
||||||
|
"&.cm-focused": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
caretColor: "hsl(var(--foreground))",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.code-editor-wrapper {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-wrapper :global(.cm-editor) {
|
||||||
|
height: 100%;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-editor-wrapper :global(.cm-placeholder) {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
381
src/lib/components/duplicate-workspace-dialog.svelte
Normal file
381
src/lib/components/duplicate-workspace-dialog.svelte
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
|
import LinkIcon from "@lucide/svelte/icons/link";
|
||||||
|
import type { Workspace } from "$lib/types/workspace";
|
||||||
|
import type { Variable } from "$lib/types/variable";
|
||||||
|
import { get_workspace_variables } from "$lib/services/variables";
|
||||||
|
import {
|
||||||
|
duplicate_workspace,
|
||||||
|
type DuplicateWorkspaceOptions,
|
||||||
|
} from "$lib/services/sync";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
sourceWorkspace: Workspace | null;
|
||||||
|
onDuplicate: (options: DuplicateWorkspaceOptions) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
onOpenChange,
|
||||||
|
sourceWorkspace,
|
||||||
|
onDuplicate,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let newName = $state("");
|
||||||
|
let newDescription = $state("");
|
||||||
|
let tags = $state<string[]>([]);
|
||||||
|
let tagInput = $state("");
|
||||||
|
let copyVariables = $state(true);
|
||||||
|
let copySecrets = $state(false);
|
||||||
|
let createSyncGroup = $state(false);
|
||||||
|
let syncGroupName = $state("");
|
||||||
|
let variablesToSync = $state<string[]>([]);
|
||||||
|
|
||||||
|
let workspaceVariables = $state<Variable[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && sourceWorkspace) {
|
||||||
|
newName = `${sourceWorkspace.Name} (Copy)`;
|
||||||
|
newDescription = sourceWorkspace.Description;
|
||||||
|
tags = [...(sourceWorkspace.Tags ?? [])];
|
||||||
|
syncGroupName = `${sourceWorkspace.Name} Environments`;
|
||||||
|
loadVariables();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadVariables() {
|
||||||
|
if (sourceWorkspace) {
|
||||||
|
workspaceVariables = await get_workspace_variables(sourceWorkspace.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVariableSync(varName: string) {
|
||||||
|
if (variablesToSync.includes(varName)) {
|
||||||
|
variablesToSync = variablesToSync.filter((v) => v !== varName);
|
||||||
|
} else {
|
||||||
|
variablesToSync = [...variablesToSync, varName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllVariables() {
|
||||||
|
variablesToSync = workspaceVariables
|
||||||
|
.filter((v) => !v.isSecret || copySecrets)
|
||||||
|
.map((v) => v.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllVariables() {
|
||||||
|
variablesToSync = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag() {
|
||||||
|
const trimmed = tagInput.trim();
|
||||||
|
if (trimmed && !tags.includes(trimmed)) {
|
||||||
|
tags = [...tags, trimmed];
|
||||||
|
tagInput = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
tags = tags.filter((t) => t !== tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!sourceWorkspace) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
await onDuplicate({
|
||||||
|
sourceWorkspaceId: sourceWorkspace.Id,
|
||||||
|
newName,
|
||||||
|
newDescription,
|
||||||
|
tags,
|
||||||
|
copyVariables,
|
||||||
|
copySecrets,
|
||||||
|
createSyncGroup,
|
||||||
|
syncGroupName: createSyncGroup ? syncGroupName : undefined,
|
||||||
|
variablesToSync: createSyncGroup ? variablesToSync : undefined,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open {onOpenChange}>
|
||||||
|
<Dialog.Content
|
||||||
|
class="sm:max-w-[550px] max-h-[85vh] overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
<CopyIcon class="size-5" />
|
||||||
|
Duplicate Workspace
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Create a copy of "{sourceWorkspace?.Name}" with all its collections and
|
||||||
|
requests.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex-1 overflow-auto space-y-4 py-4"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="new-name" class="text-end">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="new-name"
|
||||||
|
class="col-span-3"
|
||||||
|
placeholder="New workspace name"
|
||||||
|
bind:value={newName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="new-desc" class="text-end">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="new-desc"
|
||||||
|
class="col-span-3"
|
||||||
|
placeholder="Workspace description"
|
||||||
|
bind:value={newDescription}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 items-start gap-4">
|
||||||
|
<Label for="tags" class="text-end pt-2">Tags</Label>
|
||||||
|
<div class="col-span-3 space-y-2">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="tags"
|
||||||
|
placeholder="Add a tag..."
|
||||||
|
bind:value={tagInput}
|
||||||
|
onkeydown={(e) =>
|
||||||
|
e.key === "Enter" && (e.preventDefault(), addTag())}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={addTag}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each tags as tag}
|
||||||
|
<Badge variant="secondary" class="gap-1">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-1 hover:text-destructive"
|
||||||
|
onclick={() => removeTag(tag)}
|
||||||
|
aria-label="Remove tag"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h4 class="font-medium">Variables</h4>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>Copy Variables</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Copy workspace variables to the new workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={copyVariables}
|
||||||
|
aria-label="Toggle copy variables"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copyVariables
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() => (copyVariables = !copyVariables)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copyVariables
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if copyVariables}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>Copy Secret Values</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Copy secret values (otherwise they'll be empty).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={copySecrets}
|
||||||
|
aria-label="Toggle copy secrets"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {copySecrets
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() => (copySecrets = !copySecrets)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {copySecrets
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<LinkIcon class="size-4" />
|
||||||
|
<Label>Create Sync Group</Label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Link workspaces to sync selected variables between environments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={createSyncGroup}
|
||||||
|
aria-label="Toggle create sync group"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 {createSyncGroup
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() => (createSyncGroup = !createSyncGroup)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {createSyncGroup
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if createSyncGroup}
|
||||||
|
<div class="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label for="sync-name" class="text-end">Group Name</Label>
|
||||||
|
<Input
|
||||||
|
id="sync-name"
|
||||||
|
class="col-span-3"
|
||||||
|
placeholder="Sync group name"
|
||||||
|
bind:value={syncGroupName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label>Variables to Sync</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={selectAllVariables}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={deselectAllVariables}
|
||||||
|
>
|
||||||
|
Deselect All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground mb-2">
|
||||||
|
Selected variables will be kept in sync across linked workspaces.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if workspaceVariables.length === 0}
|
||||||
|
<div class="text-center py-4 text-muted-foreground text-sm">
|
||||||
|
No workspace variables to sync.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-2 max-h-[150px] overflow-auto p-2 border rounded-md"
|
||||||
|
>
|
||||||
|
{#each workspaceVariables as variable (variable.id)}
|
||||||
|
{#if !variable.isSecret || copySecrets}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 p-2 rounded text-left text-sm hover:bg-accent transition-colors {variablesToSync.includes(
|
||||||
|
variable.name
|
||||||
|
)
|
||||||
|
? 'bg-accent'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => toggleVariableSync(variable.name)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={variablesToSync.includes(variable.name)}
|
||||||
|
class="rounded"
|
||||||
|
onchange={() => {}}
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-xs truncate"
|
||||||
|
>{variable.name}</span
|
||||||
|
>
|
||||||
|
{#if variable.isSecret}
|
||||||
|
<Badge variant="secondary" class="text-xs">secret</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || !newName.trim()}>
|
||||||
|
{loading ? "Creating..." : "Duplicate Workspace"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
371
src/lib/components/request-panel.svelte
Normal file
371
src/lib/components/request-panel.svelte
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||||
|
import * as Select from "$lib/components/ui/select/index.js";
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
|
import SendIcon from "@lucide/svelte/icons/send";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import CodeEditor from "./code-editor.svelte";
|
||||||
|
import VariableInput from "./variable-input.svelte";
|
||||||
|
import type { Request, HttpMethod, BodyType } from "$lib/types/request";
|
||||||
|
import type { ResolvedVariable } from "$lib/types/variable";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
request: Request;
|
||||||
|
variables?: ResolvedVariable[];
|
||||||
|
onSend: (request: Request) => void;
|
||||||
|
onUpdate: (request: Request) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { request, variables = [], onSend, onUpdate }: Props = $props();
|
||||||
|
|
||||||
|
let localRequest = $state<Request>({ ...request });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localRequest = { ...request };
|
||||||
|
});
|
||||||
|
|
||||||
|
const methods: HttpMethod[] = [
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"PATCH",
|
||||||
|
"DELETE",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
];
|
||||||
|
const bodyTypes: { value: BodyType; label: string }[] = [
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "json", label: "JSON" },
|
||||||
|
{ value: "xml", label: "XML" },
|
||||||
|
{ value: "text", label: "Text" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
{ value: "form-data", label: "Form Data" },
|
||||||
|
{ value: "x-www-form-urlencoded", label: "URL Encoded" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getMethodColor(method: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
GET: "bg-green-500/10 text-green-500 hover:bg-green-500/20",
|
||||||
|
POST: "bg-blue-500/10 text-blue-500 hover:bg-blue-500/20",
|
||||||
|
PUT: "bg-orange-500/10 text-orange-500 hover:bg-orange-500/20",
|
||||||
|
PATCH: "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20",
|
||||||
|
DELETE: "bg-red-500/10 text-red-500 hover:bg-red-500/20",
|
||||||
|
HEAD: "bg-purple-500/10 text-purple-500 hover:bg-purple-500/20",
|
||||||
|
OPTIONS: "bg-gray-500/10 text-gray-500 hover:bg-gray-500/20",
|
||||||
|
};
|
||||||
|
return colors[method] || "bg-gray-500/10 text-gray-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMethodChange(method: HttpMethod) {
|
||||||
|
localRequest.method = method;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUrlChange(value: string) {
|
||||||
|
localRequest.url = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBodyTypeChange(value: BodyType) {
|
||||||
|
localRequest.bodyType = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBodyChange(value: string) {
|
||||||
|
localRequest.body = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
onSend(localRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBody() {
|
||||||
|
if (localRequest.bodyType === "json") {
|
||||||
|
try {
|
||||||
|
localRequest.body = JSON.stringify(
|
||||||
|
JSON.parse(localRequest.body),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
} catch {
|
||||||
|
// Invalid JSON, don't format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="border-b p-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class={`font-mono font-semibold min-w-[100px] ${getMethodColor(localRequest.method)}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{localRequest.method}
|
||||||
|
<ChevronDownIcon class="ml-1 size-4" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
{#each methods as method (method)}
|
||||||
|
<DropdownMenu.Item onclick={() => handleMethodChange(method)}>
|
||||||
|
<span
|
||||||
|
class={`font-mono font-semibold ${getMethodColor(method).split(" ")[1]}`}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</span>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder={"Enter request URL (use {{VAR_NAME}} for variables)"}
|
||||||
|
value={localRequest.url}
|
||||||
|
{variables}
|
||||||
|
oninput={handleUrlChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onclick={handleSend}>
|
||||||
|
<SendIcon class="size-4 mr-2" />
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Root value="params" class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Tabs.List
|
||||||
|
class="w-full justify-start rounded-none border-b bg-transparent p-0 h-auto"
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="params"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
Params
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="headers"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
Headers
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="body"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||||
|
>
|
||||||
|
Body
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content value="params" class="flex-1 m-0 p-4 overflow-auto">
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each localRequest.params as param, i (i)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Key"
|
||||||
|
value={param.key}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.params[i].key = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Value"
|
||||||
|
value={param.value}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.params[i].value = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.params = localRequest.params.filter(
|
||||||
|
(_, idx) => idx !== i
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.params = [
|
||||||
|
...localRequest.params,
|
||||||
|
{ key: "", value: "", enabled: true },
|
||||||
|
];
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Parameter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="headers" class="flex-1 m-0 p-4 overflow-auto">
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each localRequest.headers as header, i (i)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Key"
|
||||||
|
value={header.key}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.headers[i].key = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Value"
|
||||||
|
value={header.value}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.headers[i].value = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.headers = localRequest.headers.filter(
|
||||||
|
(_, idx) => idx !== i
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.headers = [
|
||||||
|
...localRequest.headers,
|
||||||
|
{ key: "", value: "", enabled: true },
|
||||||
|
];
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="body" class="flex-1 m-0 flex flex-col overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 p-2 border-b bg-muted/30">
|
||||||
|
<span class="text-sm text-muted-foreground">Content Type:</span>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={localRequest.bodyType}
|
||||||
|
onValueChange={(value) => handleBodyTypeChange(value as BodyType)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-40 h-8">
|
||||||
|
{bodyTypes.find((t) => t.value === localRequest.bodyType)?.label ||
|
||||||
|
"None"}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each bodyTypes as bodyType (bodyType.value)}
|
||||||
|
<Select.Item value={bodyType.value}>{bodyType.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if localRequest.bodyType === "json"}
|
||||||
|
<Button variant="ghost" size="sm" onclick={formatBody}>Format</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 p-2 overflow-hidden">
|
||||||
|
{#if localRequest.bodyType === "none"}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-muted-foreground text-sm"
|
||||||
|
>
|
||||||
|
This request does not have a body
|
||||||
|
</div>
|
||||||
|
{:else if localRequest.bodyType === "form-data" || localRequest.bodyType === "x-www-form-urlencoded"}
|
||||||
|
<div class="space-y-2 p-2">
|
||||||
|
{#each localRequest.formData as item, i (i)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Key"
|
||||||
|
value={item.key}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.formData[i].key = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VariableInput
|
||||||
|
class="flex-1"
|
||||||
|
placeholder="Value"
|
||||||
|
value={item.value}
|
||||||
|
{variables}
|
||||||
|
oninput={(value) => {
|
||||||
|
localRequest.formData[i].value = value;
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.formData = localRequest.formData.filter(
|
||||||
|
(_, idx) => idx !== i
|
||||||
|
);
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
localRequest.formData = [
|
||||||
|
...localRequest.formData,
|
||||||
|
{ key: "", value: "", type: "text", enabled: true },
|
||||||
|
];
|
||||||
|
onUpdate(localRequest);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Field
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CodeEditor
|
||||||
|
value={localRequest.body}
|
||||||
|
language={localRequest.bodyType}
|
||||||
|
placeholder="Enter request body..."
|
||||||
|
class="h-full"
|
||||||
|
onchange={handleBodyChange}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
</div>
|
||||||
176
src/lib/components/response-panel.svelte
Normal file
176
src/lib/components/response-panel.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
|
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Spinner } from "$lib/components/ui/spinner/index.js";
|
||||||
|
import CodeEditor from "./code-editor.svelte";
|
||||||
|
import CopyIcon from "@lucide/svelte/icons/copy";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import type { Response } from "$lib/types/response";
|
||||||
|
import type { BodyType } from "$lib/types/request";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
response: Response | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { response, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function getStatusColor(status: number): string {
|
||||||
|
if (status >= 200 && status < 300) return "bg-green-500/10 text-green-500";
|
||||||
|
if (status >= 300 && status < 400) return "bg-blue-500/10 text-blue-500";
|
||||||
|
if (status >= 400 && status < 500)
|
||||||
|
return "bg-yellow-500/10 text-yellow-500";
|
||||||
|
if (status >= 500) return "bg-red-500/10 text-red-500";
|
||||||
|
return "bg-gray-500/10 text-gray-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms} ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBodyType(): BodyType {
|
||||||
|
if (!response) return "text";
|
||||||
|
const ct = response.contentType.toLowerCase();
|
||||||
|
if (ct.includes("json")) return "json";
|
||||||
|
if (ct.includes("xml")) return "xml";
|
||||||
|
if (ct.includes("html")) return "html";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBody(body: string, type: BodyType): string {
|
||||||
|
if (type === "json") {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(body), null, 2);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
if (!response) return;
|
||||||
|
await navigator.clipboard.writeText(response.body);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyType = $derived(getBodyType());
|
||||||
|
let formattedBody = $derived(
|
||||||
|
response ? formatBody(response.body, bodyType) : ""
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full border-t">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<Spinner class="size-8" />
|
||||||
|
<span class="text-sm text-muted-foreground">Sending request...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if response}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-2 border-b bg-muted/30">
|
||||||
|
<Badge class={getStatusColor(response.status)}>
|
||||||
|
{response.status}
|
||||||
|
{response.statusText}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(response.duration)}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{formatSize(response.size)}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Button variant="ghost" size="sm" onclick={copyToClipboard}>
|
||||||
|
{#if copied}
|
||||||
|
<CheckIcon class="size-4 mr-1" />
|
||||||
|
Copied
|
||||||
|
{:else}
|
||||||
|
<CopyIcon class="size-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Root value="body" class="flex-1 flex flex-col">
|
||||||
|
<Tabs.List
|
||||||
|
class="w-full justify-start rounded-none border-b bg-transparent p-0"
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="body"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||||
|
>
|
||||||
|
Body
|
||||||
|
</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value="headers"
|
||||||
|
class="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent"
|
||||||
|
>
|
||||||
|
Headers ({response.headers.length})
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Content
|
||||||
|
value="body"
|
||||||
|
class="flex-1 m-0 p-0 data-[state=active]:flex data-[state=active]:flex-col"
|
||||||
|
>
|
||||||
|
<div class="flex-1 p-2">
|
||||||
|
<CodeEditor
|
||||||
|
value={formattedBody}
|
||||||
|
language={bodyType}
|
||||||
|
readonly={true}
|
||||||
|
class="h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content
|
||||||
|
value="headers"
|
||||||
|
class="flex-1 m-0 data-[state=active]:flex data-[state=active]:flex-col overflow-auto"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b">
|
||||||
|
<th
|
||||||
|
class="text-left py-2 pr-4 font-medium text-muted-foreground"
|
||||||
|
>Name</th
|
||||||
|
>
|
||||||
|
<th class="text-left py-2 font-medium text-muted-foreground"
|
||||||
|
>Value</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each response.headers as header (header.key)}
|
||||||
|
<tr class="border-b last:border-0">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">{header.key}</td>
|
||||||
|
<td class="py-2 font-mono text-xs break-all"
|
||||||
|
>{header.value}</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</Tabs.Root>
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<span class="text-sm text-muted-foreground"
|
||||||
|
>Send a request to see the response</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
28
src/lib/components/search-form.svelte
Normal file
28
src/lib/components/search-form.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||||
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
import SearchIcon from "@lucide/svelte/icons/search";
|
||||||
|
import type { HTMLFormAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLFormAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form bind:this={ref} {...restProps}>
|
||||||
|
<Sidebar.Group class="py-0">
|
||||||
|
<Sidebar.GroupContent class="relative">
|
||||||
|
<Label for="search" class="sr-only">Search</Label>
|
||||||
|
<Sidebar.Input
|
||||||
|
id="search"
|
||||||
|
placeholder="Search the docs..."
|
||||||
|
class="ps-8"
|
||||||
|
/>
|
||||||
|
<SearchIcon
|
||||||
|
class="pointer-events-none absolute start-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50"
|
||||||
|
/>
|
||||||
|
</Sidebar.GroupContent>
|
||||||
|
</Sidebar.Group>
|
||||||
|
</form>
|
||||||
300
src/lib/components/settings-dialog.svelte
Normal file
300
src/lib/components/settings-dialog.svelte
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||||
|
import * as Tabs from "$lib/components/ui/tabs/index.js";
|
||||||
|
import * as Select from "$lib/components/ui/select/index.js";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import { Input } from "$lib/components/ui/input/index.js";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import SettingsIcon from "@lucide/svelte/icons/settings";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import type { AppSettings, Theme } from "$lib/types/workspace";
|
||||||
|
import {
|
||||||
|
get_settings,
|
||||||
|
update_settings,
|
||||||
|
reset_settings,
|
||||||
|
} from "$lib/services/settings";
|
||||||
|
import {
|
||||||
|
theme as themeStore,
|
||||||
|
themes,
|
||||||
|
themeLabels,
|
||||||
|
} from "$lib/theme-switcher";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), onOpenChange }: Props = $props();
|
||||||
|
|
||||||
|
let settings = $state<AppSettings>({
|
||||||
|
theme: "system",
|
||||||
|
customThemes: [],
|
||||||
|
defaultTimeout: 30000,
|
||||||
|
followRedirects: true,
|
||||||
|
validateSsl: true,
|
||||||
|
maxHistoryItems: 100,
|
||||||
|
autoSaveRequests: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
settings = await get_settings();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
settings = await get_settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
loading = true;
|
||||||
|
await update_settings(settings);
|
||||||
|
loading = false;
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
settings = await reset_settings();
|
||||||
|
themeStore.applyTheme(settings.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThemeChange(newTheme: Theme) {
|
||||||
|
settings.theme = newTheme;
|
||||||
|
await themeStore.applyTheme(newTheme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open {onOpenChange}>
|
||||||
|
<Dialog.Content
|
||||||
|
class="sm:max-w-[600px] max-h-[80vh] overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
<SettingsIcon class="size-5" />
|
||||||
|
Settings
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Configure application settings and preferences.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<Tabs.Root value="appearance" class="flex-1 overflow-hidden">
|
||||||
|
<Tabs.List class="grid w-full grid-cols-4">
|
||||||
|
<Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="general">General</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="requests">Requests</Tabs.Trigger>
|
||||||
|
<Tabs.Trigger value="advanced">Advanced</Tabs.Trigger>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<div class="mt-4 overflow-auto max-h-[400px]">
|
||||||
|
<Tabs.Content value="appearance" class="space-y-4">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Label>Theme</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Select a color theme for the application.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{#each themes as t}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded-md border text-sm transition-colors {settings.theme ===
|
||||||
|
t
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:bg-muted'}"
|
||||||
|
onclick={() => handleThemeChange(t)}
|
||||||
|
>
|
||||||
|
<span>{themeLabels[t]}</span>
|
||||||
|
{#if settings.theme === t}
|
||||||
|
<CheckIcon class="size-4 text-primary" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 bg-muted/30">
|
||||||
|
<h4 class="font-medium mb-2">Theme Preview</h4>
|
||||||
|
<p class="text-sm text-muted-foreground mb-3">
|
||||||
|
Preview of the current theme colors.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div
|
||||||
|
class="size-8 rounded bg-background border"
|
||||||
|
title="Background"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="size-8 rounded bg-foreground"
|
||||||
|
title="Foreground"
|
||||||
|
></div>
|
||||||
|
<div class="size-8 rounded bg-primary" title="Primary"></div>
|
||||||
|
<div class="size-8 rounded bg-secondary" title="Secondary"></div>
|
||||||
|
<div class="size-8 rounded bg-muted" title="Muted"></div>
|
||||||
|
<div class="size-8 rounded bg-accent" title="Accent"></div>
|
||||||
|
<div
|
||||||
|
class="size-8 rounded bg-destructive"
|
||||||
|
title="Destructive"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="general" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="maxHistory">History Items</Label>
|
||||||
|
<Input
|
||||||
|
id="maxHistory"
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="1000"
|
||||||
|
bind:value={settings.maxHistoryItems}
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Maximum number of request history items to keep.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>Auto-save Requests</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Automatically save request changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.autoSaveRequests}
|
||||||
|
aria-label="Toggle auto-save requests"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.autoSaveRequests
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() =>
|
||||||
|
(settings.autoSaveRequests = !settings.autoSaveRequests)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.autoSaveRequests
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="requests" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="timeout">Default Timeout (ms)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout"
|
||||||
|
type="number"
|
||||||
|
min="1000"
|
||||||
|
max="300000"
|
||||||
|
step="1000"
|
||||||
|
bind:value={settings.defaultTimeout}
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Default timeout for HTTP requests in milliseconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>Follow Redirects</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Automatically follow HTTP redirects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.followRedirects}
|
||||||
|
aria-label="Toggle follow redirects"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.followRedirects
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() =>
|
||||||
|
(settings.followRedirects = !settings.followRedirects)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.followRedirects
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>Validate SSL Certificates</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Verify SSL/TLS certificates for HTTPS requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.validateSsl}
|
||||||
|
aria-label="Toggle SSL validation"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 {settings.validateSsl
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-input'}"
|
||||||
|
onclick={() => (settings.validateSsl = !settings.validateSsl)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform {settings.validateSsl
|
||||||
|
? 'translate-x-5'
|
||||||
|
: 'translate-x-0'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="advanced" class="space-y-4">
|
||||||
|
<div class="rounded-lg border p-4 bg-muted/30">
|
||||||
|
<h4 class="font-medium mb-2">Reset Settings</h4>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Reset all settings to their default values. This action cannot be
|
||||||
|
undone.
|
||||||
|
</p>
|
||||||
|
<Button variant="destructive" size="sm" onclick={handleReset}>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border p-4 bg-muted/30">
|
||||||
|
<h4 class="font-medium mb-2">Data Management</h4>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">
|
||||||
|
Export or import your workspaces, collections, and settings.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled>Export Data</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled>Import Data</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
|
</div>
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
|
<Dialog.Footer class="mt-4">
|
||||||
|
<Button variant="outline" onclick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onclick={handleSave} disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
50
src/lib/components/ui/badge/badge.svelte
Normal file
50
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||||
|
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={href ? "a" : "span"}
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="badge"
|
||||||
|
{href}
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
2
src/lib/components/ui/badge/index.ts
Normal file
2
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
23
src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal file
23
src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<EllipsisIcon class="size-4" />
|
||||||
|
<span class="sr-only">More</span>
|
||||||
|
</span>
|
||||||
20
src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
Normal file
20
src/lib/components/ui/breadcrumb/breadcrumb-item.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
class={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</li>
|
||||||
31
src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
Normal file
31
src/lib/components/ui/breadcrumb/breadcrumb-link.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
href = undefined,
|
||||||
|
child,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const attrs = $derived({
|
||||||
|
"data-slot": "breadcrumb-link",
|
||||||
|
class: cn("hover:text-foreground transition-colors", className),
|
||||||
|
href,
|
||||||
|
...restProps,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if child}
|
||||||
|
{@render child({ props: attrs })}
|
||||||
|
{:else}
|
||||||
|
<a bind:this={ref} {...attrs}>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
23
src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
Normal file
23
src/lib/components/ui/breadcrumb/breadcrumb-list.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLOlAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ol>
|
||||||
23
src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
Normal file
23
src/lib/components/ui/breadcrumb/breadcrumb-page.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
class={cn("text-foreground font-normal", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
27
src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
Normal file
27
src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLLiAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<li
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
class={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children?.()}
|
||||||
|
{:else}
|
||||||
|
<ChevronRightIcon />
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
21
src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
21
src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
class={className}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
25
src/lib/components/ui/breadcrumb/index.ts
Normal file
25
src/lib/components/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./breadcrumb.svelte";
|
||||||
|
import Ellipsis from "./breadcrumb-ellipsis.svelte";
|
||||||
|
import Item from "./breadcrumb-item.svelte";
|
||||||
|
import Separator from "./breadcrumb-separator.svelte";
|
||||||
|
import Link from "./breadcrumb-link.svelte";
|
||||||
|
import List from "./breadcrumb-list.svelte";
|
||||||
|
import Page from "./breadcrumb-page.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Ellipsis,
|
||||||
|
Item,
|
||||||
|
Separator,
|
||||||
|
Link,
|
||||||
|
List,
|
||||||
|
Page,
|
||||||
|
//
|
||||||
|
Root as Breadcrumb,
|
||||||
|
Ellipsis as BreadcrumbEllipsis,
|
||||||
|
Item as BreadcrumbItem,
|
||||||
|
Separator as BreadcrumbSeparator,
|
||||||
|
Link as BreadcrumbLink,
|
||||||
|
List as BreadcrumbList,
|
||||||
|
Page as BreadcrumbPage,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||||
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
open = $bindable(false),
|
||||||
|
...restProps
|
||||||
|
}: CollapsiblePrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||||
13
src/lib/components/ui/collapsible/index.ts
Normal file
13
src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./collapsible.svelte";
|
||||||
|
import Trigger from "./collapsible-trigger.svelte";
|
||||||
|
import Content from "./collapsible-content.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Collapsible,
|
||||||
|
Content as CollapsibleContent,
|
||||||
|
Trigger as CollapsibleTrigger,
|
||||||
|
};
|
||||||
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
43
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
43
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed start-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
17
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
17
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
7
src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
37
src/lib/components/ui/dialog/index.ts
Normal file
37
src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
const Root = DialogPrimitive.Root;
|
||||||
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.CheckboxGroup
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="dropdown-menu-checkbox-group"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
checked = $bindable(false),
|
||||||
|
indeterminate = $bindable(false),
|
||||||
|
class: className,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
bind:ref
|
||||||
|
bind:checked
|
||||||
|
bind:indeterminate
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked, indeterminate })}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
{#if indeterminate}
|
||||||
|
<MinusIcon class="size-4" />
|
||||||
|
{:else}
|
||||||
|
<CheckIcon class={cn("size-4", !checked && "text-transparent")} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{@render childrenProp?.()}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ContentProps & {
|
||||||
|
portalProps?: DropdownMenuPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Portal {...portalProps}>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-group-heading"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ItemProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:ps-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked })}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute start-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
{#if checked}
|
||||||
|
<CircleIcon class="size-2 fill-current" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{@render childrenProp?.({ checked })}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SeparatorProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
class={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
class={cn("text-muted-foreground ms-auto text-xs tracking-widest", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SubContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.SubTriggerProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:ps-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronRightIcon class="ms-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
|
||||||
52
src/lib/components/ui/dropdown-menu/index.ts
Normal file
52
src/lib/components/ui/dropdown-menu/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte";
|
||||||
|
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
|
||||||
|
import Content from "./dropdown-menu-content.svelte";
|
||||||
|
import Group from "./dropdown-menu-group.svelte";
|
||||||
|
import Item from "./dropdown-menu-item.svelte";
|
||||||
|
import Label from "./dropdown-menu-label.svelte";
|
||||||
|
import RadioGroup from "./dropdown-menu-radio-group.svelte";
|
||||||
|
import RadioItem from "./dropdown-menu-radio-item.svelte";
|
||||||
|
import Separator from "./dropdown-menu-separator.svelte";
|
||||||
|
import Shortcut from "./dropdown-menu-shortcut.svelte";
|
||||||
|
import Trigger from "./dropdown-menu-trigger.svelte";
|
||||||
|
import SubContent from "./dropdown-menu-sub-content.svelte";
|
||||||
|
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
|
||||||
|
import GroupHeading from "./dropdown-menu-group-heading.svelte";
|
||||||
|
const Sub = DropdownMenuPrimitive.Sub;
|
||||||
|
const Root = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
CheckboxGroup,
|
||||||
|
CheckboxItem,
|
||||||
|
Content,
|
||||||
|
Root as DropdownMenu,
|
||||||
|
CheckboxGroup as DropdownMenuCheckboxGroup,
|
||||||
|
CheckboxItem as DropdownMenuCheckboxItem,
|
||||||
|
Content as DropdownMenuContent,
|
||||||
|
Group as DropdownMenuGroup,
|
||||||
|
Item as DropdownMenuItem,
|
||||||
|
Label as DropdownMenuLabel,
|
||||||
|
RadioGroup as DropdownMenuRadioGroup,
|
||||||
|
RadioItem as DropdownMenuRadioItem,
|
||||||
|
Separator as DropdownMenuSeparator,
|
||||||
|
Shortcut as DropdownMenuShortcut,
|
||||||
|
Sub as DropdownMenuSub,
|
||||||
|
SubContent as DropdownMenuSubContent,
|
||||||
|
SubTrigger as DropdownMenuSubTrigger,
|
||||||
|
Trigger as DropdownMenuTrigger,
|
||||||
|
GroupHeading as DropdownMenuGroupHeading,
|
||||||
|
Group,
|
||||||
|
GroupHeading,
|
||||||
|
Item,
|
||||||
|
Label,
|
||||||
|
RadioGroup,
|
||||||
|
RadioItem,
|
||||||
|
Root,
|
||||||
|
Separator,
|
||||||
|
Shortcut,
|
||||||
|
Sub,
|
||||||
|
SubContent,
|
||||||
|
SubTrigger,
|
||||||
|
Trigger,
|
||||||
|
};
|
||||||
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
52
src/lib/components/ui/input/input.svelte
Normal file
52
src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
|
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "input",
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "file"}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
20
src/lib/components/ui/label/label.svelte
Normal file
20
src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="label"
|
||||||
|
class={cn(
|
||||||
|
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Scrollbar from "./scroll-area-scrollbar.svelte";
|
||||||
|
import Root from "./scroll-area.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Scrollbar,
|
||||||
|
//,
|
||||||
|
Root as ScrollArea,
|
||||||
|
Scrollbar as ScrollAreaScrollbar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
"flex touch-none select-none p-px transition-colors",
|
||||||
|
orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent",
|
||||||
|
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
class="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
43
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "bits-ui";
|
||||||
|
import { Scrollbar } from "./index.js";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
viewportRef = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = "vertical",
|
||||||
|
scrollbarXClasses = "",
|
||||||
|
scrollbarYClasses = "",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||||
|
orientation?: "vertical" | "horizontal" | "both" | undefined;
|
||||||
|
scrollbarXClasses?: string | undefined;
|
||||||
|
scrollbarYClasses?: string | undefined;
|
||||||
|
viewportRef?: HTMLElement | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area"
|
||||||
|
class={cn("relative", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
bind:ref={viewportRef}
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-4"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
{#if orientation === "vertical" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||||
|
{/if}
|
||||||
|
{#if orientation === "horizontal" || orientation === "both"}
|
||||||
|
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||||
|
{/if}
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
37
src/lib/components/ui/select/index.ts
Normal file
37
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Group from "./select-group.svelte";
|
||||||
|
import Label from "./select-label.svelte";
|
||||||
|
import Item from "./select-item.svelte";
|
||||||
|
import Content from "./select-content.svelte";
|
||||||
|
import Trigger from "./select-trigger.svelte";
|
||||||
|
import Separator from "./select-separator.svelte";
|
||||||
|
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import GroupHeading from "./select-group-heading.svelte";
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
ScrollDownButton,
|
||||||
|
ScrollUpButton,
|
||||||
|
GroupHeading,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
|
GroupHeading as SelectGroupHeading,
|
||||||
|
};
|
||||||
42
src/lib/components/ui/select/select-content.svelte
Normal file
42
src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
preventScroll = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SelectPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Portal {...portalProps}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
{preventScroll}
|
||||||
|
data-slot="select-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
class={cn(
|
||||||
|
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
21
src/lib/components/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-group-heading"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.GroupHeading>
|
||||||
7
src/lib/components/ui/select/select-group.svelte
Normal file
7
src/lib/components/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||||
38
src/lib/components/ui/select/select-item.svelte
Normal file
38
src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
{value}
|
||||||
|
data-slot="select-item"
|
||||||
|
class={cn(
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected, highlighted })}
|
||||||
|
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if selected}
|
||||||
|
<CheckIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ selected, highlighted })}
|
||||||
|
{:else}
|
||||||
|
{label || value}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SelectPrimitive.Item>
|
||||||
20
src/lib/components/ui/select/select-label.svelte
Normal file
20
src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="select-label"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
20
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
18
src/lib/components/ui/select/select-separator.svelte
Normal file
18
src/lib/components/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Separator as SeparatorPrimitive } from "bits-ui";
|
||||||
|
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-separator"
|
||||||
|
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
29
src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
class={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 shadow-xs flex w-fit select-none items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ChevronDownIcon class="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./separator.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Separator,
|
||||||
|
};
|
||||||
21
src/lib/components/ui/separator/separator.svelte
Normal file
21
src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
"data-slot": dataSlot = "separator",
|
||||||
|
...restProps
|
||||||
|
}: SeparatorPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot={dataSlot}
|
||||||
|
class={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
36
src/lib/components/ui/sheet/index.ts
Normal file
36
src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import Trigger from "./sheet-trigger.svelte";
|
||||||
|
import Close from "./sheet-close.svelte";
|
||||||
|
import Overlay from "./sheet-overlay.svelte";
|
||||||
|
import Content from "./sheet-content.svelte";
|
||||||
|
import Header from "./sheet-header.svelte";
|
||||||
|
import Footer from "./sheet-footer.svelte";
|
||||||
|
import Title from "./sheet-title.svelte";
|
||||||
|
import Description from "./sheet-description.svelte";
|
||||||
|
|
||||||
|
const Root = SheetPrimitive.Root;
|
||||||
|
const Portal = SheetPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Close,
|
||||||
|
Trigger,
|
||||||
|
Portal,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
Title,
|
||||||
|
Description,
|
||||||
|
//
|
||||||
|
Root as Sheet,
|
||||||
|
Close as SheetClose,
|
||||||
|
Trigger as SheetTrigger,
|
||||||
|
Portal as SheetPortal,
|
||||||
|
Overlay as SheetOverlay,
|
||||||
|
Content as SheetContent,
|
||||||
|
Header as SheetHeader,
|
||||||
|
Footer as SheetFooter,
|
||||||
|
Title as SheetTitle,
|
||||||
|
Description as SheetDescription,
|
||||||
|
};
|
||||||
7
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
|
||||||
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { tv, type VariantProps } from "tailwind-variants";
|
||||||
|
export const sheetVariants = tv({
|
||||||
|
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
|
||||||
|
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Side = VariantProps<typeof sheetVariants>["side"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import SheetOverlay from "./sheet-overlay.svelte";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
side = "right",
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SheetPrimitive.PortalProps;
|
||||||
|
side?: Side;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SheetPrimitive.Portal {...portalProps}>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="sheet-content"
|
||||||
|
class={cn(sheetVariants({ side }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
<XIcon class="size-4" />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPrimitive.Portal>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user