app ui fixes and improvements
This commit is contained in:
parent
0fdc6518c0
commit
7dfd54a20a
124
Cargo.lock
generated
124
Cargo.lock
generated
@ -633,9 +633,7 @@ dependencies = [
|
||||
name = "circle_client_ws"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"circle_ws_lib",
|
||||
"env_logger",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"gloo-console 0.3.0",
|
||||
@ -655,7 +653,6 @@ dependencies = [
|
||||
"tokio-native-tls",
|
||||
"tokio-tungstenite 0.19.0",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@ -670,7 +667,6 @@ dependencies = [
|
||||
"actix-web",
|
||||
"actix-web-actors",
|
||||
"chrono",
|
||||
"circle_client_ws",
|
||||
"clap",
|
||||
"engine",
|
||||
"env_logger",
|
||||
@ -692,7 +688,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.19.0",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -729,9 +724,11 @@ dependencies = [
|
||||
"futures-util",
|
||||
"getrandom 0.3.3",
|
||||
"gloo-console 0.3.0",
|
||||
"gloo-events 0.2.0",
|
||||
"gloo-net 0.4.0",
|
||||
"gloo-storage 0.3.0",
|
||||
"gloo-timers 0.3.0",
|
||||
"gloo-utils 0.2.0",
|
||||
"heromodels",
|
||||
"hex",
|
||||
"js-sys",
|
||||
@ -743,6 +740,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha3",
|
||||
"thiserror",
|
||||
"urlencoding",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test",
|
||||
@ -1033,27 +1031,6 @@ dependencies = [
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@ -2077,7 +2054,6 @@ dependencies = [
|
||||
"circle_ws_lib",
|
||||
"clap",
|
||||
"comfy-table",
|
||||
"dirs",
|
||||
"engine",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
@ -2127,16 +2103,6 @@ dependencies = [
|
||||
"windows-targets 0.53.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@ -2405,12 +2371,6 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ourdb"
|
||||
version = "0.1.0"
|
||||
@ -2719,17 +2679,6 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
@ -3672,7 +3621,6 @@ dependencies = [
|
||||
"rustyline",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.23.1",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
@ -4034,15 +3982,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@ -4061,21 +4000,6 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@ -4108,12 +4032,6 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@ -4126,12 +4044,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@ -4144,12 +4056,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@ -4174,12 +4080,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@ -4192,12 +4092,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@ -4210,12 +4104,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@ -4228,12 +4116,6 @@ version = "0.53.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
171
URL_ROUTING_STRATEGY.md
Normal file
171
URL_ROUTING_STRATEGY.md
Normal file
@ -0,0 +1,171 @@
|
||||
# URL and History Support Implementation Strategy
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the strategy for implementing clean URL and history support in the Circles application, starting with the LibraryView component. The goal is to enable component state changes to trigger URL updates and allow initial component state to be calculated from URLs for link sharing and refresh support.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Strengths
|
||||
- App-level routing already implemented with `AppView::to_path()` and `AppView::from_path()`
|
||||
- Browser history API integration in `App::update()`
|
||||
- Query parameter support for circles context (`?circles=url1,url2`)
|
||||
|
||||
### Gaps
|
||||
- Component-level state changes don't update URLs
|
||||
- Initial component state isn't derived from URLs
|
||||
- No support for nested routing (e.g., `/library/collection/123/item/456`)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Browser URL] --> B[App Router]
|
||||
B --> C[AppView Route Parser]
|
||||
C --> D[Component Route Parser]
|
||||
D --> E[Component State]
|
||||
E --> F[Component Renders]
|
||||
F --> G[User Interaction]
|
||||
G --> H[State Change]
|
||||
H --> I[URL Update]
|
||||
I --> A
|
||||
|
||||
subgraph "URL Structure"
|
||||
J["/library/collection/123/item/456?circles=ws1,ws2"]
|
||||
K["Path: /library/collection/123/item/456"]
|
||||
L["Query: ?circles=ws1,ws2"]
|
||||
end
|
||||
```
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase 1: Create URL Routing Infrastructure
|
||||
|
||||
#### 1.1 Router Trait Definition
|
||||
Create a reusable `UrlRouter` trait that components can implement:
|
||||
|
||||
```rust
|
||||
pub trait UrlRouter {
|
||||
type RouteState;
|
||||
fn parse_route(path: &str) -> Option<Self::RouteState>;
|
||||
fn build_route(state: &Self::RouteState) -> String;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 History Manager
|
||||
Centralized history management with:
|
||||
- Prevention of duplicate history entries
|
||||
- Handling of popstate events for back/forward navigation
|
||||
- Smart decision between `pushState` and `replaceState`
|
||||
|
||||
### Phase 2: Extend App-Level Routing
|
||||
|
||||
#### 2.1 Enhanced AppView Routing
|
||||
Modify `AppView::from_path()` to:
|
||||
- Extract base view from path (e.g., `/library` from `/library/collection/123`)
|
||||
- Pass remaining path segments to components
|
||||
- Handle nested route parsing
|
||||
|
||||
#### 2.2 Route Segment Passing
|
||||
Update component props to include:
|
||||
- `initial_route: Option<String>` - route segment for component
|
||||
- `on_route_change: Callback<String>` - notify app of route changes
|
||||
|
||||
### Phase 3: LibraryView URL Integration
|
||||
|
||||
#### 3.1 LibraryRoute Definition
|
||||
```rust
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LibraryRoute {
|
||||
Collections,
|
||||
Collection { collection_id: String },
|
||||
Item { collection_id: String, item_id: String },
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 URL Pattern Mapping
|
||||
- `/library` → `LibraryRoute::Collections`
|
||||
- `/library/collection/{id}` → `LibraryRoute::Collection`
|
||||
- `/library/collection/{id}/item/{item_id}` → `LibraryRoute::Item`
|
||||
|
||||
#### 3.3 State Synchronization
|
||||
- Parse initial route on component creation
|
||||
- Update URL when `ViewState` changes
|
||||
- Handle browser back/forward navigation
|
||||
|
||||
## Detailed Implementation Plan
|
||||
|
||||
### Step 1: Router Infrastructure
|
||||
**Files to create:**
|
||||
- `src/app/src/routing/mod.rs`
|
||||
- `src/app/src/routing/url_router.rs`
|
||||
- `src/app/src/routing/library_router.rs`
|
||||
- `src/app/src/routing/route_parser.rs`
|
||||
|
||||
### Step 2: App.rs Modifications
|
||||
**Changes to `App`:**
|
||||
1. Enhanced route parsing in `AppView::from_path()`
|
||||
2. Route segment extraction and passing to components
|
||||
3. Popstate event handling for browser navigation
|
||||
4. Updated URL building logic
|
||||
|
||||
### Step 3: LibraryView Transformation
|
||||
**Key changes:**
|
||||
1. Add `current_route: LibraryRoute` to component state
|
||||
2. Initialize state from URL on component creation
|
||||
3. Update URL when state changes via message handlers
|
||||
4. Handle route changes from browser navigation
|
||||
|
||||
### Step 4: Component Props Enhancement
|
||||
**New props structure:**
|
||||
```rust
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct LibraryViewProps {
|
||||
pub ws_addresses: Vec<String>,
|
||||
pub initial_route: Option<String>,
|
||||
pub on_route_change: Callback<String>,
|
||||
}
|
||||
```
|
||||
|
||||
## URL Examples
|
||||
|
||||
### LibraryView Routes
|
||||
- `/library` - Collections view
|
||||
- `/library/collection/ws1_collection123` - Items in collection
|
||||
- `/library/collection/ws1_collection123/item/item456` - Viewing specific item
|
||||
|
||||
### With Context
|
||||
- `/library/collection/ws1_collection123/item/item456?circles=ws1,ws2` - With circles context
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Clean Separation**: Router logic separated from component logic
|
||||
2. **Reusable**: Router trait can be implemented by other views
|
||||
3. **Minimal Code**: Leverages existing URL infrastructure
|
||||
4. **Link Sharing**: Full state encoded in URLs
|
||||
5. **Browser Integration**: Proper back/forward navigation support
|
||||
6. **SEO Friendly**: Meaningful URLs for each state
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. **Backward Compatibility**: Existing URLs continue to work
|
||||
2. **Gradual Rollout**: Start with LibraryView, extend to other components
|
||||
3. **Fallback Handling**: Graceful degradation for invalid routes
|
||||
4. **Progressive Enhancement**: Add URL support without breaking existing functionality
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Create router infrastructure
|
||||
2. Extend App.rs routing capabilities
|
||||
3. Transform LibraryView to be URL-aware
|
||||
4. Test and refine
|
||||
5. Extend pattern to other views (Intelligence, Publishing, etc.)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**: Router parsing and building functions
|
||||
2. **Integration Tests**: Component state synchronization with URLs
|
||||
3. **Browser Tests**: Back/forward navigation, refresh behavior
|
||||
4. **Link Sharing Tests**: URLs work when shared and opened in new tabs
|
||||
|
||||
This strategy provides a clean, scalable approach to URL and history support that can be extended to other components in the application.
|
@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "Timur Gordon",
|
||||
"port": 9100,
|
||||
"script_path": "scripts/test_script.rhai",
|
||||
"public_key": "023b0a9d409506f41f5782353857dea6abc16ae4643661cd94d8155fdb498642e3",
|
||||
"secret_key": "7a7074c59ccfa3465686277e9e9da34867b36a1e256271b893b6c22fbc82929e"
|
||||
},
|
||||
{
|
||||
"name": "Kristof de Spiegeleer",
|
||||
"port": 9101,
|
||||
"script_path": "scripts/test_script.rhai",
|
||||
"public_key": "030b62236efa67855b3379a9d4add1facbe8a545bafa86e1d6fbac06caae5b5b12",
|
||||
"secret_key": "04225fbb41d8c397581d7ec19ded8aaf02d8b9daf27fed9617525e4f8114a382"
|
||||
},
|
||||
{
|
||||
"name": "OurWorld",
|
||||
"port": 9000,
|
||||
|
@ -1,51 +1,65 @@
|
||||
[
|
||||
{
|
||||
"name": "Timur Gordon",
|
||||
"public_key": "023b0a9d409506f41f5782353857dea6abc16ae4643661cd94d8155fdb498642e3",
|
||||
"secret_key": "7a7074c59ccfa3465686277e9e9da34867b36a1e256271b893b6c22fbc82929e",
|
||||
"worker_queue": "rhai_tasks:023b0a9d409506f41f5782353857dea6abc16ae4643661cd94d8155fdb498642e3",
|
||||
"ws_url": "ws://127.0.0.1:9100"
|
||||
},
|
||||
{
|
||||
"name": "Kristof de Spiegeleer",
|
||||
"public_key": "030b62236efa67855b3379a9d4add1facbe8a545bafa86e1d6fbac06caae5b5b12",
|
||||
"secret_key": "04225fbb41d8c397581d7ec19ded8aaf02d8b9daf27fed9617525e4f8114a382",
|
||||
"worker_queue": "rhai_tasks:030b62236efa67855b3379a9d4add1facbe8a545bafa86e1d6fbac06caae5b5b12",
|
||||
"ws_url": "ws://127.0.0.1:9101"
|
||||
},
|
||||
{
|
||||
"name": "OurWorld",
|
||||
"public_key": "02b1ff38c18f66ffcfde1ff4931093484a96d378db55c1306a0760b39172d74099",
|
||||
"secret_key": "86ed603c86f8938060575f7b1c7e4e4ddf72030ad2ea1699a8e9d1fb3a610869",
|
||||
"worker_queue": "rhai_tasks:02b1ff38c18f66ffcfde1ff4931093484a96d378db55c1306a0760b39172d74099",
|
||||
"public_key": "02c9e7cb76e19adc3b579091d923ef273282070bc4864c1a07e94ca34a78aea8ef",
|
||||
"secret_key": "cb58d003eae0a5168b6f6fc4e822879b17e7b5987e5a76b870b75fe659e3cc60",
|
||||
"worker_queue": "rhai_tasks:02c9e7cb76e19adc3b579091d923ef273282070bc4864c1a07e94ca34a78aea8ef",
|
||||
"ws_url": "ws://127.0.0.1:9000"
|
||||
},
|
||||
{
|
||||
"name": "Dunia Cybercity",
|
||||
"public_key": "020d8b1e3baab9991a82e9b55e117f45fda58b3f90b072dbbf10888f3195bfe6b9",
|
||||
"secret_key": "b1ac20e4c6ace638f7f9e07918997fc35b2425de78152139c8b54629ca303b81",
|
||||
"worker_queue": "rhai_tasks:020d8b1e3baab9991a82e9b55e117f45fda58b3f90b072dbbf10888f3195bfe6b9",
|
||||
"public_key": "02a19f52bdde937a3f05c1bcc9b58c467d5084586a5a0b832617e131886c961771",
|
||||
"secret_key": "c2a9a0c35b7c85cadfaf9e3123829324e4b4b116239833123d73da14b13dfbde",
|
||||
"worker_queue": "rhai_tasks:02a19f52bdde937a3f05c1bcc9b58c467d5084586a5a0b832617e131886c961771",
|
||||
"ws_url": "ws://127.0.0.1:9001"
|
||||
},
|
||||
{
|
||||
"name": "Sikana",
|
||||
"public_key": "0363dbff9f2b6dbaf58d3e8774db54dcccd10e23461ebf9a93cca63f8aa321d11d",
|
||||
"secret_key": "9383663dcac577c14679c3487e6ffe7ff95040f422d391219ea530b892c1b0a0",
|
||||
"worker_queue": "rhai_tasks:0363dbff9f2b6dbaf58d3e8774db54dcccd10e23461ebf9a93cca63f8aa321d11d",
|
||||
"public_key": "032db92879e51d5adf17a99df0cedba49d69e051ffb7b224e5a50e5d02a8f3c68f",
|
||||
"secret_key": "3718bc30c8d3ee1e88f1d88e06ed7637197c2f9b422a5757201acf572e9c7345",
|
||||
"worker_queue": "rhai_tasks:032db92879e51d5adf17a99df0cedba49d69e051ffb7b224e5a50e5d02a8f3c68f",
|
||||
"ws_url": "ws://127.0.0.1:9002"
|
||||
},
|
||||
{
|
||||
"name": "Threefold",
|
||||
"public_key": "02c19cd347605dab98fb767b5e53c5fa5131d47a46b5f560b565fd4d79c1190994",
|
||||
"secret_key": "0c4f5172724218650ea5806f5c9f8d4d4c8197c0c775f9d022fd8a192ad59048",
|
||||
"worker_queue": "rhai_tasks:02c19cd347605dab98fb767b5e53c5fa5131d47a46b5f560b565fd4d79c1190994",
|
||||
"public_key": "021b452667f0c73a9f96c65dfceb0810e36109ad2408e0693a90fd4cdf7d8de0f6",
|
||||
"secret_key": "9e9532bdc279570f22b8bc853eadf8f7cdf5cacd3e506ae06b3a9f34778a372f",
|
||||
"worker_queue": "rhai_tasks:021b452667f0c73a9f96c65dfceb0810e36109ad2408e0693a90fd4cdf7d8de0f6",
|
||||
"ws_url": "ws://127.0.0.1:9003"
|
||||
},
|
||||
{
|
||||
"name": "Mbweni",
|
||||
"public_key": "0251808090b5b916e6187b63b6c97411f9d5406a9a6179408b90e3ff83042e7a9c",
|
||||
"secret_key": "c824b3334350e2b267be2d4ceb1db53e98c9f386d2855aa7130227caa580805c",
|
||||
"worker_queue": "rhai_tasks:0251808090b5b916e6187b63b6c97411f9d5406a9a6179408b90e3ff83042e7a9c",
|
||||
"public_key": "029423b664660e9d9bcdbde244fda7d2064b17089463ddfb6eed301e34fb115969",
|
||||
"secret_key": "b70c510765d1a3e315871355fb6b902662f465ef317ddbabf146163ad8b83937",
|
||||
"worker_queue": "rhai_tasks:029423b664660e9d9bcdbde244fda7d2064b17089463ddfb6eed301e34fb115969",
|
||||
"ws_url": "ws://127.0.0.1:9004"
|
||||
},
|
||||
{
|
||||
"name": "Geomind",
|
||||
"public_key": "037e2def151e7587b95519370e5d1023b9f24845e8e23a6535b0aad3cff20a859b",
|
||||
"secret_key": "9c701a02ebba983d04ecbccee5072ed2cebd67ead4677c79a72d089d3ff29295",
|
||||
"worker_queue": "rhai_tasks:037e2def151e7587b95519370e5d1023b9f24845e8e23a6535b0aad3cff20a859b",
|
||||
"public_key": "03af7abb8737dfb0d3e7eb22d4e57d5566a82be0ca7c4fe7f7cf1b37ad595044f7",
|
||||
"secret_key": "22a4b7514b3f88a697566f5c1aa12178e0974ae3f7ae6eb7054c4c9cbd30a8fa",
|
||||
"worker_queue": "rhai_tasks:03af7abb8737dfb0d3e7eb22d4e57d5566a82be0ca7c4fe7f7cf1b37ad595044f7",
|
||||
"ws_url": "ws://127.0.0.1:9005"
|
||||
},
|
||||
{
|
||||
"name": "Freezone",
|
||||
"public_key": "02d4bf2713876cff2428f3f5e7e6191028374994d43a2c0f3d62c728a22d7f4aed",
|
||||
"secret_key": "602c1bdd95489c7153676488976e9a24483cb353778332ec3b7644c3f05f5af2",
|
||||
"worker_queue": "rhai_tasks:02d4bf2713876cff2428f3f5e7e6191028374994d43a2c0f3d62c728a22d7f4aed",
|
||||
"public_key": "03a00761250dd79294ccbc4de916454736ff5a6488d8bb93c759d2dae5abf20b03",
|
||||
"secret_key": "a09d158e6f2b6706bca97a320bbc64b6278fe795820a0759f658f230fd071003",
|
||||
"worker_queue": "rhai_tasks:03a00761250dd79294ccbc4de916454736ff5a6488d8bb93c759d2dae5abf20b03",
|
||||
"ws_url": "ws://127.0.0.1:9006"
|
||||
}
|
||||
]
|
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
15
examples/ourworld/scripts/kristof.rhai
Normal file
15
examples/ourworld/scripts/kristof.rhai
Normal file
@ -0,0 +1,15 @@
|
||||
// OurWorld Circle and Library Data
|
||||
|
||||
new_circle()
|
||||
.title("Kristof de Spiegeleer")
|
||||
.description("Creating a better world.")
|
||||
.ws_url("ws://localhost:9101/ws")
|
||||
.add_circle("ws://localhost:9000/ws")
|
||||
.add_circle("ws://localhost:9001/ws")
|
||||
.add_circle("ws://localhost:9002/ws")
|
||||
.add_circle("ws://localhost:9003/ws")
|
||||
.add_circle("ws://localhost:9004/ws")
|
||||
.add_circle("ws://localhost:9005/ws")
|
||||
.add_circle("ws://localhost:8096/ws")
|
||||
.logo("🌍")
|
||||
.save_circle();
|
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
@ -10,6 +10,8 @@ new_circle()
|
||||
.add_circle("ws://localhost:9004/ws")
|
||||
.add_circle("ws://localhost:9005/ws")
|
||||
.add_circle("ws://localhost:8096/ws")
|
||||
.add_member("023b0a9d409506f41f5782353857dea6abc16ae4643661cd94d8155fdb498642e3")
|
||||
.add_member("030b62236efa67855b3379a9d4add1facbe8a545bafa86e1d6fbac06caae5b5b12")
|
||||
.logo("🌍")
|
||||
.save_circle();
|
||||
|
||||
@ -175,28 +177,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
@ -3,7 +3,7 @@
|
||||
new_circle()
|
||||
.title("Sikana")
|
||||
.description("Creating a better world.")
|
||||
.ws_url("ws://localhost:8092/ws")
|
||||
.ws_url("ws://localhost:9002/ws")
|
||||
.logo("🌍")
|
||||
.save_circle();
|
||||
|
||||
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
@ -169,28 +169,28 @@ print("Creating slides...");
|
||||
let climate_slides = save_slides(new_slides()
|
||||
.title("Climate Change Awareness")
|
||||
.description("Visual presentation on climate change impacts and solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200", "Global Temperature Rise")
|
||||
.add_slide("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200", "Melting Ice Caps")
|
||||
.add_slide("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200", "Extreme Weather Events")
|
||||
.add_slide("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200", "Renewable Energy Solutions")
|
||||
.add_slide("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200", "Sustainable Transportation"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1569163139394-de4e4f43e4e3?w=1200").title("Global Temperature Rise"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=1200").title("Melting Ice Caps"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1200").title("Extreme Weather Events"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=1200").title("Renewable Energy Solutions"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1497436072909-f5e4be1dffea?w=1200").title("Sustainable Transportation")));
|
||||
|
||||
let innovation_slides = save_slides(new_slides()
|
||||
.title("Innovation Showcase")
|
||||
.description("Cutting-edge technologies for a sustainable future")
|
||||
.add_slide("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200", "AI and Machine Learning")
|
||||
.add_slide("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200", "Blockchain Technology")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200", "IoT and Smart Cities")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200", "Quantum Computing")
|
||||
.add_slide("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200", "Biotechnology Advances"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=1200").title("AI and Machine Learning"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1639322537228-f710d846310a?w=1200").title("Blockchain Technology"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092160562-40aa08e78837?w=1200").title("IoT and Smart Cities"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092918056-0c4c3acd3789?w=1200").title("Quantum Computing"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1581092162384-8987c1d64718?w=1200").title("Biotechnology Advances")));
|
||||
|
||||
let nature_slides = save_slides(new_slides()
|
||||
.title("Biodiversity Gallery")
|
||||
.description("Celebrating Earth's incredible biodiversity")
|
||||
.add_slide("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200", "Tropical Rainforest")
|
||||
.add_slide("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200", "Coral Reef Ecosystem")
|
||||
.add_slide("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200", "Arctic Wildlife")
|
||||
.add_slide("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200", "Mountain Ecosystems"));
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1564349683136-77e08dba1ef7?w=1200").title("Tropical Rainforest"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1559827260-dc66d52bef19?w=1200").title("Coral Reef Ecosystem"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=1200").title("Arctic Wildlife"))
|
||||
.add_slide(new_slide().url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200").title("Mountain Ecosystems")));
|
||||
|
||||
// === COLLECTIONS ===
|
||||
print("Creating collections...");
|
||||
|
15
examples/ourworld/scripts/timur.rhai
Normal file
15
examples/ourworld/scripts/timur.rhai
Normal file
@ -0,0 +1,15 @@
|
||||
// OurWorld Circle and Library Data
|
||||
|
||||
new_circle()
|
||||
.title("Timur Gordon")
|
||||
.description("Creating a better world.")
|
||||
.ws_url("ws://localhost:9100/ws")
|
||||
.add_circle("ws://localhost:9000/ws")
|
||||
.add_circle("ws://localhost:9001/ws")
|
||||
.add_circle("ws://localhost:9002/ws")
|
||||
.add_circle("ws://localhost:9003/ws")
|
||||
.add_circle("ws://localhost:9004/ws")
|
||||
.add_circle("ws://localhost:9005/ws")
|
||||
.add_circle("ws://localhost:8096/ws")
|
||||
.logo("🌍")
|
||||
.save_circle();
|
11
index.html
Normal file
11
index.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Circles</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
1
src/app/.gitignore
vendored
1
src/app/.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
/dist/
|
||||
/target/
|
||||
*.db
|
@ -17,12 +17,14 @@ log = "0.4"
|
||||
wasm-logger = "0.2"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
web-sys = { version = "0.3", features = ["MouseEvent", "Element", "HtmlElement", "SvgElement", "Window", "Document", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "Storage", "Location", "Window", "Navigator", "DomRect", "MouseEvent", "FocusEvent", "InputEvent", "Element", "HtmlElement", "SvgElement", "Document", "CssStyleDeclaration", "Clipboard", "History"] }
|
||||
gloo-timers = "0.3.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
gloo-net = "0.4"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-console = "0.3" # For console logging
|
||||
gloo-events = "0.2"
|
||||
gloo-utils = "0.2"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } # For StreamExt
|
||||
futures-channel = "0.3" # For MPSC channels
|
||||
rand = "0.8" # For random traffic simulation
|
||||
@ -31,6 +33,7 @@ engine = { path = "/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/
|
||||
rhai = "1.17"
|
||||
js-sys = "0.3"
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
urlencoding = "2.1"
|
||||
|
||||
# Authentication dependencies
|
||||
secp256k1 = { workspace = true, features = ["rand", "recovery", "hashes"] }
|
||||
|
@ -1,17 +1,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::UrlSearchParams;
|
||||
use yew::platform::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::auth::{AuthManager, AuthState};
|
||||
use crate::components::auth_view::AuthView;
|
||||
use crate::components::circles_view::CirclesView;
|
||||
use crate::components::customize_view::CustomizeViewComponent;
|
||||
use crate::components::inspector_view::InspectorView;
|
||||
use crate::components::intelligence_view::IntelligenceView;
|
||||
use crate::components::library_view::LibraryView;
|
||||
use crate::components::login_component::LoginComponent;
|
||||
use crate::components::nav_island::NavIsland;
|
||||
use crate::components::publishing_view::PublishingView;
|
||||
use crate::routing::{AppRouteParser, HistoryManager};
|
||||
use crate::views::auth_view::AuthView;
|
||||
use crate::views::circles_view::CirclesView;
|
||||
use crate::views::customize_view::CustomizeView;
|
||||
use crate::views::inspector_view::InspectorView;
|
||||
use crate::views::intelligence_view::IntelligenceView;
|
||||
use crate::views::library_view::LibraryView;
|
||||
use crate::views::publishing_view::PublishingView;
|
||||
use crate::ws_manager::fetch_data_from_ws_urls;
|
||||
use heromodels::models::circle::{Circle, ThemeData};
|
||||
|
||||
// Props for the App component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
@ -30,22 +35,82 @@ pub enum AppView {
|
||||
Inspector, // Added Inspector
|
||||
}
|
||||
|
||||
impl AppView {
|
||||
pub fn to_path(&self) -> String {
|
||||
match self {
|
||||
AppView::Login => "/login".to_string(),
|
||||
AppView::Circles => "/".to_string(),
|
||||
AppView::Library => "/library".to_string(),
|
||||
AppView::Intelligence => "/intelligence".to_string(),
|
||||
AppView::Publishing => "/publishing".to_string(),
|
||||
AppView::Customize => "/customize".to_string(),
|
||||
AppView::Inspector => "/inspector".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_path(path: &str) -> Self {
|
||||
let (base_view, _sub_route) = AppRouteParser::parse_app_route(path);
|
||||
match base_view.as_str() {
|
||||
"library" => AppView::Library,
|
||||
"intelligence" => AppView::Intelligence,
|
||||
"publishing" => AppView::Publishing,
|
||||
"customize" => AppView::Customize,
|
||||
"inspector" => AppView::Inspector,
|
||||
"login" => AppView::Login,
|
||||
_ => AppView::Circles, // Default to Circles for root or unknown paths
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the sub-route for a given path and app view
|
||||
pub fn extract_sub_route(path: &str, app_view: &AppView) -> String {
|
||||
let base_view = match app_view {
|
||||
AppView::Login => "login",
|
||||
AppView::Circles => "",
|
||||
AppView::Library => "library",
|
||||
AppView::Intelligence => "intelligence",
|
||||
AppView::Publishing => "publishing",
|
||||
AppView::Customize => "customize",
|
||||
AppView::Inspector => "inspector",
|
||||
};
|
||||
|
||||
let (_parsed_base, sub_route) = AppRouteParser::parse_app_route(path);
|
||||
sub_route
|
||||
}
|
||||
|
||||
/// Build a full path from app view and sub-route
|
||||
pub fn build_full_path(&self, sub_route: &str) -> String {
|
||||
let base_path = self.to_path();
|
||||
if sub_route.is_empty() {
|
||||
base_path
|
||||
} else {
|
||||
format!("{}{}", base_path, sub_route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Msg {
|
||||
SwitchView(AppView),
|
||||
UpdateCirclesContext(Vec<String>), // Context URLs from CirclesView
|
||||
SwitchViewWithRoute(AppView, String), // New: Switch view with sub-route
|
||||
UpdateSubRoute(String), // New: Update sub-route for current view
|
||||
UpdateCirclesContext(Vec<Circle>), // Context from CirclesView is now the full Circle objects
|
||||
UpdateTheme(ThemeData),
|
||||
AuthStateChanged(AuthState),
|
||||
AuthenticationSuccessful,
|
||||
AuthenticationFailed(String),
|
||||
AttemptKeypairLogin((String, String)), // New: (public_key, private_key)
|
||||
CompleteRegistration((String, String, String)), // New: (name, generated_public_key, generated_private_key)
|
||||
PopStateChanged, // New: Handle browser back/forward navigation
|
||||
Logout,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
current_view: AppView,
|
||||
active_context_urls: Vec<String>, // Only context URLs from CirclesView
|
||||
start_circle_ws_url: String, // Initial WebSocket URL for CirclesView
|
||||
current_sub_route: String, // New: Track sub-route for current view
|
||||
active_context_circles: Vec<Circle>, // Store the full circle objects
|
||||
start_circle_ws_url: String, // Initial WebSocket URL for CirclesView
|
||||
auth_manager: AuthManager,
|
||||
auth_state: AuthState,
|
||||
theme: ThemeData,
|
||||
initial_context_url_from_query: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for App {
|
||||
@ -64,86 +129,201 @@ impl Component for App {
|
||||
let link = ctx.link().clone();
|
||||
auth_manager.set_on_state_change(link.callback(Msg::AuthStateChanged));
|
||||
|
||||
// Determine initial view based on authentication state
|
||||
let initial_view = match auth_state {
|
||||
AuthState::Authenticated { .. } => AppView::Circles,
|
||||
_ => AppView::Login,
|
||||
// Set up popstate event listener for browser back/forward navigation
|
||||
let popstate_link = ctx.link().clone();
|
||||
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move |_: web_sys::Event| {
|
||||
popstate_link.send_message(Msg::PopStateChanged);
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window
|
||||
.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref());
|
||||
}
|
||||
closure.forget(); // Keep the closure alive for the lifetime of the app
|
||||
|
||||
// Determine initial view and sub-route based on authentication state and URL
|
||||
let (initial_view, initial_sub_route) = match auth_state {
|
||||
AuthState::Authenticated { .. } => {
|
||||
let path = web_sys::window()
|
||||
.and_then(|w| w.location().pathname().ok())
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
let view = AppView::from_path(&path);
|
||||
let sub_route = AppView::extract_sub_route(&path, &view);
|
||||
(view, sub_route)
|
||||
}
|
||||
_ => (AppView::Login, String::new()),
|
||||
};
|
||||
|
||||
// Parse circle URLs from query parameters and pre-fetch their data
|
||||
let mut initial_context_url_from_query = None;
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(search) = window.location().search() {
|
||||
if let Ok(params) = UrlSearchParams::new_with_str(&search) {
|
||||
if let Some(urls_str) = params.get("circles") {
|
||||
let urls: Vec<String> = urls_str.split(',').map(String::from).collect();
|
||||
if !urls.is_empty() {
|
||||
initial_context_url_from_query = Some(urls[0].clone());
|
||||
|
||||
let link = ctx.link().clone();
|
||||
spawn_local(async move {
|
||||
let script = "get_circle().json()".to_string();
|
||||
// The script returns a single Circle object from each URL.
|
||||
let fetched_circles_map: HashMap<String, Circle> =
|
||||
fetch_data_from_ws_urls(&urls, script).await;
|
||||
|
||||
let circles: Vec<Circle> = fetched_circles_map
|
||||
.into_iter()
|
||||
.map(|(ws_url, mut circle)| {
|
||||
// Manually set the ws_url on the circle object
|
||||
// as it might not be part of the returned data.
|
||||
if circle.ws_url.is_empty() {
|
||||
circle.ws_url = ws_url;
|
||||
}
|
||||
circle
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !circles.is_empty() {
|
||||
link.send_message(Msg::UpdateCirclesContext(circles));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
current_view: initial_view,
|
||||
active_context_urls: Vec::new(),
|
||||
current_sub_route: initial_sub_route,
|
||||
active_context_circles: Vec::new(),
|
||||
start_circle_ws_url,
|
||||
auth_manager,
|
||||
auth_state,
|
||||
theme: ThemeData::default(),
|
||||
initial_context_url_from_query,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::UpdateCirclesContext(context_urls) => {
|
||||
Msg::UpdateCirclesContext(context_circles) => {
|
||||
log::info!(
|
||||
"App: Received context update from CirclesView: {:?}",
|
||||
context_urls
|
||||
"App: Received context update from CirclesView with {} circles",
|
||||
context_circles.len()
|
||||
);
|
||||
self.active_context_urls = context_urls;
|
||||
// If there's a primary circle, use its theme
|
||||
if let Some(primary_circle) = context_circles.first() {
|
||||
self.theme = primary_circle.theme.clone();
|
||||
}
|
||||
self.active_context_circles = context_circles;
|
||||
|
||||
// Update URL with the new context
|
||||
let path = self.current_view.build_full_path(&self.current_sub_route);
|
||||
let urls: Vec<String> = self
|
||||
.active_context_circles
|
||||
.iter()
|
||||
.map(|c| c.ws_url.clone())
|
||||
.collect();
|
||||
let query = if !urls.is_empty() {
|
||||
format!("circles={}", urls.join(","))
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let full_url = HistoryManager::build_full_url(&path, &query);
|
||||
if let Err(e) = HistoryManager::replace_url(&full_url) {
|
||||
log::error!("Failed to update URL with context: {:?}", e);
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::UpdateTheme(theme) => {
|
||||
log::info!("App: Theme updated via CustomizeView");
|
||||
self.theme = theme.clone();
|
||||
// Also update the theme in the active context to prevent stale data
|
||||
if let Some(primary_circle) = self.active_context_circles.get_mut(0) {
|
||||
primary_circle.theme = theme;
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::SwitchView(view) => {
|
||||
// Check if authentication is required for certain views
|
||||
match view {
|
||||
AppView::Login => {
|
||||
self.current_view = view;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.auth_manager.is_authenticated() {
|
||||
self.current_view = view;
|
||||
true
|
||||
} else {
|
||||
log::warn!(
|
||||
"Attempted to access {} view without authentication",
|
||||
format!("{:?}", view)
|
||||
);
|
||||
self.current_view = AppView::Login;
|
||||
true
|
||||
}
|
||||
}
|
||||
// Switch view with empty sub-route
|
||||
self.handle_view_switch(view, String::new())
|
||||
}
|
||||
Msg::SwitchViewWithRoute(view, sub_route) => {
|
||||
// Switch view with specific sub-route
|
||||
self.handle_view_switch(view, sub_route)
|
||||
}
|
||||
Msg::UpdateSubRoute(sub_route) => {
|
||||
// Update sub-route for current view
|
||||
if self.current_sub_route != sub_route {
|
||||
self.current_sub_route = sub_route;
|
||||
self.update_url_for_current_state();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Msg::AuthStateChanged(state) => {
|
||||
log::info!("App: Auth state changed: {:?}", state);
|
||||
let previous_auth_state_is_authed =
|
||||
matches!(self.auth_state, AuthState::Authenticated { .. });
|
||||
self.auth_state = state.clone();
|
||||
|
||||
match state {
|
||||
AuthState::Authenticated { .. } => {
|
||||
// Switch to main app view when authenticated
|
||||
if self.current_view == AppView::Login {
|
||||
self.current_view = AppView::Circles;
|
||||
}
|
||||
}
|
||||
AuthState::NotAuthenticated | AuthState::Failed(_) => {
|
||||
// Switch to login view when not authenticated
|
||||
self.current_view = AppView::Login;
|
||||
}
|
||||
_ => {}
|
||||
// If the user just logged in, switch to the Circles view
|
||||
if !previous_auth_state_is_authed
|
||||
&& matches!(self.auth_state, AuthState::Authenticated { .. })
|
||||
{
|
||||
self.current_view = AppView::Circles;
|
||||
}
|
||||
// If the user just logged out, switch to the Login view
|
||||
else if previous_auth_state_is_authed
|
||||
&& !matches!(self.auth_state, AuthState::Authenticated { .. })
|
||||
{
|
||||
self.current_view = AppView::Login;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
Msg::AuthenticationSuccessful => {
|
||||
log::info!("App: Authentication successful");
|
||||
self.current_view = AppView::Circles;
|
||||
true
|
||||
Msg::AttemptKeypairLogin((public_key, private_key)) => {
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
spawn_local(async move {
|
||||
let _ = auth_manager
|
||||
.login_with_keypair(public_key, private_key)
|
||||
.await;
|
||||
});
|
||||
false
|
||||
}
|
||||
Msg::AuthenticationFailed(error) => {
|
||||
log::error!("App: Authentication failed: {}", error);
|
||||
self.current_view = AppView::Login;
|
||||
true
|
||||
Msg::CompleteRegistration((name, public_key, private_key)) => {
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
spawn_local(async move {
|
||||
let _ = auth_manager
|
||||
.register_and_login(name, public_key, private_key)
|
||||
.await;
|
||||
});
|
||||
false
|
||||
}
|
||||
Msg::PopStateChanged => {
|
||||
// Handle browser back/forward navigation
|
||||
let current_path = HistoryManager::get_current_path();
|
||||
let new_view = AppView::from_path(¤t_path);
|
||||
let new_sub_route = AppView::extract_sub_route(¤t_path, &new_view);
|
||||
|
||||
// Only update if the route actually changed
|
||||
if self.current_view != new_view || self.current_sub_route != new_sub_route {
|
||||
self.current_view = new_view;
|
||||
self.current_sub_route = new_sub_route;
|
||||
log::info!(
|
||||
"PopState: Updated to view {:?} with sub-route '{}'",
|
||||
self.current_view,
|
||||
self.current_sub_route
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
Msg::Logout => {
|
||||
log::info!("App: User logout");
|
||||
self.auth_manager.logout();
|
||||
self.current_view = AppView::Login;
|
||||
true
|
||||
}
|
||||
}
|
||||
@ -151,71 +331,94 @@ impl Component for App {
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
let active_context_urls: Vec<String> = self
|
||||
.active_context_circles
|
||||
.iter()
|
||||
.map(|c| c.ws_url.clone())
|
||||
.collect();
|
||||
let all_circles_map: HashMap<String, Circle> = self
|
||||
.active_context_circles
|
||||
.iter()
|
||||
.map(|c| (c.ws_url.clone(), c.clone()))
|
||||
.collect();
|
||||
|
||||
// If not authenticated and not on login view, show login
|
||||
if !self.auth_manager.is_authenticated() && self.current_view != AppView::Login {
|
||||
return html! {
|
||||
<LoginComponent
|
||||
auth_manager={self.auth_manager.clone()}
|
||||
on_authenticated={link.callback(|_| Msg::AuthenticationSuccessful)}
|
||||
on_error={link.callback(Msg::AuthenticationFailed)}
|
||||
/>
|
||||
};
|
||||
}
|
||||
let style = format!(
|
||||
"--primary-color: {}; --background-color: {}; --logo-symbol: '{}'; --logo-url: url('{}');",
|
||||
self.theme.primary_color,
|
||||
self.theme.background_color,
|
||||
self.theme.logo_symbol,
|
||||
self.theme.logo_url
|
||||
);
|
||||
|
||||
let pattern_class = format!("pattern-{}", self.theme.background_pattern);
|
||||
|
||||
html! {
|
||||
<div class="yew-app-container">
|
||||
<div class={classes!("yew-app-container", pattern_class)} {style}>
|
||||
{ self.render_header(link) }
|
||||
|
||||
{ match self.current_view {
|
||||
AppView::Login => {
|
||||
html! {
|
||||
<LoginComponent
|
||||
auth_manager={self.auth_manager.clone()}
|
||||
on_authenticated={link.callback(|_| Msg::AuthenticationSuccessful)}
|
||||
on_error={link.callback(Msg::AuthenticationFailed)}
|
||||
<AuthView
|
||||
auth_state={self.auth_state.clone()}
|
||||
on_logout={link.callback(|_| Msg::Logout)}
|
||||
on_keypair_login_attempt={link.callback(|(pk, sk)| Msg::AttemptKeypairLogin((pk, sk)))}
|
||||
on_registration_complete={link.callback(|(name, pk, sk)| Msg::CompleteRegistration((name, pk, sk)))}
|
||||
/>
|
||||
}
|
||||
},
|
||||
AppView::Circles => {
|
||||
let start_url = self.initial_context_url_from_query
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.start_circle_ws_url.clone());
|
||||
html!{
|
||||
<CirclesView
|
||||
default_center_ws_url={self.start_circle_ws_url.clone()}
|
||||
default_center_ws_url={start_url}
|
||||
on_context_update={link.callback(Msg::UpdateCirclesContext)}
|
||||
/>
|
||||
}
|
||||
},
|
||||
AppView::Library => {
|
||||
let on_route_change = link.callback(Msg::UpdateSubRoute);
|
||||
html! {
|
||||
<LibraryView ws_addresses={self.active_context_urls.clone()} />
|
||||
<LibraryView
|
||||
ws_addresses={active_context_urls}
|
||||
initial_route={Some(self.current_sub_route.clone())}
|
||||
on_route_change={on_route_change}
|
||||
/>
|
||||
}
|
||||
},
|
||||
AppView::Intelligence => html! {
|
||||
<IntelligenceView
|
||||
all_circles={Rc::new(HashMap::new())}
|
||||
context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))}
|
||||
all_circles={Rc::new(all_circles_map.clone())}
|
||||
context_circle_ws_urls={Some(Rc::new(active_context_urls.clone()))}
|
||||
/>
|
||||
},
|
||||
AppView::Publishing => html! {
|
||||
<PublishingView
|
||||
all_circles={Rc::new(HashMap::new())}
|
||||
context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))}
|
||||
all_circles={Rc::new(all_circles_map.clone())}
|
||||
context_circle_ws_urls={Some(Rc::new(active_context_urls.clone()))}
|
||||
/>
|
||||
},
|
||||
AppView::Inspector => {
|
||||
html! {
|
||||
<InspectorView
|
||||
circle_ws_addresses={Rc::new(self.active_context_urls.clone())}
|
||||
circle_ws_addresses={Rc::new(active_context_urls.clone())}
|
||||
auth_manager={self.auth_manager.clone()}
|
||||
/>
|
||||
}
|
||||
},
|
||||
AppView::Customize => html! {
|
||||
<CustomizeViewComponent
|
||||
all_circles={Rc::new(HashMap::new())}
|
||||
context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))}
|
||||
app_callback={link.callback(|msg: Msg| msg)}
|
||||
/>
|
||||
AppView::Customize => {
|
||||
let primary_circle = self.active_context_circles.first();
|
||||
let ws_url = primary_circle.map(|c| c.ws_url.clone());
|
||||
html! {
|
||||
<CustomizeView
|
||||
active_circle={primary_circle.cloned()}
|
||||
ws_url={ws_url}
|
||||
app_callback={link.callback(|msg| msg)}
|
||||
/>
|
||||
}
|
||||
},
|
||||
}}
|
||||
|
||||
@ -235,20 +438,85 @@ impl Component for App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Handle view switching with sub-route support
|
||||
fn handle_view_switch(&mut self, view: AppView, sub_route: String) -> bool {
|
||||
// Ensure view switches to Login if not authenticated.
|
||||
let target_view = if let AuthState::Authenticated { .. } = self.auth_state {
|
||||
view
|
||||
} else {
|
||||
AppView::Login
|
||||
};
|
||||
|
||||
let view_changed = self.current_view != target_view;
|
||||
let route_changed = self.current_sub_route != sub_route;
|
||||
|
||||
if view_changed || route_changed {
|
||||
self.current_view = target_view;
|
||||
self.current_sub_route = if target_view == AppView::Login {
|
||||
String::new() // Clear sub-route for login
|
||||
} else {
|
||||
sub_route
|
||||
};
|
||||
|
||||
// Update browser history/URL, but not for the login view.
|
||||
if self.current_view != AppView::Login {
|
||||
self.update_url_for_current_state();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the browser URL to reflect current app state
|
||||
fn update_url_for_current_state(&self) {
|
||||
let path = self.current_view.build_full_path(&self.current_sub_route);
|
||||
let urls: Vec<String> = self
|
||||
.active_context_circles
|
||||
.iter()
|
||||
.map(|c| c.ws_url.clone())
|
||||
.collect();
|
||||
let query = if !urls.is_empty() {
|
||||
format!("circles={}", urls.join(","))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let full_url = HistoryManager::build_full_url(&path, &query);
|
||||
|
||||
if let Err(e) = HistoryManager::push_url(&full_url) {
|
||||
log::error!("Failed to update URL: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(&self, link: &html::Scope<Self>) -> Html {
|
||||
if self.current_view == AppView::Login {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let logo_html = if !self.theme.logo_url.is_empty() {
|
||||
html! { <div class="app-title-logo-image" /> }
|
||||
} else if !self.theme.logo_symbol.is_empty() {
|
||||
html! { <span class="app-title-logo-symbol">{ &self.theme.logo_symbol }</span> }
|
||||
} else {
|
||||
html! {}
|
||||
};
|
||||
|
||||
let circle_name = self
|
||||
.active_context_circles
|
||||
.first()
|
||||
.map_or("Circles".to_string(), |c| c.title.clone());
|
||||
|
||||
html! {
|
||||
<header>
|
||||
<div class="app-title-button">
|
||||
<span class="app-title-name">{ "Circles" }</span>
|
||||
{ logo_html }
|
||||
<span class="app-title-name">{ circle_name }</span>
|
||||
</div>
|
||||
<AuthView
|
||||
auth_state={self.auth_state.clone()}
|
||||
on_logout={link.callback(|_| Msg::Logout)}
|
||||
on_login={link.callback(|_| Msg::SwitchView(AppView::Login))}
|
||||
on_keypair_login_attempt={link.callback(|(pk, sk)| Msg::AttemptKeypairLogin((pk, sk)))}
|
||||
on_registration_complete={link.callback(|(name, pk, sk)| Msg::CompleteRegistration((name, pk, sk)))}
|
||||
/>
|
||||
</header>
|
||||
}
|
||||
|
@ -4,7 +4,6 @@
|
||||
//! the entire authentication process, including email lookup and
|
||||
//! integration with the client_ws library for WebSocket connections.
|
||||
|
||||
use crate::auth::email_store::{get_key_pair_for_email, is_email_available};
|
||||
use crate::auth::types::{AuthError, AuthMethod, AuthResult, AuthState};
|
||||
use circle_client_ws::auth::{derive_public_key, validate_private_key};
|
||||
use circle_client_ws::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError};
|
||||
@ -44,6 +43,7 @@ impl AuthManager {
|
||||
|
||||
/// Set callback for authentication state changes
|
||||
pub fn set_on_state_change(&self, callback: Callback<AuthState>) {
|
||||
log::info!("AuthManager: set_on_state_change CALLED.");
|
||||
*self.on_state_change.borrow_mut() = Some(callback);
|
||||
}
|
||||
|
||||
@ -57,42 +57,67 @@ impl AuthManager {
|
||||
matches!(*self.state.borrow(), AuthState::Authenticated { .. })
|
||||
}
|
||||
|
||||
/// Authenticate using email
|
||||
pub async fn authenticate_with_email(&self, email: String) -> AuthResult<()> {
|
||||
self.set_state(AuthState::Authenticating);
|
||||
|
||||
// Look up the email in the hardcoded store
|
||||
let key_pair = get_key_pair_for_email(&email)?;
|
||||
|
||||
// Validate the private key using client_ws
|
||||
validate_private_key(&key_pair.private_key).map_err(|e| AuthError::from(e))?;
|
||||
|
||||
// Set authenticated state
|
||||
let auth_state = AuthState::Authenticated {
|
||||
public_key: key_pair.public_key,
|
||||
private_key: key_pair.private_key,
|
||||
method: AuthMethod::Email(email),
|
||||
};
|
||||
|
||||
self.set_state(auth_state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authenticate using private key
|
||||
pub async fn authenticate_with_private_key(&self, private_key: String) -> AuthResult<()> {
|
||||
/// Authenticate using a provided public and private key pair.
|
||||
/// The provided public key is validated against the one derived from the private key.
|
||||
pub async fn login_with_keypair(
|
||||
&self,
|
||||
public_key_input: String,
|
||||
private_key: String,
|
||||
) -> AuthResult<()> {
|
||||
self.set_state(AuthState::Authenticating);
|
||||
|
||||
// Validate the private key using client_ws
|
||||
validate_private_key(&private_key).map_err(|e| AuthError::from(e))?;
|
||||
|
||||
// Derive public key using client_ws
|
||||
let public_key = derive_public_key(&private_key).map_err(|e| AuthError::from(e))?;
|
||||
let derived_public_key = derive_public_key(&private_key).map_err(|e| AuthError::from(e))?;
|
||||
|
||||
// Validate that the provided public key matches the derived one
|
||||
if derived_public_key != public_key_input {
|
||||
let err_msg = "Public key does not match private key.".to_string();
|
||||
self.set_state(AuthState::Failed(err_msg.clone()));
|
||||
return Err(AuthError::AuthFailed(err_msg));
|
||||
}
|
||||
|
||||
// Set authenticated state
|
||||
let auth_state = AuthState::Authenticated {
|
||||
public_key,
|
||||
public_key: derived_public_key, // Use the derived (and validated) public key
|
||||
private_key: private_key.clone(),
|
||||
method: AuthMethod::PrivateKey,
|
||||
method: AuthMethod::KeyPairLogin,
|
||||
};
|
||||
|
||||
self.set_state(auth_state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a new user with a name and a new keypair (already generated by UI), then log them in.
|
||||
/// Validates the provided public key against the one derived from the private key.
|
||||
pub async fn register_and_login(
|
||||
&self,
|
||||
user_name: String,
|
||||
public_key_input: String,
|
||||
private_key: String,
|
||||
) -> AuthResult<()> {
|
||||
self.set_state(AuthState::Authenticating);
|
||||
|
||||
// Validate the private key using client_ws
|
||||
validate_private_key(&private_key).map_err(|e| AuthError::from(e))?;
|
||||
|
||||
// Derive public key using client_ws
|
||||
let derived_public_key = derive_public_key(&private_key).map_err(|e| AuthError::from(e))?;
|
||||
|
||||
// Validate that the provided public key matches the derived one
|
||||
if derived_public_key != public_key_input {
|
||||
let err_msg = "Public key does not match private key during registration.".to_string();
|
||||
self.set_state(AuthState::Failed(err_msg.clone()));
|
||||
return Err(AuthError::AuthFailed(err_msg));
|
||||
}
|
||||
|
||||
// Set authenticated state
|
||||
let auth_state = AuthState::Authenticated {
|
||||
public_key: derived_public_key,
|
||||
private_key: private_key.clone(),
|
||||
method: AuthMethod::Registered { user_name },
|
||||
};
|
||||
|
||||
self.set_state(auth_state);
|
||||
@ -121,17 +146,6 @@ impl AuthManager {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Check if an email is available for authentication
|
||||
#[allow(dead_code)]
|
||||
pub fn is_email_available(&self, email: &str) -> bool {
|
||||
is_email_available(email)
|
||||
}
|
||||
|
||||
/// Get list of available emails for app purposes
|
||||
pub fn get_available_emails(&self) -> Vec<String> {
|
||||
crate::auth::email_store::get_available_emails()
|
||||
}
|
||||
|
||||
/// Logout and clear authentication state
|
||||
pub fn logout(&self) {
|
||||
self.set_state(AuthState::NotAuthenticated);
|
||||
@ -159,28 +173,21 @@ impl AuthManager {
|
||||
public_key: _,
|
||||
private_key,
|
||||
method,
|
||||
} => {
|
||||
match method {
|
||||
AuthMethod::Email(email) => {
|
||||
let marker = format!("email:{}", email);
|
||||
let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, marker);
|
||||
// Clear private key from session storage if user switched to email auth
|
||||
let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY);
|
||||
}
|
||||
AuthMethod::PrivateKey => {
|
||||
// Store the actual private key in sessionStorage
|
||||
let _ = SessionStorage::set(
|
||||
PRIVATE_KEY_SESSION_STORAGE_KEY,
|
||||
private_key.clone(),
|
||||
);
|
||||
// Store a marker in localStorage
|
||||
let _ = LocalStorage::set(
|
||||
AUTH_STATE_STORAGE_KEY,
|
||||
"private_key_auth_marker".to_string(),
|
||||
);
|
||||
}
|
||||
} => match method {
|
||||
AuthMethod::KeyPairLogin => {
|
||||
let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "keypair_login".to_string());
|
||||
let _ =
|
||||
SessionStorage::set(PRIVATE_KEY_SESSION_STORAGE_KEY, private_key.clone());
|
||||
}
|
||||
}
|
||||
AuthMethod::Registered { user_name } => {
|
||||
let _ = LocalStorage::set(
|
||||
AUTH_STATE_STORAGE_KEY,
|
||||
format!("registered:{}", user_name),
|
||||
);
|
||||
let _ =
|
||||
SessionStorage::set(PRIVATE_KEY_SESSION_STORAGE_KEY, private_key.clone());
|
||||
}
|
||||
},
|
||||
AuthState::NotAuthenticated => {
|
||||
let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "not_authenticated".to_string());
|
||||
let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY);
|
||||
@ -197,40 +204,35 @@ impl AuthManager {
|
||||
|
||||
/// Load authentication state from storage.
|
||||
fn load_auth_state() -> Option<AuthState> {
|
||||
if let Ok(marker) = LocalStorage::get::<String>(AUTH_STATE_STORAGE_KEY) {
|
||||
if marker == "private_key_auth_marker" {
|
||||
if let Ok(private_key) =
|
||||
SessionStorage::get::<String>(PRIVATE_KEY_SESSION_STORAGE_KEY)
|
||||
{
|
||||
if validate_private_key(&private_key).is_ok() {
|
||||
if let Ok(public_key) = derive_public_key(&private_key) {
|
||||
return Some(AuthState::Authenticated {
|
||||
public_key,
|
||||
private_key,
|
||||
method: AuthMethod::PrivateKey,
|
||||
});
|
||||
}
|
||||
// Try to load from local storage (method hint)
|
||||
if let Ok(stored_value) = LocalStorage::get::<String>(AUTH_STATE_STORAGE_KEY) {
|
||||
// Try to load private key from session storage
|
||||
if let Ok(private_key) = SessionStorage::get::<String>(PRIVATE_KEY_SESSION_STORAGE_KEY)
|
||||
{
|
||||
// Validate and derive public key
|
||||
if validate_private_key(&private_key).is_ok() {
|
||||
if let Ok(public_key) = derive_public_key(&private_key) {
|
||||
let method = if stored_value == "keypair_login" {
|
||||
AuthMethod::KeyPairLogin
|
||||
} else if stored_value.starts_with("registered:") {
|
||||
let user_name =
|
||||
stored_value.trim_start_matches("registered:").to_string();
|
||||
AuthMethod::Registered { user_name }
|
||||
} else {
|
||||
log::warn!("Invalid auth method hint found in storage: {}. Clearing stored state.", stored_value);
|
||||
// Clear potentially corrupted/outdated state
|
||||
let _ = LocalStorage::delete(AUTH_STATE_STORAGE_KEY);
|
||||
let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY);
|
||||
return None;
|
||||
};
|
||||
return Some(AuthState::Authenticated {
|
||||
public_key,
|
||||
private_key,
|
||||
method,
|
||||
});
|
||||
}
|
||||
// Invalid key in session, clear it
|
||||
let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY);
|
||||
}
|
||||
// Marker present but key missing/invalid, treat as not authenticated
|
||||
let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "not_authenticated".to_string());
|
||||
return Some(AuthState::NotAuthenticated);
|
||||
} else if let Some(email) = marker.strip_prefix("email:") {
|
||||
if let Ok(key_pair) = get_key_pair_for_email(email) {
|
||||
// Ensure session storage is clear if we are in email mode
|
||||
let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY);
|
||||
return Some(AuthState::Authenticated {
|
||||
public_key: key_pair.public_key,
|
||||
private_key: key_pair.private_key, // This is from email_store, not user input
|
||||
method: AuthMethod::Email(email.to_string()),
|
||||
});
|
||||
}
|
||||
// Email re-auth failed
|
||||
let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "not_authenticated".to_string());
|
||||
return Some(AuthState::NotAuthenticated);
|
||||
} else if marker == "not_authenticated" {
|
||||
} else if stored_value == "not_authenticated" {
|
||||
return Some(AuthState::NotAuthenticated);
|
||||
}
|
||||
}
|
||||
@ -283,64 +285,87 @@ impl Default for AuthManager {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use circle_client_ws::auth::generate_key_pair; // For generating test keypairs
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_email_authentication() {
|
||||
async fn test_keypair_login_authentication() {
|
||||
let auth_manager = AuthManager::new();
|
||||
|
||||
// Test with valid email
|
||||
// Generate a valid key pair for testing
|
||||
let key_pair = generate_key_pair().expect("Failed to generate test key pair");
|
||||
let public_key_hex = key_pair.public_key;
|
||||
let private_key_hex = key_pair.private_key;
|
||||
|
||||
// Test with valid public and private key
|
||||
let result = auth_manager
|
||||
.authenticate_with_email("alice@example.com".to_string())
|
||||
.login_with_keypair(public_key_hex.clone(), private_key_hex.clone())
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "Login failed: {:?}", result.err());
|
||||
assert!(auth_manager.is_authenticated());
|
||||
|
||||
// Check that we can get the public key
|
||||
assert!(auth_manager.get_public_key().is_some());
|
||||
assert_eq!(auth_manager.get_public_key(), Some(public_key_hex.clone()));
|
||||
|
||||
// Check auth method
|
||||
match auth_manager.get_auth_method() {
|
||||
Some(AuthMethod::Email(email)) => assert_eq!(email, "alice@example.com"),
|
||||
_ => panic!("Expected email auth method"),
|
||||
Some(AuthMethod::KeyPairLogin) => (),
|
||||
_ => panic!("Expected KeyPairLogin auth method"),
|
||||
}
|
||||
|
||||
// Test with mismatched public key
|
||||
auth_manager.logout(); // Reset state
|
||||
let wrong_public_key = "0xwrongpublickey".to_string();
|
||||
let result_mismatch = auth_manager
|
||||
.login_with_keypair(wrong_public_key, private_key_hex.clone())
|
||||
.await;
|
||||
assert!(result_mismatch.is_err());
|
||||
assert!(!auth_manager.is_authenticated());
|
||||
if let Some(AuthState::Failed(msg)) = Some(auth_manager.get_state()) {
|
||||
assert!(msg.contains("Public key does not match"));
|
||||
} else {
|
||||
panic!("Expected Failed state with specific message");
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_private_key_authentication() {
|
||||
async fn test_register_and_login_authentication() {
|
||||
let auth_manager = AuthManager::new();
|
||||
let key_pair = generate_key_pair().expect("Failed to generate test key pair");
|
||||
let public_key_hex = key_pair.public_key;
|
||||
let private_key_hex = key_pair.private_key;
|
||||
let user_name = "TestUser".to_string();
|
||||
|
||||
// Test with valid private key
|
||||
let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
|
||||
let result = auth_manager
|
||||
.authenticate_with_private_key(private_key.to_string())
|
||||
.register_and_login(
|
||||
user_name.clone(),
|
||||
public_key_hex.clone(),
|
||||
private_key_hex.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok(), "Registration failed: {:?}", result.err());
|
||||
assert!(auth_manager.is_authenticated());
|
||||
assert_eq!(auth_manager.get_public_key(), Some(public_key_hex.clone()));
|
||||
|
||||
// Check that we can get the public key
|
||||
assert!(auth_manager.get_public_key().is_some());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_invalid_email() {
|
||||
let auth_manager = AuthManager::new();
|
||||
|
||||
let result = auth_manager
|
||||
.authenticate_with_email("nonexistent@example.com".to_string())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(!auth_manager.is_authenticated());
|
||||
match auth_manager.get_auth_method() {
|
||||
Some(AuthMethod::Registered {
|
||||
user_name: reg_name,
|
||||
}) => assert_eq!(reg_name, user_name),
|
||||
_ => panic!("Expected Registered auth method with correct user name"),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_invalid_private_key() {
|
||||
let auth_manager = AuthManager::new();
|
||||
|
||||
// Need a syntactically plausible public key, even if the private key is invalid
|
||||
let dummy_public_key =
|
||||
"0x000000000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
let result = auth_manager
|
||||
.authenticate_with_private_key("invalid_key".to_string())
|
||||
.login_with_keypair(dummy_public_key, "invalid_key".to_string())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(!auth_manager.is_authenticated());
|
||||
@ -349,10 +374,11 @@ mod tests {
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_logout() {
|
||||
let auth_manager = AuthManager::new();
|
||||
let key_pair = generate_key_pair().expect("Failed to generate test key pair");
|
||||
|
||||
// Authenticate first
|
||||
let _ = auth_manager
|
||||
.authenticate_with_email("alice@example.com".to_string())
|
||||
.login_with_keypair(key_pair.public_key, key_pair.private_key)
|
||||
.await;
|
||||
assert!(auth_manager.is_authenticated());
|
||||
|
||||
@ -362,12 +388,5 @@ mod tests {
|
||||
assert!(auth_manager.get_public_key().is_none());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_email_availability() {
|
||||
let auth_manager = AuthManager::new();
|
||||
|
||||
assert!(auth_manager.is_email_available("alice@example.com"));
|
||||
assert!(auth_manager.is_email_available("admin@circles.com"));
|
||||
assert!(!auth_manager.is_email_available("nonexistent@example.com"));
|
||||
}
|
||||
// test_email_availability is removed as email auth is gone
|
||||
}
|
||||
|
@ -1,187 +0,0 @@
|
||||
//! Hardcoded email-to-private-key mappings
|
||||
//!
|
||||
//! This module provides a static mapping of email addresses to their corresponding
|
||||
//! private and public key pairs. This is designed for development and app purposes
|
||||
//! where users can authenticate using known email addresses.
|
||||
|
||||
use crate::auth::types::{AuthError, AuthResult};
|
||||
use circle_client_ws::auth::derive_public_key;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A key pair consisting of private and public keys
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyPair {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
/// Get the hardcoded email-to-key mappings
|
||||
///
|
||||
/// Returns a HashMap where:
|
||||
/// - Key: email address (String)
|
||||
/// - Value: KeyPair with private and public keys
|
||||
pub fn get_email_key_mappings() -> HashMap<String, KeyPair> {
|
||||
let mut mappings = HashMap::new();
|
||||
|
||||
// Demo users with their private keys
|
||||
// Note: These are for demonstration purposes only
|
||||
let demo_keys = vec![
|
||||
(
|
||||
"alice@example.com",
|
||||
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
),
|
||||
(
|
||||
"bob@example.com",
|
||||
"0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
|
||||
),
|
||||
(
|
||||
"charlie@example.com",
|
||||
"0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
),
|
||||
(
|
||||
"diana@example.com",
|
||||
"0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba",
|
||||
),
|
||||
(
|
||||
"eve@example.com",
|
||||
"0x1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff",
|
||||
),
|
||||
(
|
||||
"admin@circles.com",
|
||||
"0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
),
|
||||
(
|
||||
"app@circles.com",
|
||||
"0xdeadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
),
|
||||
(
|
||||
"test@circles.com",
|
||||
"0xbaadf00dcafebabe9876543210fedcba9876543210fedcba9876543210fedcba",
|
||||
),
|
||||
];
|
||||
|
||||
// Generate key pairs for each app user
|
||||
for (email, private_key) in demo_keys {
|
||||
if let Ok(public_key) = derive_public_key(private_key) {
|
||||
mappings.insert(
|
||||
email.to_string(),
|
||||
KeyPair {
|
||||
private_key: private_key.to_string(),
|
||||
public_key,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
log::error!("Failed to derive public key for email: {}", email);
|
||||
}
|
||||
}
|
||||
|
||||
mappings
|
||||
}
|
||||
|
||||
/// Look up a key pair by email address
|
||||
pub fn get_key_pair_for_email(email: &str) -> AuthResult<KeyPair> {
|
||||
let mappings = get_email_key_mappings();
|
||||
|
||||
mappings
|
||||
.get(email)
|
||||
.cloned()
|
||||
.ok_or_else(|| AuthError::EmailNotFound(email.to_string()))
|
||||
}
|
||||
|
||||
/// Get all available email addresses
|
||||
pub fn get_available_emails() -> Vec<String> {
|
||||
get_email_key_mappings().keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Check if an email address is available in the store
|
||||
#[allow(dead_code)]
|
||||
pub fn is_email_available(email: &str) -> bool {
|
||||
get_email_key_mappings().contains_key(email)
|
||||
}
|
||||
|
||||
/// Add a new email-key mapping (for runtime additions)
|
||||
/// Note: This will only persist for the current session
|
||||
#[allow(dead_code)]
|
||||
pub fn add_email_key_mapping(email: String, private_key: String) -> AuthResult<()> {
|
||||
// Validate the private key first
|
||||
let public_key = derive_public_key(&private_key)?;
|
||||
|
||||
// In a real implementation, you might want to persist this
|
||||
// For now, we just validate that it would work
|
||||
log::info!(
|
||||
"Would add mapping for email: {} with public key: {}",
|
||||
email,
|
||||
public_key
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use circle_client_ws::auth::{sign_message, validate_private_key, verify_signature};
|
||||
|
||||
#[test]
|
||||
fn test_email_mappings_exist() {
|
||||
let mappings = get_email_key_mappings();
|
||||
assert!(!mappings.is_empty());
|
||||
|
||||
// Check that alice@example.com exists
|
||||
assert!(mappings.contains_key("alice@example.com"));
|
||||
assert!(mappings.contains_key("admin@circles.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_pair_lookup() {
|
||||
let key_pair = get_key_pair_for_email("alice@example.com").unwrap();
|
||||
|
||||
// Validate that the private key is valid
|
||||
assert!(validate_private_key(&key_pair.private_key).is_ok());
|
||||
|
||||
// Validate that the public key matches the private key
|
||||
let derived_public = derive_public_key(&key_pair.private_key).unwrap();
|
||||
assert_eq!(key_pair.public_key, derived_public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signing_with_stored_keys() {
|
||||
let key_pair = get_key_pair_for_email("bob@example.com").unwrap();
|
||||
let message = "Test message";
|
||||
|
||||
// Sign a message with the stored private key
|
||||
let signature = sign_message(&key_pair.private_key, message).unwrap();
|
||||
|
||||
// Verify the signature with the stored public key
|
||||
let is_valid = verify_signature(&key_pair.public_key, message, &signature).unwrap();
|
||||
assert!(is_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_not_found() {
|
||||
let result = get_key_pair_for_email("nonexistent@example.com");
|
||||
assert!(result.is_err());
|
||||
|
||||
match result {
|
||||
Err(AuthError::EmailNotFound(email)) => {
|
||||
assert_eq!(email, "nonexistent@example.com");
|
||||
}
|
||||
_ => panic!("Expected EmailNotFound error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_available_emails() {
|
||||
let emails = get_available_emails();
|
||||
assert!(!emails.is_empty());
|
||||
assert!(emails.contains(&"alice@example.com".to_string()));
|
||||
assert!(emails.contains(&"admin@circles.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_email_available() {
|
||||
assert!(is_email_available("alice@example.com"));
|
||||
assert!(is_email_available("admin@circles.com"));
|
||||
assert!(!is_email_available("nonexistent@example.com"));
|
||||
}
|
||||
}
|
@ -1,14 +1,12 @@
|
||||
//! Authentication module for the Circles app
|
||||
//!
|
||||
//! This module provides application-specific authentication functionality including:
|
||||
//! - Email-to-private-key mappings (hardcoded for app)
|
||||
//! - Authentication manager for coordinating auth flows
|
||||
//! - Integration with the client_ws library for WebSocket authentication
|
||||
//! - Authentication manager for coordinating keypair-based login and registration flows
|
||||
//! - Integration with the client_ws library for WebSocket authentication and cryptographic operations
|
||||
//!
|
||||
//! Core cryptographic functionality is provided by the client_ws library.
|
||||
|
||||
pub mod auth_manager;
|
||||
pub mod email_store;
|
||||
pub mod types;
|
||||
|
||||
pub use auth_manager::AuthManager;
|
||||
|
@ -20,9 +20,6 @@ pub enum AuthError {
|
||||
AuthFailed(String),
|
||||
|
||||
// App-specific errors
|
||||
#[error("Email not found: {0}")]
|
||||
EmailNotFound(String),
|
||||
|
||||
#[error("Generic error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Generic(String),
|
||||
@ -47,15 +44,15 @@ impl From<circle_client_ws::auth::AuthError> for AuthError {
|
||||
/// Authentication method chosen by the user (app-specific)
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthMethod {
|
||||
PrivateKey, // Direct private key input
|
||||
Email(String), // Email-based lookup (app-specific)
|
||||
KeyPairLogin, // User logged in by providing their existing public and private key
|
||||
Registered { user_name: String }, // User registered with a name, and a new keypair was generated and provided
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthMethod::PrivateKey => write!(f, "Private Key"),
|
||||
AuthMethod::Email(email) => write!(f, "Email ({})", email),
|
||||
AuthMethod::KeyPairLogin => write!(f, "Keypair Login"),
|
||||
AuthMethod::Registered { user_name } => write!(f, "Registered (User: {})", user_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
use crate::components::library_view::DisplayLibraryItem;
|
||||
use crate::views::library_view::DisplayLibraryItem;
|
||||
use heromodels::models::library::items::TocEntry;
|
||||
use yew::prelude::*;
|
||||
|
||||
@ -33,9 +33,6 @@ impl Component for AssetDetailsCard {
|
||||
match &props.item {
|
||||
DisplayLibraryItem::Image(img) => html! {
|
||||
<div class="card asset-details-card">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Library"}
|
||||
</button>
|
||||
<div class="asset-preview">
|
||||
<img src={img.url.clone()} alt={img.title.clone()} class="asset-preview-image" />
|
||||
</div>
|
||||
@ -53,9 +50,6 @@ impl Component for AssetDetailsCard {
|
||||
},
|
||||
DisplayLibraryItem::Pdf(pdf) => html! {
|
||||
<div class="card asset-details-card">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Library"}
|
||||
</button>
|
||||
<div class="asset-preview">
|
||||
<i class="fas fa-file-pdf asset-preview-icon"></i>
|
||||
</div>
|
||||
@ -76,9 +70,6 @@ impl Component for AssetDetailsCard {
|
||||
},
|
||||
DisplayLibraryItem::Markdown(md) => html! {
|
||||
<div class="card asset-details-card">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Library"}
|
||||
</button>
|
||||
<div class="asset-preview">
|
||||
<i class="fab fa-markdown asset-preview-icon"></i>
|
||||
</div>
|
||||
@ -95,9 +86,6 @@ impl Component for AssetDetailsCard {
|
||||
},
|
||||
DisplayLibraryItem::Book(book) => html! {
|
||||
<div class="card asset-details-card">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Library"}
|
||||
</button>
|
||||
<div class="asset-preview">
|
||||
<i class="fas fa-book asset-preview-icon"></i>
|
||||
</div>
|
||||
@ -120,11 +108,8 @@ impl Component for AssetDetailsCard {
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
DisplayLibraryItem::Slides(slides) => html! {
|
||||
DisplayLibraryItem::Slideshow(slides) => html! {
|
||||
<div class="card asset-details-card">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Library"}
|
||||
</button>
|
||||
<div class="asset-preview">
|
||||
<i class="fas fa-images asset-preview-icon"></i>
|
||||
</div>
|
||||
@ -135,9 +120,9 @@ impl Component for AssetDetailsCard {
|
||||
} else { html! {} }}
|
||||
<div class="asset-metadata">
|
||||
<p><strong>{"Type:"}</strong> {"Slideshow"}</p>
|
||||
<p><strong>{"Slides:"}</strong> { slides.slide_urls.len() }</p>
|
||||
<p><strong>{"Slideshow:"}</strong> { slides.slides.len() }</p>
|
||||
{ if let Some(current_slide) = props.current_slide_index {
|
||||
html! { <p><strong>{"Current Slide:"}</strong> { format!("{} / {}", current_slide + 1, slides.slide_urls.len()) }</p> }
|
||||
html! { <p><strong>{"Current Slide:"}</strong> { format!("{} / {}", current_slide + 1, slides.slides.len()) }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,71 +0,0 @@
|
||||
use crate::auth::types::AuthState;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct AuthViewProps {
|
||||
pub auth_state: AuthState,
|
||||
pub on_logout: Callback<()>,
|
||||
pub on_login: Callback<()>, // New callback for login
|
||||
}
|
||||
|
||||
#[function_component(AuthView)]
|
||||
pub fn auth_view(props: &AuthViewProps) -> Html {
|
||||
match &props.auth_state {
|
||||
AuthState::Authenticated { public_key, .. } => {
|
||||
let on_logout = props.on_logout.clone();
|
||||
let logout_onclick = Callback::from(move |_| {
|
||||
on_logout.emit(());
|
||||
});
|
||||
|
||||
// Truncate the public key for display
|
||||
let pk_short = if public_key.len() > 10 {
|
||||
format!(
|
||||
"{}...{}",
|
||||
&public_key[..4],
|
||||
&public_key[public_key.len() - 4..]
|
||||
)
|
||||
} else {
|
||||
public_key.clone()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="auth-view-container">
|
||||
<span class="public-key" title={public_key.clone()}>{ format!("PK: {}", pk_short) }</span>
|
||||
<button
|
||||
class="logout-button"
|
||||
onclick={logout_onclick}
|
||||
title="Logout"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthState::NotAuthenticated | AuthState::Failed(_) => {
|
||||
let on_login = props.on_login.clone();
|
||||
let login_onclick = Callback::from(move |_| {
|
||||
on_login.emit(());
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="auth-info">
|
||||
<span class="auth-status">{ "Not Authenticated" }</span>
|
||||
<button
|
||||
class="login-button"
|
||||
onclick={login_onclick}
|
||||
title="Login"
|
||||
>
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating => {
|
||||
html! {
|
||||
<div class="auth-info">
|
||||
<span class="auth-status">{ "Authenticating..." }</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
use heromodels::models::library::items::{Book, TocEntry};
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct BookViewerProps {
|
||||
pub book: Book,
|
||||
pub on_back: Callback<()>,
|
||||
}
|
||||
|
||||
pub enum BookViewerMsg {
|
||||
#[allow(dead_code)]
|
||||
GoToPage(usize),
|
||||
NextPage,
|
||||
PrevPage,
|
||||
}
|
||||
|
||||
pub struct BookViewer {
|
||||
current_page: usize,
|
||||
}
|
||||
|
||||
impl Component for BookViewer {
|
||||
type Message = BookViewerMsg;
|
||||
type Properties = BookViewerProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self { current_page: 0 }
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
BookViewerMsg::GoToPage(page) => {
|
||||
self.current_page = page;
|
||||
true
|
||||
}
|
||||
BookViewerMsg::NextPage => {
|
||||
let props = _ctx.props();
|
||||
if self.current_page < props.book.pages.len().saturating_sub(1) {
|
||||
self.current_page += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
BookViewerMsg::PrevPage => {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let total_pages = props.book.pages.len();
|
||||
|
||||
let back_handler = {
|
||||
let on_back = props.on_back.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_back.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let prev_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::PrevPage);
|
||||
let next_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::NextPage);
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer book-viewer">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Collection"}
|
||||
</button>
|
||||
<div class="viewer-header">
|
||||
<h2 class="viewer-title">{ &props.book.title }</h2>
|
||||
<div class="book-navigation">
|
||||
<button
|
||||
class="nav-button"
|
||||
onclick={prev_handler}
|
||||
disabled={self.current_page == 0}
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> {"Previous"}
|
||||
</button>
|
||||
<span class="page-indicator">
|
||||
{ format!("Page {} of {}", self.current_page + 1, total_pages) }
|
||||
</span>
|
||||
<button
|
||||
class="nav-button"
|
||||
onclick={next_handler}
|
||||
disabled={self.current_page >= total_pages.saturating_sub(1)}
|
||||
>
|
||||
{"Next"} <i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<div class="book-page">
|
||||
{ if let Some(page_content) = props.book.pages.get(self.current_page) {
|
||||
self.render_markdown(page_content)
|
||||
} else {
|
||||
html! { <p>{"Page not found"}</p> }
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BookViewer {
|
||||
fn render_markdown(&self, content: &str) -> Html {
|
||||
// Simple markdown rendering - convert basic markdown to HTML
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut html_content = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with("# ") {
|
||||
html_content.push(html! { <h1>{ &line[2..] }</h1> });
|
||||
} else if line.starts_with("## ") {
|
||||
html_content.push(html! { <h2>{ &line[3..] }</h2> });
|
||||
} else if line.starts_with("### ") {
|
||||
html_content.push(html! { <h3>{ &line[4..] }</h3> });
|
||||
} else if line.starts_with("- ") {
|
||||
html_content.push(html! { <li>{ &line[2..] }</li> });
|
||||
} else if line.starts_with("**") && line.ends_with("**") {
|
||||
let text = &line[2..line.len() - 2];
|
||||
html_content.push(html! { <p><strong>{ text }</strong></p> });
|
||||
} else if !line.trim().is_empty() {
|
||||
html_content.push(html! { <p>{ line }</p> });
|
||||
} else {
|
||||
html_content.push(html! { <br/> });
|
||||
}
|
||||
}
|
||||
|
||||
html! { <div>{ for html_content }</div> }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn render_toc(&self, ctx: &Context<Self>, toc: &[TocEntry]) -> Html {
|
||||
html! {
|
||||
<ul class="toc-list">
|
||||
{ toc.iter().map(|entry| {
|
||||
let page = entry.page as usize;
|
||||
let onclick = ctx.link().callback(move |_: MouseEvent| BookViewerMsg::GoToPage(page));
|
||||
html! {
|
||||
<li class="toc-item">
|
||||
<button class="toc-link" onclick={onclick}>
|
||||
{ &entry.title }
|
||||
</button>
|
||||
{ if !entry.subsections.is_empty() {
|
||||
self.render_toc(ctx, &entry.subsections)
|
||||
} else { html! {} }}
|
||||
</li>
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
@ -1,346 +0,0 @@
|
||||
use heromodels::models::circle::Circle;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use web_sys::InputEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Import from common_models
|
||||
// Assuming AppMsg is used for updates. This might need to be specific to theme updates.
|
||||
use crate::app::Msg as AppMsg;
|
||||
|
||||
// --- Enum for Setting Control Types (can be kept local or moved if shared) ---
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub enum ThemeSettingControlType {
|
||||
ColorSelection(Vec<String>), // List of color hex values
|
||||
PatternSelection(Vec<String>), // List of pattern names/classes
|
||||
LogoSelection(Vec<String>), // List of predefined logo symbols or image URLs
|
||||
Toggle,
|
||||
TextInput, // For URL input or custom text
|
||||
}
|
||||
|
||||
// --- Data Structure for Defining a Theme Setting ---
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ThemeSettingDefinition {
|
||||
pub key: String, // Corresponds to the key in CircleData.theme HashMap
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub control_type: ThemeSettingControlType,
|
||||
pub default_value: String, // Used if not present in circle's theme
|
||||
}
|
||||
|
||||
// --- Props for the Component ---
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct CustomizeViewProps {
|
||||
pub all_circles: Rc<HashMap<String, Circle>>,
|
||||
// Assuming context_circle_ws_urls provides the WebSocket URL of the circle being customized.
|
||||
// For simplicity, we'll use the first URL if multiple are present.
|
||||
// A more robust solution might involve a dedicated `active_customization_circle_ws_url: Option<String>` prop.
|
||||
pub context_circle_ws_urls: Option<Rc<Vec<String>>>,
|
||||
pub app_callback: Callback<AppMsg>, // For emitting update messages
|
||||
}
|
||||
|
||||
// --- Statically Defined Theme Settings ---
|
||||
fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> {
|
||||
vec![
|
||||
ThemeSettingDefinition {
|
||||
key: "theme_primary_color".to_string(),
|
||||
label: "Primary Color".to_string(),
|
||||
description: "Main accent color for the interface.".to_string(),
|
||||
control_type: ThemeSettingControlType::ColorSelection(vec![
|
||||
"#3b82f6".to_string(),
|
||||
"#ef4444".to_string(),
|
||||
"#10b981".to_string(),
|
||||
"#f59e0b".to_string(),
|
||||
"#8b5cf6".to_string(),
|
||||
"#06b6d4".to_string(),
|
||||
"#ec4899".to_string(),
|
||||
"#84cc16".to_string(),
|
||||
"#f97316".to_string(),
|
||||
"#6366f1".to_string(),
|
||||
"#14b8a6".to_string(),
|
||||
"#f43f5e".to_string(),
|
||||
"#ffffff".to_string(),
|
||||
"#cbd5e1".to_string(),
|
||||
"#64748b".to_string(),
|
||||
]),
|
||||
default_value: "#3b82f6".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "theme_background_color".to_string(),
|
||||
label: "Background Color".to_string(),
|
||||
description: "Overall background color.".to_string(),
|
||||
control_type: ThemeSettingControlType::ColorSelection(vec![
|
||||
"#000000".to_string(),
|
||||
"#0a0a0a".to_string(),
|
||||
"#121212".to_string(),
|
||||
"#18181b".to_string(),
|
||||
"#1f2937".to_string(),
|
||||
"#374151".to_string(),
|
||||
"#4b5563".to_string(),
|
||||
"#f9fafb".to_string(),
|
||||
"#f3f4f6".to_string(),
|
||||
"#e5e7eb".to_string(),
|
||||
]),
|
||||
default_value: "#0a0a0a".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "background_pattern".to_string(),
|
||||
label: "Background Pattern".to_string(),
|
||||
description: "Subtle pattern for the background.".to_string(),
|
||||
control_type: ThemeSettingControlType::PatternSelection(vec![
|
||||
"none".to_string(),
|
||||
"dots".to_string(),
|
||||
"grid".to_string(),
|
||||
"diagonal".to_string(),
|
||||
"waves".to_string(),
|
||||
"mesh".to_string(),
|
||||
]),
|
||||
default_value: "none".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "circle_logo".to_string(), // Could be a symbol or a key for an image URL
|
||||
label: "Circle Logo/Symbol".to_string(),
|
||||
description: "Select a symbol or provide a URL below.".to_string(),
|
||||
control_type: ThemeSettingControlType::LogoSelection(vec![
|
||||
"◯".to_string(),
|
||||
"◆".to_string(),
|
||||
"★".to_string(),
|
||||
"▲".to_string(),
|
||||
"●".to_string(),
|
||||
"■".to_string(),
|
||||
"🌍".to_string(),
|
||||
"🚀".to_string(),
|
||||
"💎".to_string(),
|
||||
"🔥".to_string(),
|
||||
"⚡".to_string(),
|
||||
"🎯".to_string(),
|
||||
"custom_url".to_string(), // Represents using the URL input
|
||||
]),
|
||||
default_value: "◯".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "circle_logo_url".to_string(),
|
||||
label: "Custom Logo URL".to_string(),
|
||||
description: "URL for a custom logo image (PNG, SVG recommended).".to_string(),
|
||||
control_type: ThemeSettingControlType::TextInput,
|
||||
default_value: "".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "nav_dashboard_visible".to_string(),
|
||||
label: "Show Dashboard in Nav".to_string(),
|
||||
description: "".to_string(),
|
||||
control_type: ThemeSettingControlType::Toggle,
|
||||
default_value: "true".to_string(),
|
||||
},
|
||||
ThemeSettingDefinition {
|
||||
key: "nav_timeline_visible".to_string(),
|
||||
label: "Show Timeline in Nav".to_string(),
|
||||
description: "".to_string(),
|
||||
control_type: ThemeSettingControlType::Toggle,
|
||||
default_value: "true".to_string(),
|
||||
},
|
||||
// Add more settings as needed, e.g., font selection, border radius, etc.
|
||||
]
|
||||
}
|
||||
|
||||
#[function_component(CustomizeViewComponent)]
|
||||
pub fn customize_view_component(props: &CustomizeViewProps) -> Html {
|
||||
let theme_definitions = get_theme_setting_definitions();
|
||||
|
||||
// Determine the active circle for customization
|
||||
let active_circle_ws_url: Option<String> = props
|
||||
.context_circle_ws_urls
|
||||
.as_ref()
|
||||
.and_then(|ws_urls| ws_urls.first().cloned());
|
||||
|
||||
let active_circle_theme: Option<HashMap<String, String>> = active_circle_ws_url
|
||||
.as_ref()
|
||||
.and_then(|ws_url| props.all_circles.get(ws_url))
|
||||
// TODO: Re-implement theme handling. The canonical Circle struct does not have a direct 'theme' field.
|
||||
// .map(|circle_data| circle_data.theme.clone());
|
||||
.map(|_circle_data| HashMap::new()); // Placeholder, provides an empty theme
|
||||
|
||||
let on_setting_update_emitter = props.app_callback.clone();
|
||||
|
||||
html! {
|
||||
<div class="view-container customize-view">
|
||||
<div class="view-header">
|
||||
<h1 class="view-title">{"Customize Appearance"}</h1>
|
||||
{ if active_circle_ws_url.is_none() {
|
||||
html!{ <p class="customize-no-circle-msg">{"Select a circle context to customize its appearance."}</p> }
|
||||
} else { html!{} }}
|
||||
</div>
|
||||
|
||||
{ if let Some(current_circle_ws_url) = active_circle_ws_url {
|
||||
html! {
|
||||
<div class="customize-content">
|
||||
{ for theme_definitions.iter().map(|setting_def| {
|
||||
let current_value = active_circle_theme.as_ref()
|
||||
.and_then(|theme| theme.get(&setting_def.key).cloned())
|
||||
.unwrap_or_else(|| setting_def.default_value.clone());
|
||||
|
||||
render_setting_control(
|
||||
setting_def.clone(),
|
||||
current_value,
|
||||
current_circle_ws_url.clone(),
|
||||
on_setting_update_emitter.clone()
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html!{} // Or a message indicating no circle is selected for customization
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_setting_control(
|
||||
setting_def: ThemeSettingDefinition,
|
||||
current_value: String,
|
||||
circle_ws_url: String,
|
||||
_app_callback: Callback<AppMsg>,
|
||||
) -> Html {
|
||||
let setting_key = setting_def.key.clone();
|
||||
|
||||
let on_value_change = {
|
||||
let _circle_ws_url_clone = circle_ws_url.clone();
|
||||
let _setting_key_clone = setting_key.clone();
|
||||
Callback::from(move |_new_value: String| {
|
||||
// Emit a message to app.rs to update the theme
|
||||
// AppMsg should have a variant like UpdateCircleTheme(circle_id, theme_key, new_value)
|
||||
// TODO: Update this to use WebSocket URL instead of u32 ID
|
||||
// For now, we'll need to convert or update the message type
|
||||
// app_callback.emit(AppMsg::UpdateCircleThemeValue(
|
||||
// circle_ws_url_clone.clone(),
|
||||
// setting_key_clone.clone(),
|
||||
// new_value,
|
||||
// ));
|
||||
})
|
||||
};
|
||||
|
||||
let control_html = match setting_def.control_type {
|
||||
ThemeSettingControlType::ColorSelection(ref colors) => {
|
||||
let on_select = on_value_change.clone();
|
||||
html! {
|
||||
<div class="color-grid">
|
||||
{ for colors.iter().map(|color_option| {
|
||||
let is_selected = *color_option == current_value;
|
||||
let option_value = color_option.clone();
|
||||
let on_click_handler = {
|
||||
let on_select = on_select.clone();
|
||||
Callback::from(move |_| on_select.emit(option_value.clone()))
|
||||
};
|
||||
html! {
|
||||
<div
|
||||
class={classes!("color-option", is_selected.then_some("selected"))}
|
||||
style={format!("background-color: {};", color_option)}
|
||||
onclick={on_click_handler}
|
||||
title={color_option.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
ThemeSettingControlType::PatternSelection(ref patterns) => {
|
||||
let on_select = on_value_change.clone();
|
||||
html! {
|
||||
<div class="pattern-grid">
|
||||
{ for patterns.iter().map(|pattern_option| {
|
||||
let is_selected = *pattern_option == current_value;
|
||||
let option_value = pattern_option.clone();
|
||||
let pattern_class = format!("pattern-preview-{}", pattern_option.replace(" ", "-").to_lowercase());
|
||||
let on_click_handler = {
|
||||
let on_select = on_select.clone();
|
||||
Callback::from(move |_| on_select.emit(option_value.clone()))
|
||||
};
|
||||
html! {
|
||||
<div
|
||||
class={classes!("pattern-option", pattern_class, is_selected.then_some("selected"))}
|
||||
onclick={on_click_handler}
|
||||
title={pattern_option.clone()}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
ThemeSettingControlType::LogoSelection(ref logos) => {
|
||||
let on_select = on_value_change.clone();
|
||||
html! {
|
||||
<div class="logo-grid">
|
||||
{ for logos.iter().map(|logo_option| {
|
||||
let is_selected = *logo_option == current_value;
|
||||
let option_value = logo_option.clone();
|
||||
let on_click_handler = {
|
||||
let on_select = on_select.clone();
|
||||
Callback::from(move |_| on_select.emit(option_value.clone()))
|
||||
};
|
||||
html! {
|
||||
<div
|
||||
class={classes!("logo-option", is_selected.then_some("selected"))}
|
||||
onclick={on_click_handler}
|
||||
title={logo_option.clone()}
|
||||
>
|
||||
{ if logo_option == "custom_url" { "URL" } else { logo_option } }
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
ThemeSettingControlType::Toggle => {
|
||||
let checked = current_value.to_lowercase() == "true";
|
||||
let on_toggle = {
|
||||
let on_value_change = on_value_change.clone();
|
||||
Callback::from(move |e: Event| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_value_change.emit(if input.checked() {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
});
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<label class="setting-toggle-switch">
|
||||
<input type="checkbox" checked={checked} onchange={on_toggle} />
|
||||
<span class="setting-toggle-slider"></span>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
ThemeSettingControlType::TextInput => {
|
||||
let on_input = {
|
||||
let on_value_change = on_value_change.clone();
|
||||
Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
on_value_change.emit(input.value());
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<input
|
||||
type="text"
|
||||
class="setting-text-input input-base"
|
||||
placeholder={setting_def.description.clone()}
|
||||
value={current_value.clone()}
|
||||
oninput={on_input}
|
||||
/>
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="setting-item card-base">
|
||||
<div class="setting-info">
|
||||
<label class="setting-label">{ &setting_def.label }</label>
|
||||
{ if !setting_def.description.is_empty() && setting_def.control_type != ThemeSettingControlType::TextInput { // Placeholder is used for TextInput desc
|
||||
html!{ <p class="setting-description">{ &setting_def.description }</p> }
|
||||
} else { html!{} }}
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
{ control_html }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
217
src/app/src/components/library_item_cards/book.rs
Normal file
217
src/app/src/components/library_item_cards/book.rs
Normal file
@ -0,0 +1,217 @@
|
||||
use gloo_events::EventListener;
|
||||
use gloo_utils::window;
|
||||
use heromodels::models::library::items::{Book, TocEntry};
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::KeyboardEvent;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Card Component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct BookCardProps {
|
||||
pub item: Book,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(BookCard)]
|
||||
pub fn book_card(props: &BookCardProps) -> Html {
|
||||
let item = &props.item;
|
||||
let onclick = props.onclick.clone();
|
||||
|
||||
html! {
|
||||
<div class="library-item-card" {onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-book item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &item.title }</p>
|
||||
{ if let Some(desc) = &item.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} pages", item.pages.len()) }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer Component
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct BookViewerProps {
|
||||
pub book: Book,
|
||||
pub on_back: Callback<()>,
|
||||
}
|
||||
|
||||
pub enum BookViewerMsg {
|
||||
GoToPage(usize),
|
||||
NextPage,
|
||||
PrevPage,
|
||||
}
|
||||
|
||||
pub struct BookViewer {
|
||||
current_page: usize,
|
||||
_keydown_listener: Option<EventListener>,
|
||||
}
|
||||
|
||||
impl Component for BookViewer {
|
||||
type Message = BookViewerMsg;
|
||||
type Properties = BookViewerProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let link = ctx.link().clone();
|
||||
let keydown_listener = EventListener::new(&window(), "keydown", move |event| {
|
||||
if let Ok(keyboard_event) = event.clone().dyn_into::<KeyboardEvent>() {
|
||||
match keyboard_event.key().as_str() {
|
||||
"ArrowRight" => link.send_message(BookViewerMsg::NextPage),
|
||||
"ArrowLeft" => link.send_message(BookViewerMsg::PrevPage),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
current_page: 0,
|
||||
_keydown_listener: Some(keydown_listener),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
BookViewerMsg::GoToPage(page) => {
|
||||
let props = ctx.props();
|
||||
if page < props.book.pages.len() {
|
||||
self.current_page = page;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
BookViewerMsg::NextPage => {
|
||||
let props = ctx.props();
|
||||
if self.current_page < props.book.pages.len().saturating_sub(1) {
|
||||
self.current_page += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
BookViewerMsg::PrevPage => {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let total_pages = props.book.pages.len();
|
||||
|
||||
let prev_button_disabled = self.current_page == 0;
|
||||
let next_button_disabled = self.current_page >= total_pages.saturating_sub(1);
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer book-viewer">
|
||||
<div class="book-viewer-layout">
|
||||
<div class="toc-panel">
|
||||
<div class="toc-header">
|
||||
<button onclick={props.on_back.clone().reform(|_|())} class="back-button">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<h4>{ "Table of Contents" }</h4>
|
||||
</div>
|
||||
{ self.render_toc(ctx, &props.book.table_of_contents) }
|
||||
</div>
|
||||
<div class="content-panel">
|
||||
<div class="book-page">
|
||||
{ if let Some(page_content) = props.book.pages.get(self.current_page) {
|
||||
self.render_markdown(page_content)
|
||||
} else {
|
||||
html! { <p>{"Page not found"}</p> }
|
||||
}}
|
||||
</div>
|
||||
<div class="page-navigation">
|
||||
<button
|
||||
class="nav-button prev-button"
|
||||
onclick={ctx.link().callback(|_| BookViewerMsg::PrevPage)}
|
||||
disabled={prev_button_disabled}
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>{ " Previous" }
|
||||
</button>
|
||||
<span class="page-indicator">{ format!("Page {} of {}", self.current_page + 1, total_pages) }</span>
|
||||
<button
|
||||
class="nav-button next-button"
|
||||
onclick={ctx.link().callback(|_| BookViewerMsg::NextPage)}
|
||||
disabled={next_button_disabled}
|
||||
>
|
||||
{ "Next " }<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BookViewer {
|
||||
fn render_markdown(&self, content: &str) -> Html {
|
||||
// Simple markdown rendering - convert basic markdown to HTML
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut html_content = Vec::new();
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with("# ") {
|
||||
html_content.push(html! { <h1>{ &line[2..] }</h1> });
|
||||
} else if line.starts_with("## ") {
|
||||
html_content.push(html! { <h2>{ &line[3..] }</h2> });
|
||||
} else if line.starts_with("### ") {
|
||||
html_content.push(html! { <h3>{ &line[4..] }</h3> });
|
||||
} else if line.starts_with("- ") {
|
||||
html_content.push(html! { <li>{ &line[2..] }</li> });
|
||||
} else if line.starts_with("**") && line.ends_with("**") {
|
||||
let text = &line[2..line.len() - 2];
|
||||
html_content.push(html! { <p><strong>{ text }</strong></p> });
|
||||
} else if !line.trim().is_empty() {
|
||||
html_content.push(html! { <p>{ line }</p> });
|
||||
} else {
|
||||
html_content.push(html! { <br/> });
|
||||
}
|
||||
}
|
||||
|
||||
html! { <div>{ for html_content }</div> }
|
||||
}
|
||||
|
||||
pub fn render_toc(&self, ctx: &Context<Self>, toc: &[TocEntry]) -> Html {
|
||||
html! {
|
||||
<ul class="toc-list">
|
||||
{ toc.iter().map(|entry| {
|
||||
let page = entry.page as usize;
|
||||
// Check if page index is valid
|
||||
if page < ctx.props().book.pages.len() {
|
||||
let onclick = ctx.link().callback(move |_: MouseEvent| BookViewerMsg::GoToPage(page));
|
||||
html! {
|
||||
<li class="toc-item">
|
||||
<button class="toc-link" onclick={onclick}>
|
||||
{ &entry.title }
|
||||
</button>
|
||||
{ if !entry.subsections.is_empty() {
|
||||
self.render_toc(ctx, &entry.subsections)
|
||||
} else { html! {} }}
|
||||
</li>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<li class="toc-item toc-item-invalid">
|
||||
<span class="toc-link-invalid">
|
||||
{ format!("{} (invalid page)", &entry.title) }
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,34 @@
|
||||
use heromodels::models::library::items::Image;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Card Component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct ImageCardProps {
|
||||
pub item: Image,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(ImageCard)]
|
||||
pub fn image_card(props: &ImageCardProps) -> Html {
|
||||
let item = &props.item;
|
||||
let onclick = props.onclick.clone();
|
||||
|
||||
html! {
|
||||
<div class="library-item-card" {onclick}>
|
||||
<div class="item-preview">
|
||||
<img src={item.url.clone()} class="item-thumbnail-img" alt={item.title.clone()} />
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &item.title }</p>
|
||||
{ if let Some(desc) = &item.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer Component
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct ImageViewerProps {
|
||||
pub image: Image,
|
||||
@ -29,12 +57,6 @@ impl Component for ImageViewer {
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer image-viewer">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Collection"}
|
||||
</button>
|
||||
<div class="viewer-header">
|
||||
<h2 class="viewer-title">{ &props.image.title }</h2>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<img
|
||||
src={props.image.url.clone()}
|
@ -1,6 +1,34 @@
|
||||
use heromodels::models::library::items::Markdown;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Card Component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct MarkdownCardProps {
|
||||
pub item: Markdown,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(MarkdownCard)]
|
||||
pub fn markdown_card(props: &MarkdownCardProps) -> Html {
|
||||
let item = &props.item;
|
||||
let onclick = props.onclick.clone();
|
||||
|
||||
html! {
|
||||
<div class="library-item-card" {onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fab fa-markdown item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &item.title }</p>
|
||||
{ if let Some(desc) = &item.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer Component
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct MarkdownViewerProps {
|
||||
pub markdown: Markdown,
|
||||
@ -29,12 +57,6 @@ impl Component for MarkdownViewer {
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer markdown-viewer">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Collection"}
|
||||
</button>
|
||||
<div class="viewer-header">
|
||||
<h2 class="viewer-title">{ &props.markdown.title }</h2>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<div class="markdown-content">
|
||||
{ self.render_markdown(&props.markdown.content) }
|
5
src/app/src/components/library_item_cards/mod.rs
Normal file
5
src/app/src/components/library_item_cards/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod book;
|
||||
pub mod image;
|
||||
pub mod markdown;
|
||||
pub mod pdf;
|
||||
pub mod slides;
|
@ -1,6 +1,35 @@
|
||||
use heromodels::models::library::items::Pdf;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Card Component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct PdfCardProps {
|
||||
pub item: Pdf,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(PdfCard)]
|
||||
pub fn pdf_card(props: &PdfCardProps) -> Html {
|
||||
let item = &props.item;
|
||||
let onclick = props.onclick.clone();
|
||||
|
||||
html! {
|
||||
<div class="library-item-card" {onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-file-pdf item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &item.title }</p>
|
||||
{ if let Some(desc) = &item.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} pages", item.page_count) }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer Component
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct PdfViewerProps {
|
||||
pub pdf: Pdf,
|
||||
@ -29,12 +58,6 @@ impl Component for PdfViewer {
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer pdf-viewer">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Collection"}
|
||||
</button>
|
||||
<div class="viewer-header">
|
||||
<h2 class="viewer-title">{ &props.pdf.title }</h2>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<iframe
|
||||
src={format!("{}#toolbar=1&navpanes=1&scrollbar=1", props.pdf.url)}
|
169
src/app/src/components/library_item_cards/slides.rs
Normal file
169
src/app/src/components/library_item_cards/slides.rs
Normal file
@ -0,0 +1,169 @@
|
||||
use heromodels::models::library::items::Slideshow;
|
||||
use yew::prelude::*;
|
||||
|
||||
// Card Component
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct SlidesCardProps {
|
||||
pub item: Slideshow,
|
||||
pub onclick: Callback<MouseEvent>,
|
||||
}
|
||||
|
||||
#[function_component(SlidesCard)]
|
||||
pub fn slides_card(props: &SlidesCardProps) -> Html {
|
||||
let item = &props.item;
|
||||
let onclick = props.onclick.clone();
|
||||
|
||||
html! {
|
||||
<div class="library-item-card" {onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-images item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &item.title }</p>
|
||||
{ if let Some(desc) = &item.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} slides", item.slides.len()) }</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// Viewer Component
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct SlidesViewerProps {
|
||||
pub slides: Slideshow,
|
||||
pub on_back: Callback<()>,
|
||||
}
|
||||
|
||||
pub enum SlidesViewerMsg {
|
||||
GoToSlide(usize),
|
||||
NextSlide,
|
||||
PrevSlide,
|
||||
}
|
||||
|
||||
pub struct SlidesViewer {
|
||||
current_slide_index: usize,
|
||||
}
|
||||
|
||||
impl Component for SlidesViewer {
|
||||
type Message = SlidesViewerMsg;
|
||||
type Properties = SlidesViewerProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
current_slide_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SlidesViewerMsg::GoToSlide(slide) => {
|
||||
self.current_slide_index = slide;
|
||||
true
|
||||
}
|
||||
SlidesViewerMsg::NextSlide => {
|
||||
let props = _ctx.props();
|
||||
if self.current_slide_index < props.slides.slides.len().saturating_sub(1) {
|
||||
self.current_slide_index += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
SlidesViewerMsg::PrevSlide => {
|
||||
if self.current_slide_index > 0 {
|
||||
self.current_slide_index -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let total_slides = props.slides.slides.len();
|
||||
|
||||
let back_handler = {
|
||||
let on_back = props.on_back.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_back.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let prev_handler = ctx
|
||||
.link()
|
||||
.callback(|_: MouseEvent| SlidesViewerMsg::PrevSlide);
|
||||
let next_handler = ctx
|
||||
.link()
|
||||
.callback(|_: MouseEvent| SlidesViewerMsg::NextSlide);
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer slides-viewer">
|
||||
<div class="viewer-content">
|
||||
<div class="slide-header">
|
||||
<h4 class="slide-title-elegant">
|
||||
{ if let Some(title) = props.slides.slides.get(self.current_slide_index).and_then(|slide| slide.title.clone()) {
|
||||
title.clone()
|
||||
} else {
|
||||
format!("Slide {} of {}", self.current_slide_index + 1, total_slides)
|
||||
}}
|
||||
</h4>
|
||||
{ if let Some(description) = props.slides.slides.get(self.current_slide_index).and_then(|slide| slide.description.clone()) {
|
||||
html! { <p class="slide-description">{ description }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
<div class="slide-container">
|
||||
{ if let Some(slide) = props.slides.slides.get(self.current_slide_index) {
|
||||
html! {
|
||||
<div class="slide">
|
||||
<img
|
||||
src={slide.image_url.clone()}
|
||||
alt={
|
||||
props.slides.slides.get(self.current_slide_index)
|
||||
.and_then(|slide| slide.title.clone())
|
||||
.unwrap_or(format!("Slide {}", self.current_slide_index + 1))
|
||||
.clone()
|
||||
}
|
||||
class="slide-image"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <p>{"Slide not found"}</p> }
|
||||
}}
|
||||
</div>
|
||||
<div class="slide-thumbnails-container" style="display: flex; align-items: center; justify-content: space-between;">
|
||||
<button
|
||||
class="nav-button nav-button-left"
|
||||
onclick={prev_handler}
|
||||
disabled={self.current_slide_index == 0}
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="slide-thumbnails" style="display: flex; gap: 8px; overflow-x: auto;">
|
||||
{ props.slides.slides.iter().enumerate().map(|(index, slide)| {
|
||||
let is_current = index == self.current_slide_index;
|
||||
let onclick = ctx.link().callback(move |_: MouseEvent| SlidesViewerMsg::GoToSlide(index));
|
||||
html! {
|
||||
<div
|
||||
class={classes!("slide-thumbnail", if is_current { "active" } else { "" })}
|
||||
onclick={onclick}
|
||||
>
|
||||
<img src={slide.image_url.clone()} alt={format!("Slide {}", index + 1)} />
|
||||
<span class="thumbnail-number">{ index + 1 }</span>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</div>
|
||||
<button
|
||||
class="nav-button nav-button-right"
|
||||
onclick={next_handler}
|
||||
disabled={self.current_slide_index >= total_slides.saturating_sub(1)}
|
||||
>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
@ -1,657 +0,0 @@
|
||||
//! Login component for authentication
|
||||
//!
|
||||
//! This component provides a user interface for authentication using either
|
||||
//! email addresses (with hardcoded key lookup) or direct private key input.
|
||||
|
||||
use crate::auth::{AuthManager, AuthMethod, AuthState};
|
||||
use web_sys::HtmlInputElement;
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Props for the login component
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LoginProps {
|
||||
/// Authentication manager instance
|
||||
pub auth_manager: AuthManager,
|
||||
/// Callback when authentication is successful
|
||||
#[prop_or_default]
|
||||
pub on_authenticated: Option<Callback<()>>,
|
||||
/// Callback when authentication fails
|
||||
#[prop_or_default]
|
||||
pub on_error: Option<Callback<String>>,
|
||||
}
|
||||
|
||||
/// Login method selection
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) enum LoginMethod {
|
||||
Email,
|
||||
PrivateKey,
|
||||
CreateKey,
|
||||
}
|
||||
|
||||
/// Messages for the login component
|
||||
pub enum LoginMsg {
|
||||
SetLoginMethod(LoginMethod),
|
||||
SetEmail(String),
|
||||
SetPrivateKey(String),
|
||||
SubmitLogin,
|
||||
AuthStateChanged(AuthState),
|
||||
ShowAvailableEmails,
|
||||
HideAvailableEmails,
|
||||
SelectEmail(String),
|
||||
GenerateNewKey,
|
||||
CopyToClipboard(String),
|
||||
UseGeneratedKey,
|
||||
}
|
||||
|
||||
/// Login component state
|
||||
pub struct LoginComponent {
|
||||
login_method: LoginMethod,
|
||||
email: String,
|
||||
private_key: String,
|
||||
is_loading: bool,
|
||||
error_message: Option<String>,
|
||||
show_available_emails: bool,
|
||||
available_emails: Vec<String>,
|
||||
auth_state: AuthState,
|
||||
generated_private_key: Option<String>,
|
||||
generated_public_key: Option<String>,
|
||||
copy_feedback: Option<String>,
|
||||
}
|
||||
|
||||
impl Component for LoginComponent {
|
||||
type Message = LoginMsg;
|
||||
type Properties = LoginProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let auth_manager = ctx.props().auth_manager.clone();
|
||||
let auth_state = auth_manager.get_state();
|
||||
|
||||
// Set up auth state change callback
|
||||
let link = ctx.link().clone();
|
||||
auth_manager.set_on_state_change(link.callback(LoginMsg::AuthStateChanged));
|
||||
|
||||
// Get available emails for app
|
||||
let available_emails = auth_manager.get_available_emails();
|
||||
|
||||
Self {
|
||||
login_method: LoginMethod::Email,
|
||||
email: String::new(),
|
||||
private_key: String::new(),
|
||||
is_loading: false,
|
||||
error_message: None,
|
||||
show_available_emails: false,
|
||||
available_emails,
|
||||
auth_state,
|
||||
generated_private_key: None,
|
||||
generated_public_key: None,
|
||||
copy_feedback: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
LoginMsg::SetLoginMethod(method) => {
|
||||
self.login_method = method.clone();
|
||||
self.error_message = None;
|
||||
self.copy_feedback = None;
|
||||
// Clear generated keys when switching away from CreateKey method
|
||||
if method != LoginMethod::CreateKey {
|
||||
self.generated_private_key = None;
|
||||
self.generated_public_key = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::SetEmail(email) => {
|
||||
self.email = email;
|
||||
self.error_message = None;
|
||||
true
|
||||
}
|
||||
LoginMsg::SetPrivateKey(private_key) => {
|
||||
self.private_key = private_key;
|
||||
self.error_message = None;
|
||||
true
|
||||
}
|
||||
LoginMsg::SubmitLogin => {
|
||||
if self.is_loading {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.is_loading = true;
|
||||
self.error_message = None;
|
||||
|
||||
let auth_manager = ctx.props().auth_manager.clone();
|
||||
let link = ctx.link().clone();
|
||||
let on_authenticated = ctx.props().on_authenticated.clone();
|
||||
let on_error = ctx.props().on_error.clone();
|
||||
|
||||
match self.login_method {
|
||||
LoginMethod::Email => {
|
||||
let email = self.email.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match auth_manager.authenticate_with_email(email).await {
|
||||
Ok(()) => {
|
||||
if let Some(callback) = on_authenticated {
|
||||
callback.emit(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(callback) = on_error {
|
||||
callback.emit(e.to_string());
|
||||
}
|
||||
link.send_message(LoginMsg::AuthStateChanged(
|
||||
AuthState::Failed(e.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
LoginMethod::PrivateKey => {
|
||||
let private_key = self.private_key.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
match auth_manager
|
||||
.authenticate_with_private_key(private_key)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if let Some(callback) = on_authenticated {
|
||||
callback.emit(());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(callback) = on_error {
|
||||
callback.emit(e.to_string());
|
||||
}
|
||||
link.send_message(LoginMsg::AuthStateChanged(
|
||||
AuthState::Failed(e.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
LoginMethod::CreateKey => {
|
||||
// This shouldn't happen as CreateKey method doesn't have a submit button
|
||||
// But if it does, treat it as an error
|
||||
self.error_message =
|
||||
Some("Please generate a key first, then use it to login.".to_string());
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::AuthStateChanged(state) => {
|
||||
self.auth_state = state.clone();
|
||||
match state {
|
||||
AuthState::Authenticating => {
|
||||
self.is_loading = true;
|
||||
self.error_message = None;
|
||||
}
|
||||
AuthState::Authenticated { .. } => {
|
||||
self.is_loading = false;
|
||||
self.error_message = None;
|
||||
}
|
||||
AuthState::Failed(error) => {
|
||||
self.is_loading = false;
|
||||
self.error_message = Some(error);
|
||||
}
|
||||
AuthState::NotAuthenticated => {
|
||||
self.is_loading = false;
|
||||
self.error_message = None;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::ShowAvailableEmails => {
|
||||
self.show_available_emails = true;
|
||||
true
|
||||
}
|
||||
LoginMsg::HideAvailableEmails => {
|
||||
self.show_available_emails = false;
|
||||
true
|
||||
}
|
||||
LoginMsg::SelectEmail(email) => {
|
||||
self.email = email;
|
||||
self.show_available_emails = false;
|
||||
self.error_message = None;
|
||||
true
|
||||
}
|
||||
LoginMsg::GenerateNewKey => {
|
||||
use circle_client_ws::auth as crypto_utils;
|
||||
|
||||
match crypto_utils::generate_private_key() {
|
||||
Ok(private_key) => match crypto_utils::derive_public_key(&private_key) {
|
||||
Ok(public_key) => {
|
||||
self.generated_private_key = Some(private_key);
|
||||
self.generated_public_key = Some(public_key);
|
||||
self.error_message = None;
|
||||
self.copy_feedback = None;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message =
|
||||
Some(format!("Failed to derive public key: {}", e));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Failed to generate private key: {}", e));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::CopyToClipboard(text) => {
|
||||
// Simple fallback: show the text in an alert for now
|
||||
// TODO: Implement proper clipboard API when web_sys is properly configured
|
||||
if let Some(window) = web_sys::window() {
|
||||
window
|
||||
.alert_with_message(&format!("Copy this key:\n\n{}", text))
|
||||
.ok();
|
||||
self.copy_feedback =
|
||||
Some("Key shown in alert - please copy manually".to_string());
|
||||
}
|
||||
true
|
||||
}
|
||||
LoginMsg::UseGeneratedKey => {
|
||||
if let Some(private_key) = &self.generated_private_key {
|
||||
self.private_key = private_key.clone();
|
||||
self.login_method = LoginMethod::PrivateKey;
|
||||
self.copy_feedback = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let link = ctx.link();
|
||||
|
||||
// If already authenticated, show status
|
||||
if let AuthState::Authenticated {
|
||||
method, public_key, ..
|
||||
} = &self.auth_state
|
||||
{
|
||||
return self.render_authenticated_view(method, public_key, link);
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<h2 class="login-title">{ "Authenticate to Circles" }</h2>
|
||||
|
||||
{ self.render_method_selector(link) }
|
||||
{ self.render_login_form(link) }
|
||||
{ self.render_error_message() }
|
||||
{ self.render_loading_indicator() }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginComponent {
|
||||
fn render_method_selector(&self, link: &html::Scope<Self>) -> Html {
|
||||
html! {
|
||||
<div class="login-method-selector">
|
||||
<div class="method-tabs">
|
||||
<button
|
||||
class={classes!("method-tab", if self.login_method == LoginMethod::Email { "active" } else { "" })}
|
||||
onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::Email))}
|
||||
disabled={self.is_loading}
|
||||
>
|
||||
<i class="fas fa-envelope"></i>
|
||||
{ " Email" }
|
||||
</button>
|
||||
<button
|
||||
class={classes!("method-tab", if self.login_method == LoginMethod::PrivateKey { "active" } else { "" })}
|
||||
onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::PrivateKey))}
|
||||
disabled={self.is_loading}
|
||||
>
|
||||
<i class="fas fa-key"></i>
|
||||
{ " Private Key" }
|
||||
</button>
|
||||
<button
|
||||
class={classes!("method-tab", if self.login_method == LoginMethod::CreateKey { "active" } else { "" })}
|
||||
onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::CreateKey))}
|
||||
disabled={self.is_loading}
|
||||
>
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
{ " Create Key" }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_form(&self, link: &html::Scope<Self>) -> Html {
|
||||
match self.login_method {
|
||||
LoginMethod::Email => self.render_email_form(link),
|
||||
LoginMethod::PrivateKey => self.render_private_key_form(link),
|
||||
LoginMethod::CreateKey => self.render_create_key_form(link),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_email_form(&self, link: &html::Scope<Self>) -> Html {
|
||||
let on_email_input = link.batch_callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Some(LoginMsg::SetEmail(input.value()))
|
||||
});
|
||||
|
||||
let on_submit = link.callback(|e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
LoginMsg::SubmitLogin
|
||||
});
|
||||
|
||||
html! {
|
||||
<form class="login-form" onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="email-input">{ "Email Address" }</label>
|
||||
<div class="email-input-container">
|
||||
<input
|
||||
id="email-input"
|
||||
type="email"
|
||||
class="form-input"
|
||||
placeholder="Enter your email address"
|
||||
value={self.email.clone()}
|
||||
oninput={on_email_input}
|
||||
disabled={self.is_loading}
|
||||
required=true
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="email-dropdown-btn"
|
||||
onclick={link.callback(|_| LoginMsg::ShowAvailableEmails)}
|
||||
disabled={self.is_loading}
|
||||
title="Show available app emails"
|
||||
>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
{ self.render_email_dropdown(link) }
|
||||
<small class="form-help">
|
||||
{ "Use one of the app email addresses or click the dropdown to see available options." }
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
disabled={self.is_loading || self.email.is_empty()}
|
||||
>
|
||||
{ if self.is_loading { "Authenticating..." } else { "Login with Email" } }
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_private_key_form(&self, link: &html::Scope<Self>) -> Html {
|
||||
let on_private_key_input = link.batch_callback(|e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
Some(LoginMsg::SetPrivateKey(input.value()))
|
||||
});
|
||||
|
||||
let on_submit = link.callback(|e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
LoginMsg::SubmitLogin
|
||||
});
|
||||
|
||||
html! {
|
||||
<form class="login-form" onsubmit={on_submit}>
|
||||
<div class="form-group">
|
||||
<label for="private-key-input">{ "Private Key" }</label>
|
||||
<input
|
||||
id="private-key-input"
|
||||
type="password"
|
||||
class="form-input"
|
||||
placeholder="Enter your private key (hex format)"
|
||||
value={self.private_key.clone()}
|
||||
oninput={on_private_key_input}
|
||||
disabled={self.is_loading}
|
||||
required=true
|
||||
/>
|
||||
<small class="form-help">
|
||||
{ "Enter your secp256k1 private key in hexadecimal format (with or without 0x prefix)." }
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="login-btn"
|
||||
disabled={self.is_loading || self.private_key.is_empty()}
|
||||
>
|
||||
{ if self.is_loading { "Authenticating..." } else { "Login with Private Key" } }
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_email_dropdown(&self, link: &html::Scope<Self>) -> Html {
|
||||
if !self.show_available_emails {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="email-dropdown">
|
||||
<div class="dropdown-header">
|
||||
<span>{ "Available Demo Emails" }</span>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-close"
|
||||
onclick={link.callback(|_| LoginMsg::HideAvailableEmails)}
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-list">
|
||||
{ for self.available_emails.iter().map(|email| {
|
||||
let email_clone = email.clone();
|
||||
html! {
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-item"
|
||||
onclick={link.callback(move |_| LoginMsg::SelectEmail(email_clone.clone()))}
|
||||
>
|
||||
<i class="fas fa-user"></i>
|
||||
{ email }
|
||||
</button>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_error_message(&self) -> Html {
|
||||
if let Some(error) = &self.error_message {
|
||||
html! {
|
||||
<div class="error-message">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{ error }
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loading_indicator(&self) -> Html {
|
||||
if self.is_loading {
|
||||
html! {
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
<span>{ "Authenticating..." }</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_authenticated_view(
|
||||
&self,
|
||||
method: &AuthMethod,
|
||||
public_key: &str,
|
||||
link: &html::Scope<Self>,
|
||||
) -> Html {
|
||||
let method_display = match method {
|
||||
AuthMethod::Email(email) => format!("Email: {}", email),
|
||||
AuthMethod::PrivateKey => "Private Key".to_string(),
|
||||
};
|
||||
|
||||
let short_public_key = if public_key.len() > 20 {
|
||||
format!(
|
||||
"{}...{}",
|
||||
&public_key[..10],
|
||||
&public_key[public_key.len() - 10..]
|
||||
)
|
||||
} else {
|
||||
public_key.to_string()
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="authenticated-container">
|
||||
<div class="authenticated-card">
|
||||
<div class="auth-success-icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<h3>{ "Authentication Successful" }</h3>
|
||||
<div class="auth-details">
|
||||
<div class="auth-detail">
|
||||
<label>{ "Method:" }</label>
|
||||
<span>{ method_display }</span>
|
||||
</div>
|
||||
<div class="auth-detail">
|
||||
<label>{ "Public Key:" }</label>
|
||||
<span class="public-key" title={public_key.to_string()}>{ short_public_key }</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="logout-btn"
|
||||
onclick={link.callback(|_| {
|
||||
// This would need to be handled by the parent component
|
||||
LoginMsg::AuthStateChanged(AuthState::NotAuthenticated)
|
||||
})}
|
||||
>
|
||||
{ "Logout" }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_create_key_form(&self, link: &html::Scope<Self>) -> Html {
|
||||
html! {
|
||||
<div class="create-key-form">
|
||||
<div class="form-group">
|
||||
<h3>{ "Generate New secp256k1 Keypair" }</h3>
|
||||
<p class="form-help">
|
||||
{ "Create a new cryptographic keypair for authentication. " }
|
||||
{ "Make sure to securely store your private key!" }
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="generate-key-btn"
|
||||
onclick={link.callback(|_| LoginMsg::GenerateNewKey)}
|
||||
disabled={self.is_loading}
|
||||
>
|
||||
<i class="fas fa-dice"></i>
|
||||
{ " Generate New Keypair" }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ self.render_generated_keys(link) }
|
||||
{ self.render_copy_feedback() }
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_generated_keys(&self, link: &html::Scope<Self>) -> Html {
|
||||
if let (Some(private_key), Some(public_key)) =
|
||||
(&self.generated_private_key, &self.generated_public_key)
|
||||
{
|
||||
let private_key_clone = private_key.clone();
|
||||
let public_key_clone = public_key.clone();
|
||||
|
||||
html! {
|
||||
<div class="generated-keys">
|
||||
<div class="key-section">
|
||||
<label>{ "Private Key (Keep Secret!)" }</label>
|
||||
<div class="key-display">
|
||||
<input
|
||||
type="text"
|
||||
class="key-input"
|
||||
value={private_key.clone()}
|
||||
readonly=true
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
onclick={link.callback(move |_| LoginMsg::CopyToClipboard(private_key_clone.clone()))}
|
||||
title="Copy private key to clipboard"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="key-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{ " Store this private key securely! Anyone with access to it can control your account." }
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="key-section">
|
||||
<label>{ "Public Key (Safe to Share)" }</label>
|
||||
<div class="key-display">
|
||||
<input
|
||||
type="text"
|
||||
class="key-input"
|
||||
value={public_key.clone()}
|
||||
readonly=true
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn"
|
||||
onclick={link.callback(move |_| LoginMsg::CopyToClipboard(public_key_clone.clone()))}
|
||||
title="Copy public key to clipboard"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="key-info">
|
||||
{ "This is your public address that others can use to identify you." }
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="key-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="use-key-btn"
|
||||
onclick={link.callback(|_| LoginMsg::UseGeneratedKey)}
|
||||
>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
{ " Use This Key to Login" }
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="generate-new-btn"
|
||||
onclick={link.callback(|_| LoginMsg::GenerateNewKey)}
|
||||
>
|
||||
<i class="fas fa-redo"></i>
|
||||
{ " Generate New Keypair" }
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_copy_feedback(&self) -> Html {
|
||||
if let Some(feedback) = &self.copy_feedback {
|
||||
html! {
|
||||
<div class="copy-feedback">
|
||||
<i class="fas fa-check"></i>
|
||||
{ feedback }
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,31 +1,19 @@
|
||||
// This file declares the `components` module.
|
||||
pub mod circles_view;
|
||||
pub mod library_view;
|
||||
pub mod library_item_cards;
|
||||
pub mod nav_island;
|
||||
// pub use library_view::{LibraryView, LibraryViewProps}; // Kept commented as it's unused or handled in app.rs
|
||||
// Kept commented as it's unused or handled in app.rs
|
||||
// pub mod dashboard_view; // Commented out as dashboard_view.rs doesn't exist yet
|
||||
pub mod chat;
|
||||
pub mod customize_view;
|
||||
pub mod inspector_auth_tab;
|
||||
pub mod inspector_interact_tab;
|
||||
pub mod inspector_logs_tab;
|
||||
pub mod inspector_network_tab;
|
||||
pub mod inspector_view;
|
||||
pub mod intelligence_view;
|
||||
pub mod network_animation_view;
|
||||
pub mod publishing_view;
|
||||
pub mod sidebar_layout;
|
||||
pub mod world_map_svg;
|
||||
|
||||
// Authentication components
|
||||
pub mod auth_view;
|
||||
pub mod login_component;
|
||||
|
||||
// Library viewer components
|
||||
pub mod asset_details_card;
|
||||
pub mod book_viewer;
|
||||
pub mod image_viewer;
|
||||
pub mod markdown_viewer;
|
||||
pub mod pdf_viewer;
|
||||
pub mod slides_viewer;
|
||||
|
@ -28,7 +28,7 @@ pub fn sidebar_layout(props: &SidebarLayoutProps) -> Html {
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="sidebar-layout" onclick={on_background_click_handler}>
|
||||
<div class="layout-sidebar" onclick={on_background_click_handler}>
|
||||
<div class="sidebar" onclick={on_sidebar_click}>
|
||||
{ props.sidebar_content.clone() }
|
||||
</div>
|
||||
|
@ -1,140 +0,0 @@
|
||||
use heromodels::models::library::items::Slides;
|
||||
use yew::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct SlidesViewerProps {
|
||||
pub slides: Slides,
|
||||
pub on_back: Callback<()>,
|
||||
}
|
||||
|
||||
pub enum SlidesViewerMsg {
|
||||
GoToSlide(usize),
|
||||
NextSlide,
|
||||
PrevSlide,
|
||||
}
|
||||
|
||||
pub struct SlidesViewer {
|
||||
current_slide_index: usize,
|
||||
}
|
||||
|
||||
impl Component for SlidesViewer {
|
||||
type Message = SlidesViewerMsg;
|
||||
type Properties = SlidesViewerProps;
|
||||
|
||||
fn create(_ctx: &Context<Self>) -> Self {
|
||||
Self {
|
||||
current_slide_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
SlidesViewerMsg::GoToSlide(slide) => {
|
||||
self.current_slide_index = slide;
|
||||
true
|
||||
}
|
||||
SlidesViewerMsg::NextSlide => {
|
||||
let props = _ctx.props();
|
||||
if self.current_slide_index < props.slides.slide_urls.len().saturating_sub(1) {
|
||||
self.current_slide_index += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
SlidesViewerMsg::PrevSlide => {
|
||||
if self.current_slide_index > 0 {
|
||||
self.current_slide_index -= 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
let props = ctx.props();
|
||||
let total_slides = props.slides.slide_urls.len();
|
||||
|
||||
let back_handler = {
|
||||
let on_back = props.on_back.clone();
|
||||
Callback::from(move |_: MouseEvent| {
|
||||
on_back.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let prev_handler = ctx
|
||||
.link()
|
||||
.callback(|_: MouseEvent| SlidesViewerMsg::PrevSlide);
|
||||
let next_handler = ctx
|
||||
.link()
|
||||
.callback(|_: MouseEvent| SlidesViewerMsg::NextSlide);
|
||||
|
||||
html! {
|
||||
<div class="asset-viewer slides-viewer">
|
||||
<button class="back-button" onclick={back_handler}>
|
||||
<i class="fas fa-arrow-left"></i> {"Back to Collection"}
|
||||
</button>
|
||||
<div class="viewer-header">
|
||||
<h2 class="viewer-title">{ &props.slides.title }</h2>
|
||||
<div class="slides-navigation">
|
||||
<button
|
||||
class="nav-button"
|
||||
onclick={prev_handler}
|
||||
disabled={self.current_slide_index == 0}
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> {"Previous"}
|
||||
</button>
|
||||
<span class="slide-indicator">
|
||||
{ format!("Slide {} of {}", self.current_slide_index + 1, total_slides) }
|
||||
</span>
|
||||
<button
|
||||
class="nav-button"
|
||||
onclick={next_handler}
|
||||
disabled={self.current_slide_index >= total_slides.saturating_sub(1)}
|
||||
>
|
||||
{"Next"} <i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="viewer-content">
|
||||
<div class="slide-container">
|
||||
{ if let Some(slide_url) = props.slides.slide_urls.get(self.current_slide_index) {
|
||||
html! {
|
||||
<div class="slide">
|
||||
<img
|
||||
src={slide_url.clone()}
|
||||
alt={
|
||||
props.slides.slide_titles.get(self.current_slide_index)
|
||||
.and_then(|t| t.as_ref())
|
||||
.unwrap_or(&format!("Slide {}", self.current_slide_index + 1))
|
||||
.clone()
|
||||
}
|
||||
class="slide-image"
|
||||
/>
|
||||
{ if let Some(Some(title)) = props.slides.slide_titles.get(self.current_slide_index) {
|
||||
html! { <div class="slide-title">{ title }</div> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! { <p>{"Slide not found"}</p> }
|
||||
}}
|
||||
</div>
|
||||
<div class="slide-thumbnails">
|
||||
{ props.slides.slide_urls.iter().enumerate().map(|(index, url)| {
|
||||
let is_current = index == self.current_slide_index;
|
||||
let onclick = ctx.link().callback(move |_: MouseEvent| SlidesViewerMsg::GoToSlide(index));
|
||||
html! {
|
||||
<div
|
||||
class={classes!("slide-thumbnail", if is_current { "active" } else { "" })}
|
||||
onclick={onclick}
|
||||
>
|
||||
<img src={url.clone()} alt={format!("Slide {}", index + 1)} />
|
||||
<span class="thumbnail-number">{ index + 1 }</span>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@ mod app;
|
||||
mod auth; // Declares the authentication module
|
||||
mod components; // Declares the components module
|
||||
mod rhai_executor; // Declares the rhai_executor module
|
||||
mod routing; // Declares the routing module
|
||||
pub mod views;
|
||||
mod ws_manager; // Declares the WebSocket manager module
|
||||
|
||||
// This function is called when the WASM module is loaded.
|
||||
|
279
src/app/src/routing/library_router.rs
Normal file
279
src/app/src/routing/library_router.rs
Normal file
@ -0,0 +1,279 @@
|
||||
use super::url_router::{RouteParser, UrlRouter};
|
||||
|
||||
/// Route state for the LibraryView component
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LibraryRoute {
|
||||
/// Show collections list
|
||||
Collections,
|
||||
/// Show items in a specific collection
|
||||
Collection { collection_id: String },
|
||||
/// Show a specific item from a collection
|
||||
Item {
|
||||
collection_id: String,
|
||||
item_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for LibraryRoute {
|
||||
fn default() -> Self {
|
||||
LibraryRoute::Collections
|
||||
}
|
||||
}
|
||||
|
||||
impl LibraryRoute {
|
||||
/// Get the collection_id if this route involves a collection
|
||||
pub fn collection_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
LibraryRoute::Collections => None,
|
||||
LibraryRoute::Collection { collection_id } => Some(collection_id),
|
||||
LibraryRoute::Item { collection_id, .. } => Some(collection_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the item_id if this route involves an item
|
||||
pub fn item_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
LibraryRoute::Item { item_id, .. } => Some(item_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this route represents the collections view
|
||||
pub fn is_collections(&self) -> bool {
|
||||
matches!(self, LibraryRoute::Collections)
|
||||
}
|
||||
|
||||
/// Check if this route represents a collection items view
|
||||
pub fn is_collection_items(&self) -> bool {
|
||||
matches!(self, LibraryRoute::Collection { .. })
|
||||
}
|
||||
|
||||
/// Check if this route represents an item viewer
|
||||
pub fn is_item_viewer(&self) -> bool {
|
||||
matches!(self, LibraryRoute::Item { .. })
|
||||
}
|
||||
}
|
||||
|
||||
/// Router implementation for LibraryView
|
||||
pub struct LibraryRouter;
|
||||
|
||||
impl UrlRouter for LibraryRouter {
|
||||
type RouteState = LibraryRoute;
|
||||
|
||||
/// Parse a route path into LibraryRoute
|
||||
/// Expected formats:
|
||||
/// - "" or "/" -> Collections
|
||||
/// - "/collection/{id}" -> Collection
|
||||
/// - "/collection/{id}/item/{item_id}" -> Item
|
||||
fn parse_route(path: &str) -> Option<Self::RouteState> {
|
||||
let segments = RouteParser::split_path(path);
|
||||
|
||||
match segments.len() {
|
||||
// Empty path -> Collections view
|
||||
0 => Some(LibraryRoute::Collections),
|
||||
|
||||
// "/collection/{id}" -> Collection view
|
||||
2 if segments[0] == "collection" => {
|
||||
let encoded_collection_id = &segments[1];
|
||||
if encoded_collection_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// URL decode the collection ID
|
||||
match urlencoding::decode(encoded_collection_id) {
|
||||
Ok(collection_id) => Some(LibraryRoute::Collection {
|
||||
collection_id: collection_id.to_string(),
|
||||
}),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "/collection/{id}/item/{item_id}" -> Item view
|
||||
4 if segments[0] == "collection" && segments[2] == "item" => {
|
||||
let encoded_collection_id = &segments[1];
|
||||
let encoded_item_id = &segments[3];
|
||||
if encoded_collection_id.is_empty() || encoded_item_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// URL decode both IDs
|
||||
match (
|
||||
urlencoding::decode(encoded_collection_id),
|
||||
urlencoding::decode(encoded_item_id),
|
||||
) {
|
||||
(Ok(collection_id), Ok(item_id)) => Some(LibraryRoute::Item {
|
||||
collection_id: collection_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid path
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a route path from LibraryRoute
|
||||
fn build_route(state: &Self::RouteState) -> String {
|
||||
match state {
|
||||
LibraryRoute::Collections => "".to_string(),
|
||||
LibraryRoute::Collection { collection_id } => {
|
||||
format!("/collection/{}", urlencoding::encode(collection_id))
|
||||
}
|
||||
LibraryRoute::Item {
|
||||
collection_id,
|
||||
item_id,
|
||||
} => {
|
||||
format!(
|
||||
"/collection/{}/item/{}",
|
||||
urlencoding::encode(collection_id),
|
||||
urlencoding::encode(item_id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_collections_route() {
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route(""),
|
||||
Some(LibraryRoute::Collections)
|
||||
);
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/"),
|
||||
Some(LibraryRoute::Collections)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_collection_route() {
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection/ws1_123"),
|
||||
Some(LibraryRoute::Collection {
|
||||
collection_id: "ws1_123".to_string()
|
||||
})
|
||||
);
|
||||
|
||||
// Test URL encoded collection ID
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection/ws%3A%2F%2Flocalhost%3A9000%2Fws_29"),
|
||||
Some(LibraryRoute::Collection {
|
||||
collection_id: "ws://localhost:9000/ws_29".to_string()
|
||||
})
|
||||
);
|
||||
|
||||
// Empty collection ID should be invalid
|
||||
assert_eq!(LibraryRouter::parse_route("/collection/"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_item_route() {
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection/ws1_123/item/item456"),
|
||||
Some(LibraryRoute::Item {
|
||||
collection_id: "ws1_123".to_string(),
|
||||
item_id: "item456".to_string()
|
||||
})
|
||||
);
|
||||
|
||||
// Test URL encoded IDs
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection/ws%3A%2F%2Flocalhost%3A9000%2Fws_29/item/8"),
|
||||
Some(LibraryRoute::Item {
|
||||
collection_id: "ws://localhost:9000/ws_29".to_string(),
|
||||
item_id: "8".to_string()
|
||||
})
|
||||
);
|
||||
|
||||
// Empty IDs should be invalid
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection//item/item456"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
LibraryRouter::parse_route("/collection/ws1_123/item/"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_routes() {
|
||||
assert_eq!(LibraryRouter::parse_route("/invalid"), None);
|
||||
assert_eq!(LibraryRouter::parse_route("/collection"), None);
|
||||
assert_eq!(LibraryRouter::parse_route("/collection/id/invalid"), None);
|
||||
assert_eq!(LibraryRouter::parse_route("/collection/id/item"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_routes() {
|
||||
assert_eq!(LibraryRouter::build_route(&LibraryRoute::Collections), "");
|
||||
|
||||
assert_eq!(
|
||||
LibraryRouter::build_route(&LibraryRoute::Collection {
|
||||
collection_id: "ws1_123".to_string()
|
||||
}),
|
||||
"/collection/ws1_123"
|
||||
);
|
||||
|
||||
// Test URL encoding for WebSocket URLs
|
||||
assert_eq!(
|
||||
LibraryRouter::build_route(&LibraryRoute::Collection {
|
||||
collection_id: "ws://localhost:9000/ws_29".to_string()
|
||||
}),
|
||||
"/collection/ws%3A//localhost%3A9000/ws_29"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
LibraryRouter::build_route(&LibraryRoute::Item {
|
||||
collection_id: "ws1_123".to_string(),
|
||||
item_id: "item456".to_string()
|
||||
}),
|
||||
"/collection/ws1_123/item/item456"
|
||||
);
|
||||
|
||||
// Test URL encoding for WebSocket URLs with item
|
||||
assert_eq!(
|
||||
LibraryRouter::build_route(&LibraryRoute::Item {
|
||||
collection_id: "ws://localhost:9000/ws_29".to_string(),
|
||||
item_id: "8".to_string()
|
||||
}),
|
||||
"/collection/ws%3A//localhost%3A9000/ws_29/item/8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_route_helpers() {
|
||||
let collections = LibraryRoute::Collections;
|
||||
let collection = LibraryRoute::Collection {
|
||||
collection_id: "test".to_string(),
|
||||
};
|
||||
let item = LibraryRoute::Item {
|
||||
collection_id: "test".to_string(),
|
||||
item_id: "item1".to_string(),
|
||||
};
|
||||
|
||||
assert!(collections.is_collections());
|
||||
assert!(!collections.is_collection_items());
|
||||
assert!(!collections.is_item_viewer());
|
||||
assert_eq!(collections.collection_id(), None);
|
||||
assert_eq!(collections.item_id(), None);
|
||||
|
||||
assert!(!collection.is_collections());
|
||||
assert!(collection.is_collection_items());
|
||||
assert!(!collection.is_item_viewer());
|
||||
assert_eq!(collection.collection_id(), Some("test"));
|
||||
assert_eq!(collection.item_id(), None);
|
||||
|
||||
assert!(!item.is_collections());
|
||||
assert!(!item.is_collection_items());
|
||||
assert!(item.is_item_viewer());
|
||||
assert_eq!(item.collection_id(), Some("test"));
|
||||
assert_eq!(item.item_id(), Some("item1"));
|
||||
}
|
||||
}
|
7
src/app/src/routing/mod.rs
Normal file
7
src/app/src/routing/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod library_router;
|
||||
pub mod route_parser;
|
||||
pub mod url_router;
|
||||
|
||||
pub use library_router::*;
|
||||
pub use route_parser::*;
|
||||
pub use url_router::*;
|
143
src/app/src/routing/route_parser.rs
Normal file
143
src/app/src/routing/route_parser.rs
Normal file
@ -0,0 +1,143 @@
|
||||
use super::url_router::RouteParser;
|
||||
|
||||
/// Enhanced route parsing utilities for the application
|
||||
pub struct AppRouteParser;
|
||||
|
||||
impl AppRouteParser {
|
||||
/// Parse a full URL path and extract the app view and sub-route
|
||||
/// Returns (app_view, sub_route) where sub_route is the remaining path
|
||||
pub fn parse_app_route(path: &str) -> (String, String) {
|
||||
let base_view = RouteParser::extract_base_view(path);
|
||||
let sub_route = RouteParser::extract_sub_path(path, &base_view);
|
||||
(base_view, sub_route)
|
||||
}
|
||||
|
||||
/// Build a full app route from app view and sub-route
|
||||
pub fn build_app_route(app_view: &str, sub_route: &str) -> String {
|
||||
RouteParser::build_path(app_view, sub_route)
|
||||
}
|
||||
|
||||
/// Check if a path matches a specific app view
|
||||
pub fn matches_app_view(path: &str, app_view: &str) -> bool {
|
||||
let base_view = RouteParser::extract_base_view(path);
|
||||
base_view == app_view
|
||||
}
|
||||
|
||||
/// Extract query parameters from a URL search string
|
||||
pub fn parse_query_params(search: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
|
||||
if search.is_empty() {
|
||||
return params;
|
||||
}
|
||||
|
||||
let search = search.trim_start_matches('?');
|
||||
for pair in search.split('&') {
|
||||
if let Some((key, value)) = pair.split_once('=') {
|
||||
params.insert(
|
||||
urlencoding::decode(key).unwrap_or_default().to_string(),
|
||||
urlencoding::decode(value).unwrap_or_default().to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
|
||||
/// Build query string from parameters
|
||||
pub fn build_query_string(params: &std::collections::HashMap<String, String>) -> String {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let pairs: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||
.collect();
|
||||
|
||||
format!("?{}", pairs.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_parse_app_route() {
|
||||
assert_eq!(
|
||||
AppRouteParser::parse_app_route("/library/collection/123"),
|
||||
("library".to_string(), "/collection/123".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
AppRouteParser::parse_app_route("/library"),
|
||||
("library".to_string(), "".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
AppRouteParser::parse_app_route("/"),
|
||||
("".to_string(), "".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_app_route() {
|
||||
assert_eq!(
|
||||
AppRouteParser::build_app_route("library", "/collection/123"),
|
||||
"/library/collection/123"
|
||||
);
|
||||
|
||||
assert_eq!(AppRouteParser::build_app_route("library", ""), "/library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_app_view() {
|
||||
assert!(AppRouteParser::matches_app_view(
|
||||
"/library/collection/123",
|
||||
"library"
|
||||
));
|
||||
assert!(AppRouteParser::matches_app_view("/library", "library"));
|
||||
assert!(!AppRouteParser::matches_app_view(
|
||||
"/intelligence",
|
||||
"library"
|
||||
));
|
||||
assert!(!AppRouteParser::matches_app_view("/", "library"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query_params() {
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert("circles".to_string(), "ws1,ws2".to_string());
|
||||
expected.insert("view".to_string(), "collections".to_string());
|
||||
|
||||
assert_eq!(
|
||||
AppRouteParser::parse_query_params("?circles=ws1,ws2&view=collections"),
|
||||
expected
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
AppRouteParser::parse_query_params("circles=ws1,ws2&view=collections"),
|
||||
expected
|
||||
);
|
||||
|
||||
assert_eq!(AppRouteParser::parse_query_params(""), HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_query_string() {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("circles".to_string(), "ws1,ws2".to_string());
|
||||
params.insert("view".to_string(), "collections".to_string());
|
||||
|
||||
let result = AppRouteParser::build_query_string(¶ms);
|
||||
// Order might vary, so check both possibilities
|
||||
assert!(
|
||||
result == "?circles=ws1%2Cws2&view=collections"
|
||||
|| result == "?view=collections&circles=ws1%2Cws2"
|
||||
);
|
||||
|
||||
assert_eq!(AppRouteParser::build_query_string(&HashMap::new()), "");
|
||||
}
|
||||
}
|
154
src/app/src/routing/url_router.rs
Normal file
154
src/app/src/routing/url_router.rs
Normal file
@ -0,0 +1,154 @@
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
/// Trait for components that want to handle URL routing
|
||||
pub trait UrlRouter {
|
||||
type RouteState: Clone + PartialEq;
|
||||
|
||||
/// Parse a route path into component state
|
||||
fn parse_route(path: &str) -> Option<Self::RouteState>;
|
||||
|
||||
/// Build a route path from component state
|
||||
fn build_route(state: &Self::RouteState) -> String;
|
||||
}
|
||||
|
||||
/// Utility functions for URL and history management
|
||||
pub struct HistoryManager;
|
||||
|
||||
impl HistoryManager {
|
||||
/// Update the browser URL using pushState (creates new history entry)
|
||||
pub fn push_url(url: &str) -> Result<(), String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(history) = window.history() {
|
||||
history
|
||||
.push_state_with_url(&JsValue::NULL, "", Some(url))
|
||||
.map_err(|e| format!("Failed to push URL: {:?}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("Failed to access browser history".to_string())
|
||||
}
|
||||
|
||||
/// Update the browser URL using replaceState (replaces current history entry)
|
||||
pub fn replace_url(url: &str) -> Result<(), String> {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Ok(history) = window.history() {
|
||||
history
|
||||
.replace_state_with_url(&JsValue::NULL, "", Some(url))
|
||||
.map_err(|e| format!("Failed to replace URL: {:?}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("Failed to access browser history".to_string())
|
||||
}
|
||||
|
||||
/// Get the current pathname from the browser
|
||||
pub fn get_current_path() -> String {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().pathname().ok())
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
/// Get the current search params from the browser
|
||||
pub fn get_current_search() -> String {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().search().ok())
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
}
|
||||
|
||||
/// Build a full URL with path and query parameters
|
||||
pub fn build_full_url(path: &str, query_params: &str) -> String {
|
||||
if query_params.is_empty() {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}?{}", path, query_params.trim_start_matches('?'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions for parsing URL paths
|
||||
pub struct RouteParser;
|
||||
|
||||
impl RouteParser {
|
||||
/// Split a path into segments, filtering out empty segments
|
||||
pub fn split_path(path: &str) -> Vec<String> {
|
||||
path.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract the base view from a path (first segment)
|
||||
pub fn extract_base_view(path: &str) -> String {
|
||||
let segments = Self::split_path(path);
|
||||
segments.first().cloned().unwrap_or_else(|| "".to_string())
|
||||
}
|
||||
|
||||
/// Extract the remaining path after the base view
|
||||
pub fn extract_sub_path(path: &str, base_view: &str) -> String {
|
||||
let full_segments = Self::split_path(path);
|
||||
if full_segments.is_empty() || full_segments[0] != base_view {
|
||||
return "".to_string();
|
||||
}
|
||||
|
||||
let sub_segments = &full_segments[1..];
|
||||
if sub_segments.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!("/{}", sub_segments.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a path from base view and sub-path
|
||||
pub fn build_path(base_view: &str, sub_path: &str) -> String {
|
||||
if sub_path.is_empty() {
|
||||
format!("/{}", base_view)
|
||||
} else {
|
||||
format!("/{}{}", base_view, sub_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_split_path() {
|
||||
assert_eq!(
|
||||
RouteParser::split_path("/library/collection/123"),
|
||||
vec!["library", "collection", "123"]
|
||||
);
|
||||
assert_eq!(RouteParser::split_path("/library"), vec!["library"]);
|
||||
assert_eq!(RouteParser::split_path("/"), Vec::<String>::new());
|
||||
assert_eq!(RouteParser::split_path(""), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_view() {
|
||||
assert_eq!(
|
||||
RouteParser::extract_base_view("/library/collection/123"),
|
||||
"library"
|
||||
);
|
||||
assert_eq!(RouteParser::extract_base_view("/library"), "library");
|
||||
assert_eq!(RouteParser::extract_base_view("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_sub_path() {
|
||||
assert_eq!(
|
||||
RouteParser::extract_sub_path("/library/collection/123", "library"),
|
||||
"/collection/123"
|
||||
);
|
||||
assert_eq!(RouteParser::extract_sub_path("/library", "library"), "");
|
||||
assert_eq!(RouteParser::extract_sub_path("/other/path", "library"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_path() {
|
||||
assert_eq!(
|
||||
RouteParser::build_path("library", "/collection/123"),
|
||||
"/library/collection/123"
|
||||
);
|
||||
assert_eq!(RouteParser::build_path("library", ""), "/library");
|
||||
}
|
||||
}
|
257
src/app/src/views/auth_view.rs
Normal file
257
src/app/src/views/auth_view.rs
Normal file
@ -0,0 +1,257 @@
|
||||
use crate::auth::types::AuthState;
|
||||
use circle_client_ws::auth::generate_keypair;
|
||||
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
||||
use web_sys::{window, HtmlInputElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
/// Generates a new, cryptographically secure keypair.
|
||||
/// Returns Ok((public_key_hex, private_key_hex)) or Err(error_message).
|
||||
fn generate_secure_keypair() -> Result<(String, String), String> {
|
||||
generate_keypair().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct AuthViewProps {
|
||||
pub auth_state: AuthState,
|
||||
pub on_logout: Callback<()>,
|
||||
// Callback with (public_key, private_key)
|
||||
pub on_keypair_login_attempt: Callback<(String, String)>,
|
||||
// Callback with (name, generated_public_key, generated_private_key)
|
||||
pub on_registration_complete: Callback<(String, String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
enum AuthFormPage {
|
||||
Login,
|
||||
Register,
|
||||
ShowGeneratedKeys,
|
||||
}
|
||||
|
||||
#[function_component(AuthView)]
|
||||
pub fn auth_view(props: &AuthViewProps) -> Html {
|
||||
let current_page_handle = use_state(|| AuthFormPage::Login);
|
||||
let login_public_key_handle = use_state(String::new);
|
||||
let login_private_key_handle = use_state(String::new);
|
||||
let register_name_handle = use_state(String::new);
|
||||
let generated_info_handle = use_state(|| None::<(String, String, String)>); // name, pub_key, priv_key
|
||||
let error_message_handle = use_state(|| None::<String>);
|
||||
let success_message_handle = use_state(|| None::<String>);
|
||||
|
||||
let on_logout_cb = props.on_logout.clone();
|
||||
let on_keypair_login_attempt_cb = props.on_keypair_login_attempt.clone();
|
||||
let on_registration_complete_cb = props.on_registration_complete.clone();
|
||||
|
||||
// Helper function to render error/success messages
|
||||
let render_messages = |error: &Option<String>,
|
||||
success: &Option<String>,
|
||||
auth_error: &Option<String>|
|
||||
-> Html {
|
||||
html! {
|
||||
<>
|
||||
{ for auth_error.iter().map(|msg| html!{ <p class="text-secondary-accent text-center">{ msg }</p> }) }
|
||||
{ for error.iter().map(|msg| html!{ <p class="text-secondary-accent text-center">{ msg }</p> }) }
|
||||
{ for success.iter().map(|msg| html!{ <p class="text-primary-accent text-center">{ msg }</p> }) }
|
||||
</>
|
||||
}
|
||||
};
|
||||
|
||||
match &props.auth_state {
|
||||
AuthState::Authenticated { public_key, .. } => {
|
||||
let logout_onclick = Callback::from(move |_| on_logout_cb.emit(()));
|
||||
let pk_short = if public_key.len() > 10 {
|
||||
format!(
|
||||
"{}...{}",
|
||||
&public_key[..4],
|
||||
&public_key[public_key.len() - 4..]
|
||||
)
|
||||
} else {
|
||||
public_key.clone()
|
||||
};
|
||||
html! {
|
||||
<div class="auth-layout">
|
||||
<span class="public-key" title={public_key.clone()}>{ format!("PK: {}", pk_short) }</span>
|
||||
<button class="button-base action-button" onclick={logout_onclick} title="Logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthState::Authenticating => {
|
||||
html! {
|
||||
<div class="auth-container">
|
||||
<div class="card-base auth-card">
|
||||
<div class="card-content text-center p-lg">
|
||||
<p>{ "Authenticating..." }</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthState::NotAuthenticated | AuthState::Failed(_) => {
|
||||
let current_page = (*current_page_handle).clone();
|
||||
let auth_error = match &props.auth_state {
|
||||
AuthState::Failed(msg) => Some(msg.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let set_page = {
|
||||
let handle = current_page_handle.clone();
|
||||
let error_handle = error_message_handle.clone();
|
||||
let success_handle = success_message_handle.clone();
|
||||
Callback::from(move |page: AuthFormPage| {
|
||||
handle.set(page);
|
||||
error_handle.set(None);
|
||||
success_handle.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
let page_html = match current_page {
|
||||
AuthFormPage::Login => {
|
||||
let on_submit = {
|
||||
let pub_key = login_public_key_handle.clone();
|
||||
let priv_key = login_private_key_handle.clone();
|
||||
let error_handle = error_message_handle.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
if pub_key.is_empty() || priv_key.is_empty() {
|
||||
error_handle.set(Some(
|
||||
"Public and Private keys cannot be empty.".to_string(),
|
||||
));
|
||||
} else {
|
||||
on_keypair_login_attempt_cb
|
||||
.emit(((*pub_key).clone(), (*priv_key).clone()));
|
||||
}
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<div class="card-base auth-card">
|
||||
<div class="card-header"><h2 class="card-title">{"Login"}</h2></div>
|
||||
<form onsubmit={on_submit} class="card-content flex-col gap-md">
|
||||
{ render_messages(&*error_message_handle, &*success_message_handle, &auth_error) }
|
||||
<input type="text" class="input-base" placeholder="Public Key" value={(*login_public_key_handle).clone()} oninput={Callback::from(move |e: InputEvent| login_public_key_handle.set(e.target_unchecked_into::<HtmlInputElement>().value()))} />
|
||||
<input type="password" class="input-base" placeholder="Private Key" value={(*login_private_key_handle).clone()} oninput={Callback::from(move |e: InputEvent| login_private_key_handle.set(e.target_unchecked_into::<HtmlInputElement>().value()))} />
|
||||
<div class="card-footer">
|
||||
<button type="button" class="button-base button-secondary" onclick={set_page.reform(|_| AuthFormPage::Register)}>{ "Need an account?" }</button>
|
||||
<button type="submit" class="button-base button-primary">{ "Login" }</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthFormPage::Register => {
|
||||
let on_submit = {
|
||||
let name_handle = register_name_handle.clone();
|
||||
let error_handle = error_message_handle.clone();
|
||||
let generated_handle = generated_info_handle.clone();
|
||||
let page_handle = current_page_handle.clone();
|
||||
Callback::from(move |e: SubmitEvent| {
|
||||
e.prevent_default();
|
||||
let name = (*name_handle).trim();
|
||||
if name.is_empty() {
|
||||
error_handle.set(Some("Name cannot be empty.".to_string()));
|
||||
} else {
|
||||
match generate_secure_keypair() {
|
||||
Ok((pub_key, priv_key)) => {
|
||||
generated_handle.set(Some((
|
||||
name.to_string(),
|
||||
pub_key,
|
||||
priv_key,
|
||||
)));
|
||||
page_handle.set(AuthFormPage::ShowGeneratedKeys);
|
||||
}
|
||||
Err(err) => error_handle.set(Some(err)),
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
html! {
|
||||
<div class="card-base auth-card">
|
||||
<div class="card-header"><h2 class="card-title">{"Register"}</h2></div>
|
||||
<form onsubmit={on_submit} class="card-content flex-col gap-md">
|
||||
{ render_messages(&*error_message_handle, &*success_message_handle, &auth_error) }
|
||||
<input type="text" class="input-base" placeholder="Your Name" value={(*register_name_handle).clone()} oninput={Callback::from(move |e: InputEvent| register_name_handle.set(e.target_unchecked_into::<HtmlInputElement>().value()))} />
|
||||
<div class="card-footer">
|
||||
<button type="button" class="button-base button-secondary" onclick={set_page.reform(|_| AuthFormPage::Login)}>{ "Already have an account?" }</button>
|
||||
<button type="submit" class="button-base button-primary">{ "Register & Generate Keys" }</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
AuthFormPage::ShowGeneratedKeys => {
|
||||
if let Some((name, pub_key, priv_key)) = &*generated_info_handle {
|
||||
let on_confirm = {
|
||||
let on_reg_complete = on_registration_complete_cb.clone();
|
||||
let info = generated_info_handle.clone();
|
||||
let success = success_message_handle.clone();
|
||||
let page = set_page.clone();
|
||||
let login_pub = login_public_key_handle.clone();
|
||||
let name = name.clone();
|
||||
let pub_key = pub_key.clone();
|
||||
let priv_key = priv_key.clone();
|
||||
Callback::from(move |_| {
|
||||
on_reg_complete.emit((
|
||||
name.clone(),
|
||||
pub_key.clone(),
|
||||
priv_key.clone(),
|
||||
));
|
||||
info.set(None);
|
||||
success.set(Some(
|
||||
"Registration complete! You can now log in.".to_string(),
|
||||
));
|
||||
login_pub.set(pub_key.clone());
|
||||
page.emit(AuthFormPage::Login);
|
||||
})
|
||||
};
|
||||
let copy_cb = |data: String| {
|
||||
Callback::from(move |_| {
|
||||
let data_c = data.clone();
|
||||
spawn_local(async move {
|
||||
if let Some(window) = window() {
|
||||
let clipboard = window.navigator().clipboard();
|
||||
let promise = clipboard.write_text(&data_c);
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="card-base auth-card">
|
||||
<div class="card-header"><h2 class="card-title">{"Save Your Keys"}</h2></div>
|
||||
<div class="card-content flex-col gap-md">
|
||||
<p class="text-muted text-center">{"IMPORTANT: Save these keys securely. You will NOT be able to recover them."}</p>
|
||||
<div class="key-display flex-col gap-xs">
|
||||
<label class="font-semibold">{"Public Key"}</label>
|
||||
<div class="flex-row gap-sm items-center">
|
||||
<input type="text" readonly=true class="input-base" value={pub_key.clone()} />
|
||||
<button type="button" class="button-base action-button" onclick={copy_cb(pub_key.clone())}>{ "Copy" }</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="key-display flex-col gap-xs">
|
||||
<label class="font-semibold">{"Private Key"}</label>
|
||||
<div class="flex-row gap-sm items-center">
|
||||
<input type="password" readonly=true class="input-base" value={priv_key.clone()} />
|
||||
<button type="button" class="button-base action-button" onclick={copy_cb(priv_key.clone())}>{ "Copy" }</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button" class="button-base button-primary" onclick={on_confirm}>{ "I have saved my keys, proceed to login" }</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="auth-container">
|
||||
{page_html}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@ impl Reducible for RotationState {
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct CirclesViewProps {
|
||||
pub default_center_ws_url: String, // The starting center circle WebSocket URL
|
||||
pub on_context_update: Callback<Vec<String>>, // Single callback for context updates
|
||||
pub on_context_update: Callback<Vec<Circle>>, // Callback now sends full Circle objects
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@ -209,7 +209,7 @@ impl Component for CirclesView {
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
<div class="circles-view"
|
||||
<div
|
||||
onclick={on_background_click_handler}
|
||||
onwheel={on_wheel_handler}>
|
||||
<div class="flower-container">
|
||||
@ -290,30 +290,36 @@ impl CirclesView {
|
||||
|
||||
/// Update circles context and notify parent
|
||||
fn update_circles_context(&self, ctx: &Context<Self>) {
|
||||
let context_urls = if self.is_selected {
|
||||
// When selected, context is only the center circle
|
||||
vec![self.center_circle.clone()]
|
||||
let context_circles: Vec<Circle> = if self.is_selected {
|
||||
// If selected, context is only the center circle
|
||||
if let Some(center_circle_obj) = self.circles.get(&self.center_circle) {
|
||||
vec![center_circle_obj.clone()]
|
||||
} else {
|
||||
vec![] // Should not happen if logic is correct, but safe to handle
|
||||
}
|
||||
} else {
|
||||
// When unselected, context includes center + available surrounding circles
|
||||
let mut urls = vec![self.center_circle.clone()];
|
||||
|
||||
if let Some(center_circle) = self.circles.get(&self.center_circle) {
|
||||
// Add surrounding circles that are already loaded
|
||||
for surrounding_url in ¢er_circle.circles {
|
||||
if self.circles.contains_key(surrounding_url) {
|
||||
urls.push(surrounding_url.clone());
|
||||
// If not selected, context is center + all available surrounding circles
|
||||
let mut circles_in_context = Vec::new();
|
||||
if let Some(center_circle_obj) = self.circles.get(&self.center_circle) {
|
||||
circles_in_context.push(center_circle_obj.clone());
|
||||
for surrounding_url in ¢er_circle_obj.circles {
|
||||
if let Some(surrounding_circle_obj) = self.circles.get(surrounding_url) {
|
||||
circles_in_context.push(surrounding_circle_obj.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
urls
|
||||
circles_in_context
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"CirclesView: Updating context with {} URLs",
|
||||
context_urls.len()
|
||||
);
|
||||
ctx.props().on_context_update.emit(context_urls);
|
||||
if !context_circles.is_empty() {
|
||||
log::debug!(
|
||||
"CirclesView: Updating context with {} circles. Primary: {}",
|
||||
context_circles.len(),
|
||||
context_circles[0].title
|
||||
);
|
||||
}
|
||||
|
||||
ctx.props().on_context_update.emit(context_circles);
|
||||
}
|
||||
|
||||
/// Handle circle click logic
|
331
src/app/src/views/customize_view.rs
Normal file
331
src/app/src/views/customize_view.rs
Normal file
@ -0,0 +1,331 @@
|
||||
use crate::app::Msg as AppMsg;
|
||||
use crate::ws_manager::fetch_data_from_ws_url;
|
||||
use heromodels::models::circle::{Circle, ThemeData};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{HtmlInputElement, InputEvent};
|
||||
use yew::prelude::*;
|
||||
|
||||
const THEME_COLORS: &[&str] = &[
|
||||
"#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16",
|
||||
"#f97316", "#6366f1", "#14b8a6", "#f43f5e", "#ffffff", "#cbd5e1", "#64748b", "#0a0a0a",
|
||||
];
|
||||
const THEME_PATTERNS: &[&str] = &["none", "dots", "grid", "diagonal", "waves", "mesh"];
|
||||
const THEME_SYMBOLS: &[&str] = &["○", "●", "□", "■", "△", "▲", "◇", "◆"];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Msg {
|
||||
SetTheme(ThemeData),
|
||||
UpdatePrimaryColor(String),
|
||||
UpdateBackgroundColor(String),
|
||||
UpdateBackgroundPattern(String),
|
||||
UpdateLogoSymbol(String),
|
||||
UpdateLogoUrl(String),
|
||||
ToggleNavDashboard,
|
||||
ToggleNavTimeline,
|
||||
SaveChanges,
|
||||
NoOp,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq, Clone)]
|
||||
pub struct CustomizeViewProps {
|
||||
pub active_circle: Option<Circle>,
|
||||
pub ws_url: Option<String>,
|
||||
pub app_callback: Callback<AppMsg>,
|
||||
}
|
||||
|
||||
pub struct CustomizeView {
|
||||
theme: ThemeData,
|
||||
ws_url: Option<String>,
|
||||
app_callback: Callback<AppMsg>,
|
||||
}
|
||||
|
||||
impl Component for CustomizeView {
|
||||
type Message = Msg;
|
||||
type Properties = CustomizeViewProps;
|
||||
|
||||
fn create(ctx: &Context<Self>) -> Self {
|
||||
let props = ctx.props();
|
||||
let theme = props
|
||||
.active_circle
|
||||
.as_ref()
|
||||
.map_or_else(ThemeData::default, |c| c.theme.clone());
|
||||
let ws_url = props.ws_url.clone();
|
||||
|
||||
// Fetch the theme from the server if a ws_url is available
|
||||
if let Some(url) = ws_url.clone() {
|
||||
let link = ctx.link().clone();
|
||||
spawn_local(async move {
|
||||
let script = "get_circle().theme.json()".to_string();
|
||||
match fetch_data_from_ws_url::<ThemeData>(&url, &script).await {
|
||||
Ok(server_theme) => link.send_message(Msg::SetTheme(server_theme)),
|
||||
Err(e) => log::error!("Failed to fetch initial theme: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
theme,
|
||||
ws_url,
|
||||
app_callback: props.app_callback.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::SetTheme(theme) => {
|
||||
self.theme = theme;
|
||||
true
|
||||
}
|
||||
Msg::UpdatePrimaryColor(color) => {
|
||||
self.theme.primary_color = color;
|
||||
true
|
||||
}
|
||||
Msg::UpdateBackgroundColor(color) => {
|
||||
self.theme.background_color = color;
|
||||
true
|
||||
}
|
||||
Msg::UpdateBackgroundPattern(pattern) => {
|
||||
self.theme.background_pattern = pattern;
|
||||
true
|
||||
}
|
||||
Msg::UpdateLogoSymbol(symbol) => {
|
||||
self.theme.logo_symbol = symbol;
|
||||
true
|
||||
}
|
||||
Msg::UpdateLogoUrl(url) => {
|
||||
self.theme.logo_url = url;
|
||||
true
|
||||
}
|
||||
Msg::ToggleNavDashboard => {
|
||||
self.theme.nav_dashboard_visible = !self.theme.nav_dashboard_visible;
|
||||
true
|
||||
}
|
||||
Msg::ToggleNavTimeline => {
|
||||
self.theme.nav_timeline_visible = !self.theme.nav_timeline_visible;
|
||||
true
|
||||
}
|
||||
Msg::SaveChanges => {
|
||||
if let Some(ws_url) = self.ws_url.clone() {
|
||||
let script_parts = vec![
|
||||
"let c = get_circle();".to_string(),
|
||||
format!("c.theme.primary_color = \"{}\";", self.theme.primary_color),
|
||||
format!(
|
||||
"c.theme.background_color = \"{}\";",
|
||||
self.theme.background_color
|
||||
),
|
||||
format!(
|
||||
"c.theme.background_pattern = \"{}\";",
|
||||
self.theme.background_pattern
|
||||
),
|
||||
format!("c.theme.logo_symbol = \"{}\";", self.theme.logo_symbol),
|
||||
format!("c.theme.logo_url = \"{}\";", self.theme.logo_url),
|
||||
format!(
|
||||
"c.theme.nav_dashboard_visible = {};",
|
||||
self.theme.nav_dashboard_visible
|
||||
),
|
||||
format!(
|
||||
"c.theme.nav_timeline_visible = {};",
|
||||
self.theme.nav_timeline_visible
|
||||
),
|
||||
"save_circle(c).json();".to_string(),
|
||||
];
|
||||
let script = script_parts.join("\n");
|
||||
let app_callback = self.app_callback.clone();
|
||||
spawn_local(async move {
|
||||
match fetch_data_from_ws_url::<serde_json::Value>(&ws_url, &script).await {
|
||||
Ok(response) => {
|
||||
log::info!("Received response from save_circle: {:?}", response);
|
||||
|
||||
let mut theme_updated = false;
|
||||
// First, try to parse the response as a Circle object directly
|
||||
if let Ok(circle) =
|
||||
serde_json::from_value::<Circle>(response.clone())
|
||||
{
|
||||
app_callback.emit(AppMsg::UpdateTheme(circle.theme));
|
||||
theme_updated = true;
|
||||
// If not, check if it's a string that can be parsed into a Circle
|
||||
} else if let Some(json_str) = response.as_str() {
|
||||
if let Ok(circle) = serde_json::from_str::<Circle>(json_str) {
|
||||
app_callback.emit(AppMsg::UpdateTheme(circle.theme));
|
||||
theme_updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if theme_updated {
|
||||
log::info!("Theme update message sent to App component.");
|
||||
} else {
|
||||
log::error!("Failed to parse Circle/Theme from response.");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("Failed to update theme: {}", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
Msg::NoOp => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||
log::info!("CustomizeView current theme: {:?}", self.theme);
|
||||
let link = ctx.link();
|
||||
let theme = &self.theme;
|
||||
|
||||
if self.ws_url.is_none() {
|
||||
return html! { <div class="customize-view-container">{"Select a circle to customize"}</div> };
|
||||
}
|
||||
|
||||
html! {
|
||||
<main>
|
||||
<header>
|
||||
<h2>{"Customize Theme"}</h2>
|
||||
<p>{"Modify the appearance and behavior of your circle."}</p>
|
||||
</header>
|
||||
<ul>
|
||||
<li>
|
||||
<section>
|
||||
{ self.render_color_setting_content(link, "Primary Color", &theme.primary_color, |c| Msg::UpdatePrimaryColor(c)) }
|
||||
{ self.render_color_setting_content(link, "Background Color", &theme.background_color, |c| Msg::UpdateBackgroundColor(c)) }
|
||||
</section>
|
||||
</li>
|
||||
<li>
|
||||
<section>
|
||||
{ self.render_color_setting_content(link, "Background Color", &theme.background_color, |c| Msg::UpdateBackgroundColor(c)) }
|
||||
{ self.render_pattern_setting(link, "Background Pattern", &theme.background_pattern, |p| Msg::UpdateBackgroundPattern(p)) }
|
||||
</section>
|
||||
</li>
|
||||
<li class="setting-item-group card-base">
|
||||
<div class="setting-column">
|
||||
{ self.render_logo_symbol_setting_content(link, "Logo Symbol", &theme.logo_symbol, |s| Msg::UpdateLogoSymbol(s)) }
|
||||
</div>
|
||||
<div class="setting-column">
|
||||
{ self.render_text_input_setting_content(link, "Logo URL", &theme.logo_url, |u| Msg::UpdateLogoUrl(u)) }
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button onclick={link.callback(|_| Msg::SaveChanges)}>{ "Save Changes" }</button>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomizeView {
|
||||
fn render_color_setting_content(
|
||||
&self,
|
||||
link: &html::Scope<Self>,
|
||||
label: &str,
|
||||
current_value: &str,
|
||||
msg_mapper: fn(String) -> Msg,
|
||||
) -> Html {
|
||||
html! {
|
||||
<article>
|
||||
<header>
|
||||
<h3>{ label }</h3>
|
||||
<p>{"Choose a color for branding."}</p>
|
||||
</header>
|
||||
<div class="color-grid">
|
||||
{ for THEME_COLORS.iter().map(|color| {
|
||||
let is_selected = *color == current_value;
|
||||
let on_click = link.callback(move |_| msg_mapper(color.to_string()));
|
||||
html! {
|
||||
<div class="color-option" style={format!("background-color: {}", color)} onclick={on_click}>
|
||||
if is_selected {
|
||||
<div class="checkmark">{"✔"}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pattern_setting(
|
||||
&self,
|
||||
link: &html::Scope<Self>,
|
||||
label: &str,
|
||||
current_value: &str,
|
||||
msg_mapper: fn(String) -> Msg,
|
||||
) -> Html {
|
||||
html! {
|
||||
<li class="setting-item card-base">
|
||||
<header>
|
||||
<h3 class="setting-label">{ label }</h3>
|
||||
</header>
|
||||
<div class="setting-control">
|
||||
<div class="pattern-grid">
|
||||
{ for THEME_PATTERNS.iter().map(|pattern| {
|
||||
let is_selected = *pattern == current_value;
|
||||
let on_click = link.callback(move |_| msg_mapper(pattern.to_string()));
|
||||
let pattern_class = format!("pattern-preview-{}", pattern.replace(" ", "-").to_lowercase());
|
||||
html! {
|
||||
<div
|
||||
class={classes!("pattern-option", pattern_class, is_selected.then_some("selected"))}
|
||||
onclick={on_click}
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_logo_symbol_setting_content(
|
||||
&self,
|
||||
link: &html::Scope<Self>,
|
||||
label: &str,
|
||||
current_value: &str,
|
||||
msg_mapper: fn(String) -> Msg,
|
||||
) -> Html {
|
||||
html! {
|
||||
<>
|
||||
<div class="setting-info">
|
||||
<label>{ label }</label>
|
||||
<p>{"Select a symbol to represent your circle."}</p>
|
||||
</div>
|
||||
<div class="symbol-options">
|
||||
{ for THEME_SYMBOLS.iter().map(|symbol| {
|
||||
let is_selected = *symbol == current_value;
|
||||
let on_click = link.callback(move |_| msg_mapper(symbol.to_string()));
|
||||
html! {
|
||||
<div class={classes!("symbol-option", is_selected.then_some("selected"))} onclick={on_click}>
|
||||
{ symbol }
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_input_setting_content(
|
||||
&self,
|
||||
link: &html::Scope<Self>,
|
||||
label: &str,
|
||||
current_value: &str,
|
||||
msg_mapper: fn(String) -> Msg,
|
||||
) -> Html {
|
||||
let on_input = link.callback(move |e: InputEvent| {
|
||||
let input: HtmlInputElement = e.target_unchecked_into();
|
||||
msg_mapper(input.value())
|
||||
});
|
||||
html! {
|
||||
<>
|
||||
<div class="setting-info">
|
||||
<label>{ label }</label>
|
||||
<p>{"Provide a URL for your circle's logo."}</p>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<input
|
||||
type="text"
|
||||
class="setting-text-input input-base"
|
||||
value={current_value.to_string()}
|
||||
oninput={on_input}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
||||
}
|
@ -144,7 +144,7 @@ impl Component for IntelligenceView {
|
||||
let on_new_conversation = link.callback(|_| IntelligenceMsg::StartNewConversation);
|
||||
|
||||
html! {
|
||||
<div class="view-container sidebar-layout">
|
||||
<main class="layout-sidebar">
|
||||
<div class="card">
|
||||
<h3>{"Conversations"}</h3>
|
||||
<button onclick={on_new_conversation} class="new-conversation-btn">{ "+ New Chat" }</button>
|
||||
@ -207,7 +207,7 @@ impl Component for IntelligenceView {
|
||||
<button type="submit" class="button-base button-primary send-button">{ "Send" }</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
use crate::components::{
|
||||
asset_details_card::AssetDetailsCard, book_viewer::BookViewer, image_viewer::ImageViewer,
|
||||
markdown_viewer::MarkdownViewer, pdf_viewer::PdfViewer, slides_viewer::SlidesViewer,
|
||||
};
|
||||
use crate::components::asset_details_card::AssetDetailsCard;
|
||||
use crate::components::library_item_cards::book::{BookCard, BookViewer};
|
||||
use crate::components::library_item_cards::image::{ImageCard, ImageViewer};
|
||||
use crate::components::library_item_cards::markdown::{MarkdownCard, MarkdownViewer};
|
||||
use crate::components::library_item_cards::pdf::{PdfCard, PdfViewer};
|
||||
use crate::components::library_item_cards::slides::{SlidesCard, SlidesViewer};
|
||||
use crate::routing::{LibraryRoute, LibraryRouter, UrlRouter};
|
||||
use crate::ws_manager::{fetch_data_from_ws_url, fetch_data_from_ws_urls};
|
||||
use heromodels::models::library::collection::Collection;
|
||||
use heromodels::models::library::items::{Book, Image, Markdown, Pdf, Slides};
|
||||
use heromodels::models::library::items::{Book, Image, Markdown, Pdf, Slideshow};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
@ -13,6 +16,8 @@ use yew::prelude::*;
|
||||
#[derive(Clone, PartialEq, Properties)]
|
||||
pub struct LibraryViewProps {
|
||||
pub ws_addresses: Vec<String>,
|
||||
pub initial_route: Option<String>,
|
||||
pub on_route_change: Callback<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@ -21,7 +26,7 @@ pub enum DisplayLibraryItem {
|
||||
Pdf(Pdf),
|
||||
Markdown(Markdown),
|
||||
Book(Book),
|
||||
Slides(Slides),
|
||||
Slideshow(Slideshow),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@ -41,9 +46,11 @@ pub enum Msg {
|
||||
ViewItem(DisplayLibraryItem),
|
||||
BackToLibrary,
|
||||
BackToCollections,
|
||||
RouteChanged(LibraryRoute),
|
||||
}
|
||||
|
||||
pub struct LibraryView {
|
||||
current_route: LibraryRoute,
|
||||
selected_collection_index: Option<usize>,
|
||||
collections: HashMap<String, Collection>,
|
||||
display_collections: Vec<DisplayLibraryCollection>,
|
||||
@ -68,6 +75,22 @@ impl Component for LibraryView {
|
||||
let props = ctx.props();
|
||||
let ws_addresses = props.ws_addresses.clone();
|
||||
|
||||
// Parse initial route from props
|
||||
let initial_route = if let Some(route_str) = &props.initial_route {
|
||||
LibraryRouter::parse_route(route_str).unwrap_or_default()
|
||||
} else {
|
||||
LibraryRoute::default()
|
||||
};
|
||||
|
||||
// Determine initial view state from route
|
||||
let view_state = if initial_route.is_item_viewer() {
|
||||
ViewState::ItemViewer
|
||||
} else if initial_route.is_collection_items() {
|
||||
ViewState::CollectionItems
|
||||
} else {
|
||||
ViewState::Collections
|
||||
};
|
||||
|
||||
let link = ctx.link().clone();
|
||||
spawn_local(async move {
|
||||
let collections = get_collections(&ws_addresses).await;
|
||||
@ -75,17 +98,21 @@ impl Component for LibraryView {
|
||||
});
|
||||
|
||||
Self {
|
||||
current_route: initial_route,
|
||||
selected_collection_index: None,
|
||||
collections: HashMap::new(),
|
||||
display_collections: Vec::new(),
|
||||
loading: true,
|
||||
error: None,
|
||||
viewing_item: None,
|
||||
view_state: ViewState::Collections,
|
||||
view_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
|
||||
let mut should_render = false;
|
||||
|
||||
// Handle WebSocket address changes
|
||||
if ctx.props().ws_addresses != old_props.ws_addresses {
|
||||
let ws_addresses = ctx.props().ws_addresses.clone();
|
||||
let link = ctx.link().clone();
|
||||
@ -97,17 +124,33 @@ impl Component for LibraryView {
|
||||
let collections = get_collections(&ws_addresses).await;
|
||||
link.send_message(Msg::CollectionsFetched(collections));
|
||||
});
|
||||
should_render = true;
|
||||
}
|
||||
true
|
||||
|
||||
// Handle route changes from browser navigation
|
||||
if ctx.props().initial_route != old_props.initial_route {
|
||||
if let Some(route_str) = &ctx.props().initial_route {
|
||||
if let Some(new_route) = LibraryRouter::parse_route(route_str) {
|
||||
if new_route != self.current_route {
|
||||
ctx.link().send_message(Msg::RouteChanged(new_route));
|
||||
should_render = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let new_route = LibraryRoute::Collections;
|
||||
if new_route != self.current_route {
|
||||
ctx.link().send_message(Msg::RouteChanged(new_route));
|
||||
should_render = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
should_render
|
||||
}
|
||||
|
||||
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||
match msg {
|
||||
Msg::CollectionsFetched(collections) => {
|
||||
log::info!(
|
||||
"Collections fetched: {:?}",
|
||||
collections.keys().collect::<Vec<_>>()
|
||||
);
|
||||
self.collections = collections.clone();
|
||||
self.loading = false;
|
||||
|
||||
@ -134,40 +177,97 @@ impl Component for LibraryView {
|
||||
});
|
||||
}
|
||||
|
||||
// Sync with current route now that collections are loaded
|
||||
self.sync_state_with_route();
|
||||
true
|
||||
}
|
||||
Msg::ItemsFetched(collection_key, items) => {
|
||||
// Find the display collection and update its items using exact key matching
|
||||
// Find the display collection and update its items
|
||||
if let Some(display_collection) = self
|
||||
.display_collections
|
||||
.iter_mut()
|
||||
.find(|dc| dc.collection_key == collection_key)
|
||||
{
|
||||
display_collection.items = items.into_iter().map(Rc::new).collect();
|
||||
|
||||
// If this collection matches our current route, sync state again
|
||||
if let LibraryRoute::Item { collection_id, .. } = &self.current_route {
|
||||
if collection_id == &collection_key {
|
||||
self.sync_state_with_route();
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::ViewItem(item) => {
|
||||
self.viewing_item = Some(item);
|
||||
self.viewing_item = Some(item.clone());
|
||||
self.view_state = ViewState::ItemViewer;
|
||||
|
||||
// Update route and notify parent
|
||||
if let Some(collection_key) = self.find_collection_key_for_item(&item) {
|
||||
if let Some(item_id) = self.get_item_id(&item) {
|
||||
let new_route = LibraryRoute::Item {
|
||||
collection_id: collection_key,
|
||||
item_id,
|
||||
};
|
||||
self.current_route = new_route.clone();
|
||||
let route_str = LibraryRouter::build_route(&new_route);
|
||||
ctx.props().on_route_change.emit(route_str);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::BackToLibrary => {
|
||||
self.viewing_item = None;
|
||||
self.view_state = ViewState::CollectionItems;
|
||||
|
||||
// Update route to collection view
|
||||
if let Some(collection_id) = self.current_route.collection_id() {
|
||||
let new_route = LibraryRoute::Collection {
|
||||
collection_id: collection_id.to_string(),
|
||||
};
|
||||
self.current_route = new_route.clone();
|
||||
let route_str = LibraryRouter::build_route(&new_route);
|
||||
ctx.props().on_route_change.emit(route_str);
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::BackToCollections => {
|
||||
self.viewing_item = None;
|
||||
self.selected_collection_index = None;
|
||||
self.view_state = ViewState::Collections;
|
||||
|
||||
// Update route to collections view
|
||||
let new_route = LibraryRoute::Collections;
|
||||
self.current_route = new_route.clone();
|
||||
let route_str = LibraryRouter::build_route(&new_route);
|
||||
ctx.props().on_route_change.emit(route_str);
|
||||
true
|
||||
}
|
||||
Msg::SelectCollection(idx) => {
|
||||
self.selected_collection_index = Some(idx);
|
||||
self.view_state = ViewState::CollectionItems;
|
||||
|
||||
// Update route to collection view
|
||||
if let Some(collection) = self.display_collections.get(idx) {
|
||||
let new_route = LibraryRoute::Collection {
|
||||
collection_id: collection.collection_key.clone(),
|
||||
};
|
||||
self.current_route = new_route.clone();
|
||||
let route_str = LibraryRouter::build_route(&new_route);
|
||||
ctx.props().on_route_change.emit(route_str);
|
||||
}
|
||||
true
|
||||
}
|
||||
Msg::RouteChanged(new_route) => {
|
||||
if new_route != self.current_route {
|
||||
self.current_route = new_route;
|
||||
self.sync_state_with_route();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +281,7 @@ impl Component for LibraryView {
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="view-container sidebar-layout">
|
||||
<main class="layout-sidebar">
|
||||
<div class="sidebar">
|
||||
<AssetDetailsCard
|
||||
item={item.clone()}
|
||||
@ -190,10 +290,10 @@ impl Component for LibraryView {
|
||||
current_slide_index={None}
|
||||
/>
|
||||
</div>
|
||||
<div class="library-content">
|
||||
<div>
|
||||
{ self.render_viewer_component(item, back_callback) }
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
}
|
||||
} else {
|
||||
html! { <p>{"No item selected"}</p> }
|
||||
@ -203,21 +303,17 @@ impl Component for LibraryView {
|
||||
// Collection items view with click-outside to go back
|
||||
let back_handler = ctx.link().callback(|_: MouseEvent| Msg::BackToCollections);
|
||||
html! {
|
||||
<div class="view-container layout">
|
||||
<div class="library-content" onclick={back_handler}>
|
||||
<div onclick={back_handler}>
|
||||
{ self.render_collection_items_view(ctx) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
ViewState::Collections => {
|
||||
// Collections view - no click-outside needed
|
||||
html! {
|
||||
<div class="view-container layout">
|
||||
<div class="library-content">
|
||||
<div>
|
||||
{ self.render_collections_view(ctx) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -225,6 +321,74 @@ impl Component for LibraryView {
|
||||
}
|
||||
|
||||
impl LibraryView {
|
||||
/// Sync component state with current route
|
||||
fn sync_state_with_route(&mut self) {
|
||||
match &self.current_route {
|
||||
LibraryRoute::Collections => {
|
||||
self.view_state = ViewState::Collections;
|
||||
self.selected_collection_index = None;
|
||||
self.viewing_item = None;
|
||||
}
|
||||
LibraryRoute::Collection { collection_id } => {
|
||||
self.view_state = ViewState::CollectionItems;
|
||||
self.viewing_item = None;
|
||||
|
||||
// Find collection index by key
|
||||
if let Some(idx) = self
|
||||
.display_collections
|
||||
.iter()
|
||||
.position(|c| &c.collection_key == collection_id)
|
||||
{
|
||||
self.selected_collection_index = Some(idx);
|
||||
}
|
||||
}
|
||||
LibraryRoute::Item {
|
||||
collection_id,
|
||||
item_id,
|
||||
} => {
|
||||
self.view_state = ViewState::ItemViewer;
|
||||
|
||||
// Find and set the item
|
||||
if let Some(item) = self.find_item_by_ids(collection_id, item_id) {
|
||||
self.viewing_item = Some(item);
|
||||
|
||||
// Also set collection index
|
||||
if let Some(idx) = self
|
||||
.display_collections
|
||||
.iter()
|
||||
.position(|c| &c.collection_key == collection_id)
|
||||
{
|
||||
self.selected_collection_index = Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_library_item_card(
|
||||
&self,
|
||||
item: &DisplayLibraryItem,
|
||||
onclick: Callback<MouseEvent>,
|
||||
) -> Html {
|
||||
match item {
|
||||
DisplayLibraryItem::Image(img_item) => {
|
||||
html! { <ImageCard item={img_item.clone()} {onclick} /> }
|
||||
}
|
||||
DisplayLibraryItem::Pdf(pdf_item) => {
|
||||
html! { <PdfCard item={pdf_item.clone()} {onclick} /> }
|
||||
}
|
||||
DisplayLibraryItem::Markdown(md_item) => {
|
||||
html! { <MarkdownCard item={md_item.clone()} {onclick} /> }
|
||||
}
|
||||
DisplayLibraryItem::Book(book_item) => {
|
||||
html! { <BookCard item={book_item.clone()} {onclick} /> }
|
||||
}
|
||||
DisplayLibraryItem::Slideshow(slides_item) => {
|
||||
html! { <SlidesCard item={slides_item.clone()} {onclick} /> }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_viewer_component(
|
||||
&self,
|
||||
item: &DisplayLibraryItem,
|
||||
@ -243,7 +407,7 @@ impl LibraryView {
|
||||
DisplayLibraryItem::Book(book) => html! {
|
||||
<BookViewer book={book.clone()} on_back={back_callback} />
|
||||
},
|
||||
DisplayLibraryItem::Slides(slides) => html! {
|
||||
DisplayLibraryItem::Slideshow(slides) => html! {
|
||||
<SlidesViewer slides={slides.clone()} on_back={back_callback} />
|
||||
},
|
||||
}
|
||||
@ -258,9 +422,13 @@ impl LibraryView {
|
||||
html! { <p class="no-collections-message">{"No collections available."}</p> }
|
||||
} else {
|
||||
html! {
|
||||
<>
|
||||
<h1>{"Collections"}</h1>
|
||||
<div class="collections-grid">
|
||||
<main>
|
||||
<h1>{"Library"}</h1>
|
||||
<div class="layout-sidebar">
|
||||
<div class="sidebar">
|
||||
|
||||
</div>
|
||||
<ul>
|
||||
{ self.display_collections.iter().enumerate().map(|(idx, collection)| {
|
||||
let onclick = ctx.link().callback(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
@ -268,18 +436,32 @@ impl LibraryView {
|
||||
});
|
||||
let _item_count = collection.items.len();
|
||||
html! {
|
||||
<div class="card" onclick={onclick}>
|
||||
<h3 class="collection-title">{ &collection.title }</h3>
|
||||
{ if let Some(desc) = &collection.description {
|
||||
html! { <p class="collection-description">{ desc }</p> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</div>
|
||||
<li onclick={onclick}>
|
||||
<article>
|
||||
<h3 class="collection-title">{ &collection.title }</h3>
|
||||
{ if let Some(desc) = &collection.description {
|
||||
html! { <p class="collection-description">{ desc }</p> }
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
<div class="library-items-grid">
|
||||
{ collection.items.iter().take(6).map(|item| {
|
||||
let item_clone = item.as_ref().clone();
|
||||
let onclick = ctx.link().callback(move |e: MouseEvent| {
|
||||
e.stop_propagation();
|
||||
Msg::ViewItem(item_clone.clone())
|
||||
});
|
||||
|
||||
self.render_library_item_card(item.as_ref(), onclick)
|
||||
}).collect::<Html>() }
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Html>() }
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
</main>
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -305,76 +487,7 @@ impl LibraryView {
|
||||
Msg::ViewItem(item_clone.clone())
|
||||
});
|
||||
|
||||
match item.as_ref() {
|
||||
DisplayLibraryItem::Image(img) => html! {
|
||||
<div class="library-item-card" onclick={onclick}>
|
||||
<div class="item-preview">
|
||||
<img src={img.url.clone()} class="item-thumbnail-img" alt={img.title.clone()} />
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &img.title }</p>
|
||||
{ if let Some(desc) = &img.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
DisplayLibraryItem::Pdf(pdf) => html! {
|
||||
<div class="library-item-card" onclick={onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-file-pdf item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &pdf.title }</p>
|
||||
{ if let Some(desc) = &pdf.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} pages", pdf.page_count) }</p>
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
DisplayLibraryItem::Markdown(md) => html! {
|
||||
<div class="library-item-card" onclick={onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fab fa-markdown item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &md.title }</p>
|
||||
{ if let Some(desc) = &md.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
DisplayLibraryItem::Book(book) => html! {
|
||||
<div class="library-item-card" onclick={onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-book item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &book.title }</p>
|
||||
{ if let Some(desc) = &book.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} pages", book.pages.len()) }</p>
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
DisplayLibraryItem::Slides(slides) => html! {
|
||||
<div class="library-item-card" onclick={onclick}>
|
||||
<div class="item-preview">
|
||||
<i class="fas fa-images item-preview-fallback-icon"></i>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="item-title">{ &slides.title }</p>
|
||||
{ if let Some(desc) = &slides.description {
|
||||
html! { <p class="item-description">{ desc }</p> }
|
||||
} else { html! {} }}
|
||||
<p class="item-meta">{ format!("{} slides", slides.slide_urls.len()) }</p>
|
||||
</div>
|
||||
</div>
|
||||
},
|
||||
}
|
||||
self.render_library_item_card(item.as_ref(), onclick)
|
||||
}).collect::<Html>() }
|
||||
</div>
|
||||
</>
|
||||
@ -386,6 +499,45 @@ impl LibraryView {
|
||||
self.render_collections_view(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find collection key for a given item
|
||||
fn find_collection_key_for_item(&self, item: &DisplayLibraryItem) -> Option<String> {
|
||||
for collection in &self.display_collections {
|
||||
for collection_item in &collection.items {
|
||||
if collection_item.as_ref() == item {
|
||||
return Some(collection.collection_key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get item ID from DisplayLibraryItem
|
||||
fn get_item_id(&self, item: &DisplayLibraryItem) -> Option<String> {
|
||||
match item {
|
||||
DisplayLibraryItem::Image(img) => Some(img.base_data.id.to_string()),
|
||||
DisplayLibraryItem::Pdf(pdf) => Some(pdf.base_data.id.to_string()),
|
||||
DisplayLibraryItem::Markdown(md) => Some(md.base_data.id.to_string()),
|
||||
DisplayLibraryItem::Book(book) => Some(book.base_data.id.to_string()),
|
||||
DisplayLibraryItem::Slideshow(slides) => Some(slides.base_data.id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find item by collection key and item ID
|
||||
fn find_item_by_ids(&self, collection_key: &str, item_id: &str) -> Option<DisplayLibraryItem> {
|
||||
for collection in &self.display_collections {
|
||||
if collection.collection_key == collection_key {
|
||||
for item in &collection.items {
|
||||
if let Some(id) = self.get_item_id(item.as_ref()) {
|
||||
if id == item_id {
|
||||
return Some(item.as_ref().clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to fetch collections from WebSocket URLs
|
||||
@ -448,12 +600,15 @@ async fn fetch_collection_items(ws_url: &str, collection: &Collection) -> Vec<Di
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Slides
|
||||
// Fetch Slideshow
|
||||
for slides_id in &collection.slides {
|
||||
match fetch_data_from_ws_url::<Slides>(ws_url, &format!("get_slides({}).json()", slides_id))
|
||||
.await
|
||||
match fetch_data_from_ws_url::<Slideshow>(
|
||||
ws_url,
|
||||
&format!("get_slides({}).json()", slides_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(slides) => items.push(DisplayLibraryItem::Slides(slides)),
|
||||
Ok(slides) => items.push(DisplayLibraryItem::Slideshow(slides)),
|
||||
Err(e) => log::error!("Failed to fetch slides {}: {}", slides_id, e),
|
||||
}
|
||||
}
|
7
src/app/src/views/mod.rs
Normal file
7
src/app/src/views/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod auth_view;
|
||||
pub mod circles_view;
|
||||
pub mod customize_view;
|
||||
pub mod inspector_view;
|
||||
pub mod intelligence_view;
|
||||
pub mod library_view;
|
||||
pub mod publishing_view;
|
@ -137,7 +137,7 @@ impl Component for PublishingView {
|
||||
match &self.current_view {
|
||||
PublishingViewEnum::PublicationsList => {
|
||||
html! {
|
||||
<div class="view-container publishing-view-container">
|
||||
<div class="layout publishing-layout">
|
||||
<div class="view-header publishing-header">
|
||||
<h1 class="view-title">{"Publications"}</h1>
|
||||
<div class="publishing-actions">
|
||||
@ -169,7 +169,7 @@ impl Component for PublishingView {
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
<div class="view-container publishing-view-container">
|
||||
<div class="layout publishing-layout">
|
||||
<div class="publishing-content">
|
||||
{ render_expanded_publication_card(
|
||||
&pub_data,
|
||||
@ -183,7 +183,7 @@ impl Component for PublishingView {
|
||||
} else {
|
||||
// Fallback to list if specific publication not found (e.g., after context change)
|
||||
html! {
|
||||
<div class="view-container publishing-view-container">
|
||||
<div class="layout publishing-layout">
|
||||
<div class="view-header publishing-header">
|
||||
<h1 class="view-title">{"Publications"}</h1>
|
||||
</div>
|
@ -6,16 +6,6 @@
|
||||
--glow: 0 0 15px var(--primary-accent); /* Using var(--primary-accent) from common.css */
|
||||
}
|
||||
|
||||
.circles-view {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 0; /* Base layer, ensure content is above this */
|
||||
/* background-color: rgba(0, 255, 0, 0.1); */ /* Debug background removed */
|
||||
}
|
||||
|
||||
.app-title { /* This was in styles.css, seems related to the circles view context */
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
@ -169,18 +159,6 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Additional styling for the .sole-selected class if needed for other effects */
|
||||
.circle.center-circle.sole-selected {
|
||||
/* Example: slightly different border or shadow if desired */
|
||||
/* box-shadow: 0 0 20px 5px var(--primary, #3b82f6); */
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-title-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -213,9 +191,20 @@ header {
|
||||
}
|
||||
|
||||
.app-title-logo-symbol {
|
||||
font-size: 1.5em; /* Larger for symbol */
|
||||
font-size: 1.5em;
|
||||
margin-right: 10px;
|
||||
line-height: 1; /* Ensure it aligns well */
|
||||
line-height: 1;
|
||||
color: var(--primary-accent);
|
||||
}
|
||||
|
||||
.app-title-logo-image {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 10px;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.app-title-name {
|
||||
|
@ -2,6 +2,15 @@
|
||||
|
||||
/* Global CSS Custom Properties */
|
||||
:root {
|
||||
/* --- Dynamic Theme Variables --- */
|
||||
/* These are set dynamically from the app state. Defaults are provided for when no theme is active. */
|
||||
--primary-color: #00AEEF;
|
||||
--background-color: #121212;
|
||||
--background-pattern: none;
|
||||
--logo-url: none;
|
||||
--logo-symbol: "◎"; /* Default symbol */
|
||||
|
||||
/* --- Base Palette --- */
|
||||
--font-primary: 'Inter', sans-serif;
|
||||
--font-secondary: 'Roboto Mono', monospace;
|
||||
|
||||
@ -12,14 +21,17 @@
|
||||
--surface-medium: #4A4A4A;
|
||||
--surface-light: #5A5A5A;
|
||||
|
||||
--primary-accent: #00AEEF; /* Bright Blue */
|
||||
--secondary-accent: #FF4081; /* Bright Pink */
|
||||
--tertiary-accent: #FFC107; /* Amber */
|
||||
/* --- Accent Palette (derived from theme) --- */
|
||||
--primary-accent: var(--primary-color); /* Main theme color */
|
||||
--secondary-accent: #FF4081; /* Bright Pink - could also be themed if needed */
|
||||
--tertiary-accent: #FFC107; /* Amber - could also be themed if needed */
|
||||
|
||||
/* --- Text Palette --- */
|
||||
--text-primary: #E0E0E0;
|
||||
--text-secondary: #B0B0B0;
|
||||
--text-disabled: #757575;
|
||||
|
||||
/* --- UI Metrics --- */
|
||||
--border-color: #424242;
|
||||
--border-radius-small: 4px;
|
||||
--border-radius-medium: 8px;
|
||||
@ -57,10 +69,14 @@ html {
|
||||
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
background-color: var(--bg-dark);
|
||||
background-image: var(--background-pattern);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden; /* Prevent horizontal scroll */
|
||||
transition: background-color var(--transition-speed) ease;
|
||||
}
|
||||
|
||||
/* Common Scrollbar Styles */
|
||||
@ -88,16 +104,6 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Base Layout Classes */
|
||||
.view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px); /* Assuming nav-island is 60px, adjust as needed */
|
||||
overflow: hidden;
|
||||
padding: var(--spacing-lg);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.view-main-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto; /* For scrollable content within views */
|
||||
@ -210,21 +216,6 @@ body {
|
||||
background-color: color-mix(in srgb, var(--primary-accent) 85%, white);
|
||||
}
|
||||
|
||||
|
||||
.card-base {
|
||||
background-color: var(--surface-dark);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-small);
|
||||
transition: box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.card-base:hover {
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -337,11 +328,8 @@ body {
|
||||
|
||||
/* Responsive Design Placeholders (can be expanded) */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
/* Adjust base font size or spacing for smaller screens if needed */
|
||||
/* Example: --spacing-unit: 6px; */
|
||||
}
|
||||
.view-container {
|
||||
|
||||
main {
|
||||
padding: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
height: calc(100vh - 50px); /* Example: smaller nav island */
|
||||
@ -512,6 +500,26 @@ body {
|
||||
content: " ";
|
||||
}
|
||||
|
||||
/* Auth View Specific Styling */
|
||||
.auth-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 450px; /* Or any other width that fits the design */
|
||||
/* The card-base class provides the rest of the styling */
|
||||
}
|
||||
|
||||
.generated-keys .key-display input[readonly] {
|
||||
background-color: var(--bg-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Error content styling */
|
||||
.error-content {
|
||||
display: flex;
|
||||
@ -590,4 +598,95 @@ body {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 200px);
|
||||
margin: 0px 40px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.yew-app-container {
|
||||
background-color: var(--background-color);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.yew-app-container > header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.setting-item-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.setting-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
ul > li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
article {
|
||||
background-color: var(--surface-dark);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: var(--spacing-md);
|
||||
box-shadow: var(--shadow-small);
|
||||
transition: box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
article:hover {
|
||||
box-shadow: var(--shadow-medium);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Asset Viewer Styles */
|
||||
/* This is the main container for any item-specific viewer (book, image, etc.) */
|
||||
.asset-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-dark);
|
||||
overflow: hidden; /* Prevents the viewer frame from scrolling; inner content scrolls */
|
||||
}
|
@ -2,12 +2,12 @@
|
||||
/* :root variables moved to common.css or are view-specific if necessary */
|
||||
|
||||
.customize-view {
|
||||
/* Extends .view-container from common.css */
|
||||
/* Extends .layout from common.css */
|
||||
align-items: center; /* Specific alignment for this view */
|
||||
height: calc(100vh - 120px); /* Specific height */
|
||||
margin: 100px 40px 60px 40px; /* Specific margins */
|
||||
/* font-family will be inherited from common.css body */
|
||||
/* Other .view-container properties like display, flex-direction, color, background are inherited or set by common.css */
|
||||
/* Other .layout properties like display, flex-direction, color, background are inherited or set by common.css */
|
||||
}
|
||||
|
||||
.view-header {
|
||||
@ -134,6 +134,7 @@
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 50px;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
|
@ -1,14 +1,14 @@
|
||||
/* Governance View - Game-like Interactive Design */
|
||||
/* :root variables moved to common.css or are view-specific if necessary (using literal hex for some status colors) */
|
||||
|
||||
.governance-view-container {
|
||||
/* Extends .view-container from common.css */
|
||||
.governance-layout {
|
||||
/* Extends .layout from common.css */
|
||||
height: calc(100vh - 120px); /* Specific height */
|
||||
margin: 100px 40px 60px 40px; /* Specific margins */
|
||||
gap: var(--spacing-lg); /* Was 24px, using common.css spacing */
|
||||
/* font-family will be inherited from common.css body */
|
||||
/* Other .view-container properties like display, flex-direction, color, background are inherited or set by common.css */
|
||||
}
|
||||
/* Other .layout properties like display, flex-direction, color, background are inherited or set by common.css */
|
||||
}
|
||||
|
||||
/* Featured Proposal - Main Focus */
|
||||
.featured-proposal-container {
|
||||
@ -529,7 +529,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.governance-view-container {
|
||||
.governance-layout {
|
||||
margin: 20px;
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
/* Intelligence View - Ultra Minimalistic Design */
|
||||
/* :root variables moved to common.css or are view-specific if necessary */
|
||||
|
||||
.intelligence-view-container {
|
||||
/* Extends .view-container from common.css but with flex-direction: row */
|
||||
.intelligence-layout {
|
||||
/* Extends .layout from common.css but with flex-direction: row */
|
||||
flex-direction: row; /* Specific direction for this view */
|
||||
height: calc(100vh - 120px); /* Specific height */
|
||||
margin: 100px 40px 60px 40px; /* Specific margins */
|
||||
gap: var(--spacing-lg); /* Was 24px, using common.css spacing */
|
||||
/* font-family will be inherited from common.css body */
|
||||
/* Other .view-container properties like display, color, background, overflow are inherited or set by common.css */
|
||||
/* Other .layout properties like display, color, background, overflow are inherited or set by common.css */
|
||||
}
|
||||
|
||||
.new-conversation-btn {
|
||||
|
@ -1,24 +1,13 @@
|
||||
/* Library View - Ultra Minimalistic Design */
|
||||
/* :root variables moved to common.css or are view-specific if necessary */
|
||||
|
||||
.sidebar-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - 120px);
|
||||
margin: 100px 40px;
|
||||
.layout-sidebar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 5fr;
|
||||
height: calc(100vh - 200px);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 120px);
|
||||
margin: 100px 40px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.layout .library-content {
|
||||
flex: 1;
|
||||
border-radius: var(--border-radius-large);
|
||||
@ -101,17 +90,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Collections Grid for main view */
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Click-outside areas for navigation */
|
||||
.view-container.layout[onclick],
|
||||
.view-container.sidebar-layout[onclick] {
|
||||
.layout.layout[onclick],
|
||||
.layout.layout-sidebar[onclick] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -255,12 +237,7 @@
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* Asset Viewer Styles */
|
||||
.asset-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.viewer-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
@ -340,12 +317,108 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.book-viewer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr; /* Fixed TOC width, flexible content */
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.toc-panel {
|
||||
background-color: var(--surface-dark);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--border-radius-medium);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding-bottom: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toc-header .back-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.2em;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
.toc-header .back-button:hover {
|
||||
color: var(--primary-accent);
|
||||
}
|
||||
|
||||
.toc-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-list .toc-list { /* Nested lists */
|
||||
padding-left: var(--spacing-md);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.toc-item .toc-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.toc-item .toc-link:hover {
|
||||
background-color: var(--surface-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toc-item-invalid .toc-link-invalid {
|
||||
color: var(--text-disabled);
|
||||
font-style: italic;
|
||||
padding: var(--spacing-sm);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Prevent this panel from scrolling, inner content will */
|
||||
min-height: 0; /* Fix for flexbox inside grid cell, allows child to scroll */
|
||||
}
|
||||
|
||||
.book-page {
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--surface-medium);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--surface-dark);
|
||||
border-radius: var(--border-radius-medium);
|
||||
flex: 1;
|
||||
flex-grow: 1; /* Takes up available space */
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.page-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) 0;
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
|
||||
.slide-container {
|
||||
@ -427,12 +500,12 @@
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-layout {
|
||||
.layout-sidebar {
|
||||
margin: 40px 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
main {
|
||||
margin: 40px 20px;
|
||||
}
|
||||
|
||||
@ -445,11 +518,6 @@
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.collections-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.library-items-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
|
@ -265,7 +265,7 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Slides Viewer */
|
||||
/* Slideshow Viewer */
|
||||
.slides-viewer .viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
@ -1,7 +1,7 @@
|
||||
/* app/static/styles.css */
|
||||
/* Contains remaining global variables or styles not covered by common.css or specific view CSS files. */
|
||||
|
||||
.auth-view-container {
|
||||
.auth-layout {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
|
@ -11,14 +11,12 @@ log = { workspace = true }
|
||||
futures-channel = { workspace = true, features = ["sink"] }
|
||||
futures-util = { workspace = true, features = ["sink"] }
|
||||
thiserror = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
url = { workspace = true }
|
||||
http = "0.2"
|
||||
|
||||
# Authentication dependencies
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
# Optional crypto dependencies (enabled by default)
|
||||
secp256k1 = { workspace = true, optional = true }
|
||||
@ -44,7 +42,6 @@ tokio-native-tls = "0.3.0"
|
||||
tokio = { workspace = true, features = ["rt", "macros", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
|
||||
|
@ -8,6 +8,12 @@
|
||||
|
||||
use crate::auth::types::{AuthError, AuthResult};
|
||||
|
||||
pub fn generate_keypair() -> AuthResult<(String, String)> {
|
||||
let private_key = generate_private_key()?;
|
||||
let public_key = derive_public_key(&private_key)?;
|
||||
Ok((public_key, private_key))
|
||||
}
|
||||
|
||||
/// Generate a new random private key
|
||||
pub fn generate_private_key() -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
|
@ -50,8 +50,8 @@ pub use types::{AuthCredentials, AuthError, AuthResult, NonceResponse};
|
||||
|
||||
pub mod crypto_utils;
|
||||
pub use crypto_utils::{
|
||||
derive_public_key, generate_private_key, parse_private_key, sign_message, validate_private_key,
|
||||
verify_signature,
|
||||
derive_public_key, generate_keypair, generate_private_key, parse_private_key, sign_message,
|
||||
validate_private_key, verify_signature,
|
||||
};
|
||||
|
||||
/// Check if the authentication feature is enabled
|
||||
|
BIN
src/launcher/.DS_Store
vendored
BIN
src/launcher/.DS_Store
vendored
Binary file not shown.
@ -15,7 +15,6 @@ path = "src/cmd/main.rs"
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
dirs = "5.0"
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
comfy-table = "7.0"
|
||||
@ -36,6 +35,7 @@ tokio-tungstenite = "0.23"
|
||||
url = "2.5.2"
|
||||
|
||||
[dev-dependencies]
|
||||
secp256k1 = { version = "0.28.0", features = ["rand-std"] }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = "3.3"
|
||||
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
|
||||
|
@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
// std::process::{Command, Child, Stdio}; // All parts of this line are no longer used directly here
|
||||
use actix_web::dev::ServerHandle;
|
||||
@ -47,6 +48,8 @@ pub struct CircleConfig {
|
||||
pub name: String,
|
||||
pub port: u16,
|
||||
pub script_path: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
pub secret_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
@ -103,7 +106,26 @@ pub async fn setup_and_spawn_circles(
|
||||
);
|
||||
|
||||
let secp = Secp256k1::new();
|
||||
let (secret_key, public_key) = secp.generate_keypair(&mut rand::thread_rng());
|
||||
let (secret_key, public_key) = if let (Some(sk_str), Some(pk_str)) =
|
||||
(&config.secret_key, &config.public_key)
|
||||
{
|
||||
info!("Using provided keypair for circle '{}'", config.name);
|
||||
let secret_key = secp256k1::SecretKey::from_str(sk_str)
|
||||
.map_err(|e| format!("Invalid secret key for circle '{}': {}", config.name, e))?;
|
||||
let public_key = secp256k1::PublicKey::from_str(pk_str)
|
||||
.map_err(|e| format!("Invalid public key for circle '{}': {}", config.name, e))?;
|
||||
if public_key != secp256k1::PublicKey::from_secret_key(&secp, &secret_key) {
|
||||
return Err(format!(
|
||||
"Provided public key does not match secret key for circle '{}'",
|
||||
config.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
(secret_key, public_key)
|
||||
} else {
|
||||
info!("Generating new keypair for circle '{}'", config.name);
|
||||
secp.generate_keypair(&mut rand::thread_rng())
|
||||
};
|
||||
let public_key_hex = public_key.to_string();
|
||||
|
||||
// Spawn Rhai worker as a Tokio task
|
||||
|
@ -1,15 +1,18 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use launcher::{setup_and_spawn_circles, shutdown_circles, CircleConfig};
|
||||
use secp256k1::Secp256k1;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use url::Url;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_launcher_starts_and_stops_circle() {
|
||||
// 1. Setup: Define the test circle configuration directly
|
||||
async fn test_launcher_starts_and_stops_circle_with_generated_keys() {
|
||||
// 1. Setup: Define the test circle configuration directly, without keys
|
||||
let test_circle_config = vec![CircleConfig {
|
||||
name: "test_circle".to_string(),
|
||||
name: "test_circle_generated".to_string(),
|
||||
port: 8088, // Use a distinct port for testing
|
||||
script_path: None,
|
||||
public_key: None,
|
||||
secret_key: None,
|
||||
}];
|
||||
|
||||
// 2. Action: Run the launcher setup with the direct config
|
||||
@ -22,7 +25,9 @@ async fn test_launcher_starts_and_stops_circle() {
|
||||
assert_eq!(outputs.len(), 1, "Expected one circle output");
|
||||
|
||||
let circle_output = &outputs[0];
|
||||
assert_eq!(circle_output.name, "test_circle");
|
||||
assert_eq!(circle_output.name, "test_circle_generated");
|
||||
assert!(!circle_output.public_key.is_empty()); // Key should be generated
|
||||
assert!(!circle_output.secret_key.is_empty());
|
||||
|
||||
// 4. Verification: Check if the WebSocket server is connectable
|
||||
let ws_url = Url::parse(&circle_output.ws_url).expect("Failed to parse WS URL");
|
||||
@ -34,8 +39,6 @@ async fn test_launcher_starts_and_stops_circle() {
|
||||
|
||||
if let Ok((ws_stream, _)) = connection_attempt {
|
||||
let (mut write, _read) = ws_stream.split();
|
||||
|
||||
// Optional: Send a message to test connectivity further
|
||||
write
|
||||
.send(tokio_tungstenite::tungstenite::Message::Ping(vec![]))
|
||||
.await
|
||||
@ -45,3 +48,73 @@ async fn test_launcher_starts_and_stops_circle() {
|
||||
// 5. Cleanup: Shutdown the circles
|
||||
shutdown_circles(running_circles).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_launcher_uses_provided_keypair() {
|
||||
// 1. Setup: Generate a keypair to provide to the config
|
||||
let secp = Secp256k1::new();
|
||||
let (secret_key, public_key) = secp.generate_keypair(&mut secp256k1::rand::thread_rng());
|
||||
let secret_key_str = secret_key.display_secret().to_string();
|
||||
let public_key_str = public_key.to_string();
|
||||
|
||||
// 2. Setup: Define the test circle configuration with the generated keypair
|
||||
let test_circle_config = vec![CircleConfig {
|
||||
name: "test_circle_with_keys".to_string(),
|
||||
port: 8089, // Use another distinct port
|
||||
script_path: None,
|
||||
public_key: Some(public_key_str.clone()),
|
||||
secret_key: Some(secret_key_str.clone()),
|
||||
}];
|
||||
|
||||
// 3. Action: Run the launcher setup
|
||||
let (running_circles, outputs) = setup_and_spawn_circles(test_circle_config)
|
||||
.await
|
||||
.expect("Failed to setup and spawn circles with provided keys");
|
||||
|
||||
// 4. Verification: Check if the output public key matches the provided one
|
||||
assert_eq!(outputs.len(), 1, "Expected one circle output");
|
||||
let circle_output = &outputs[0];
|
||||
assert_eq!(circle_output.name, "test_circle_with_keys");
|
||||
assert_eq!(
|
||||
circle_output.public_key, public_key_str,
|
||||
"The public key in the output should match the one provided"
|
||||
);
|
||||
assert_eq!(
|
||||
circle_output.secret_key, secret_key_str,
|
||||
"The secret key in the output should match the one provided"
|
||||
);
|
||||
|
||||
// 5. Cleanup
|
||||
shutdown_circles(running_circles).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_launcher_fails_with_mismatched_keypair() {
|
||||
// 1. Setup: Generate two different keypairs
|
||||
let secp = Secp256k1::new();
|
||||
let (secret_key1, _public_key1) = secp.generate_keypair(&mut secp256k1::rand::thread_rng());
|
||||
let (_secret_key2, public_key2) = secp.generate_keypair(&mut secp256k1::rand::thread_rng());
|
||||
|
||||
let secret_key_str = secret_key1.display_secret().to_string();
|
||||
let public_key_str = public_key2.to_string(); // Mismatched public key
|
||||
|
||||
// 2. Setup: Define config with mismatched keys
|
||||
let test_circle_config = vec![CircleConfig {
|
||||
name: "test_circle_mismatched_keys".to_string(),
|
||||
port: 8090,
|
||||
script_path: None,
|
||||
public_key: Some(public_key_str),
|
||||
secret_key: Some(secret_key_str),
|
||||
}];
|
||||
|
||||
// 3. Action & Verification: Expect an error
|
||||
let result = setup_and_spawn_circles(test_circle_config).await;
|
||||
assert!(result.is_err(), "Expected an error due to mismatched keys");
|
||||
if let Err(e) = result {
|
||||
assert!(
|
||||
e.to_string()
|
||||
.contains("Provided public key does not match secret key"),
|
||||
"Error message did not contain expected text"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
BIN
src/server_ws/.DS_Store
vendored
BIN
src/server_ws/.DS_Store
vendored
Binary file not shown.
@ -32,7 +32,6 @@ secp256k1 = { workspace = true, optional = true }
|
||||
hex = { workspace = true, optional = true }
|
||||
sha3 = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
urlencoding = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
|
||||
@ -45,7 +44,6 @@ auth = ["secp256k1", "hex", "sha3", "rand"]
|
||||
tokio-tungstenite = { version = "0.19.0", features = ["native-tls"] }
|
||||
futures-util = { workspace = true }
|
||||
url = { workspace = true }
|
||||
circle_client_ws = { path = "../client_ws" }
|
||||
rhailib_worker = { path = "../../../rhailib/src/worker" }
|
||||
engine = { path = "../../../rhailib/src/engine" }
|
||||
heromodels = { path = "../../../db/heromodels" }
|
||||
|
@ -5,7 +5,6 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true, features = ["native-tls"] }
|
||||
futures-util = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tracing = "0.1"
|
||||
|
Loading…
Reference in New Issue
Block a user