app ui fixes and improvements

This commit is contained in:
timurgordon 2025-06-25 03:51:29 +03:00
parent 0fdc6518c0
commit 7dfd54a20a
66 changed files with 3218 additions and 2340 deletions

124
Cargo.lock generated
View File

@ -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
View 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.

View File

@ -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,

View File

@ -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"
}
]

View File

@ -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...");

View File

@ -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...");

View File

@ -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...");

View 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();

View File

@ -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...");

View File

@ -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...");

View File

@ -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...");

View File

@ -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...");

View 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
View 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
View File

@ -1,2 +1,3 @@
/dist/
/target/
*.db

View File

@ -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"] }

View File

@ -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(&current_path);
let new_sub_route = AppView::extract_sub_route(&current_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>
}

View File

@ -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
}

View File

@ -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"));
}
}

View File

@ -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;

View File

@ -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),
}
}
}

View File

@ -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>

View File

@ -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>
}
}
}
}

View File

@ -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>
}
}
}

View File

@ -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>
}
}

View 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>
}
}
}

View File

@ -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()}

View File

@ -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) }

View File

@ -0,0 +1,5 @@
pub mod book;
pub mod image;
pub mod markdown;
pub mod pdf;
pub mod slides;

View File

@ -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)}

View 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>
}
}
}

View File

@ -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! {}
}
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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>
}
}
}

View File

@ -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.

View 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"));
}
}

View 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::*;

View 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(&params);
// 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()), "");
}
}

View 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");
}
}

View 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>
}
}
}
}

View File

@ -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 &center_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 &center_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

View 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>
</>
}
}
}

View File

@ -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>
}
}
}

View File

@ -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
View 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;

View File

@ -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>

View File

@ -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 {

View File

@ -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 */
}

View File

@ -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 {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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;

View File

@ -265,7 +265,7 @@
overflow-y: auto;
}
/* Slides Viewer */
/* Slideshow Viewer */
.slides-viewer .viewer-header {
display: flex;
justify-content: space-between;

View File

@ -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;

View File

@ -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 }

View File

@ -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")]

View File

@ -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

Binary file not shown.

View File

@ -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"] }

View File

@ -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

View File

@ -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"
);
}
}

Binary file not shown.

View File

@ -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" }

View File

@ -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"