mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-14 18:47:42 +01:00
refactor: refactoring Coco App (#112)
* feat: impl Coco server related APIs * chore: remove unused method * fix: invoke Rust interfaces in tauri::run() * chore: add invoke * feat: add add_coco_server * fix: trim the tailing forward slash * feat: interface get_user_profiles * chore: add * fix: store the servers in add interface * chore: ass * fix: skip non-publich servers with no token * feat: add * feat: get datasources and connectors * fix: invoke interfaces in tauri::run() * chore: add SidebarRef * refactor: refactoring coco-app * refactor: refactoring coco app * refactor: refactoring project layout * refactor: refactoring server management * chore: cleanup code * chore: display error when connect failed * refactor: refactoring refresh server's info * refactor: refactoring how to connect the coco serverg * chore: rename to cloud * refactor: refactoring remove coco server * fix: refresh current selected server * fix: reset server selection * chore: update login status * feat: add error message tips * fix: fix login and logout * refactor: refactoring http client * fix: fix the datasources * chore: minor fix * refactor: refactoring code * fix: fix search api * chore: optimize part of icons * chore: fix build * refactor: search list icon * refactor: search list icon * chore: lib * feat: add plugin-os --------- Co-authored-by: rain <15911122312@163.com> Co-authored-by: medcl <m@medcl.net>
This commit is contained in:
@@ -12,12 +12,12 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.10",
|
||||
"@react-oauth/google": "^0.12.1",
|
||||
"@tauri-apps/api": ">=2.0.0",
|
||||
"@tauri-apps/api": "^2.2.0",
|
||||
"@tauri-apps/plugin-autostart": "~2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.1",
|
||||
"@tauri-apps/plugin-os": "^2.0.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": ">=2.0.0",
|
||||
"@tauri-apps/plugin-websocket": "~2",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
@@ -45,7 +45,7 @@
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": ">=2.0.0",
|
||||
"@tauri-apps/cli": "^2.2.7",
|
||||
"@types/lodash": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.8.4",
|
||||
|
||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^0.12.1
|
||||
version: 0.12.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tauri-apps/api':
|
||||
specifier: '>=2.0.0'
|
||||
version: 2.0.2
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@tauri-apps/plugin-autostart':
|
||||
specifier: ~2
|
||||
version: 2.2.0
|
||||
@@ -30,7 +30,7 @@ importers:
|
||||
specifier: ~2.0.1
|
||||
version: 2.0.1
|
||||
'@tauri-apps/plugin-os':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: '>=2.0.0'
|
||||
@@ -109,8 +109,8 @@ importers:
|
||||
version: 5.0.0(@types/react@18.3.11)(immer@10.1.1)(react@18.3.1)
|
||||
devDependencies:
|
||||
'@tauri-apps/cli':
|
||||
specifier: '>=2.0.0'
|
||||
version: 2.0.3
|
||||
specifier: ^2.2.7
|
||||
version: 2.2.7
|
||||
'@types/lodash':
|
||||
specifier: ^4.17.12
|
||||
version: 4.17.12
|
||||
@@ -638,75 +638,75 @@ packages:
|
||||
resolution: {integrity: sha512-ZMOc3eu9amwvkC6M69h3hWt4/EsFaAXmtkiw4xd2LN59/lTb4ZQiVfq2QKlRcu1rj3n/Tcr7U30ZopvHwXBGIg==}
|
||||
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
|
||||
|
||||
'@tauri-apps/api@2.0.2':
|
||||
resolution: {integrity: sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==}
|
||||
'@tauri-apps/api@2.2.0':
|
||||
resolution: {integrity: sha512-R8epOeZl1eJEl603aUMIGb4RXlhPjpgxbGVEaqY+0G5JG9vzV/clNlzTeqc+NLYXVqXcn8mb4c5b9pJIUDEyAg==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.0.3':
|
||||
resolution: {integrity: sha512-jIsbxGWS+As1ZN7umo90nkql/ZAbrDK0GBT6UsgHSz5zSwwArICsZFFwE1pLZip5yoiV5mn3TGG2c1+v+0puzQ==}
|
||||
'@tauri-apps/cli-darwin-arm64@2.2.7':
|
||||
resolution: {integrity: sha512-54kcpxZ3X1Rq+pPTzk3iIcjEVY4yv493uRx/80rLoAA95vAC0c//31Whz75UVddDjJfZvXlXZ3uSZ+bnCOnt0A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.0.3':
|
||||
resolution: {integrity: sha512-ROITHtLTA1muyrwgyuwyasmaLCGtT4as/Kd1kerXaSDtFcYrnxiM984ZD0+FDUEDl5BgXtYa/sKKkKQFjgmM0A==}
|
||||
'@tauri-apps/cli-darwin-x64@2.2.7':
|
||||
resolution: {integrity: sha512-Vgu2XtBWemLnarB+6LqQeLanDlRj7CeFN//H8bVVdjbNzxcSxsvbLYMBP8+3boa7eBnjDrqMImRySSgL6IrwTw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.0.3':
|
||||
resolution: {integrity: sha512-bQ3EZwCFfrLg/ZQ2I8sLuifSxESz4TP56SleTkKsPtTIZgNnKpM88PRDz4neiRroHVOq8NK0X276qi9LjGcXPw==}
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.2.7':
|
||||
resolution: {integrity: sha512-+Clha2iQAiK9zoY/KKW0KLHkR0k36O78YLx5Sl98tWkwI3OBZFg5H5WT1plH/4sbZIS2aLFN6dw58/JlY9Bu/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.0.3':
|
||||
resolution: {integrity: sha512-aLfAA8P9OTErVUk3sATxtXqpAtlfDPMPp4fGjDysEELG/MyekGhmh2k/kG/i32OdPeCfO+Nr37wJksARJKubGw==}
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.2.7':
|
||||
resolution: {integrity: sha512-Z/Lp4SQe6BUEOays9BQAEum2pvZF4w9igyXijP+WbkOejZx4cDvarFJ5qXrqSLmBh7vxrdZcLwoLk9U//+yQrg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.0.3':
|
||||
resolution: {integrity: sha512-I4MVD7nf6lLLRmNQPpe5beEIFM6q7Zkmh77ROA5BNu/+vHNL5kiTMD+bmd10ZL2r753A6pO7AvqkIxcBuIl0tg==}
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.2.7':
|
||||
resolution: {integrity: sha512-+8HZ+txff/Y3YjAh80XcLXcX8kpGXVdr1P8AfjLHxHdS6QD4Md+acSxGTTNbplmHuBaSHJvuTvZf9tU1eDCTDg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.0.3':
|
||||
resolution: {integrity: sha512-C6Jkx2zZGKkoi+sg5FK9GoH/0EvAaOgrZfF5azV5EALGba46g7VpWcZgp9zFUd7K2IzTi+0OOY8TQ2OVfKZgew==}
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.2.7':
|
||||
resolution: {integrity: sha512-ahlSnuCnUntblp9dG7/w5ZWZOdzRFi3zl0oScgt7GF4KNAOEa7duADsxPA4/FT2hLRa0SvpqtD4IYFvCxoVv3Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.0.3':
|
||||
resolution: {integrity: sha512-qi4ghmTfSAl+EEUDwmwI9AJUiOLNSmU1RgiGgcPRE+7A/W+Am9UnxYySAiRbB/gJgTl9sj/pqH5Y9duP1/sqHg==}
|
||||
'@tauri-apps/cli-linux-x64-musl@2.2.7':
|
||||
resolution: {integrity: sha512-+qKAWnJRSX+pjjRbKAQgTdFY8ecdcu8UdJ69i7wn3ZcRn2nMMzOO2LOMOTQV42B7/Q64D1pIpmZj9yblTMvadA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.0.3':
|
||||
resolution: {integrity: sha512-UXxHkYmFesC97qVmZre4vY7oDxRDtC2OeKNv0bH+iSnuUp/ROxzJYGyaelnv9Ybvgl4YVqDCnxgB28qMM938TA==}
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.2.7':
|
||||
resolution: {integrity: sha512-aa86nRnrwT04u9D9fhf5JVssuAZlUCCc8AjqQjqODQjMd4BMA2+d4K9qBMpEG/1kVh95vZaNsLogjEaqSTTw4A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.0.3':
|
||||
resolution: {integrity: sha512-D+xoaa35RGlkXDpnL5uDTpj29untuC5Wp6bN9snfgFDagD0wnFfC8+2ZQGu16bD0IteWqDI0OSoIXhNvy+F+wg==}
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.2.7':
|
||||
resolution: {integrity: sha512-EiJ5/25tLSQOSGvv+t6o3ZBfOTKB5S3vb+hHQuKbfmKdRF0XQu2YPdIi1CQw1DU97ZAE0Dq4frvnyYEKWgMzVQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.0.3':
|
||||
resolution: {integrity: sha512-eWV9XWb4dSYHXl13OtYWLjX1JHphUEkHkkGwJrhr8qFBm7RbxXxQvrsUEprSi51ug/dwJenjJgM4zR8By4htfw==}
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.2.7':
|
||||
resolution: {integrity: sha512-ZB8Kw90j8Ld+9tCWyD2fWCYfIrzbQohJ4DJSidNwbnehlZzP7wAz6Z3xjsvUdKtQ3ibtfoeTqVInzCCEpI+pWg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tauri-apps/cli@2.0.3':
|
||||
resolution: {integrity: sha512-JwEyhc5BAVpn4E8kxzY/h7+bVOiXQdudR1r3ODMfyyumZBfgIWqpD/WuTcPq6Yjchju1BSS+80jAE/oYwI/RKg==}
|
||||
'@tauri-apps/cli@2.2.7':
|
||||
resolution: {integrity: sha512-ZnsS2B4BplwXP37celanNANiIy8TCYhvg5RT09n72uR/o+navFZtGpFSqljV8fy1Y4ixIPds8FrGSXJCN2BerA==}
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2727,78 +2727,78 @@ snapshots:
|
||||
|
||||
'@tauri-apps/api@2.0.0-alpha.6': {}
|
||||
|
||||
'@tauri-apps/api@2.0.2': {}
|
||||
'@tauri-apps/api@2.2.0': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.0.3':
|
||||
'@tauri-apps/cli-darwin-arm64@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-darwin-x64@2.0.3':
|
||||
'@tauri-apps/cli-darwin-x64@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.0.3':
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.0.3':
|
||||
'@tauri-apps/cli-linux-arm64-gnu@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.0.3':
|
||||
'@tauri-apps/cli-linux-arm64-musl@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.0.3':
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-linux-x64-musl@2.0.3':
|
||||
'@tauri-apps/cli-linux-x64-musl@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.0.3':
|
||||
'@tauri-apps/cli-win32-arm64-msvc@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.0.3':
|
||||
'@tauri-apps/cli-win32-ia32-msvc@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.0.3':
|
||||
'@tauri-apps/cli-win32-x64-msvc@2.2.7':
|
||||
optional: true
|
||||
|
||||
'@tauri-apps/cli@2.0.3':
|
||||
'@tauri-apps/cli@2.2.7':
|
||||
optionalDependencies:
|
||||
'@tauri-apps/cli-darwin-arm64': 2.0.3
|
||||
'@tauri-apps/cli-darwin-x64': 2.0.3
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.0.3
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.0.3
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.0.3
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.0.3
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.0.3
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.0.3
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.0.3
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.0.3
|
||||
'@tauri-apps/cli-darwin-arm64': 2.2.7
|
||||
'@tauri-apps/cli-darwin-x64': 2.2.7
|
||||
'@tauri-apps/cli-linux-arm-gnueabihf': 2.2.7
|
||||
'@tauri-apps/cli-linux-arm64-gnu': 2.2.7
|
||||
'@tauri-apps/cli-linux-arm64-musl': 2.2.7
|
||||
'@tauri-apps/cli-linux-x64-gnu': 2.2.7
|
||||
'@tauri-apps/cli-linux-x64-musl': 2.2.7
|
||||
'@tauri-apps/cli-win32-arm64-msvc': 2.2.7
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.2.7
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.2.7
|
||||
|
||||
'@tauri-apps/plugin-autostart@2.2.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-deep-link@2.2.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-global-shortcut@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-http@2.0.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-os@2.2.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-shell@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.0.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.0.2
|
||||
'@tauri-apps/api': 2.2.0
|
||||
|
||||
'@tauri-apps/plugin-window@2.0.0-alpha.1':
|
||||
dependencies:
|
||||
|
||||
259
src-tauri/Cargo.lock
generated
259
src-tauri/Cargo.lock
generated
@@ -17,6 +17,18 @@ version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -41,6 +53,12 @@ dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
@@ -584,6 +602,13 @@ dependencies = [
|
||||
name = "coco"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
"pizza-common",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -598,6 +623,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-theme",
|
||||
"tauri-plugin-websocket",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -611,7 +637,7 @@ dependencies = [
|
||||
"cocoa-foundation",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
"objc",
|
||||
]
|
||||
@@ -738,7 +764,7 @@ dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1236,6 +1262,15 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1243,7 +1278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1257,6 +1292,12 @@ dependencies = [
|
||||
"syn 2.0.90",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1282,6 +1323,21 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -1289,6 +1345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1371,6 +1428,7 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1518,8 +1576,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1723,6 +1783,10 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -1853,10 +1917,26 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.9"
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
@@ -2321,6 +2401,23 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2677,12 +2774,65 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
|
||||
dependencies = [
|
||||
"bitflags 2.6.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.90",
|
||||
]
|
||||
|
||||
[[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.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
@@ -2936,6 +3086,24 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pizza-common"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/infinilabs/pizza-common?branch=main#60ff2901bd302777b8c754cfd0bc436fac377838"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"camino",
|
||||
"getrandom 0.2.15",
|
||||
"hashbrown 0.14.5",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.31"
|
||||
@@ -3307,9 +3475,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.8"
|
||||
version = "0.12.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b"
|
||||
checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3324,11 +3492,13 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -3342,8 +3512,10 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -3483,6 +3655,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.21"
|
||||
@@ -3516,6 +3697,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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.6.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.22.0"
|
||||
@@ -3796,7 +4000,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
@@ -4606,6 +4810,16 @@ dependencies = [
|
||||
"syn 2.0.90",
|
||||
]
|
||||
|
||||
[[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.0"
|
||||
@@ -4716,6 +4930,27 @@ dependencies = [
|
||||
"winnow 0.6.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
@@ -4943,6 +5178,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
|
||||
@@ -6,7 +6,6 @@ authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
@@ -18,6 +17,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2.0.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "main"}
|
||||
|
||||
tauri = { version = "2.0.6", features = ["macos-private-api", "tray-icon", "image-png", "unstable"] }
|
||||
tauri-plugin-shell = "2.0.0"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -31,8 +32,14 @@ tauri-plugin-oauth = { git = "https://github.com/FabianLars/tauri-plugin-oauth",
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
tauri-plugin-single-instance = "2.0.0"
|
||||
tauri-plugin-store = "2.2.0"
|
||||
reqwest = "0.12.12"
|
||||
futures = "0.3.31"
|
||||
ordered-float = { version = "4.6.0", default-features = false }
|
||||
lazy_static = "1.5.0"
|
||||
log = "0.4.22"
|
||||
tokio = "1.40.0"
|
||||
once_cell = "1.20.2"
|
||||
|
||||
# tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
[profile.dev]
|
||||
incremental = true # Compile your binary in smaller steps.
|
||||
|
||||
9
src-tauri/src/common/auth.rs
Normal file
9
src-tauri/src/common/auth.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::common::health::Status;
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct RequestAccessTokenResponse {
|
||||
pub access_token: String,
|
||||
pub expire_at: u32,
|
||||
}
|
||||
19
src-tauri/src/common/connector.rs
Normal file
19
src-tauri/src/common/connector.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Connector {
|
||||
pub id: String,
|
||||
pub created: Option<String>,
|
||||
pub updated: Option<String>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub url: Option<String>,
|
||||
pub assets: Option<ConnectorAssets>,
|
||||
}
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectorAssets {
|
||||
pub icons: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
20
src-tauri/src/common/datasource.rs
Normal file
20
src-tauri/src/common/datasource.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::common::connector::Connector;
|
||||
|
||||
// The DataSource struct representing a datasource, which includes the Connector
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct DataSource {
|
||||
pub id: String,
|
||||
pub created: Option<String>,
|
||||
pub updated: Option<String>,
|
||||
pub r#type: Option<String>, // Using 'r#type' to escape the reserved keyword 'type'
|
||||
pub name: Option<String>,
|
||||
pub connector: Option<ConnectorConfig>,
|
||||
pub connector_info: Option<Connector>,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct ConnectorConfig {
|
||||
pub id: Option<String>,
|
||||
pub config: Option<serde_json::Value>, // Using serde_json::Value to handle any type of config
|
||||
}
|
||||
16
src-tauri/src/common/health.rs
Normal file
16
src-tauri/src/common/health.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Status {
|
||||
Green,
|
||||
Yellow,
|
||||
Red,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Health {
|
||||
pub services: HashMap<String, Status>,
|
||||
pub status: Status,
|
||||
}
|
||||
7
src-tauri/src/common/mod.rs
Normal file
7
src-tauri/src/common/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod health;
|
||||
pub mod profile;
|
||||
pub mod server;
|
||||
pub mod auth;
|
||||
pub mod datasource;
|
||||
pub mod connector;
|
||||
pub mod search_response;
|
||||
15
src-tauri/src/common/profile.rs
Normal file
15
src-tauri/src/common/profile.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub avatar: String,
|
||||
pub preferences: Preferences,
|
||||
}
|
||||
80
src-tauri/src/common/search_response.rs
Normal file
80
src-tauri/src/common/search_response.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::error::Error;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResponse<T> {
|
||||
pub took: u64,
|
||||
pub timed_out: bool,
|
||||
pub _shards: Shards,
|
||||
pub hits: Hits<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Shards {
|
||||
pub total: u64,
|
||||
pub successful: u64,
|
||||
pub skipped: u64,
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Hits<T> {
|
||||
pub total: Total,
|
||||
pub max_score: Option<f32>,
|
||||
pub hits: Vec<SearchHit<T>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Total {
|
||||
pub value: u64,
|
||||
pub relation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchHit<T> {
|
||||
pub _index: String,
|
||||
pub _type: String,
|
||||
pub _id: String,
|
||||
pub _score: Option<f32>,
|
||||
pub _source: T, // This will hold the type we pass in (e.g., DataSource)
|
||||
}
|
||||
|
||||
pub async fn parse_search_results<T>(response: Response) -> Result<Vec<T>, Box<dyn Error>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
// Log the response status and headers
|
||||
// dbg!(&response.status());
|
||||
// dbg!(&response.headers());
|
||||
|
||||
// Parse the response body to a serde::Value
|
||||
let body = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
|
||||
// Log the raw body before further processing
|
||||
// dbg!(&body);
|
||||
|
||||
// Deserialize into the generic search response
|
||||
let search_response: SearchResponse<T> = serde_json::from_value(body)
|
||||
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
||||
|
||||
// Log the deserialized search response
|
||||
// dbg!(&search_response);
|
||||
|
||||
// Collect the _source part from all hits
|
||||
let results: Vec<T> = search_response
|
||||
.hits
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| hit._source)
|
||||
.collect();
|
||||
|
||||
// Log the final results before returning
|
||||
// dbg!(&results);
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
116
src-tauri/src/common/server.rs
Normal file
116
src-tauri/src/common/server.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::hash::{Hash, Hasher};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::common::profile::UserProfile;
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Provider {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub website: String,
|
||||
pub eula: String,
|
||||
pub privacy_policy: String,
|
||||
pub banner: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Version {
|
||||
pub number: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone,Serialize, Deserialize)]
|
||||
pub struct Sso {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct AuthProvider {
|
||||
pub sso: Sso,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
pub id: String,
|
||||
#[serde(default = "default_bool_type")]
|
||||
pub builtin: bool,
|
||||
pub name: String,
|
||||
pub endpoint: String,
|
||||
pub provider: Provider,
|
||||
pub version: Version,
|
||||
pub updated: String,
|
||||
#[serde(default = "default_bool_type")]
|
||||
pub public: bool,
|
||||
#[serde(default = "default_available_type")]
|
||||
pub available: bool,
|
||||
#[serde(default = "default_user_profile_type")] // Custom default function for empty string
|
||||
pub profile: Option<UserProfile>,
|
||||
pub auth_provider: AuthProvider,
|
||||
#[serde(default = "default_priority_type")]
|
||||
pub priority: u32,
|
||||
}
|
||||
|
||||
impl PartialEq for Server {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Server {}
|
||||
|
||||
impl Hash for Server {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
pub struct ServerAccessToken {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
pub id: String,
|
||||
pub access_token: String,
|
||||
pub expired_at: u32, //unix timestamp in seconds
|
||||
}
|
||||
|
||||
impl ServerAccessToken{
|
||||
pub fn new(id: String, access_token: String, expired_at: u32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
access_token,
|
||||
expired_at: expired_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ServerAccessToken {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ServerAccessToken {}
|
||||
|
||||
impl Hash for ServerAccessToken {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_empty_string() -> String {
|
||||
"".to_string() // Default to empty string if not provided
|
||||
}
|
||||
|
||||
fn default_bool_type() -> bool {
|
||||
false // Default to false if not provided
|
||||
}
|
||||
|
||||
fn default_available_type() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_priority_type() -> u32 {
|
||||
0
|
||||
}
|
||||
fn default_user_profile_type() -> Option<UserProfile> {
|
||||
None
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
mod autostart;
|
||||
mod common;
|
||||
mod server;
|
||||
mod shortcut;
|
||||
mod util;
|
||||
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindow};
|
||||
use tauri::{AppHandle, Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tokio::runtime::Runtime as RT; // Add this import
|
||||
// Add this import
|
||||
|
||||
/// Tauri store name
|
||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||
|
||||
#[tauri::command]
|
||||
fn change_window_height(handle: AppHandle, height: u32) {
|
||||
@@ -80,12 +89,39 @@ pub fn run() {
|
||||
change_autostart,
|
||||
hide_coco,
|
||||
switch_tray_icon,
|
||||
// show_panel,
|
||||
// hide_panel,
|
||||
// close_panel
|
||||
server::servers::add_coco_server,
|
||||
server::servers::remove_coco_server,
|
||||
server::servers::list_coco_servers,
|
||||
server::servers::logout_coco_server,
|
||||
server::servers::refresh_coco_server_info,
|
||||
server::auth::handle_sso_callback,
|
||||
server::profile::get_user_profiles,
|
||||
server::datasource::get_datasources_by_server,
|
||||
server::connector::get_connectors_by_server,
|
||||
server::search::query_coco_servers,
|
||||
// server::get_coco_server_health_info,
|
||||
// server::get_coco_servers_health_info,
|
||||
// server::get_user_profiles,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors
|
||||
])
|
||||
.setup(|app| {
|
||||
init(app.app_handle());
|
||||
|
||||
|
||||
// Get app handle
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
// Create a single Tokio runtime instance
|
||||
let rt = RT::new().expect("Failed to create Tokio runtime");
|
||||
|
||||
// Use the runtime to spawn the async initialization tasks
|
||||
rt.spawn(async move {
|
||||
dbg!("Running async initialization tasks");
|
||||
init(&app_handle).await; // Pass a reference to `app_handle`
|
||||
dbg!("Async initialization tasks completed");
|
||||
});
|
||||
|
||||
|
||||
|
||||
shortcut::enable_shortcut(app);
|
||||
enable_tray(app);
|
||||
@@ -108,13 +144,26 @@ pub fn run() {
|
||||
dbg!(event.urls());
|
||||
});
|
||||
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(ctx)
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
fn init(_app_handle: &AppHandle) {
|
||||
pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
dbg!("Initializing...");
|
||||
// Await the async functions to load the servers and tokens
|
||||
if let Err(err) = load_or_insert_default_server(app_handle).await {
|
||||
eprintln!("Failed to load servers: {}", err);
|
||||
}
|
||||
|
||||
if let Err(err) = load_servers_token(app_handle).await {
|
||||
eprintln!("Failed to load server tokens: {}", err);
|
||||
}
|
||||
|
||||
dbg!("Initialization completed");
|
||||
|
||||
// let window: WebviewWindow = app_handle.get_webview_window("main").unwrap();
|
||||
|
||||
// let panel = window.to_panel().unwrap();
|
||||
@@ -231,15 +280,15 @@ fn enable_tray(app: &mut tauri::App) {
|
||||
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
|
||||
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
|
||||
let open_i = MenuItem::with_id(app, "open", "Open Coco", true, None::<&str>).unwrap();
|
||||
let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
|
||||
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
|
||||
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
|
||||
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&open_i)
|
||||
.separator()
|
||||
// .item(&hide_i)
|
||||
.item(&about_i)
|
||||
// .item(&about_i)
|
||||
.item(&settings_i)
|
||||
.separator()
|
||||
.item(&quit_i)
|
||||
|
||||
92
src-tauri/src/server/auth.rs
Normal file
92
src-tauri/src/server/auth.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::fmt::format;
|
||||
use reqwest::StatusCode;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use crate::common::auth::RequestAccessTokenResponse;
|
||||
use crate::common::server::{Server, ServerAccessToken};
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::profile::get_user_profiles;
|
||||
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server};
|
||||
use crate::util;
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
// Remove the endpoint part and keep just the path for the request
|
||||
format!("/auth/request_access_token?request_id={}", request_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn handle_sso_callback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
request_id: String,
|
||||
code: String,
|
||||
) -> Result<(), String> {
|
||||
// Retrieve the server details using the server ID
|
||||
let server = get_server_by_id(&server_id);
|
||||
|
||||
if let Some(mut server) = server {
|
||||
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
|
||||
save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60*15));
|
||||
let path = request_access_token_url( &request_id);
|
||||
|
||||
// Send the request for the access token using the util::http::HttpClient::get method
|
||||
let response = HttpClient::get(&server_id, &path)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
// Check if the response has a valid content length
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
// Deserialize the response body to get the access token
|
||||
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
// Save the access token for the server
|
||||
let access_token = ServerAccessToken::new(
|
||||
server_id.clone(),
|
||||
token.access_token.clone(),
|
||||
token.expire_at,
|
||||
);
|
||||
dbg!(&server_id, &request_id, &code, &token);
|
||||
save_access_token(server_id.clone(), access_token);
|
||||
persist_servers_token(&app_handle)?;
|
||||
|
||||
// Update the server's profile using the util::http::HttpClient::get method
|
||||
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
|
||||
dbg!(&profile);
|
||||
|
||||
match profile {
|
||||
Ok(p) => {
|
||||
server.profile = Some(p);
|
||||
server.available = true;
|
||||
save_server(&server);
|
||||
persist_servers(&app_handle)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Failed to get user profile: {}", e)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
|
||||
response.status(),
|
||||
path,
|
||||
code,
|
||||
response
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Server not found for ID: {}, Request ID: {}, Code: {}",
|
||||
server_id, request_id, code
|
||||
))
|
||||
}
|
||||
}
|
||||
147
src-tauri/src/server/connector.rs
Normal file
147
src-tauri/src/server/connector.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use crate::common::connector::Connector;
|
||||
use crate::common::search_response::parse_search_results;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::servers::{get_all_servers, list_coco_servers};
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
lazy_static! {
|
||||
static ref CONNECTOR_CACHE: Arc<RwLock<HashMap<String, HashMap<String, Connector>>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||
}
|
||||
|
||||
pub fn save_connectors_to_cache(server_id: &str, connectors: Vec<Connector>) {
|
||||
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Acquire write lock
|
||||
let connectors_map: HashMap<String, Connector> = connectors
|
||||
.into_iter()
|
||||
.map(|connector| (connector.id.clone(), connector))
|
||||
.collect();
|
||||
cache.insert(server_id.to_string(), connectors_map);
|
||||
}
|
||||
|
||||
pub fn get_connector_by_id(server_id: &str, connector_id: &str) -> Option<Connector> {
|
||||
let cache = CONNECTOR_CACHE.read().unwrap(); // Async read lock
|
||||
let server_cache = cache.get(server_id)?;
|
||||
let connector = server_cache.get(connector_id)?;
|
||||
Some(connector.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_connectors<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
dbg!("Attempting to refresh all connectors");
|
||||
|
||||
let servers = get_all_servers();
|
||||
|
||||
// Collect all the tasks for fetching and refreshing connectors
|
||||
let mut serverMap = HashMap::new();
|
||||
for server in servers {
|
||||
dbg!("start fetch connectors for server: {}", &server.id);
|
||||
let connectors = match get_connectors_by_server(app_handle.clone(), server.id.clone()).await {
|
||||
Ok(connectors) => {
|
||||
let connectors_map: HashMap<String, Connector> = connectors
|
||||
.into_iter()
|
||||
.map(|connector| (connector.id.clone(), connector))
|
||||
.collect();
|
||||
connectors_map
|
||||
}
|
||||
Err(e) => {
|
||||
dbg!("Failed to get connectors for server {}: {}", &server.id, e);
|
||||
HashMap::new() // Return empty map on failure
|
||||
}
|
||||
};
|
||||
|
||||
serverMap.insert(server.id.clone(), connectors);
|
||||
dbg!("end fetch connectors for server: {}", &server.id);
|
||||
}
|
||||
|
||||
// After all tasks have finished, perform a read operation on the cache
|
||||
let cache_size = {
|
||||
// Insert connectors into the cache (async write lock)
|
||||
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Async write lock
|
||||
cache.clear();
|
||||
cache.extend(serverMap);
|
||||
// let cache = CONNECTOR_CACHE.read().await; // Async read lock
|
||||
cache.len()
|
||||
};
|
||||
|
||||
dbg!("finished refresh connectors: {:?}", cache_size);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_connectors_from_cache_or_remote(server_id: &str) -> Result<Vec<Connector>, String> {
|
||||
// Acquire the read lock and check cache for connectors
|
||||
let cache = CONNECTOR_CACHE.read().unwrap(); // Acquire read lock
|
||||
if let Some(connectors) = cache.get(server_id).cloned() {
|
||||
return Ok(connectors.values().cloned().collect());
|
||||
}
|
||||
|
||||
// Drop the read lock before performing async operations
|
||||
drop(cache);
|
||||
|
||||
// If the cache is empty, fetch connectors from the remote server
|
||||
let connectors = fetch_connectors_by_server(server_id).await?;
|
||||
|
||||
// Convert the Vec<Connector> into HashMap<String, Connector>
|
||||
let connectors_map: HashMap<String, Connector> = connectors.clone()
|
||||
.into_iter()
|
||||
.map(|connector| (connector.id.clone(), connector)) // Assuming Connector has an `id` field
|
||||
.collect();
|
||||
|
||||
// Optionally, update the cache after fetching data from remote
|
||||
let mut cache = CONNECTOR_CACHE.write().unwrap(); // Acquire write lock
|
||||
cache.insert(server_id.to_string(), connectors_map.clone());
|
||||
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
|
||||
dbg!("start get_connectors_by_server: id =", &id);
|
||||
|
||||
// Use the generic GET method from HttpClient
|
||||
let resp = HttpClient::get(&id, "/connector/_search")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
dbg!("Error fetching connector for id {}: {}", &id, &e);
|
||||
format!("Error fetching connector: {}", e)
|
||||
})?;
|
||||
|
||||
// Log the raw response status and headers
|
||||
dbg!("Response status: {:?}", resp.status());
|
||||
dbg!("Response headers: {:?}", resp.headers());
|
||||
|
||||
// Ensure the response body is not empty or invalid
|
||||
if resp.status().is_success() {
|
||||
dbg!("Received successful response for id: {}", &id);
|
||||
} else {
|
||||
dbg!("Failed to fetch connectors. Response status: {}", resp.status());
|
||||
}
|
||||
|
||||
// Parse the search results directly from the response body
|
||||
let datasources: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
|
||||
dbg!("Error parsing search results for id {}: {}", &id, &e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// Log the parsed results
|
||||
dbg!("Parsed connectors: {:?}", &datasources);
|
||||
|
||||
// Save the connectors to the cache
|
||||
save_connectors_to_cache(&id, datasources.clone());
|
||||
|
||||
dbg!("end get_connectors_by_server: id =", &id);
|
||||
return Ok(datasources);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_connectors_by_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<Vec<Connector>, String> {
|
||||
//fetch_connectors_by_server
|
||||
|
||||
let connectors = fetch_connectors_by_server(&id).await?;
|
||||
Ok(connectors)
|
||||
}
|
||||
138
src-tauri/src/server/datasource.rs
Normal file
138
src-tauri/src/server/datasource.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use crate::common::datasource::DataSource;
|
||||
use crate::common::search_response::parse_search_results;
|
||||
use crate::server::connector::{fetch_connectors_by_server, get_connector_by_id, get_connectors_by_server, get_connectors_from_cache_or_remote};
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::servers::{get_all_servers, list_coco_servers};
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use crate::common::connector::Connector;
|
||||
|
||||
lazy_static! {
|
||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String,HashMap<String,DataSource>>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||
}
|
||||
|
||||
pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
||||
let mut cache = DATASOURCE_CACHE.write().unwrap(); // Acquire write lock
|
||||
let datasources_map: HashMap<String, DataSource> = datasources
|
||||
.into_iter()
|
||||
.map(|datasource| {
|
||||
(datasource.id.clone(), datasource)
|
||||
})
|
||||
.collect();
|
||||
cache.insert(server_id.to_string(), datasources_map);
|
||||
}
|
||||
|
||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||
dbg!("cache: {:?}", &cache);
|
||||
let server_cache = cache.get(server_id)?; // Get the server's cache
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_datasources<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
) -> Result<(), String> {
|
||||
dbg!("Attempting to refresh all datasources");
|
||||
|
||||
let servers = get_all_servers();
|
||||
|
||||
let mut serverMap = HashMap::new();
|
||||
|
||||
for server in servers {
|
||||
dbg!("fetch datasources for server: {}", &server.id);
|
||||
|
||||
// Attempt to get datasources by server, and continue even if it fails
|
||||
let mut connectors = match get_datasources_by_server(app_handle.clone(), server.id.clone()).await {
|
||||
Ok(connectors) => {
|
||||
// Process connectors only after fetching them
|
||||
let connectors_map: HashMap<String, DataSource> = connectors
|
||||
.into_iter()
|
||||
.map(|mut connector| {
|
||||
(connector.id.clone(), connector)
|
||||
})
|
||||
.collect();
|
||||
dbg!("connectors_map: {:?}", &connectors_map);
|
||||
connectors_map
|
||||
}
|
||||
Err(e) => {
|
||||
dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
|
||||
let mut new_map = HashMap::new();
|
||||
for (id, mut datasource) in connectors.iter() {
|
||||
dbg!("connector: {:?}", &datasource);
|
||||
if let Some(existing_connector) = get_connector_by_id(&server.id, &datasource.id) {
|
||||
// If found in cache, update the connector's info
|
||||
dbg!("Found connector in cache for {}: {:?}", &datasource.id, &existing_connector);
|
||||
let mut obj = datasource.clone();
|
||||
obj.connector_info = Some(existing_connector);
|
||||
new_map.insert(id.clone(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
serverMap.insert(server.id.clone(), new_map);
|
||||
}
|
||||
|
||||
// Perform a read operation after all writes are done
|
||||
let cache_size = {
|
||||
let mut cache = DATASOURCE_CACHE.write().unwrap();
|
||||
cache.clear();
|
||||
cache.extend(serverMap);
|
||||
cache.len()
|
||||
};
|
||||
dbg!("datasource_map size: {:?}", cache_size);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_datasources_by_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
dbg!("get_datasources_by_server: id = {}", &id);
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
let resp = HttpClient::get(&id, "/datasource/_search")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
dbg!("Error fetching datasource: {}", &e);
|
||||
format!("Error fetching datasource: {}", e)
|
||||
})?;
|
||||
|
||||
// Parse the search results from the response
|
||||
let mut datasources:Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
dbg!("Error parsing search results: {}", &e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// let connectors=fetch_connectors_by_server(id.as_str()).await?;
|
||||
//
|
||||
// // Convert the Vec<Connector> into HashMap<String, Connector>
|
||||
// let connectors_map: HashMap<String, Connector> = connectors
|
||||
// .into_iter()
|
||||
// .map(|connector| (connector.id.clone(), connector)) // Assuming Connector has an `id` field
|
||||
// .collect();
|
||||
//
|
||||
// for datasource in datasources.iter_mut() {
|
||||
// if let Some(connector) = &datasource.connector {
|
||||
// if let Some(connector_id) = connector.id.as_ref() {
|
||||
// if let Some(existing_connector) = connectors_map.get(connector_id) {
|
||||
// // If found in cache, update the connector's info
|
||||
// datasource.connector_info = Some(existing_connector.clone());
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
dbg!("Parsed datasources: {:?}", &datasources);
|
||||
|
||||
// Save the updated datasources to cache
|
||||
save_datasource_to_cache(&id, datasources.clone());
|
||||
|
||||
Ok(datasources)
|
||||
}
|
||||
79
src-tauri/src/server/health.rs
Normal file
79
src-tauri/src/server/health.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use core::panic;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use futures::TryFutureExt;
|
||||
use ordered_float::OrderedFloat;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use crate::util::http::HTTP_CLIENT;
|
||||
|
||||
fn health_url(endpoint: &str) -> String {
|
||||
format!("{endpoint}/health")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_coco_server_health_info(endpoint: String) -> bool {
|
||||
let response = match HTTP_CLIENT
|
||||
.get(health_url(&endpoint))
|
||||
.send()
|
||||
.map_err(|_request_err| ())
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let json: JsonMap<String, Json> = response.json().await.expect("invalid response");
|
||||
let status = json
|
||||
.get("status")
|
||||
.expect("response does not have a [status] field")
|
||||
.as_str()
|
||||
.expect("status field is not a string");
|
||||
|
||||
status != "red"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_coco_servers_health_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<HashMap<String, bool>, ()> {
|
||||
// let coco_server_endpoints = _list_coco_server_endpoints(&app_handle).await?;
|
||||
//
|
||||
// let mut futures = FuturesUnordered::new();
|
||||
// for coco_server_endpoint in coco_server_endpoints {
|
||||
// let request_future = HTTP_CLIENT.get(health_url(&coco_server_endpoint)).send();
|
||||
// futures.push(request_future.map(|request_result| (coco_server_endpoint, request_result)));
|
||||
// }
|
||||
//
|
||||
// let mut health_info = HashMap::new();
|
||||
//
|
||||
// while let Some((endpoint, res_response)) = futures.next().await {
|
||||
// let healthy = match res_response {
|
||||
// Ok(response) => {
|
||||
// let json: JsonMap<String, Json> = response.json().await.expect("invalid response");
|
||||
// let status = json
|
||||
// .get("status")
|
||||
// .expect("response does not have a [status] field")
|
||||
// .as_str()
|
||||
// .expect("status field is not a string");
|
||||
// status != "red"
|
||||
// }
|
||||
// Err(_) => false,
|
||||
// };
|
||||
//
|
||||
// health_info.insert(endpoint, healthy);
|
||||
// }
|
||||
//
|
||||
// Ok(health_info)
|
||||
|
||||
Ok()
|
||||
}
|
||||
144
src-tauri/src/server/http_client.rs
Normal file
144
src-tauri/src/server/http_client.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
use lazy_static::lazy_static;
|
||||
use tauri::AppHandle;
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::Mutex;
|
||||
use reqwest::{Client, Method, RequestBuilder, Response, StatusCode};
|
||||
|
||||
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
||||
let client = Client::builder()
|
||||
.read_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
|
||||
.connect_timeout(Duration::from_secs(3)) // Set a timeout of 3 second
|
||||
.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds
|
||||
.danger_accept_invalid_certs(true) // example for self-signed certificates
|
||||
.build()
|
||||
.expect("Failed to build client");
|
||||
Mutex::new(client)
|
||||
});
|
||||
|
||||
pub struct HttpClient;
|
||||
|
||||
impl HttpClient {
|
||||
// Utility function for properly joining paths
|
||||
pub(crate) fn join_url(base: &str, path: &str) -> String {
|
||||
let base = base.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
format!("{}/{}", base, path)
|
||||
}
|
||||
|
||||
pub async fn send_raw_request(
|
||||
method: Method,
|
||||
url: &str,
|
||||
headers: Option<reqwest::header::HeaderMap>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
|
||||
let request_builder = Self::get_request_builder(method, url, headers, body).await;
|
||||
|
||||
// Send the request
|
||||
let response = match request_builder.send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
dbg!("Failed to send request: {}", &e);
|
||||
return Err(format!("Failed to send request: {}", e));
|
||||
}
|
||||
};
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn get_request_builder(
|
||||
method: Method,
|
||||
url: &str,
|
||||
headers: Option<reqwest::header::HeaderMap>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> RequestBuilder {
|
||||
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
||||
|
||||
// Build the request
|
||||
let mut request_builder = client.request(method.clone(), url);
|
||||
|
||||
// Add headers if present
|
||||
if let Some(h) = headers {
|
||||
request_builder = request_builder.headers(h);
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
if let Some(b) = body {
|
||||
request_builder = request_builder.body(b);
|
||||
}
|
||||
|
||||
request_builder
|
||||
}
|
||||
|
||||
pub async fn send_request(
|
||||
server_id: &str,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
// Fetch the server using the server_id
|
||||
let server = get_server_by_id(server_id);
|
||||
if let Some(s) = server {
|
||||
// Construct the URL
|
||||
let url = HttpClient::join_url(&s.endpoint, path);
|
||||
|
||||
// dbg!(&url);
|
||||
// dbg!(&server_id);
|
||||
|
||||
// Retrieve the token for the server (token is optional)
|
||||
let token = get_server_token(server_id).map(|t| t.access_token.clone());
|
||||
|
||||
|
||||
// Create headers map (optional "X-API-TOKEN" header)
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if let Some(t) = token {
|
||||
headers.insert("X-API-TOKEN", reqwest::header::HeaderValue::from_str(&t).unwrap());
|
||||
}
|
||||
|
||||
// dbg!(&headers);
|
||||
|
||||
Self::send_raw_request(method, &url, Some(headers), body).await
|
||||
|
||||
} else {
|
||||
Err("Server not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convenience method for GET requests (as it's the most common)
|
||||
pub async fn get(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::GET, path, None).await
|
||||
}
|
||||
|
||||
// Convenience method for POST requests
|
||||
pub async fn post(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
body: reqwest::Body,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request( server_id, Method::POST, path, Some(body)).await
|
||||
}
|
||||
|
||||
// Convenience method for PUT requests
|
||||
pub async fn put(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
body: reqwest::Body,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request( server_id, Method::PUT, path, Some(body)).await
|
||||
}
|
||||
|
||||
// Convenience method for DELETE requests
|
||||
pub async fn delete(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::DELETE, path, None).await
|
||||
}
|
||||
}
|
||||
20
src-tauri/src/server/mod.rs
Normal file
20
src-tauri/src/server/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! This file contains Rust APIs related to Coco Server management.
|
||||
|
||||
use futures::FutureExt;
|
||||
use futures::StreamExt;
|
||||
use futures::TryFutureExt;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use std::sync::LazyLock;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
pub mod servers;
|
||||
pub mod auth;
|
||||
// pub mod health;
|
||||
pub mod datasource;
|
||||
pub mod connector;
|
||||
pub mod search;
|
||||
pub mod profile;
|
||||
pub mod http_client;
|
||||
|
||||
26
src-tauri/src/server/profile.rs
Normal file
26
src-tauri/src/server/profile.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::common::profile::UserProfile;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profiles<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
) -> Result<UserProfile, String> {
|
||||
// Use the generic GET method from HttpClient
|
||||
let response = HttpClient::get( &server_id, "/account/profile")
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching profile: {}", e))?;
|
||||
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let profile: UserProfile = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Profile not found or empty response".to_string())
|
||||
}
|
||||
182
src-tauri/src/server/search.rs
Normal file
182
src-tauri/src/server/search.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::collections::HashMap;
|
||||
use ordered_float::OrderedFloat;
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use tauri::{ AppHandle, Runtime};
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as Json;
|
||||
use crate::server::http_client::{HttpClient, HTTP_CLIENT};
|
||||
use crate::server::servers::{get_all_servers, get_server_token, get_servers_as_hashmap};
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
|
||||
struct DocumentsSizedCollector {
|
||||
size: u64,
|
||||
/// Documents and socres
|
||||
///
|
||||
/// Sorted by score, in descending order.
|
||||
docs: Vec<(JsonMap<String, Json>, OrderedFloat<f64>)>,
|
||||
}
|
||||
|
||||
impl DocumentsSizedCollector {
|
||||
fn new(size: u64) -> Self {
|
||||
// there will be size + 1 documents in docs at max
|
||||
let docs = Vec::with_capacity((size + 1).try_into().expect("overflow"));
|
||||
|
||||
Self { size, docs }
|
||||
}
|
||||
|
||||
fn push(&mut self, item: JsonMap<String, Json>, score: f64) {
|
||||
let score = OrderedFloat(score);
|
||||
let insert_idx = match self.docs.binary_search_by(|(_doc, s)| score.cmp(s)) {
|
||||
Ok(idx) => idx,
|
||||
Err(idx) => idx,
|
||||
};
|
||||
|
||||
self.docs.insert(insert_idx, (item, score));
|
||||
|
||||
// cast usize to u64 is safe
|
||||
if self.docs.len() as u64 > self.size {
|
||||
self.docs.truncate(self.size.try_into().expect(
|
||||
"self.size < a number of type usize, it can be expressed using usize, we are safe",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn documents(self) -> impl ExactSizeIterator<Item = JsonMap<String, Json>> {
|
||||
self.docs.into_iter().map(|(doc, _score)| doc)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct QueryResponse {
|
||||
failed_coco_servers: Vec<String>,
|
||||
documents: Vec<JsonMap<String, Json>>,
|
||||
total_hits: u64,
|
||||
}
|
||||
|
||||
|
||||
fn get_name(provider_info: &JsonMap<String, Json>) -> &str {
|
||||
provider_info
|
||||
.get("name")
|
||||
.expect("provider info does not have a [name] field")
|
||||
.as_str()
|
||||
.expect("field [name] should be a string")
|
||||
}
|
||||
|
||||
fn get_public(provider_info: &JsonMap<String, Json>) -> bool {
|
||||
provider_info
|
||||
.get("public")
|
||||
.expect("provider info does not have a [public] field")
|
||||
.as_bool()
|
||||
.expect("field [public] should be a string")
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn query_coco_servers<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: HashMap<String, String>,
|
||||
) -> Result<QueryResponse, ()> {
|
||||
println!(
|
||||
"DBG: query_coco_servers, from: {} size: {} query_strings {:?}",
|
||||
from, size, query_strings
|
||||
);
|
||||
|
||||
let coco_servers = get_servers_as_hashmap();
|
||||
let mut futures = FuturesUnordered::new();
|
||||
let size_for_each_request = (from + size).to_string();
|
||||
|
||||
for (_,server) in coco_servers {
|
||||
let url = HttpClient::join_url(&server.endpoint, "/query/_search");
|
||||
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
||||
let mut request_builder = client.request(Method::GET, url);
|
||||
|
||||
if !server.public {
|
||||
if let Some(token) = get_server_token(&server.id).map(|t| t.access_token) {
|
||||
request_builder = request_builder.header("X-API-TOKEN", token);
|
||||
}
|
||||
}
|
||||
let query_strings_cloned = query_strings.clone(); // Clone for each iteration
|
||||
|
||||
let size=size_for_each_request.clone();
|
||||
let future = async move {
|
||||
let response = request_builder
|
||||
.query(&[("from", "0"), ("size", size.as_str())])
|
||||
.query(&query_strings_cloned) // Use cloned instance
|
||||
.send()
|
||||
.await;
|
||||
(server.id, response)
|
||||
};
|
||||
|
||||
futures.push(future);
|
||||
}
|
||||
|
||||
let mut total_hits = 0;
|
||||
let mut failed_coco_servers = Vec::new();
|
||||
let mut docs_collector = DocumentsSizedCollector::new(size);
|
||||
|
||||
while let Some((name, res_response)) = futures.next().await {
|
||||
match res_response {
|
||||
Ok(response) => {
|
||||
if let Ok(mut body) = response.json::<JsonMap<String, Json>>().await {
|
||||
if let Some(Json::Object(mut hits)) = body.remove("hits") {
|
||||
if let Some(Json::Number(hits_total_value)) = hits.get("total").and_then(|t| t.get("value")) {
|
||||
if let Some(hits_total) = hits_total_value.as_u64() {
|
||||
total_hits += hits_total;
|
||||
}
|
||||
}
|
||||
if let Some(Json::Array(hits_hits)) = hits.remove("hits") {
|
||||
for hit in hits_hits.into_iter().filter_map(|h| h.as_object().cloned()) {
|
||||
if let (Some(Json::Number(score)), Some(Json::Object(source))) = (hit.get("_score"), hit.get("_source")) {
|
||||
if let Some(score_value) = score.as_f64() {
|
||||
docs_collector.push(source.clone(), score_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => failed_coco_servers.push(name),
|
||||
}
|
||||
}
|
||||
|
||||
let docs=docs_collector.documents().collect();
|
||||
|
||||
// dbg!(&total_hits);
|
||||
// dbg!(&failed_coco_servers);
|
||||
// dbg!(&docs);
|
||||
|
||||
Ok(QueryResponse {
|
||||
failed_coco_servers,
|
||||
total_hits,
|
||||
documents:docs ,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_docs_collector() {
|
||||
let mut collector = DocumentsSizedCollector::new(3);
|
||||
|
||||
for i in 0..10 {
|
||||
collector.push(JsonMap::new(), i as f64);
|
||||
}
|
||||
|
||||
assert_eq!(collector.docs.len(), 3);
|
||||
assert!(collector
|
||||
.docs
|
||||
.into_iter()
|
||||
.map(|(_doc, score)| score)
|
||||
.eq(vec![
|
||||
OrderedFloat(9.0),
|
||||
OrderedFloat(8.0),
|
||||
OrderedFloat(7.0)
|
||||
]));
|
||||
}
|
||||
}
|
||||
516
src-tauri/src/server/servers.rs
Normal file
516
src-tauri/src/server/servers.rs
Normal file
@@ -0,0 +1,516 @@
|
||||
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Method, StatusCode};
|
||||
use serde_json::from_value;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use crate::server::connector::refresh_all_connectors;
|
||||
use crate::server::datasource::refresh_all_datasources;
|
||||
// Assuming you're using serde_json
|
||||
|
||||
lazy_static! {
|
||||
static ref SERVER_CACHE: Arc<RwLock<HashMap<String,Server>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||
static ref SERVER_TOKEN: Arc<RwLock<HashMap<String,ServerAccessToken>>> = Arc::new(RwLock::new(HashMap::new()));
|
||||
}
|
||||
|
||||
fn check_server_exists(id: &str) -> bool {
|
||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
||||
cache.contains_key(id)
|
||||
}
|
||||
|
||||
pub fn get_server_by_id(id: &str) -> Option<Server> {
|
||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
||||
cache.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
|
||||
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
|
||||
cache.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
|
||||
let mut cache = SERVER_TOKEN.write().unwrap();
|
||||
cache.insert(server_id, token).is_none()
|
||||
}
|
||||
|
||||
fn check_endpoint_exists(endpoint: &str) -> bool {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.values().any(|server| server.endpoint == endpoint)
|
||||
}
|
||||
|
||||
pub fn save_server(server: &Server) -> bool {
|
||||
let mut cache = SERVER_CACHE.write().unwrap();
|
||||
cache.insert(server.id.clone(), server.clone()).is_none() // If the server id did not exist, `insert` will return `None`
|
||||
}
|
||||
|
||||
fn remove_server_by_id(id: String) -> bool {
|
||||
dbg!("remove server by id:", &id);
|
||||
let mut cache = SERVER_CACHE.write().unwrap();
|
||||
let deleted = cache.remove(id.as_str());
|
||||
deleted.is_some()
|
||||
}
|
||||
|
||||
|
||||
pub fn persist_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
let servers: Vec<Server> = cache.values().cloned().collect();
|
||||
|
||||
// Serialize the servers into JSON automatically
|
||||
let json_servers: Vec<JsonValue> = servers
|
||||
.into_iter()
|
||||
.map(|server| serde_json::to_value(server).expect("Failed to serialize server")) // Automatically serialize all fields
|
||||
.collect();
|
||||
|
||||
// dbg!(format!("persist servers: {:?}", &json_servers));
|
||||
|
||||
// Save the serialized servers to Tauri's store
|
||||
app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should never fail")
|
||||
.set(COCO_SERVERS, json_servers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_server_token(id: &str) -> bool {
|
||||
dbg!("remove server token by id:", &id);
|
||||
let mut cache = SERVER_TOKEN.write().unwrap();
|
||||
cache.remove(id).is_some()
|
||||
}
|
||||
|
||||
pub fn persist_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
let cache = SERVER_TOKEN.read().unwrap(); // Acquire a read lock, not a write lock, since you're not modifying the cache
|
||||
|
||||
// Convert HashMap to Vec for serialization (iterating over values of HashMap)
|
||||
let servers: Vec<ServerAccessToken> = cache.values().cloned().collect();
|
||||
|
||||
// Serialize the servers into JSON automatically
|
||||
let json_servers: Vec<JsonValue> = servers
|
||||
.into_iter()
|
||||
.map(|server| serde_json::to_value(server).expect("Failed to serialize access_tokens")) // Automatically serialize all fields
|
||||
.collect();
|
||||
|
||||
dbg!(format!("persist servers token: {:?}", &json_servers));
|
||||
|
||||
// Save the serialized servers to Tauri's store
|
||||
app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should never fail")
|
||||
.set(COCO_SERVER_TOKENS, json_servers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Function to get the default server if the request or parsing fails
|
||||
fn get_default_server() -> Server {
|
||||
Server {
|
||||
id: "default_coco_server".to_string(),
|
||||
builtin: true,
|
||||
name: "Coco Cloud".to_string(),
|
||||
endpoint: "https://coco.infini.cloud".to_string(),
|
||||
provider: Provider {
|
||||
name: "INFINI Labs".to_string(),
|
||||
icon: "https://coco.infini.cloud/icon.png".to_string(),
|
||||
website: "http://infinilabs.com".to_string(),
|
||||
eula: "http://infinilabs.com/eula.txt".to_string(),
|
||||
privacy_policy: "http://infinilabs.com/privacy_policy.txt".to_string(),
|
||||
banner: "https://coco.infini.cloud/banner.jpg".to_string(),
|
||||
description: "Coco AI Server - Search, Connect, Collaborate, AI-powered enterprise search, all in one space.".to_string(),
|
||||
},
|
||||
version: Version {
|
||||
number: "1.0.0_SNAPSHOT".to_string(),
|
||||
},
|
||||
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
|
||||
public: false,
|
||||
available: true,
|
||||
profile: None,
|
||||
auth_provider: AuthProvider {
|
||||
sso: Sso {
|
||||
url: "https://coco.infini.cloud/sso/login/".to_string(),
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers_token<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<ServerAccessToken>, String> {
|
||||
|
||||
dbg!("Attempting to load servers token");
|
||||
|
||||
let store = app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should not fail");
|
||||
|
||||
// Check if the servers key exists in the store
|
||||
if !store.has(COCO_SERVER_TOKENS) {
|
||||
return Err("Failed to read servers from store: No servers found".to_string());
|
||||
}
|
||||
|
||||
// Load servers from store
|
||||
let servers: Option<JsonValue> = store.get(COCO_SERVER_TOKENS);
|
||||
|
||||
// Handle the None case
|
||||
let servers = servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
|
||||
|
||||
// Convert each item in the JsonValue array to a Server
|
||||
if let JsonValue::Array(servers_array) = servers {
|
||||
// Deserialize each JsonValue into Server, filtering out any errors
|
||||
let deserialized_tokens: Vec<ServerAccessToken> = servers_array
|
||||
.into_iter()
|
||||
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances
|
||||
.collect();
|
||||
|
||||
if deserialized_tokens.is_empty() {
|
||||
return Err("Failed to deserialize any servers from the store.".to_string());
|
||||
}
|
||||
|
||||
for server in deserialized_tokens.iter() {
|
||||
save_access_token(server.id.clone(), server.clone());
|
||||
}
|
||||
|
||||
dbg!(format!("loaded {:?} servers's token", &deserialized_tokens.len()));
|
||||
|
||||
Ok(deserialized_tokens)
|
||||
} else {
|
||||
Err("Failed to read servers from store: Invalid format".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_servers<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
|
||||
let store = app_handle
|
||||
.store(COCO_TAURI_STORE)
|
||||
.expect("create or load a store should not fail");
|
||||
|
||||
// Check if the servers key exists in the store
|
||||
if !store.has(COCO_SERVERS) {
|
||||
return Err("Failed to read servers from store: No servers found".to_string());
|
||||
}
|
||||
|
||||
// Load servers from store
|
||||
let servers: Option<JsonValue> = store.get(COCO_SERVERS);
|
||||
|
||||
// Handle the None case
|
||||
let servers = servers.ok_or_else(|| "Failed to read servers from store: No servers found".to_string())?;
|
||||
|
||||
// Convert each item in the JsonValue array to a Server
|
||||
if let JsonValue::Array(servers_array) = servers {
|
||||
// Deserialize each JsonValue into Server, filtering out any errors
|
||||
let deserialized_servers: Vec<Server> = servers_array
|
||||
.into_iter()
|
||||
.filter_map(|server_json| from_value(server_json).ok()) // Only keep valid Server instances
|
||||
.collect();
|
||||
|
||||
if deserialized_servers.is_empty() {
|
||||
return Err("Failed to deserialize any servers from the store.".to_string());
|
||||
}
|
||||
|
||||
for server in deserialized_servers.iter() {
|
||||
save_server(&server);
|
||||
}
|
||||
|
||||
// dbg!(format!("load servers: {:?}", &deserialized_servers));
|
||||
|
||||
Ok(deserialized_servers)
|
||||
} else {
|
||||
Err("Failed to read servers from store: Invalid format".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Function to load servers or insert a default one if none exist
|
||||
pub async fn load_or_insert_default_server<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<Server>, String> {
|
||||
|
||||
dbg!("Attempting to load or insert default server");
|
||||
|
||||
let exists_servers = load_servers(&app_handle).await;
|
||||
if exists_servers.is_ok() && !exists_servers.as_ref()?.is_empty() {
|
||||
dbg!(format!("loaded {} servers", &exists_servers.clone()?.len()));
|
||||
return exists_servers;
|
||||
}
|
||||
|
||||
let default = get_default_server();
|
||||
save_server(&default);
|
||||
|
||||
dbg!("loaded default servers");
|
||||
|
||||
Ok(vec![default])
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_coco_servers<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
let servers: Vec<Server> =get_all_servers();
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.clone()
|
||||
}
|
||||
|
||||
pub fn get_all_servers() -> Vec<Server> {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// We store added Coco servers in the Tauri store using this key.
|
||||
pub const COCO_SERVERS: &str = "coco_servers";
|
||||
|
||||
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<Server, String> {
|
||||
// Retrieve the server from the cache
|
||||
let server = {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.get(&id).cloned()
|
||||
};
|
||||
|
||||
if let Some(server) = server {
|
||||
let is_builtin = server.builtin;
|
||||
let profile = server.profile;
|
||||
|
||||
// Use the HttpClient to send the request
|
||||
let response = HttpClient::get(&id, "/provider/_info") // Assuming "/provider-info" is the endpoint
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
server.id = id;
|
||||
server.builtin = is_builtin;
|
||||
server.available = true;
|
||||
server.profile = profile;
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
save_server(&server);
|
||||
persist_servers(&app_handle).expect("Failed to persist coco servers.");
|
||||
|
||||
|
||||
//refresh connectors and datasources
|
||||
if let Err(err) = refresh_all_connectors(&app_handle).await {
|
||||
return Err(format!("Failed to load server connectors: {}", err))
|
||||
}
|
||||
|
||||
if let Err(err) = refresh_all_datasources(&app_handle).await {
|
||||
return Err(format!("Failed to load server datasources: {}", err))
|
||||
}
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => {
|
||||
Err(format!("Failed to deserialize the response: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
} else {
|
||||
Err("Server not found.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
endpoint: String,
|
||||
) -> Result<Server, String> {
|
||||
load_or_insert_default_server(&app_handle).await
|
||||
.expect("Failed to load default servers");
|
||||
|
||||
// Remove the trailing '/' from the endpoint to ensure correct URL construction
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
// Check if the server with this endpoint already exists
|
||||
if check_endpoint_exists(endpoint) {
|
||||
dbg!(format!("This Coco server has already been registered: {:?}", &endpoint));
|
||||
return Err("This Coco server has already been registered.".into());
|
||||
}
|
||||
|
||||
let url = provider_info_url(&endpoint);
|
||||
|
||||
// Use the HttpClient to fetch provider information
|
||||
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
dbg!(format!("Get provider info response: {:?}", &response));
|
||||
|
||||
// Check if the response status is OK (200)
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
// Perform necessary checks and adjustments on the server data
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
|
||||
if server.id.is_empty() {
|
||||
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
|
||||
}
|
||||
|
||||
if server.name.is_empty() {
|
||||
server.name = "Coco Cloud".to_string();
|
||||
}
|
||||
|
||||
// Save the new server to the cache
|
||||
save_server(&server);
|
||||
|
||||
// Persist the servers to the store
|
||||
persist_servers(&app_handle)
|
||||
.expect("Failed to persist Coco servers.");
|
||||
|
||||
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => {
|
||||
Err(format!("Failed to deserialize the response: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), ()> {
|
||||
remove_server_token(id.as_str());
|
||||
remove_server_by_id(id);
|
||||
persist_servers(&app_handle).expect("failed to save servers");
|
||||
persist_servers_token(&app_handle).expect("failed to save server tokens");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn logout_coco_server<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
|
||||
dbg!("Attempting to log out server by id:", &id);
|
||||
|
||||
// Check if server token exists
|
||||
if let Some(token) = get_server_token(id.as_str()) {
|
||||
dbg!("Found server token for id:", &id);
|
||||
|
||||
// Remove the server token from cache
|
||||
remove_server_token(id.as_str());
|
||||
|
||||
// Persist the updated tokens
|
||||
if let Err(e) = persist_servers_token(&app_handle) {
|
||||
dbg!("Failed to save tokens for id: {}. Error: {:?}", &id, &e);
|
||||
return Err(format!("Failed to save tokens: {}", &e));
|
||||
}
|
||||
} else {
|
||||
// Log the case where server token is not found
|
||||
dbg!("No server token found for id: {}", &id);
|
||||
}
|
||||
|
||||
// Check if the server exists
|
||||
if let Some(mut server) = get_server_by_id(id.as_str()) {
|
||||
dbg!("Found server for id:", &id);
|
||||
|
||||
// Clear server profile
|
||||
server.profile = None;
|
||||
|
||||
// Save the updated server data
|
||||
save_server(&server);
|
||||
|
||||
// Persist the updated server data
|
||||
if let Err(e) = persist_servers(&app_handle) {
|
||||
dbg!("Failed to save server for id: {}. Error: {:?}", &id, &e);
|
||||
return Err(format!("Failed to save server: {}", &e));
|
||||
}
|
||||
} else {
|
||||
// Log the case where server is not found
|
||||
dbg!("No server found for id: {}", &id);
|
||||
return Err(format!("No server found for id: {}", id));
|
||||
}
|
||||
|
||||
dbg!("Successfully logged out server with id:", &id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the trailing slash from the server's endpoint if present.
|
||||
fn trim_endpoint_last_forward_slash(server: &mut Server) {
|
||||
if server.endpoint.ends_with('/') {
|
||||
server.endpoint.pop(); // Remove the last character
|
||||
while server.endpoint.ends_with('/') {
|
||||
server.endpoint.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to construct the provider info URL.
|
||||
fn provider_info_url(endpoint: &str) -> String {
|
||||
format!("{endpoint}/provider/_info")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_endpoint_last_forward_slash() {
|
||||
let mut server = Server {
|
||||
id: "test".to_string(),
|
||||
builtin: false,
|
||||
name: "".to_string(),
|
||||
endpoint: "https://example.com///".to_string(),
|
||||
provider: Provider {
|
||||
name: "".to_string(),
|
||||
icon: "".to_string(),
|
||||
website: "".to_string(),
|
||||
eula: "".to_string(),
|
||||
privacy_policy: "".to_string(),
|
||||
banner: "".to_string(),
|
||||
description: "".to_string(),
|
||||
},
|
||||
version: Version {
|
||||
number: "".to_string(),
|
||||
},
|
||||
updated: "".to_string(),
|
||||
public: false,
|
||||
available: false,
|
||||
profile: None,
|
||||
auth_provider: AuthProvider {
|
||||
sso: Sso {
|
||||
url: "".to_string(),
|
||||
},
|
||||
},
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
|
||||
assert_eq!(server.endpoint, "https://example.com");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use tauri::App;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
@@ -8,9 +9,6 @@ use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
/// Tauri store name
|
||||
const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||
|
||||
/// Tauri's store is a key-value database, we use it to store our registered
|
||||
/// global shortcut.
|
||||
///
|
||||
|
||||
0
src-tauri/src/util/mod.rs
Normal file
0
src-tauri/src/util/mod.rs
Normal file
27
src/App.tsx
27
src/App.tsx
@@ -1,27 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import "./i18n";
|
||||
import CommandInput from "./components/CommandInput";
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-background
|
||||
w-screen h-screen
|
||||
`}
|
||||
>
|
||||
<main className="w-[100%] h-[100%] flex flex-col items-center justify-center">
|
||||
<div className="text-xl text-primary">{t("welcome")}</div>
|
||||
|
||||
<div className="mx-0 mt-5 w-[100%]">
|
||||
<CommandInput />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -60,7 +60,7 @@ export const tauriFetch = async <T = any>({
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || (auth && auth[endpoint_http]?.token) || "";
|
||||
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || (auth && auth[endpoint_http]?.token) || undefined;
|
||||
|
||||
// debug API
|
||||
const requestInfo = {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1,435 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CircleAlert,
|
||||
Bolt,
|
||||
X,
|
||||
SquareArrowRight,
|
||||
// UserRoundPen,
|
||||
} from "lucide-react";
|
||||
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import source_default_img from "@/assets/images/source_default.png";
|
||||
import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import file_efault_img from "@/assets/images/file_efault.png";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
type ISearchData = Record<string, any[]>;
|
||||
|
||||
interface DropdownListProps {
|
||||
selected: (item: any) => void;
|
||||
suggests: any[];
|
||||
SearchData: ISearchData;
|
||||
IsError: boolean;
|
||||
isSearchComplete: boolean;
|
||||
isChatMode: boolean;
|
||||
}
|
||||
|
||||
function DropdownList({
|
||||
selected,
|
||||
suggests,
|
||||
SearchData,
|
||||
IsError,
|
||||
isChatMode,
|
||||
}: DropdownListProps) {
|
||||
let globalIndex = 0;
|
||||
const globalItemIndexMap: any[] = [];
|
||||
// const letterFirstIndex: any = {
|
||||
// a: 0,
|
||||
// s: 0,
|
||||
// d: 0,
|
||||
// f: 0,
|
||||
// };
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const connector_data = useConnectStore((state) => state.connector_data);
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
const setSourceData = useSearchStore((state) => state.setSourceData);
|
||||
|
||||
const [showError, setShowError] = useState<boolean>(IsError);
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [selectedName, setSelectedName] = useState<string>("");
|
||||
const [showIndex, setShowIndex] = useState<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
isChatMode && setSelectedItem(null);
|
||||
}, [isChatMode]);
|
||||
|
||||
const handleOpenURL = async (url: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await open(url);
|
||||
// console.log("URL opened in default browser");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// console.log(
|
||||
// "handleKeyDown",
|
||||
// e.key,
|
||||
// showIndex,
|
||||
// e.key >= "0" && e.key <= "9" && showIndex
|
||||
// );
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) => {
|
||||
const res =
|
||||
prev === null || prev === 0 ? suggests.length - 1 : prev - 1;
|
||||
|
||||
return res;
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) =>
|
||||
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
|
||||
);
|
||||
} else if (e.key === "Meta") {
|
||||
e.preventDefault();
|
||||
if (selectedItem !== null) {
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
setSelectedName(item?._source?.source?.name);
|
||||
}
|
||||
setShowIndex(true);
|
||||
}
|
||||
|
||||
if (e.key === "ArrowRight" && selectedItem !== null) {
|
||||
e.preventDefault();
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
goToTwoPage(item);
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
// console.log("Enter key pressed", selectedItem);
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key >= "0" && e.key <= "9" && showIndex) {
|
||||
// console.log(`number ${e.key}`);
|
||||
const item = globalItemIndexMap[parseInt(e.key, 10)];
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
// console.log("handleKeyUp", e.key);
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (!e.metaKey) {
|
||||
setShowIndex(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [showIndex, selectedItem, suggests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem !== null && itemRefs.current[selectedItem]) {
|
||||
itemRefs.current[selectedItem]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
function findConnectorIcon(item: any) {
|
||||
const id = item?._source?.source?.id || "";
|
||||
|
||||
const result_source = datasourceData[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === id
|
||||
);
|
||||
|
||||
const connector_id = result_source?._source?.connector?.id;
|
||||
|
||||
const result_connector = connector_data[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector?._source;
|
||||
}
|
||||
|
||||
function getTypeIcon(item: any) {
|
||||
const connectorSource = findConnectorIcon(item);
|
||||
const icons = connectorSource?.icon;
|
||||
|
||||
if (!icons) {
|
||||
return theme === "dark" ? source_default_dark_img : source_default_img;
|
||||
}
|
||||
|
||||
if (icons?.startsWith("http://") || icons?.startsWith("https://")) {
|
||||
return icons;
|
||||
} else {
|
||||
return endpoint_http + icons;
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(item: any) {
|
||||
const connectorSource = findConnectorIcon(item);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
|
||||
const selectedIcon = icons[item?._source?.icon];
|
||||
|
||||
if (!selectedIcon) {
|
||||
return file_efault_img;
|
||||
}
|
||||
|
||||
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
|
||||
return selectedIcon;
|
||||
} else {
|
||||
return endpoint_http + selectedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function getRichIcon(item: any) {
|
||||
const connectorSource = findConnectorIcon(item);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
|
||||
const selectedIcon = icons[item?._source?.rich_categories?.[0]?.icon];
|
||||
|
||||
if (!selectedIcon) {
|
||||
return theme === "dark" ? source_default_dark_img : source_default_img;
|
||||
}
|
||||
|
||||
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
|
||||
return selectedIcon;
|
||||
} else {
|
||||
return endpoint_http + selectedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function goToTwoPage(item: any) {
|
||||
setSourceData(item);
|
||||
selected && selected(item);
|
||||
}
|
||||
|
||||
// function numberToLetter(num: number): string {
|
||||
// const mapping = ["A", "S", "D", "F"];
|
||||
// if (num >= 0 && num < mapping.length) {
|
||||
// const letter = mapping[num];
|
||||
// letterFirstIndex[letter.toLocaleLowerCase()] = globalIndex
|
||||
// return letter
|
||||
// } else {
|
||||
// return "";
|
||||
// }
|
||||
// }
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
{showError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[#333] p-2">
|
||||
<CircleAlert className="text-[#FF0000] w-[14px] h-[14px]" />
|
||||
Coco server is unavailable, only local results and available services
|
||||
are displayed.
|
||||
<Bolt className="text-[#000] w-[14px] h-[14px] cursor-pointer" />
|
||||
<X
|
||||
className="text-[#666] w-[16px] h-[16px] cursor-pointer"
|
||||
onClick={() => setShowError(false)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.entries(SearchData).map(([sourceName, items]) => (
|
||||
<div key={sourceName}>
|
||||
{Object.entries(SearchData).length < 5 ? (
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
||||
<img className="w-4 h-4" src={getTypeIcon(items[0])} alt="icon" />
|
||||
{sourceName}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
<SquareArrowRight
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => goToTwoPage(items[0])}
|
||||
/>
|
||||
{showIndex && sourceName === selectedName ? (
|
||||
<div
|
||||
className={`absolute right-2
|
||||
w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item: any) => {
|
||||
const isSelected = selectedItem === globalIndex;
|
||||
const currentIndex = globalIndex;
|
||||
globalItemIndexMap.push(item);
|
||||
globalIndex++;
|
||||
return (
|
||||
<div
|
||||
key={item._id}
|
||||
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
onMouseEnter={() => setSelectedItem(currentIndex)}
|
||||
onClick={() => {
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-2 py-2.5 text-sm flex gap-7 items-center justify-between rounded-lg transition-colors ${
|
||||
isSelected
|
||||
? "text-white bg-[#950599] hover:bg-[#950599]"
|
||||
: "text-[#333] dark:text-[#d8d8d8]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center justify-start max-w-[450px]">
|
||||
<img className="w-5 h-5" src={getIcon(item)} alt="icon" />
|
||||
<span
|
||||
className={`text-sm truncate text-left ${
|
||||
isSelected ? "font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{item?._source?.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
|
||||
{Object.entries(SearchData).length < 5 ? null : (
|
||||
<img
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
src={getTypeIcon(item)}
|
||||
alt="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(item);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item?._source?.rich_categories ? (
|
||||
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
|
||||
{Object.entries(SearchData).length < 5 ? (
|
||||
<img
|
||||
className="w-4 h-4 mr-2 cursor-pointer"
|
||||
src={getRichIcon(item)}
|
||||
alt="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(item);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={`${
|
||||
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
|
||||
} text-right truncate`}
|
||||
>
|
||||
{item?._source?.rich_categories?.map(
|
||||
(rich_item: any, index: number) => {
|
||||
if (
|
||||
item?._source?.rich_categories.length > 2 &&
|
||||
index ===
|
||||
item?._source?.rich_categories.length - 1
|
||||
)
|
||||
return "";
|
||||
else
|
||||
return (
|
||||
(index !== 0 ? "/" : "") + rich_item?.label
|
||||
);
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{item?._source?.rich_categories.length > 2 ? (
|
||||
<span
|
||||
className={`${
|
||||
isSelected ? "text-[#C8C8C8]" : "text-[#666]"
|
||||
} text-right truncate`}
|
||||
>
|
||||
{"/" + item?._source?.rich_categories?.at(-1)?.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : item?._source?.category || item?._source?.subcategory ? (
|
||||
<span
|
||||
className={`text-[12px] truncate ${
|
||||
isSelected
|
||||
? "text-[#DCDCDC]"
|
||||
: "text-[#999] dark:text-[#666]"
|
||||
}`}
|
||||
>
|
||||
{(item?._source?.category || "") +
|
||||
(item?._source?.subcategory
|
||||
? `/${item?._source?.subcategory}`
|
||||
: "")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`text-[12px] truncate ${
|
||||
isSelected
|
||||
? "text-[#DCDCDC]"
|
||||
: "text-[#999] dark:text-[#666]"
|
||||
}`}
|
||||
>
|
||||
{item?._source?.type || ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isSelected ? (
|
||||
<div
|
||||
className={`absolute ${
|
||||
showIndex && currentIndex < 10 ? "right-7" : "right-0"
|
||||
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
|
||||
isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
↩︎
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showIndex && currentIndex < 10 ? (
|
||||
<div
|
||||
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
|
||||
isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
{currentIndex}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownList;
|
||||
@@ -234,7 +234,7 @@ const ChatAI = forwardRef<ChatAIRef, ChatAIProps>(
|
||||
skipTaskbar: true,
|
||||
decorations: true,
|
||||
closable: true,
|
||||
url: "/ui/chat",
|
||||
url: "/ui/app/chat",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
RefreshCcw,
|
||||
Globe,
|
||||
PackageOpen,
|
||||
GitFork,
|
||||
CalendarSync,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
onOpenUrl,
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ConnectService } from "./ConnectService";
|
||||
import { OpenBrowserURL } from "@/utils/index";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
|
||||
export default function CocoCloud() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
const app_uid = useAppStore((state) => state.app_uid);
|
||||
const setAppUid = useAppStore((state) => state.setAppUid);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const endpoint = useAppStore((state) => state.endpoint);
|
||||
|
||||
const auth = useAuthStore((state) => state.auth);
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const userInfo = useAuthStore((state) => state.userInfo);
|
||||
const setUserInfo = useAuthStore((state) => state.setUserInfo);
|
||||
const defaultService = useConnectStore((state) => state.defaultService);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setDefaultService = useConnectStore((state) => state.setDefaultService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const deleteOtherService = useConnectStore(
|
||||
(state) => state.deleteOtherService
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("currentService", currentService);
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
setError(null);
|
||||
setEndpoint(currentService.endpoint);
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
const getProfile = useCallback(async () => {
|
||||
const response: any = await tauriFetch({
|
||||
url: `/account/profile`,
|
||||
method: "GET",
|
||||
});
|
||||
console.log("getProfile", response);
|
||||
setUserInfo(response.data || {}, endpoint);
|
||||
}, [endpoint]);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, provider: string | null) => {
|
||||
if (!code) {
|
||||
setError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, provider });
|
||||
const response: any = await tauriFetch({
|
||||
url: `/auth/request_access_token?request_id=${app_uid}`,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-TOKEN": code,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
"response",
|
||||
`/auth/request_access_token?request_id=${app_uid}`,
|
||||
code,
|
||||
response
|
||||
);
|
||||
|
||||
if (response.data?.access_token) {
|
||||
setAuth(
|
||||
{
|
||||
token: response.data?.access_token,
|
||||
expires: response.data?.expire_at,
|
||||
plan: { upgraded: false, last_checked: 0 },
|
||||
},
|
||||
endpoint
|
||||
);
|
||||
|
||||
getProfile();
|
||||
} else {
|
||||
setAuth(undefined, endpoint);
|
||||
setError("Sign in failed: " + response.data?.error?.reason);
|
||||
}
|
||||
|
||||
getCurrentWindow()
|
||||
.setFocus()
|
||||
.catch(() => {});
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", error);
|
||||
setError("Sign in failed: catch");
|
||||
setAuth(undefined, endpoint);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[app_uid, endpoint]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
// url = "coco://oauth_callback?code=cu8ag982sdb06e0j6k3g&provider=coco-cloud"
|
||||
const urlObject = new URL(url);
|
||||
console.log("urlObject:", urlObject);
|
||||
|
||||
const code = urlObject.searchParams.get("code");
|
||||
const provider = urlObject.searchParams.get("provider");
|
||||
handleOAuthCallback(code, provider);
|
||||
|
||||
// switch (urlObject.hostname) {
|
||||
// case "/oauth_callback":
|
||||
|
||||
// break;
|
||||
|
||||
// default:
|
||||
// console.log("Unhandled deep link path:", urlObject.pathname);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
setError("Invalid URL format");
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
useEffect(() => {
|
||||
// handleUrl("");
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
setError("Failed to get initial URLs");
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, [app_uid]);
|
||||
|
||||
const LoginClick = useCallback(() => {
|
||||
if (loading) return;
|
||||
setAuth(undefined, endpoint);
|
||||
|
||||
let uid = uuidv4();
|
||||
setAppUid(uid);
|
||||
|
||||
console.log("LoginClick", uid, currentService.auth_provider.sso.url);
|
||||
|
||||
OpenBrowserURL(
|
||||
`${currentService.auth_provider.sso.url}/?provider=coco-cloud&product=coco&request_id=${uid}`
|
||||
);
|
||||
|
||||
setLoading(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
function goToHref(url: string) {
|
||||
OpenBrowserURL(url);
|
||||
}
|
||||
|
||||
const refreshClick = useCallback(() => {
|
||||
setRefreshLoading(true);
|
||||
tauriFetch({
|
||||
url: `/provider/_info`,
|
||||
method: "GET",
|
||||
})
|
||||
.then((res) => {
|
||||
setEndpoint(res.data.endpoint);
|
||||
setCurrentService(res.data || {});
|
||||
if (res.data?.endpoint === "https://coco.infini.cloud/") {
|
||||
setDefaultService(res.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}, [JSON.stringify(defaultService)]);
|
||||
|
||||
function addService() {
|
||||
setIsConnect(false);
|
||||
}
|
||||
|
||||
const deleteClick = useCallback(() => {
|
||||
deleteOtherService(currentService);
|
||||
setAuth(undefined, endpoint);
|
||||
setUserInfo({}, endpoint);
|
||||
}, [JSON.stringify(currentService), endpoint]);
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar addService={addService} />
|
||||
|
||||
<main className="flex-1 p-4 py-8">
|
||||
{/* <div>
|
||||
{error && (
|
||||
<div className="text-red-500 dark:text-red-400 mb-4">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div> */}
|
||||
|
||||
{isConnect ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
||||
<img
|
||||
width="100%"
|
||||
src={currentService.provider.banner || bannerImg}
|
||||
alt="banner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium">
|
||||
{currentService.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => goToHref(currentService.provider.website)}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => refreshClick()}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${
|
||||
refreshLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{currentService.endpoint !== defaultService.endpoint ? (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => deleteClick()}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
|
||||
<span className="flex items-center gap-1">
|
||||
<PackageOpen className="w-4 h-4" />{" "}
|
||||
{currentService.provider.name}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-4 h-4" />{" "}
|
||||
{currentService.version.number}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarSync className="w-4 h-4" /> {currentService.updated}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{currentService.provider.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentService.auth_provider.sso.url ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Account Information
|
||||
</h2>
|
||||
{auth && auth[endpoint] ? (
|
||||
<UserProfile userInfo={userInfo[endpoint]} />
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
||||
onClick={LoginClick}
|
||||
>
|
||||
{loading ? "Login..." : "Login"}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
goToHref(currentService.provider.privacy_policy)
|
||||
}
|
||||
>
|
||||
EULA | Privacy Policy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{auth && auth[endpoint] ? <DataSourcesList /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<ConnectService setIsConnect={setIsConnect} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface ConnectServiceProps {
|
||||
setIsConnect: (isConnect: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConnectService({ setIsConnect }: ConnectServiceProps) {
|
||||
const addOtherServices = useConnectStore((state) => state.addOtherServices);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const defaultService = useConnectStore((state) => state.defaultService);
|
||||
const otherServices = useConnectStore((state) => state.otherServices);
|
||||
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
|
||||
const [endpointLink, setEndpointLink] = useState("");
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
console.log("Connecting Google Drive with name:", endpointLink);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
const addService = useCallback(() => {
|
||||
if (!endpointLink) return;
|
||||
if (!endpointLink.startsWith("http://") && !endpointLink.startsWith("https://")) {
|
||||
return
|
||||
}
|
||||
setRefreshLoading(true);
|
||||
//
|
||||
let baseURL = endpointLink;
|
||||
if (baseURL.endsWith("/")) {
|
||||
baseURL = baseURL.slice(0, -1);
|
||||
}
|
||||
|
||||
tauriFetch({
|
||||
url: `${baseURL}/provider/_info`,
|
||||
method: "GET",
|
||||
})
|
||||
.then((res) => {
|
||||
if (
|
||||
res.data?.endpoint === defaultService.endpoint ||
|
||||
otherServices.some(
|
||||
(item: any) => item.endpoint === res.data?.endpoint
|
||||
)
|
||||
) {
|
||||
console.error(`${res.data?.endpoint} Repeated`);
|
||||
} else {
|
||||
addOtherServices(res.data);
|
||||
setCurrentService(res.data);
|
||||
setEndpoint(res.data.endpoint);
|
||||
setIsConnect(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}, [endpointLink]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<button
|
||||
className=" text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-[rgba(228,229,239,1)] dark:border-gray-700 p-1"
|
||||
onClick={goBack}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-xl text-[#101010] dark:text-white">
|
||||
Connecting to third-party services
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Third-party services are provided by other platforms or providers, and
|
||||
users can integrate these services into Coco AI to expand the scope of
|
||||
search data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="endpoint"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2.5"
|
||||
>
|
||||
Server address
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="endpoint"
|
||||
value={endpointLink}
|
||||
placeholder="For example: https://coco.infini.cloud/"
|
||||
onChange={(e) => setEndpointLink(e.target.value)}
|
||||
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
onClick={addService}
|
||||
>
|
||||
{refreshLoading ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { DataSourceItem } from "./DataSourceItem";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
export function DataSourcesList() {
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
|
||||
async function getDatasourceData() {
|
||||
setRefreshLoading(true);
|
||||
try {
|
||||
const response = await tauriFetch({
|
||||
url: `/datasource/_search`,
|
||||
method: "GET",
|
||||
});
|
||||
console.log("datasource", response);
|
||||
const data = response.data?.hits?.hits || [];
|
||||
setDatasourceData(data, endpoint_http);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data:", error);
|
||||
}
|
||||
setRefreshLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getDatasourceData()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
|
||||
Data Source
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => getDatasourceData()}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{datasourceData[endpoint_http]?.map((source) => (
|
||||
<DataSourceItem key={source._id} {...source._source} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (email: string, password: string) => void;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSubmit }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(email, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import cocoLogoImg from "@/assets/app-icon.png";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface SidebarProps {
|
||||
addService: () => void;
|
||||
}
|
||||
|
||||
type StringBooleanMap = {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
|
||||
export function Sidebar({ addService }: SidebarProps) {
|
||||
const defaultService = useConnectStore((state) => state.defaultService);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const otherServices = useConnectStore((state) => state.otherServices);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
|
||||
const [defaultHealth, setDefaultHealth] = useState(false);
|
||||
const [otherHealth, setOtherHealth] = useState<StringBooleanMap>({});
|
||||
|
||||
const addServiceClick = () => {
|
||||
addService();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDefaultHealth();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getOtherHealth(currentService);
|
||||
setEndpoint(currentService.endpoint);
|
||||
}, [currentService.endpoint]);
|
||||
|
||||
const getDefaultHealth = () => {
|
||||
let baseURL = defaultService.endpoint
|
||||
if (baseURL.endsWith("/")) {
|
||||
baseURL = baseURL.slice(0, -1);
|
||||
}
|
||||
tauriFetch({
|
||||
url: `${baseURL}/health`,
|
||||
method: "GET",
|
||||
})
|
||||
.then((res) => {
|
||||
// "services": {
|
||||
// "system_cluster": "yellow"
|
||||
// },
|
||||
// "status": "yellow"
|
||||
setDefaultHealth(res.data?.status !== "red");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const getOtherHealth = (item: any) => {
|
||||
if (!item.endpoint) return;
|
||||
//
|
||||
let baseURL = item.endpoint
|
||||
if (baseURL.endsWith("/")) {
|
||||
baseURL = baseURL.slice(0, -1);
|
||||
}
|
||||
tauriFetch({
|
||||
url: `${baseURL}/health`,
|
||||
method: "GET",
|
||||
})
|
||||
.then((res) => {
|
||||
let obj = {
|
||||
...otherHealth,
|
||||
[item.endpoint]: res.data?.status !== "red",
|
||||
};
|
||||
setOtherHealth(obj);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="p-4 py-8">
|
||||
<div
|
||||
className={`flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-6 ${
|
||||
currentService.endpoint === defaultService.endpoint
|
||||
? "border border-[rgba(0,135,255,1)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setCurrentService(defaultService);
|
||||
setEndpoint(defaultService.endpoint);
|
||||
getDefaultHealth();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={defaultService.provider.icon || cocoLogoImg}
|
||||
alt="cocoLogoImg"
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
|
||||
<span className="font-medium">{defaultService.name}</span>
|
||||
<div className="flex-1" />
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{defaultHealth ? (
|
||||
<div className="w-3 h-3 rounded-full bg-[#00DB5E]"></div>
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF4747]"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Third-party services
|
||||
</div>
|
||||
|
||||
{otherServices?.map((item, index) => (
|
||||
<div
|
||||
key={item.name + index}
|
||||
className={`flex items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
|
||||
currentService.endpoint === item.endpoint
|
||||
? "border border-[rgba(0,135,255,1)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setEndpoint(item.endpoint);
|
||||
setCurrentService(item);
|
||||
getOtherHealth(item);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.provider.icon || cocoLogoImg}
|
||||
alt="LogoImg"
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<div className="flex-1" />
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{otherHealth[item.endpoint] ? (
|
||||
<div className="w-3 h-3 rounded-full bg-[#00DB5E]"></div>
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF4747]"></div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
onClick={addServiceClick}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
export default `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<title>Coco Auth</title>
|
||||
<style>
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.container {
|
||||
padding: 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.logo {
|
||||
width: 130px;
|
||||
height: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
font-size: 21px;
|
||||
line-height: 26px;
|
||||
color: #12161F;
|
||||
margin: 0;
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
margin-top: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Coco<h1>
|
||||
<p id="message">You are now signed in. Please re-open the Coco desktop app to continue.</p>
|
||||
<div id="error-container"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Github, Mail, Apple } from 'lucide-react';
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { LoginForm } from '@/components/Auth/LoginForm';
|
||||
import { SocialButton } from '@/components/Auth/SocialButton';
|
||||
import { Divider } from '@/components/Auth/Divider';
|
||||
import { authWitheGithub } from '@/utils/index';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const uid = searchParams.get("uid");
|
||||
const code = searchParams.get("code");
|
||||
|
||||
useEffect(()=>{
|
||||
|
||||
}, [code])
|
||||
|
||||
function GithubClick() {
|
||||
uid && authWitheGithub(uid)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md bg-gray-800 rounded-xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Welcome Back</h1>
|
||||
<p className="text-gray-400">Sign in to continue to Coco</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<SocialButton
|
||||
icon={<Github className="w-5 h-5" />}
|
||||
provider="GitHub"
|
||||
onClick={() => GithubClick()}
|
||||
/>
|
||||
<SocialButton
|
||||
icon={<Mail className="w-5 h-5" />}
|
||||
provider="Google"
|
||||
onClick={() => console.log('Google login')}
|
||||
/>
|
||||
<SocialButton
|
||||
icon={<Apple className="w-5 h-5" />}
|
||||
provider="Apple"
|
||||
onClick={() => console.log('Apple login')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<LoginForm onSubmit={(email, password) => console.log(email, password)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
// import { invoke } from "@tauri-apps/api/core";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import debounce from "lodash/debounce";
|
||||
import { Textarea } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
import SendIcon from "../icons/Send";
|
||||
|
||||
export default function ChatInput() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const [info, setInfo] = useState("");
|
||||
const isMac = true;
|
||||
|
||||
useEffect(() => {
|
||||
const syncMessage = debounce(async () => {
|
||||
try {
|
||||
// await invoke("ask_sync", { message: JSON.stringify(message) });
|
||||
} catch (error) {
|
||||
console.error("Error syncing message:", error);
|
||||
}
|
||||
}, 300); // Debounce by 300ms
|
||||
|
||||
syncMessage();
|
||||
return () => syncMessage.cancel(); // Cleanup debounce on unmount
|
||||
}, [message]);
|
||||
|
||||
useHotkeys(
|
||||
isMac ? "meta+enter" : "ctrl+enter",
|
||||
async (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
await handleSend();
|
||||
},
|
||||
{
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
[message]
|
||||
);
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInfo("");
|
||||
setMessage(e.target.value);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message) return;
|
||||
try {
|
||||
// await invoke("ask_send", { message: JSON.stringify(message) });
|
||||
|
||||
// Send a GET request
|
||||
const response = await fetch("https://test.tauri.app/data.json", {
|
||||
method: "GET",
|
||||
});
|
||||
setInfo(JSON.stringify(response));
|
||||
// console.log(response.status); // e.g. 200
|
||||
// console.log(response.statusText); // e.g. "OK"
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
setInfo(JSON.stringify(error));
|
||||
}
|
||||
setMessage("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = "";
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[100%] h-[100%]">
|
||||
<div>{info}</div>
|
||||
<div className="relative flex dark:bg-app-gray-2/[0.98] dark:text-slate-200 items-center gap-1">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
onChange={handleInput}
|
||||
spellCheck="false"
|
||||
autoFocus
|
||||
className={clsx(
|
||||
"mt-3 block w-full resize-none rounded-xl border border-transparent bg-white/10",
|
||||
"py-3 px-4 text-sm text-black placeholder-gray-500 shadow-md",
|
||||
// Transition for smoother appearance changes
|
||||
"transition-colors duration-300 ease-in-out",
|
||||
// Dark mode styles
|
||||
"dark:bg-gray-800 dark:text-white dark:placeholder-gray-400",
|
||||
"focus:border-blue-400 focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50",
|
||||
"dark:focus:border-white dark:focus:ring-white/25",
|
||||
"focus:outline-none"
|
||||
)}
|
||||
placeholder={t("InputMessage")}
|
||||
/>
|
||||
<SendIcon
|
||||
size={30}
|
||||
className="absolute right-2 text-gray-400/80 dark:text-gray-600 cursor-pointer"
|
||||
onClick={handleSend}
|
||||
title={`Send message (${isMac ? "⌘⏎" : "⌃⏎"})`}
|
||||
aria-label="Send message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
476
src/components/Cloud/Cloud.tsx
Normal file
476
src/components/Cloud/Cloud.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
RefreshCcw,
|
||||
Globe,
|
||||
PackageOpen,
|
||||
GitFork,
|
||||
CalendarSync,
|
||||
Trash2,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
onOpenUrl,
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { UserProfile } from "./UserProfile";
|
||||
import { DataSourcesList } from "./DataSourcesList";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Connect } from "./Connect.tsx";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
|
||||
export default function Cloud() {
|
||||
const SidebarRef = useRef<{ refreshData: () => void; }>(null);
|
||||
|
||||
// const [error, setError] = useState<string | null>(null);
|
||||
const error = useAppStore((state) => state.error);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
// const [ssoRequestID, setSSORequestID] = useState("");
|
||||
const ssoRequestID = useAppStore((state) => state.ssoRequestID);
|
||||
const setSSORequestID = useAppStore((state) => state.setSSORequestID);
|
||||
|
||||
// const ssoServerID = useAppStore((state) => state.ssoServerID);
|
||||
// const setSSOServerID = useAppStore((state) => state.setSSOServerID);
|
||||
|
||||
const endpoint = useAppStore((state) => state.endpoint);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const serverList = useConnectStore((state) => state.serverList);
|
||||
const setServerList = useConnectStore((state) => state.setServerList);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
// const [profiles, setProfiles] = useState<any>({});
|
||||
// const [userInfo, setUserInfo] = useState<any>({});
|
||||
|
||||
|
||||
|
||||
//fetch the servers
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("currentService", currentService);
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
setError("");
|
||||
// setEndpoint(currentService.endpoint);
|
||||
setIsConnect(true);
|
||||
// setUserInfo(profiles[endpoint] || {})
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
// const get_user_profiles = useCallback(() => {
|
||||
// invoke("get_user_profiles")
|
||||
// .then((res: any) => {
|
||||
// console.log("get_user_profiles", res);
|
||||
// setProfiles(res);
|
||||
// console.log("setUserInfo", res[endpoint]);
|
||||
// setUserInfo(res[endpoint] || {})
|
||||
// })
|
||||
// .catch((err: any) => {
|
||||
// console.error(err);
|
||||
// });
|
||||
// }, [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
// get_user_profiles()
|
||||
}, [])
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
invoke("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
console.log("list_coco_servers", res);
|
||||
setServerList(res);
|
||||
if (resetSelection && res.length > 0) {
|
||||
console.log("setCurrentService", res[res.length - 1]);
|
||||
setCurrentService(res[res.length - 1]);
|
||||
} else {
|
||||
console.warn("Service list is empty or last item has no id");
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
const add_coco_server = (endpointLink: string) => {
|
||||
if (!endpointLink) {
|
||||
throw new Error('Endpoint is required');
|
||||
}
|
||||
if (!endpointLink.startsWith("http://") && !endpointLink.startsWith("https://")) {
|
||||
throw new Error('Invalid Endpoint');
|
||||
}
|
||||
|
||||
setRefreshLoading(true);
|
||||
|
||||
return invoke("add_coco_server", { endpoint: endpointLink })
|
||||
.then((res: any) => {
|
||||
console.log("add_coco_server", res);
|
||||
fetchServers(false)
|
||||
.then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("fetchServers failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up to outer promise chain
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// Handle the invoke error
|
||||
console.error("add coco server failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
|
||||
if (!code) {
|
||||
setError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await invoke("handle_sso_callback", {
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code
|
||||
});
|
||||
|
||||
if (serverId != null) {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow()
|
||||
.setFocus()
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
setError("SSO login failed: " + e);
|
||||
// setAuth(undefined, endpoint);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[ssoRequestID, endpoint]
|
||||
);
|
||||
|
||||
const handleUrl = (url: string) => {
|
||||
try {
|
||||
// url = "coco://oauth_callback?code=cuhhi8o2sdbbbcoe0g10ktmht6aky3jmd4xkwsgvzf748i4zdgr898bfeu3kze7ffdusdtbgtnpke8ng3fe6&provider=coco-cloud/"
|
||||
const urlObject = new URL(url);
|
||||
console.log("handle urlObject:", urlObject);
|
||||
|
||||
//TODO, pass request_id and check with local, if the request_id are same, then continue
|
||||
const reqId = urlObject.searchParams.get("request_id");
|
||||
const code = urlObject.searchParams.get("code");
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
setError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = currentService?.id;
|
||||
handleOAuthCallback(code, serverId);
|
||||
|
||||
// switch (urlObject.hostname) {
|
||||
// case "/oauth_callback":
|
||||
|
||||
// break;
|
||||
|
||||
// default:
|
||||
// console.log("Unhandled deep link path:", urlObject.pathname);
|
||||
// }
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
setError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Fetch the initial deep link intent
|
||||
useEffect(() => {
|
||||
// Function to handle pasted URL
|
||||
const handlePaste = (event: any) => {
|
||||
const pastedText = event.clipboardData.getData('text');
|
||||
console.log("handle paste text:", pastedText);
|
||||
if (isValidCallbackUrl(pastedText)) {
|
||||
// Handle the URL as if it's a deep link
|
||||
console.log("handle callback on paste:", pastedText);
|
||||
handleUrl(pastedText);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if the pasted URL is valid for our deep link scheme
|
||||
const isValidCallbackUrl = (url: string) => {
|
||||
return url && url.startsWith('coco://oauth_callback');
|
||||
};
|
||||
|
||||
// Adding event listener for paste events
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
getCurrentDeepLinkUrls()
|
||||
.then((urls) => {
|
||||
console.log("URLs:", urls);
|
||||
if (urls && urls.length > 0) {
|
||||
if (isValidCallbackUrl(urls[0])) {
|
||||
handleUrl(urls[0]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
setError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [ssoRequestID]);
|
||||
|
||||
// const generateLogin = () => {
|
||||
// const requestID = uuidv4();
|
||||
// setSSORequestID(requestID);
|
||||
// setSSOServerID(currentService?.id); // Set server ID
|
||||
//
|
||||
// // The URL is now updated when ssoRequestID and ssoServerID are both set
|
||||
// };
|
||||
|
||||
const LoginClick = useCallback(() => {
|
||||
if (loading) return; // Prevent multiple clicks if already loading
|
||||
|
||||
// If the appUid doesn't exist, generate one
|
||||
// if (!ssoRequestID) {
|
||||
let requestID = uuidv4();
|
||||
setSSORequestID(requestID);
|
||||
// setSSOServerID(currentService?.id);
|
||||
// }
|
||||
|
||||
// Generate the login URL with the current appUid
|
||||
const url = `${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${requestID}`;
|
||||
|
||||
console.log("Open SSO link, requestID:", ssoRequestID, url);
|
||||
|
||||
// Open the URL in a browser
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
// Start loading state
|
||||
setLoading(true);
|
||||
|
||||
}, [ssoRequestID, loading, currentService]);
|
||||
|
||||
const refreshClick = (id: string) => {
|
||||
setRefreshLoading(true);
|
||||
invoke("refresh_coco_server_info", { id })
|
||||
.then((res: any) => {
|
||||
console.log("refresh_coco_server_info", id, JSON.stringify(res));
|
||||
fetchServers(false).then(r => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
//update currentService
|
||||
setCurrentService(res);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
function onAddServer() {
|
||||
setIsConnect(false);
|
||||
}
|
||||
function onLogout(id: string) {
|
||||
console.log("onLogout", id);
|
||||
setRefreshLoading(true);
|
||||
invoke("logout_coco_server", { id })
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
refreshClick(id);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
}).finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const remove_coco_server = (id: string) => {
|
||||
invoke("remove_coco_server", { id })
|
||||
.then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then(r => {
|
||||
console.log("fetchServers", r);
|
||||
})
|
||||
})
|
||||
.catch((err: any) => {
|
||||
//TODO display the error message
|
||||
setError(err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-50 dark:bg-gray-900">
|
||||
<Sidebar ref={SidebarRef} onAddServer={onAddServer} serverList={serverList} />
|
||||
|
||||
<main className="flex-1 p-4 py-8">
|
||||
|
||||
{isConnect ? (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="w-full rounded-[4px] bg-[rgba(229,229,229,1)] dark:bg-gray-800 mb-6">
|
||||
<img
|
||||
width="100%"
|
||||
src={currentService?.provider?.banner || bannerImg}
|
||||
alt="banner"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium">
|
||||
{currentService?.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => OpenURLWithBrowser(currentService?.provider?.website)}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => refreshClick(currentService?.id)}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!currentService?.builtin && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => remove_coco_server(currentService?.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-2 flex">
|
||||
<span className="flex items-center gap-1">
|
||||
<PackageOpen className="w-4 h-4" />{" "}
|
||||
{currentService?.provider?.name}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<GitFork className="w-4 h-4" />{" "}
|
||||
{currentService?.version?.number}
|
||||
</span>
|
||||
<span className="mx-4">|</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarSync className="w-4 h-4" /> {currentService?.updated}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{currentService?.provider?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentService?.auth_provider?.sso?.url ? (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Account Information
|
||||
</h2>
|
||||
{currentService?.profile ? (
|
||||
<UserProfile server={currentService?.id} userInfo={currentService?.profile} onLogout={onLogout} />
|
||||
) : (
|
||||
<div>
|
||||
{/* Login Button (conditionally rendered when not loading) */}
|
||||
{!loading && (
|
||||
<button
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mb-3"
|
||||
onClick={LoginClick}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel Button and Copy URL button while loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="px-6 py-2 text-white bg-red-500 rounded-md hover:bg-red-600 transition-colors mb-3"
|
||||
onClick={() => setLoading(false)} // Reset loading state
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${currentService?.auth_provider?.sso?.url}/?provider=${currentService?.id}&product=coco&request_id=${ssoRequestID}`
|
||||
);
|
||||
}}
|
||||
className="text-xl text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<Copy className="inline mr-2" /> {/* Lucide Copy Icon */}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Privacy Policy Link */}
|
||||
<button
|
||||
className="text-xs text-[#0096FB] dark:text-blue-400 block"
|
||||
onClick={() =>
|
||||
OpenURLWithBrowser(currentService?.provider?.privacy_policy)
|
||||
}
|
||||
>
|
||||
EULA | Privacy Policy
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{currentService?.profile ? <DataSourcesList server={currentService?.id} /> : null}
|
||||
</div>
|
||||
) : (
|
||||
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/Cloud/Connect.tsx
Normal file
124
src/components/Cloud/Connect.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import {useAppStore} from "@/stores/appStore";
|
||||
|
||||
|
||||
interface ConnectServiceProps {
|
||||
setIsConnect: (isConnect: boolean) => void;
|
||||
onAddServer: (endpoint: string) => void;
|
||||
}
|
||||
|
||||
export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
const [endpointLink, setEndpointLink] = useState("");
|
||||
const [refreshLoading, ] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState(''); // State to store the error message
|
||||
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
setIsConnect(true);
|
||||
};
|
||||
|
||||
const onAddServerClick = async (endpoint: string) => {
|
||||
console.log("onAddServer", endpoint);
|
||||
try {
|
||||
await onAddServer(endpoint);
|
||||
setIsConnect(true); // Only set as connected if the server is added successfully
|
||||
} catch (err: any) {
|
||||
// Handle the error if something goes wrong
|
||||
const errorMessage = typeof err === 'string' ? err : err?.message || 'An unknown error occurred.';
|
||||
setErrorMessage("ERR:"+errorMessage);
|
||||
setError(errorMessage);
|
||||
console.error('Error:', errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to close the error message
|
||||
const closeError = () => {
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<button
|
||||
className=" text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border border-[rgba(228,229,239,1)] dark:border-gray-700 p-1"
|
||||
onClick={goBack}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-xl text-[#101010] dark:text-white">
|
||||
Connecting to Your Coco-Server
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Running your own private instance of coco-server ensures complete control over
|
||||
your data, keeping it secure and accessible only within your environment.
|
||||
Enjoy enhanced privacy, better performance, and seamless integration with your
|
||||
internal systems.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="endpoint"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2.5"
|
||||
>
|
||||
Server address
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="endpoint"
|
||||
value={endpointLink}
|
||||
placeholder="For example: https://coco.infini.cloud/"
|
||||
onChange={(e) => setEndpointLink(e.target.value)}
|
||||
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
onClick={()=>onAddServerClick(endpointLink)}
|
||||
>
|
||||
{refreshLoading ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/*//TODO move to outer container, move error state to global*/}
|
||||
{errorMessage && (
|
||||
<div
|
||||
className="mb-8"
|
||||
>
|
||||
<div style={{
|
||||
color: 'red',
|
||||
marginTop: '10px',
|
||||
display: 'block', // Makes sure the error message starts on a new line
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
onClick={closeError}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'red',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,10 +29,10 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
||||
const connector_id = connector?.id;
|
||||
|
||||
const result_connector = connector_data[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === connector_id
|
||||
(data: any) => data.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector?._source;
|
||||
return result_connector;
|
||||
}
|
||||
|
||||
function getTypeIcon() {
|
||||
80
src/components/Cloud/DataSourcesList.tsx
Normal file
80
src/components/Cloud/DataSourcesList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {RefreshCcw} from "lucide-react";
|
||||
|
||||
import {DataSourceItem} from "./DataSourceItem";
|
||||
import {useConnectStore} from "@/stores/connectStore";
|
||||
import {useAppStore} from "@/stores/appStore";
|
||||
import {invoke} from "@tauri-apps/api/core";
|
||||
|
||||
export function DataSourcesList({server}: { server: string }) {
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
||||
|
||||
function initServerAppData({server}: { server: string }) {
|
||||
//fetch datasource data
|
||||
invoke("get_connectors_by_server", {id: server})
|
||||
.then((res: any) => {
|
||||
console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
});
|
||||
|
||||
//fetch datasource data
|
||||
invoke("get_datasources_by_server", {id: server})
|
||||
.then((res: any) => {
|
||||
console.log("get_datasources_by_server", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {
|
||||
});
|
||||
}
|
||||
|
||||
async function getDatasourceData() {
|
||||
setRefreshLoading(true);
|
||||
try {
|
||||
initServerAppData({server});
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setRefreshLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getDatasourceData()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="flex justify-between text-xl font-medium text-gray-900 dark:text-white">
|
||||
Data Source
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => getDatasourceData()}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`w-3.5 h-3.5 ${refreshLoading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{datasourceData[server]?.map((source) => (
|
||||
<DataSourceItem key={source.id} {...source} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/Cloud/Sidebar.tsx
Normal file
83
src/components/Cloud/Sidebar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import cocoLogoImg from "@/assets/app-icon.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface SidebarProps {
|
||||
onAddServer: () => void;
|
||||
serverList: any[];
|
||||
}
|
||||
|
||||
export const Sidebar = forwardRef<{ refreshData: () => void; }, SidebarProps>(
|
||||
({ onAddServer, serverList }, _ref) => {
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const onAddServerClick = () => {
|
||||
onAddServer();
|
||||
};
|
||||
|
||||
// Extracted server item rendering
|
||||
const renderServerItem = (item: any) => {
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
|
||||
currentService?.id === item?.id ? "dark:bg-blue-900/20 dark:bg-blue-900" // Apply background color when selected
|
||||
: "bg-gray-50 dark:bg-gray-900" // Default background color when not selected
|
||||
}`}
|
||||
onClick={() => setCurrentService(item)}
|
||||
>
|
||||
<img
|
||||
src={item?.provider?.icon || cocoLogoImg}
|
||||
alt="LogoImg"
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<span className="font-medium">{item?.name}</span>
|
||||
<div className="flex-1" />
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{item?.available ? (
|
||||
<div className="w-3 h-3 rounded-full bg-[#00DB5E]" />
|
||||
) : (
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF4747]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 min-h-[550px] border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||
<div className="p-4 py-8">
|
||||
{/* Render Built-in Servers */}
|
||||
<div>
|
||||
{serverList
|
||||
.filter((item) => item?.builtin)
|
||||
.map((item) => renderServerItem(item))}
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Your Coco-Servers
|
||||
</div>
|
||||
|
||||
{/* Render Non-Built-in Servers */}
|
||||
<div>
|
||||
{serverList
|
||||
.filter((item) => !item?.builtin)
|
||||
.map((item) => renderServerItem(item))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
className="w-full flex items-center justify-center p-2 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
onClick={onAddServerClick}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,8 +1,5 @@
|
||||
import { User, LogOut } from "lucide-react";
|
||||
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface UserPreferences {
|
||||
theme: "dark" | "light";
|
||||
language: string;
|
||||
@@ -16,17 +13,15 @@ interface UserInfo {
|
||||
}
|
||||
|
||||
interface UserProfileProps {
|
||||
server: string; //server's id
|
||||
userInfo: UserInfo;
|
||||
onLogout: (server: string) => void;
|
||||
}
|
||||
|
||||
export function UserProfile({ userInfo }: UserProfileProps) {
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const setUserInfo = useAuthStore((state) => state.setUserInfo);
|
||||
const endpoint = useAppStore((state) => state.endpoint);
|
||||
|
||||
export function UserProfile({ server,userInfo,onLogout }: UserProfileProps) {
|
||||
const handleLogout = () => {
|
||||
setAuth(undefined, endpoint);
|
||||
setUserInfo({}, endpoint);
|
||||
onLogout(server);
|
||||
console.log("Logout",server);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Command } from "lucide-react";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
||||
function CommandInput() {
|
||||
return (
|
||||
<div className="h-[100%] w-[100%]">
|
||||
<div className="mx-auto px-4 py-16">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-lg shadow-sm border border-gray-200">
|
||||
<Command className="w-5 h-5 text-gray-500" />
|
||||
<span className="text-gray-600">Press</span>
|
||||
<kbd className="px-2 py-1 text-sm font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
|
||||
⌘
|
||||
</kbd>
|
||||
<kbd className="px-2 py-1 text-sm font-semibold text-gray-700 bg-gray-100 border border-gray-200 rounded-md">
|
||||
K
|
||||
</kbd>
|
||||
<span className="text-gray-600">to open command palette</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CommandPalette />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandInput;
|
||||
@@ -1,189 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Search,
|
||||
Settings,
|
||||
Calculator,
|
||||
Calendar,
|
||||
Mail,
|
||||
Music,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const commands: CommandItem[] = [
|
||||
{
|
||||
id: "settings",
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
title: "Settings",
|
||||
description: "Adjust your preferences",
|
||||
action: () => console.log("Settings clicked"),
|
||||
},
|
||||
{
|
||||
id: "calculator",
|
||||
icon: <Calculator className="w-5 h-5" />,
|
||||
title: "Calculator",
|
||||
description: "Perform quick calculations",
|
||||
action: () => console.log("Calculator clicked"),
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
icon: <Calendar className="w-5 h-5" />,
|
||||
title: "Calendar",
|
||||
description: "View your schedule",
|
||||
action: () => console.log("Calendar clicked"),
|
||||
},
|
||||
{
|
||||
id: "mail",
|
||||
icon: <Mail className="w-5 h-5" />,
|
||||
title: "Mail",
|
||||
description: "Check your inbox",
|
||||
action: () => console.log("Mail clicked"),
|
||||
},
|
||||
{
|
||||
id: "music",
|
||||
icon: <Music className="w-5 h-5" />,
|
||||
title: "Music",
|
||||
description: "Control playback",
|
||||
action: () => console.log("Music clicked"),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
icon: <User className="w-5 h-5" />,
|
||||
title: "Profile",
|
||||
description: "View your profile",
|
||||
action: () => console.log("Profile clicked"),
|
||||
},
|
||||
];
|
||||
|
||||
const filteredCommands = commands.filter(
|
||||
(command) =>
|
||||
command.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
command.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((prev) => !prev);
|
||||
} else if (e.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < filteredCommands.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === "Enter" && filteredCommands[selectedIndex]) {
|
||||
filteredCommands[selectedIndex].action();
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
[filteredCommands, selectedIndex]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [search]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="min-h-screen px-4 text-center">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="inline-block w-full max-w-2xl my-16 text-left align-middle transition-all transform">
|
||||
<div className="relative bg-white rounded-xl shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center px-4 border-b border-gray-200">
|
||||
<Search className="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="w-full px-4 py-4 text-gray-700 bg-transparent border-none focus:outline-none focus:ring-0"
|
||||
placeholder="Search commands..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="w-32 flex items-center gap-1">
|
||||
<kbd className="px-2 py-1 text-xs font-semibold text-gray-500 bg-gray-100 border border-gray-200 rounded-md">
|
||||
Esc
|
||||
</kbd>
|
||||
<span className="text-gray-400">to close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="px-4 py-14 text-center text-gray-500">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={command.id}
|
||||
className={`px-4 py-3 flex items-center gap-3 cursor-pointer transition-colors ${
|
||||
selectedIndex === index
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
command.action();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
selectedIndex === index
|
||||
? "text-blue-600"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{command.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{command.title}</div>
|
||||
<div
|
||||
className={`text-sm ${
|
||||
selectedIndex === index
|
||||
? "text-blue-500"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,8 +53,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
// console.log("Enter key pressed", selectedItem);
|
||||
const item = suggests[selectedItem];
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
@@ -63,8 +63,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
|
||||
if (e.key >= "0" && e.key <= "9" && showIndex) {
|
||||
// console.log(`number ${e.key}`);
|
||||
const item = suggests[parseInt(e.key, 10)];
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
@@ -121,8 +121,8 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
onMouseEnter={() => setSelectedItem(index)}
|
||||
onClick={() => {
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
@@ -134,14 +134,14 @@ function DropdownList({ selected, suggests }: DropdownListProps) {
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<img className="w-5 h-5" src={item?._source?.icon} alt="icon" />
|
||||
<img className="w-5 h-5" src={item?.icon} alt="icon" />
|
||||
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
|
||||
{item?._source?.title}
|
||||
{item?.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center relative">
|
||||
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
|
||||
{item?._source?.source}
|
||||
{item?.source}
|
||||
</span>
|
||||
{showIndex && index < 10 ? (
|
||||
<div
|
||||
23
src/components/Common/Icons/IconWrapper.tsx
Normal file
23
src/components/Common/Icons/IconWrapper.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface IconWrapperProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
onClick: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function IconWrapper({ children, className="", onClick }: IconWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IconWrapper;
|
||||
50
src/components/Common/Icons/ItemIcon.tsx
Normal file
50
src/components/Common/Icons/ItemIcon.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
File,
|
||||
} from "lucide-react";
|
||||
|
||||
import IconWrapper from './IconWrapper';
|
||||
import ThemedIcon from './ThemedIcon';
|
||||
import { useFindConnectorIcon } from "./hooks"
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface ItemIconProps {
|
||||
item: any;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ItemIcon({
|
||||
item,
|
||||
className = "w-5 h-5 flex-shrink-0",
|
||||
onClick = () => {}
|
||||
}: ItemIconProps) {
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const connectorSource = useFindConnectorIcon(item);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
const selectedIcon = icons[item?.icon];
|
||||
|
||||
if (!selectedIcon) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<ThemedIcon component={File} className={className} />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={selectedIcon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ItemIcon;
|
||||
47
src/components/Common/Icons/RichIcon.tsx
Normal file
47
src/components/Common/Icons/RichIcon.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
|
||||
import IconWrapper from './IconWrapper';
|
||||
import ThemedIcon from './ThemedIcon';
|
||||
import { useFindConnectorIcon } from "./hooks"
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface RichIconProps {
|
||||
item: any;
|
||||
className?: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function RichIcon({ item, className, onClick }: RichIconProps) {
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const connectorSource = useFindConnectorIcon(item);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
|
||||
const selectedIcon = icons[item?.rich_categories?.[0]?.icon];
|
||||
|
||||
if (!selectedIcon) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<ThemedIcon component={Folder} className={className} />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={selectedIcon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RichIcon;
|
||||
27
src/components/Common/Icons/ThemedIcon.tsx
Normal file
27
src/components/Common/Icons/ThemedIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface ThemedIconProps {
|
||||
component: React.ElementType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ThemedIcon({ component: Component, className = "" }: ThemedIconProps) {
|
||||
const [color, setColor] = useState("#000");
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
const isDark = document.body.classList.contains("dark");
|
||||
setColor(isDark ? "#DCDCDC" : "#999");
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return <Component className={className} color={color} />;
|
||||
}
|
||||
|
||||
export default ThemedIcon;
|
||||
48
src/components/Common/Icons/TypeIcon.tsx
Normal file
48
src/components/Common/Icons/TypeIcon.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Box } from "lucide-react";
|
||||
|
||||
import IconWrapper from './IconWrapper';
|
||||
import ThemedIcon from './ThemedIcon';
|
||||
import { useFindConnectorIcon } from "./hooks"
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface TypeIconProps {
|
||||
item: any;
|
||||
className?: string;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function TypeIcon({
|
||||
item,
|
||||
className = "w-5 h-5 flex-shrink-0",
|
||||
onClick = () => { }
|
||||
}: TypeIconProps) {
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
const connectorSource = useFindConnectorIcon(item);
|
||||
const selectedIcon = connectorSource?.icon;
|
||||
|
||||
if (!selectedIcon) {
|
||||
// console.log("go default folder:");
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<ThemedIcon component={Box} className={className} />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedIcon.startsWith("http://") || selectedIcon.startsWith("https://")) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={selectedIcon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={`${endpoint_http}${selectedIcon}`} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default TypeIcon;
|
||||
21
src/components/Common/Icons/hooks.ts
Normal file
21
src/components/Common/Icons/hooks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
export function useFindConnectorIcon(item: any) {
|
||||
const connector_data = useConnectStore((state) => state.connector_data);
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const id = item?.source?.id || "";
|
||||
|
||||
const result_source = datasourceData[currentService?.id]?.find(
|
||||
(data: any) => data.id === id
|
||||
);
|
||||
|
||||
const connector_id = result_source?.connector?.id;
|
||||
|
||||
const result_connector = connector_data[currentService?.id]?.find(
|
||||
(data: any) => data.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Library, Mic, Send, Plus, AudioLines, Image } from "lucide-react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import ChatSwitch from "../SearchChat/ChatSwitch";
|
||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||
import { useChatStore } from "../../stores/chatStore";
|
||||
import StopIcon from "../../icons/Stop";
|
||||
@@ -5,7 +5,7 @@ import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
|
||||
import DropdownList from "./DropdownList";
|
||||
import { Footer } from "./Footer";
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { SearchResults } from "../Search/SearchResults";
|
||||
import { tauriFetch } from "../../api/tauriFetchClient";
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
interface SearchProps {
|
||||
@@ -8,7 +8,7 @@ import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
|
||||
import InputBox from "./InputBox";
|
||||
import Search from "./Search";
|
||||
import ChatAI, { ChatAIRef } from "../ChatAI/Chat";
|
||||
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
|
||||
import { useWindows } from "../../hooks/useWindows";
|
||||
|
||||
// const appWindow = new Window("main");
|
||||
@@ -1,30 +1,54 @@
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
// MenuItems, MenuItem
|
||||
Menu,
|
||||
MenuButton,
|
||||
// MenuItems, MenuItem
|
||||
} from "@headlessui/react";
|
||||
// import { Settings, LogOut, User, ChevronUp, Home } from "lucide-react";
|
||||
// import { Link } from "react-router-dom";
|
||||
|
||||
import logoImg from "../assets/32x32.png";
|
||||
import {useAppStore} from "@/stores/appStore";
|
||||
import {OctagonAlert} from 'lucide-react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
|
||||
const error = useAppStore((state) => state.error);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
|
||||
{/* Move the warning message outside the border */}
|
||||
{error && (
|
||||
<div className="fixed bottom-6 left-0 right-0 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 rounded-lg shadow-lg p-4 m-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<OctagonAlert size={32} color="red" className="mr-2" />
|
||||
<span className="text-xs text-red-500 dark:text-red-400 flex-1">{error}</span>
|
||||
<X
|
||||
className="cursor-pointer ml-2"
|
||||
onClick={() => setError("")}
|
||||
size={32}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton
|
||||
className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-5 h-5 text-gray-600 dark:text-gray-400"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Coco
|
||||
</span>
|
||||
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
|
||||
</MenuButton>
|
||||
{/* <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" /> */}
|
||||
</MenuButton>
|
||||
|
||||
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
@@ -85,20 +109,22 @@ const Footer = () => {
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems> */}
|
||||
</Menu>
|
||||
</Menu>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Version 1.0.0
|
||||
</span>
|
||||
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<button className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Check for Updates
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string;
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
export const ChatInterface: React.FC<ChatInterfaceProps> = ({ query }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
content: query,
|
||||
isUser: true,
|
||||
},
|
||||
]);
|
||||
|
||||
setIsTyping(true);
|
||||
setTimeout(() => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: (Date.now() + 1).toString(),
|
||||
content: "hello world...",
|
||||
isUser: false,
|
||||
},
|
||||
]);
|
||||
setIsTyping(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] p-3 rounded-lg ${
|
||||
message.isUser
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 p-3 rounded-lg">...</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SearchIcon, MessageCircleIcon } from "lucide-react";
|
||||
|
||||
import { SearchResults } from "./SearchResults";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
type Mode = "search" | "chat";
|
||||
|
||||
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const [mode, setMode] = useState<Mode>("search");
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
isOpen ? onClose() : setIsOpen(true);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode((prev) => (prev === "search" ? "chat" : "search"));
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-20"
|
||||
>
|
||||
<div className="w-full max-w-2xl bg-white rounded-lg shadow-2xl">
|
||||
<div className="flex items-center p-4 border-b">
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
mode === "search" ? "bg-blue-100 text-blue-600" : ""
|
||||
}`}
|
||||
>
|
||||
<SearchIcon size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className={`p-2 ml-2 rounded-md transition-colors ${
|
||||
mode === "chat" ? "bg-blue-100 text-blue-600" : ""
|
||||
}`}
|
||||
>
|
||||
<MessageCircleIcon size={20} />
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={mode === "search" ? "Search for files..." : "Input Message..."}
|
||||
className="flex-1 ml-4 p-2 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto p-4">
|
||||
{mode === "search" ? (
|
||||
<SearchResults query={query} />
|
||||
) : (
|
||||
<ChatInterface query={query} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileIcon, FolderIcon } from "lucide-react";
|
||||
|
||||
interface SearchResultsProps {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export const SearchResults: React.FC<SearchResultsProps> = ({ query }) => {
|
||||
const [results, setResults] = useState<
|
||||
Array<{ name: string; path: string; type: "file" | "folder" }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const searchFiles = async () => {
|
||||
setResults([
|
||||
{ name: "Document.pdf", path: "/documents/Document.pdf", type: "file" },
|
||||
{ name: "Projects", path: "/documents/projects", type: "folder" },
|
||||
]);
|
||||
};
|
||||
|
||||
if (query) {
|
||||
searchFiles();
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center p-3 hover:bg-gray-100 rounded-md cursor-pointer"
|
||||
>
|
||||
{result.type === "file" ? (
|
||||
<FileIcon size={16} />
|
||||
) : (
|
||||
<FolderIcon size={16} />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<div className="font-medium">{result.name}</div>
|
||||
<div className="text-sm text-gray-500">{result.path}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
||||
export default function MySearch() {
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsCommandPaletteOpen(true)}
|
||||
className="fixed bottom-4 right-4 p-4 bg-blue-500 text-white rounded-full"
|
||||
>
|
||||
Open the Command Palette
|
||||
</button>
|
||||
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
onClose={() => setIsCommandPaletteOpen(false)}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,19 +20,19 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
function findConnectorIcon(item: any) {
|
||||
const id = item?._source?.source?.id || "";
|
||||
const id = item?.source?.id || "";
|
||||
|
||||
const result_source = datasourceData[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === id
|
||||
(data: any) => data.id === id
|
||||
);
|
||||
|
||||
const connector_id = result_source?._source?.connector?.id;
|
||||
const connector_id = result_source?.connector?.id;
|
||||
|
||||
const result_connector = connector_data[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === connector_id
|
||||
(data: any) => data.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector?._source;
|
||||
return result_connector;
|
||||
}
|
||||
|
||||
function getTypeIcon(item: any) {
|
||||
@@ -58,7 +58,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
|
||||
{/* <div className="mb-4">
|
||||
<iframe
|
||||
src={document?._source?.metadata?.web_view_link}
|
||||
src={document?.metadata?.web_view_link}
|
||||
style={{ width: "100%", height: "500px" }}
|
||||
title="Text Preview"
|
||||
/>
|
||||
@@ -74,7 +74,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Name</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-60 break-words">
|
||||
{document?._source?.title || "-"}
|
||||
{document?.title || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
src={getTypeIcon(document)}
|
||||
alt="icon"
|
||||
/>
|
||||
{document?._source?.source?.name || "-"}
|
||||
{document?.source?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="flex justify-between font-normal text-xs mb-2.5">
|
||||
@@ -95,43 +95,43 @@ export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
-
|
||||
</div>
|
||||
</div> */}
|
||||
{document?._source?.updated ? (
|
||||
{document?.updated ? (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Updated at</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?._source?.updated || "-"}
|
||||
{document?.updated || "-"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{document?._source?.last_updated_by?.user?.username ? (
|
||||
{document?.last_updated_by?.user?.username ? (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Update by</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?._source?.last_updated_by?.user?.username || "-"}
|
||||
{document?.last_updated_by?.user?.username || "-"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{document?._source?.owner?.username ? (
|
||||
{document?.owner?.username ? (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Created by</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?._source?.owner?.username || "-"}
|
||||
{document?.owner?.username || "-"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{document?._source?.type ? (
|
||||
{document?.type ? (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Type</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?._source?.type || "-"}
|
||||
{document?.type || "-"}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{document?._source?.size ? (
|
||||
{document?.size ? (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">Size</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{formatter.bytes(document?._source?.size || 0)}
|
||||
{formatter.bytes(document?.size || 0)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useInfiniteScroll } from "ahooks";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { isTauri, invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { SearchHeader } from "./SearchHeader";
|
||||
import file_efault_img from "@/assets/images/file_efault.png";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import ItemIcon from "@/components/Common/Icons/ItemIcon";
|
||||
|
||||
interface DocumentListProps {
|
||||
onSelectDocument: (id: string) => void;
|
||||
@@ -26,11 +23,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
getDocDetail,
|
||||
isChatMode,
|
||||
}) => {
|
||||
const connector_data = useConnectStore((state) => state.connector_data);
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -39,26 +32,30 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
const { data, loading } = useInfiniteScroll(
|
||||
async (d) => {
|
||||
const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1;
|
||||
const from = d?.list.length || 0;
|
||||
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
let queryStrings: any = {
|
||||
query: input,
|
||||
datasource: sourceData?.source?.id,
|
||||
};
|
||||
|
||||
let url = `/query/_search?query=${input}&datasource=${sourceData?._source?.source?.id}&from=${from}&size=${PAGE_SIZE}`;
|
||||
|
||||
if (sourceData?._source?.rich_categories) {
|
||||
url = `/query/_search?query=${input}&rich_category=${sourceData?._source?.rich_categories[0]?.key}&from=${from}&size=${PAGE_SIZE}`;
|
||||
if (sourceData?.rich_categories) {
|
||||
queryStrings = {
|
||||
query: input,
|
||||
rich_category: sourceData?.rich_categories[0]?.key,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tauriFetch({
|
||||
url,
|
||||
method: "GET",
|
||||
const response: any = await invoke("query_coco_servers", {
|
||||
from: from,
|
||||
size: PAGE_SIZE,
|
||||
queryStrings,
|
||||
});
|
||||
const list = response?.documents || [];
|
||||
const total = response?.total_hits || 0;
|
||||
|
||||
const list = response.data?.hits?.hits || [];
|
||||
const total = response.data?.hits?.total?.value || 0;
|
||||
|
||||
console.log("doc", url, response.data?.hits)
|
||||
console.log("doc", response?.documents);
|
||||
|
||||
setTotal(total);
|
||||
|
||||
@@ -84,10 +81,11 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
setTimeout(() => {
|
||||
const parentRef = containerRef.current;
|
||||
if (parentRef && parentRef.childElementCount > 10) {
|
||||
const itemHeight = (parentRef.firstChild as HTMLElement)?.offsetHeight || 80;
|
||||
const itemHeight =
|
||||
(parentRef.firstChild as HTMLElement)?.offsetHeight || 80;
|
||||
parentRef.scrollTo({
|
||||
top: (parentRef.lastChild as HTMLElement)?.offsetTop - itemHeight,
|
||||
behavior: 'instant',
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -104,43 +102,10 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
parentRef.scrollTo({
|
||||
top:
|
||||
parentRef.lastChild?.offsetTop - (data?.list?.length + 1) * itemHeight,
|
||||
behavior: 'instant',
|
||||
behavior: "instant",
|
||||
});
|
||||
};
|
||||
|
||||
function findConnectorIcon(item: any) {
|
||||
const id = item?._source?.source?.id || "";
|
||||
|
||||
const result_source = datasourceData[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === id
|
||||
);
|
||||
|
||||
const connector_id = result_source?._source?.connector?.id;
|
||||
|
||||
const result_connector = connector_data[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector?._source;
|
||||
}
|
||||
|
||||
function getIcon(item: any) {
|
||||
const connectorSource = findConnectorIcon(item);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
|
||||
const selectedIcon = icons[item?._source?.icon];
|
||||
|
||||
if (!selectedIcon) {
|
||||
return file_efault_img;
|
||||
}
|
||||
|
||||
if (selectedIcon?.startsWith("http://") || selectedIcon?.startsWith("https://")) {
|
||||
return selectedIcon;
|
||||
} else {
|
||||
return endpoint_http + selectedIcon;
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter(index: number, item: any) {
|
||||
getDocDetail(item);
|
||||
setSelectedItem(index);
|
||||
@@ -179,8 +144,8 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
const item = data?.list?.[selectedItem];
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -216,13 +181,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
const isSelected = selectedItem === index;
|
||||
return (
|
||||
<div
|
||||
key={item._id + index}
|
||||
key={item.id + index}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
onMouseEnter={() => onMouseEnter(index, item)}
|
||||
onClick={() => {
|
||||
if (item?._source?.url) {
|
||||
handleOpenURL(item?._source?.url);
|
||||
}
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-2 py-2.5 text-sm flex items-center gap-3 rounded-lg transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
@@ -231,17 +196,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1 min-w-0">
|
||||
<img
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
src={getIcon(item)}
|
||||
alt="icon"
|
||||
/>
|
||||
|
||||
<ItemIcon item={item} />
|
||||
<span
|
||||
className={`text-sm truncate ${
|
||||
isSelected ? "font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{item?._source?.title}
|
||||
{item?.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,14 +218,14 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
{!loading && data?.list.length === 0 && (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center"
|
||||
>
|
||||
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
|
||||
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
|
||||
No Results
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center"
|
||||
>
|
||||
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
|
||||
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
|
||||
No Results
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
245
src/components/Search/DropdownList.tsx
Normal file
245
src/components/Search/DropdownList.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
CircleAlert,
|
||||
Bolt,
|
||||
X,
|
||||
ArrowBigRight,
|
||||
} from "lucide-react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import ThemedIcon from "@/components/Common/Icons/ThemedIcon";
|
||||
import IconWrapper from "@/components/Common/Icons/IconWrapper";
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import ItemIcon from "@/components/Common/Icons/ItemIcon";
|
||||
import ListRight from "./ListRight";
|
||||
|
||||
type ISearchData = Record<string, any[]>;
|
||||
|
||||
interface DropdownListProps {
|
||||
selected: (item: any) => void;
|
||||
suggests: any[];
|
||||
SearchData: ISearchData;
|
||||
IsError: boolean;
|
||||
isSearchComplete: boolean;
|
||||
isChatMode: boolean;
|
||||
}
|
||||
|
||||
function DropdownList({
|
||||
selected,
|
||||
suggests,
|
||||
SearchData,
|
||||
IsError,
|
||||
isChatMode,
|
||||
}: DropdownListProps) {
|
||||
const globalIndex = useRef(0);
|
||||
const globalItemIndexMap = useRef<any[]>([]);
|
||||
|
||||
const setSourceData = useSearchStore((state: { setSourceData: any; }) => state.setSourceData);
|
||||
|
||||
const [showError, setShowError] = useState<boolean>(IsError);
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [selectedName, setSelectedName] = useState<string>("");
|
||||
const [showIndex, setShowIndex] = useState<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatMode) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}, [isChatMode]);
|
||||
|
||||
const handleOpenURL = async (url: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await open(url);
|
||||
// console.log("URL opened in default browser");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
// console.log(
|
||||
// "handleKeyDown",
|
||||
// e.key,
|
||||
// showIndex,
|
||||
// e.key >= "0" && e.key <= "9" && showIndex
|
||||
// );
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) => {
|
||||
const res =
|
||||
prev === null || prev === 0 ? suggests.length - 1 : prev - 1;
|
||||
|
||||
return res;
|
||||
});
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) =>
|
||||
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
|
||||
);
|
||||
} else if (e.key === "Meta") {
|
||||
e.preventDefault();
|
||||
if (selectedItem !== null) {
|
||||
const item = globalItemIndexMap.current[selectedItem];
|
||||
setSelectedName(item?.source?.name);
|
||||
}
|
||||
setShowIndex(true);
|
||||
}
|
||||
|
||||
if (e.key === "ArrowRight" && selectedItem !== null) {
|
||||
e.preventDefault();
|
||||
const item = globalItemIndexMap.current[selectedItem];
|
||||
goToTwoPage(item);
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
// console.log("Enter key pressed", selectedItem);
|
||||
const item = globalItemIndexMap.current[selectedItem];
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key >= "0" && e.key <= "9" && showIndex) {
|
||||
// console.log(`number ${e.key}`);
|
||||
const item = globalItemIndexMap.current[parseInt(e.key, 10)];
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
}, [suggests, selectedItem, showIndex, selected, handleOpenURL, globalItemIndexMap]);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
// console.log("handleKeyUp", e.key);
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (!e.metaKey) {
|
||||
setShowIndex(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [handleKeyDown, handleKeyUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem !== null && itemRefs.current[selectedItem]) {
|
||||
itemRefs.current[selectedItem]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
function goToTwoPage(item: any) {
|
||||
setSourceData(item);
|
||||
selected && selected(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
{showError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[#333] p-2">
|
||||
<CircleAlert className="text-[#FF0000] w-[14px] h-[14px]" />
|
||||
Coco server is unavailable, only local results and available services
|
||||
are displayed.
|
||||
<Bolt className="text-[#000] w-[14px] h-[14px] cursor-pointer" />
|
||||
<X
|
||||
className="text-[#666] w-[16px] h-[16px] cursor-pointer"
|
||||
onClick={() => setShowError(false)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.entries(SearchData).map(([sourceName, items]) => (
|
||||
<div key={sourceName}>
|
||||
{Object.entries(SearchData).length < 5 ? (
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
||||
<TypeIcon item={items[0]} className="w-4 h-4" />
|
||||
{sourceName}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
|
||||
<IconWrapper
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(items[0]);
|
||||
}}
|
||||
>
|
||||
<ThemedIcon component={ArrowBigRight} className="w-4 h-4" />
|
||||
</IconWrapper>
|
||||
|
||||
{showIndex && sourceName === selectedName ? (
|
||||
<div
|
||||
className={`absolute right-2
|
||||
w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item: any, index: number) => {
|
||||
const isSelected = selectedItem === globalIndex.current;
|
||||
const currentIndex = globalIndex.current;
|
||||
globalItemIndexMap.current.push(item);
|
||||
globalIndex.current++;
|
||||
return (
|
||||
<div
|
||||
key={item.id + index}
|
||||
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
onMouseEnter={() => setSelectedItem(currentIndex)}
|
||||
onClick={() => {
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-2 py-2.5 text-sm flex gap-7 items-center justify-between rounded-lg transition-colors ${isSelected
|
||||
? "text-white bg-[#950599] hover:bg-[#950599]"
|
||||
: "text-[#333] dark:text-[#d8d8d8]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center justify-start max-w-[450px]">
|
||||
<ItemIcon item={item} />
|
||||
<span
|
||||
className={`text-sm truncate text-left ${isSelected ? "font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{item?.title}
|
||||
</span>
|
||||
</div>
|
||||
<ListRight goToTwoPage={goToTwoPage} item={item} isSelected={isSelected} showIndex={showIndex} currentIndex={currentIndex}/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownList;
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Command,
|
||||
ArrowDown01,
|
||||
AppWindowMac,
|
||||
// AppWindowMac,
|
||||
CornerDownLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -18,7 +18,7 @@ interface FooterProps {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function Footer({ name }: FooterProps) {
|
||||
export default function Footer({ }: FooterProps) {
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
|
||||
const connector_data = useConnectStore((state) => state.connector_data);
|
||||
@@ -29,19 +29,19 @@ export default function Footer({ name }: FooterProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
function findConnectorIcon(item: any) {
|
||||
const id = item?._source?.source?.id || "";
|
||||
const id = item?.source?.id || "";
|
||||
|
||||
const result_source = datasourceData[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === id
|
||||
(data: any) => data.id === id
|
||||
);
|
||||
|
||||
const connector_id = result_source?._source?.connector?.id;
|
||||
const connector_id = result_source?.connector?.id;
|
||||
|
||||
const result_connector = connector_data[endpoint_http]?.find(
|
||||
(data: any) => data._source.id === connector_id
|
||||
(data: any) => data.id === connector_id
|
||||
);
|
||||
|
||||
return result_connector?._source;
|
||||
return result_connector;
|
||||
}
|
||||
|
||||
function getTypeIcon(item: any) {
|
||||
@@ -66,21 +66,21 @@ export default function Footer({ name }: FooterProps) {
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
{sourceData?._source?.source?.name ? (
|
||||
{sourceData?.source?.name ? (
|
||||
<img className="w-5 h-5" src={getTypeIcon(sourceData)} alt="icon" />
|
||||
) : (
|
||||
<img src={logoImg} className="w-5 h-5" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{sourceData?._source?.source?.name || "Version 1.0.0"}
|
||||
{sourceData?.source?.name || "Version 1.0.0"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{name ? (
|
||||
{/* {name ? (
|
||||
<div className="flex gap-2 items-center text-[#666] text-xs">
|
||||
<AppWindowMac className="w-5 h-5" /> {name}
|
||||
</div>
|
||||
) : null}
|
||||
) : null} */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -5,18 +5,19 @@ import {
|
||||
Plus,
|
||||
AudioLines,
|
||||
Image,
|
||||
SquareArrowLeft,
|
||||
ArrowBigLeft,
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
import ChatSwitch from "../SearchChat/ChatSwitch";
|
||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import StopIcon from "@/icons/Stop";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { isMac } from "@/utils/platform";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
@@ -39,10 +40,10 @@ export default function ChatInput({
|
||||
disabledChange,
|
||||
reconnect,
|
||||
}: ChatInputProps) {
|
||||
const showTooltip = useAppStore((state) => state.showTooltip);
|
||||
const showTooltip = useAppStore((state: { showTooltip: boolean }) => state.showTooltip);
|
||||
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const setSourceData = useSearchStore((state) => state.setSourceData);
|
||||
const sourceData = useSearchStore((state: { sourceData: any; }) => state.sourceData);
|
||||
const setSourceData = useSearchStore((state: { setSourceData: any; }) => state.setSourceData);
|
||||
|
||||
useEffect(() => {
|
||||
setSourceData(undefined);
|
||||
@@ -76,11 +77,13 @@ export default function ChatInput({
|
||||
(e: KeyboardEvent) => {
|
||||
pressedKeys.add(e.code);
|
||||
|
||||
if (e.code === "MetaLeft" || e.code === "MetaRight") {
|
||||
if ((isMac && (e.code === "MetaLeft" || e.code === "MetaRight")) ||
|
||||
(!isMac && (e.code === "ControlLeft" || e.code === "ControlRight"))) {
|
||||
setIsCommandPressed(true);
|
||||
}
|
||||
|
||||
if (pressedKeys.has("MetaLeft") || pressedKeys.has("MetaRight")) {
|
||||
if ((isMac && (pressedKeys.has("MetaLeft") || pressedKeys.has("MetaRight"))) ||
|
||||
(!isMac && (pressedKeys.has("ControlLeft") || pressedKeys.has("ControlRight")))) {
|
||||
// e.preventDefault();
|
||||
switch (e.code) {
|
||||
case "Comma":
|
||||
@@ -128,7 +131,8 @@ export default function ChatInput({
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
pressedKeys.delete(e.code);
|
||||
if (e.code === "MetaLeft" || e.code === "MetaRight") {
|
||||
if ((isMac && (e.code === "MetaLeft" || e.code === "MetaRight")) ||
|
||||
(!isMac && (e.code === "ControlLeft" || e.code === "ControlRight"))) {
|
||||
setIsCommandPressed(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -198,7 +202,7 @@ export default function ChatInput({
|
||||
<div className="p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative">
|
||||
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
|
||||
{!isChatMode && sourceData ? (
|
||||
<SquareArrowLeft
|
||||
<ArrowBigLeft
|
||||
className="w-4 h-4 text-[#000] dark:text-[#d8d8d8] cursor-pointer"
|
||||
onClick={() => setSourceData(undefined)}
|
||||
/>
|
||||
@@ -242,9 +246,8 @@ export default function ChatInput({
|
||||
) : null}
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute ${
|
||||
!isChatMode && sourceData ? "left-7" : ""
|
||||
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
|
||||
className={`absolute ${!isChatMode && sourceData ? "left-7" : ""
|
||||
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
|
||||
>
|
||||
I
|
||||
</div>
|
||||
@@ -262,11 +265,10 @@ export default function ChatInput({
|
||||
|
||||
{isChatMode && curChatEnd ? (
|
||||
<button
|
||||
className={`ml-1 p-1 ${
|
||||
inputValue
|
||||
? "bg-[#0072FF]"
|
||||
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
|
||||
} rounded-full transition-colors`}
|
||||
className={`ml-1 p-1 ${inputValue
|
||||
? "bg-[#0072FF]"
|
||||
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
|
||||
} rounded-full transition-colors`}
|
||||
type="submit"
|
||||
onClick={() => onSend(inputValue.trim())}
|
||||
>
|
||||
@@ -386,7 +388,7 @@ export default function ChatInput({
|
||||
) : null}
|
||||
<ChatSwitch
|
||||
isChatMode={isChatMode}
|
||||
onChange={(value) => {
|
||||
onChange={(value: boolean) => {
|
||||
value && disabledChange();
|
||||
changeMode(value);
|
||||
setSourceData(undefined);
|
||||
111
src/components/Search/ListRight.tsx
Normal file
111
src/components/Search/ListRight.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import RichIcon from "@/components/Common/Icons/RichIcon";
|
||||
|
||||
interface ListRightProps {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
showIndex: boolean;
|
||||
currentIndex: number;
|
||||
goToTwoPage: (item: any) => void;
|
||||
}
|
||||
|
||||
export default function ListRight({
|
||||
item,
|
||||
isSelected,
|
||||
showIndex,
|
||||
currentIndex,
|
||||
goToTwoPage
|
||||
}: ListRightProps) {
|
||||
|
||||
return (
|
||||
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
|
||||
{item?.rich_categories ? null : (
|
||||
<div
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(item);
|
||||
}}
|
||||
>
|
||||
<TypeIcon
|
||||
item={item}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(item);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item?.rich_categories ? (
|
||||
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
|
||||
<RichIcon
|
||||
item={item}
|
||||
className="w-4 h-4 mr-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(item);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}
|
||||
>
|
||||
{item?.rich_categories?.map(
|
||||
(rich_item: any, index: number) => {
|
||||
if (
|
||||
item?.rich_categories.length > 2 &&
|
||||
index === item?.rich_categories.length - 1
|
||||
)
|
||||
return "";
|
||||
return (index !== 0 ? "/" : "") + rich_item?.label;
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{item?.rich_categories.length > 2 ? (
|
||||
<span className={`${isSelected ? "text-[#C8C8C8]" : "text-[#666]"} text-right truncate`}>
|
||||
{"/" + item?.rich_categories?.at(-1)?.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : item?.category || item?.subcategory ? (
|
||||
<span
|
||||
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
|
||||
}`}
|
||||
>
|
||||
{(item?.category || "") + (item?.subcategory ? `/${item?.subcategory}` : "")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={`text-[12px] truncate ${isSelected ? "text-[#DCDCDC]" : "text-[#999] dark:text-[#666]"
|
||||
}`}
|
||||
>
|
||||
{item?.last_updated_by?.user?.username || item?.owner?.username || item?.updated || item?.created || item?.type || ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isSelected ? (
|
||||
<div
|
||||
className={`absolute ${showIndex && currentIndex < 10 ? "right-7" : "right-0"
|
||||
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
↩︎
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showIndex && currentIndex < 10 ? (
|
||||
<div
|
||||
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
{currentIndex}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Command } from "lucide-react";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
// import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
import DropdownList from "./DropdownList";
|
||||
import Footer from "./Footer";
|
||||
import { tauriFetch } from "@/api/tauriFetchClient";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
// import { res_search2 } from "@/mock/index";
|
||||
import { SearchResults } from "../SearchChat/SearchResults";
|
||||
import { SearchResults } from "@/components/Search/SearchResults";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
interface SearchProps {
|
||||
@@ -19,8 +17,6 @@ interface SearchProps {
|
||||
}
|
||||
|
||||
function Search({ isChatMode, input }: SearchProps) {
|
||||
const appStore = useAppStore();
|
||||
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
|
||||
const [IsError, setIsError] = useState<boolean>(false);
|
||||
@@ -76,17 +72,20 @@ function Search({ isChatMode, input }: SearchProps) {
|
||||
// return;
|
||||
//
|
||||
try {
|
||||
const response = await tauriFetch({
|
||||
url: `/query/_search?query=${input}`,
|
||||
method: "GET",
|
||||
baseURL: appStore.endpoint_http,
|
||||
});
|
||||
// const response = await tauriFetch({
|
||||
// url: `/query/_search?query=${input}`,
|
||||
// method: "GET",
|
||||
// baseURL: appStore.endpoint_http,
|
||||
// });
|
||||
|
||||
const response: any = await invoke("query_coco_servers", { from: 0, size: 10, queryStrings: { query: input } });
|
||||
// failed_coco_servers documents
|
||||
|
||||
console.log("_suggest", input, response);
|
||||
let data = response.data?.hits?.hits || [];
|
||||
let data = response?.documents || [];
|
||||
setSuggests(data);
|
||||
const search_data = data.reduce((acc: any, item: any) => {
|
||||
const name = item?._source?.source?.name;
|
||||
const name = item?.source?.name;
|
||||
if (!acc[name]) {
|
||||
acc[name] = [];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { DocumentList } from "./DocumentList";
|
||||
import { DocumentDetail } from "./DocumentDetail";
|
||||
|
||||
@@ -21,16 +22,16 @@ export function SearchResults({ input, isChatMode }: SearchResultsProps) {
|
||||
<div className="h-full flex">
|
||||
{/* Left Panel */}
|
||||
<DocumentList
|
||||
onSelectDocument={setSelectedDocumentId}
|
||||
selectedId={selectedDocumentId}
|
||||
input={input}
|
||||
getDocDetail={getDocDetail}
|
||||
isChatMode={isChatMode}
|
||||
/>
|
||||
onSelectDocument={setSelectedDocumentId}
|
||||
selectedId={selectedDocumentId}
|
||||
input={input}
|
||||
getDocDetail={getDocDetail}
|
||||
isChatMode={isChatMode}
|
||||
/>
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<DocumentDetail document={detailData}/>
|
||||
<DocumentDetail document={detailData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Globe, Github } from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { OpenBrowserURL } from "@/utils";
|
||||
import { OpenURLWithBrowser } from "@/utils";
|
||||
import logoLight from "@/assets/images/logo-text-light.svg";
|
||||
import logoDark from "@/assets/images/logo-text-dark.svg";
|
||||
|
||||
@@ -20,8 +20,8 @@ export default function AboutView(){
|
||||
Search, Connect, Collaborate—All in one place
|
||||
</div>
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<button onClick={() => OpenBrowserURL('https://coco.rs')} className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"><Globe className="w-3 text-blue-500"/></button>
|
||||
<button onClick={() => OpenBrowserURL('https://github.com/infinilabs/coco-app')} className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700" ><Github className="w-3 text-blue-500"/></button>
|
||||
<button onClick={() => OpenURLWithBrowser('https://coco.rs')} className="w-6 h-6 mr-2.5 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700"><Globe className="w-3 text-blue-500"/></button>
|
||||
<button onClick={() => OpenURLWithBrowser('https://github.com/infinilabs/coco-app')} className="w-6 h-6 flex justify-center rounded-[6px] border-[1px] gray-200 dark:border-gray-700" ><Github className="w-3 text-blue-500"/></button>
|
||||
</div>
|
||||
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
Version 1.0.0
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import * as shell from "@tauri-apps/plugin-shell";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { OpenBrowserURL } from "@/utils/index";
|
||||
import logoImg from "@/assets/32x32.png";
|
||||
import callbackTemplate from "@/components/Auth/callback.template";
|
||||
import { clientEnv } from "@/utils/env";
|
||||
|
||||
export default function Account() {
|
||||
const app_uid = useAppStore((state) => state.app_uid);
|
||||
const setAppUid = useAppStore((state) => state.setAppUid);
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const { auth, setAuth } = useAuthStore();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const setupAuthListener = async () => {
|
||||
try {
|
||||
if (!(auth && auth[endpoint_http])) {
|
||||
// Replace the current route with signin
|
||||
// navigate("/signin", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set up auth listener:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setupAuthListener();
|
||||
|
||||
// Clean up logic on unmount
|
||||
return () => {
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await invoke("plugin:oauth|stop");
|
||||
} catch (e) {
|
||||
// Ignore errors if no server is running
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
cleanup();
|
||||
};
|
||||
}, [JSON.stringify(auth)]);
|
||||
|
||||
async function signIn() {
|
||||
let res: (url: URL) => void;
|
||||
|
||||
try {
|
||||
const stopListening = await listen(
|
||||
"oauth://url",
|
||||
(data: { payload: string }) => {
|
||||
if (!data.payload.includes("token")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlObject = new URL(data.payload);
|
||||
res(urlObject);
|
||||
}
|
||||
);
|
||||
|
||||
// Stop any existing OAuth server first
|
||||
try {
|
||||
await invoke("plugin:oauth|stop");
|
||||
} catch (e) {
|
||||
// Ignore errors if no server is running
|
||||
}
|
||||
|
||||
const port: string = await invoke("plugin:oauth|start", {
|
||||
config: {
|
||||
response: callbackTemplate,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
// Add a cleanup function to stop the server after handling the request
|
||||
cleanup: true,
|
||||
},
|
||||
});
|
||||
|
||||
await shell.open(
|
||||
`${endpoint_http || clientEnv.COCO_SERVER_URL}/api/desktop/session/request?port=${port}`
|
||||
);
|
||||
|
||||
const url = await new Promise<URL>((r) => {
|
||||
res = r;
|
||||
});
|
||||
stopListening();
|
||||
|
||||
const token = url.searchParams.get("token");
|
||||
const user_id = url.searchParams.get("user_id");
|
||||
const expires = Number(url.searchParams.get("expires"));
|
||||
if (!token || !expires || !user_id) {
|
||||
throw new Error("Invalid token or expires");
|
||||
}
|
||||
|
||||
await setAuth({
|
||||
token,
|
||||
user_id,
|
||||
expires,
|
||||
plan: { upgraded: false, last_checked: 0 },
|
||||
}, endpoint_http);
|
||||
|
||||
getCurrentWindow()
|
||||
.setFocus()
|
||||
.catch(() => {});
|
||||
|
||||
return navigate("/");
|
||||
} catch (error) {
|
||||
console.error("Sign in failed:", error);
|
||||
await setAuth(undefined, endpoint_http);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeUser() {
|
||||
let uid = app_uid;
|
||||
if (!uid) {
|
||||
uid = uuidv4();
|
||||
setAppUid(uid);
|
||||
}
|
||||
// const response = await fetch("/api/register", {
|
||||
// method: "POST",
|
||||
// headers: { "Content-Type": "application/json" },
|
||||
// body: JSON.stringify({ uid }),
|
||||
// });
|
||||
// const { token } = await response.json();
|
||||
// localStorage.setItem("auth_token", token);
|
||||
OpenBrowserURL(`http://localhost:1420/login?uid=${uid}`);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function LoginClick(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
||||
event.preventDefault();
|
||||
initializeUser();
|
||||
}
|
||||
return (
|
||||
<div className="h-[450px] bg-gradient-to-br from-purple-100 via-purple-200 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-black flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-md flex flex-col items-center text-center space-y-8">
|
||||
<div className="animate-pulse">
|
||||
<img
|
||||
src={logoImg}
|
||||
alt="logo"
|
||||
className="w-12 h-12 text-red-500 dark:text-red-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Get Started
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm leading-relaxed max-w-sm dark:text-gray-300">
|
||||
You need to log in or create an account to view your organizations,
|
||||
manage your custom extensions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||
<a
|
||||
href="#"
|
||||
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-sm/6 font-semibold text-gray-900 dark:text-gray-100"
|
||||
onClick={LoginClick}
|
||||
>
|
||||
{loading ? "Signing In..." : "Sign In"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,45 @@ import SettingsToggle from "./SettingsToggle";
|
||||
import { ShortcutItem } from "./ShortcutItem";
|
||||
import { Shortcut } from "./shortcut";
|
||||
import { useShortcutEditor } from "@/hooks/useShortcutEditor";
|
||||
import { ThemeOption } from "./index2";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import {AppTheme} from "@/utils/tauri.ts";
|
||||
import {useTheme} from "@/contexts/ThemeContext.tsx";
|
||||
// import { useAuthStore } from "@/stores/authStore";
|
||||
// import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
|
||||
export function ThemeOption({
|
||||
icon: Icon,
|
||||
title,
|
||||
theme,
|
||||
}: {
|
||||
icon: any;
|
||||
title: string;
|
||||
theme: AppTheme;
|
||||
}) {
|
||||
const { theme: currentTheme, changeTheme } = useTheme();
|
||||
|
||||
const isSelected = currentTheme === theme;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => changeTheme(theme)}
|
||||
className={`p-4 rounded-lg border-2 ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
} flex flex-col items-center justify-center space-y-2 transition-all`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 ${isSelected ? "text-blue-500" : ""}`} />
|
||||
<span
|
||||
className={`text-sm font-medium ${isSelected ? "text-blue-500" : ""}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GeneralSettings() {
|
||||
const [launchAtLogin, setLaunchAtLogin] = useState(true);
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Settings,
|
||||
Search,
|
||||
Command,
|
||||
Keyboard,
|
||||
Globe,
|
||||
Zap,
|
||||
ChevronRight,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
|
||||
function NavItem({ icon: Icon, label, active, onClick }: any) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center px-3 py-2 rounded-lg text-sm ${
|
||||
active
|
||||
? "bg-indigo-50 text-indigo-600"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-3" />
|
||||
<span className="font-medium">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingItem({ icon: Icon, title, description, action }: any) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4 border-b border-gray-100">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="p-2 bg-gray-50 rounded-lg">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSettings() {
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
const sections = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "appearance", label: "Appearance", icon: Search },
|
||||
{ id: "extensions", label: "Extensions", icon: Command },
|
||||
{ id: "keyboard", label: "Keyboard", icon: Keyboard },
|
||||
{ id: "advanced", label: "Advanced", icon: Zap },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-2xl border border-gray-200 flex min-h-[500px]">
|
||||
<div className="w-64 p-4 border-r border-gray-100">
|
||||
<div className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<NavItem
|
||||
key={section.id}
|
||||
icon={section.icon}
|
||||
label={section.label}
|
||||
active={activeSection === section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-6">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">
|
||||
Settings
|
||||
</h1>
|
||||
|
||||
<div className="space-y-1">
|
||||
<SettingItem
|
||||
icon={Globe}
|
||||
title="Language"
|
||||
description="Choose your preferred language for the interface"
|
||||
action={
|
||||
<button className="flex items-center text-sm text-gray-600 hover:text-gray-900">
|
||||
English
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingItem
|
||||
icon={darkMode ? Moon : Sun}
|
||||
title="Appearance"
|
||||
description="Switch between light and dark mode"
|
||||
action={
|
||||
<button
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
darkMode ? "translate-x-6" : "translate-x-1"
|
||||
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingItem
|
||||
icon={Command}
|
||||
title="Keyboard Shortcuts"
|
||||
description="Customize your keyboard shortcuts"
|
||||
action={
|
||||
<button className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:border-gray-300">
|
||||
Configure
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingItem
|
||||
icon={Zap}
|
||||
title="Performance Mode"
|
||||
description="Optimize for better performance"
|
||||
action={
|
||||
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-indigo-600 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
<span className="translate-x-6 inline-block h-4 w-4 transform rounded-full bg-white transition-transform" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppSettings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user