diff --git a/Cargo.lock b/Cargo.lock index 47064c2..9c9d2d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/URL_ROUTING_STRATEGY.md b/URL_ROUTING_STRATEGY.md new file mode 100644 index 0000000..10bf86e --- /dev/null +++ b/URL_ROUTING_STRATEGY.md @@ -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; + 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` - route segment for component +- `on_route_change: Callback` - 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, + pub initial_route: Option, + pub on_route_change: Callback, +} +``` + +## 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. \ No newline at end of file diff --git a/examples/ourworld/circles.json b/examples/ourworld/circles.json index 99ea795..6dc805a 100644 --- a/examples/ourworld/circles.json +++ b/examples/ourworld/circles.json @@ -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, diff --git a/examples/ourworld/ourworld_output.json b/examples/ourworld/ourworld_output.json index c98c062..3a65f9e 100644 --- a/examples/ourworld/ourworld_output.json +++ b/examples/ourworld/ourworld_output.json @@ -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" } ] \ No newline at end of file diff --git a/examples/ourworld/scripts/dunia_cybercity.rhai b/examples/ourworld/scripts/dunia_cybercity.rhai index 857de75..a084bb7 100644 --- a/examples/ourworld/scripts/dunia_cybercity.rhai +++ b/examples/ourworld/scripts/dunia_cybercity.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/freezone.rhai b/examples/ourworld/scripts/freezone.rhai index 7b234d5..1caef11 100644 --- a/examples/ourworld/scripts/freezone.rhai +++ b/examples/ourworld/scripts/freezone.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/geomind.rhai b/examples/ourworld/scripts/geomind.rhai index 6704c6b..78d88e0 100644 --- a/examples/ourworld/scripts/geomind.rhai +++ b/examples/ourworld/scripts/geomind.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/kristof.rhai b/examples/ourworld/scripts/kristof.rhai new file mode 100644 index 0000000..6e1cffe --- /dev/null +++ b/examples/ourworld/scripts/kristof.rhai @@ -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(); diff --git a/examples/ourworld/scripts/mbweni.rhai b/examples/ourworld/scripts/mbweni.rhai index fe943a1..cdf76e2 100644 --- a/examples/ourworld/scripts/mbweni.rhai +++ b/examples/ourworld/scripts/mbweni.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/ourworld.rhai b/examples/ourworld/scripts/ourworld.rhai index 02e9819..4b9ad79 100644 --- a/examples/ourworld/scripts/ourworld.rhai +++ b/examples/ourworld/scripts/ourworld.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/sikana.rhai b/examples/ourworld/scripts/sikana.rhai index d68e549..9a17032 100644 --- a/examples/ourworld/scripts/sikana.rhai +++ b/examples/ourworld/scripts/sikana.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/threefold.rhai b/examples/ourworld/scripts/threefold.rhai index 0087319..f2c2357 100644 --- a/examples/ourworld/scripts/threefold.rhai +++ b/examples/ourworld/scripts/threefold.rhai @@ -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..."); diff --git a/examples/ourworld/scripts/timur.rhai b/examples/ourworld/scripts/timur.rhai new file mode 100644 index 0000000..da98615 --- /dev/null +++ b/examples/ourworld/scripts/timur.rhai @@ -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(); \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..3a08ce9 --- /dev/null +++ b/index.html @@ -0,0 +1,11 @@ + + + + + Circles + + + + + + diff --git a/src/app/.gitignore b/src/app/.gitignore index adb443d..48b324d 100644 --- a/src/app/.gitignore +++ b/src/app/.gitignore @@ -1,2 +1,3 @@ /dist/ /target/ +*.db \ No newline at end of file diff --git a/src/app/Cargo.toml b/src/app/Cargo.toml index 16710e4..22b8c0c 100644 --- a/src/app/Cargo.toml +++ b/src/app/Cargo.toml @@ -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"] } diff --git a/src/app/src/app.rs b/src/app/src/app.rs index c41df83..4710032 100644 --- a/src/app/src/app.rs +++ b/src/app/src/app.rs @@ -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), // Context URLs from CirclesView + SwitchViewWithRoute(AppView, String), // New: Switch view with sub-route + UpdateSubRoute(String), // New: Update sub-route for current view + UpdateCirclesContext(Vec), // 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, // 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, // 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, } 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); + + 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 = 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 = + fetch_data_from_ws_urls(&urls, script).await; + + let circles: Vec = 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, 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 = self + .active_context_circles + .iter() + .map(|c| c.ws_url.clone()) + .collect(); + let query = if !urls.is_empty() { + format!("circles={}", urls.join(",")) + } else { + "".to_string() + }; + let full_url = HistoryManager::build_full_url(&path, &query); + if let Err(e) = HistoryManager::replace_url(&full_url) { + log::error!("Failed to update URL with context: {:?}", e); + } + true + } + Msg::UpdateTheme(theme) => { + log::info!("App: Theme updated via CustomizeView"); + self.theme = theme.clone(); + // Also update the theme in the active context to prevent stale data + if let Some(primary_circle) = self.active_context_circles.get_mut(0) { + primary_circle.theme = theme; + } true } Msg::SwitchView(view) => { - // Check if authentication is required for certain views - match view { - AppView::Login => { - self.current_view = view; - true - } - _ => { - if self.auth_manager.is_authenticated() { - self.current_view = view; - true - } else { - log::warn!( - "Attempted to access {} view without authentication", - format!("{:?}", view) - ); - self.current_view = AppView::Login; - true - } - } + // Switch view with empty sub-route + self.handle_view_switch(view, String::new()) + } + Msg::SwitchViewWithRoute(view, sub_route) => { + // Switch view with specific sub-route + self.handle_view_switch(view, sub_route) + } + Msg::UpdateSubRoute(sub_route) => { + // Update sub-route for current view + if self.current_sub_route != sub_route { + self.current_sub_route = sub_route; + self.update_url_for_current_state(); + true + } else { + false } } Msg::AuthStateChanged(state) => { log::info!("App: Auth state changed: {:?}", state); + let previous_auth_state_is_authed = + matches!(self.auth_state, AuthState::Authenticated { .. }); self.auth_state = state.clone(); - match state { - AuthState::Authenticated { .. } => { - // Switch to main app view when authenticated - if self.current_view == AppView::Login { - self.current_view = AppView::Circles; - } - } - AuthState::NotAuthenticated | AuthState::Failed(_) => { - // Switch to login view when not authenticated - self.current_view = AppView::Login; - } - _ => {} + // If the user just logged in, switch to the Circles view + if !previous_auth_state_is_authed + && matches!(self.auth_state, AuthState::Authenticated { .. }) + { + self.current_view = AppView::Circles; } + // If the user just logged out, switch to the Login view + else if previous_auth_state_is_authed + && !matches!(self.auth_state, AuthState::Authenticated { .. }) + { + self.current_view = AppView::Login; + } + true } - Msg::AuthenticationSuccessful => { - log::info!("App: Authentication successful"); - self.current_view = AppView::Circles; - true + Msg::AttemptKeypairLogin((public_key, private_key)) => { + let auth_manager = self.auth_manager.clone(); + spawn_local(async move { + let _ = auth_manager + .login_with_keypair(public_key, private_key) + .await; + }); + false } - Msg::AuthenticationFailed(error) => { - log::error!("App: Authentication failed: {}", error); - self.current_view = AppView::Login; - true + Msg::CompleteRegistration((name, public_key, private_key)) => { + let auth_manager = self.auth_manager.clone(); + spawn_local(async move { + let _ = auth_manager + .register_and_login(name, public_key, private_key) + .await; + }); + false + } + Msg::PopStateChanged => { + // Handle browser back/forward navigation + let current_path = HistoryManager::get_current_path(); + let new_view = AppView::from_path(¤t_path); + let new_sub_route = AppView::extract_sub_route(¤t_path, &new_view); + + // Only update if the route actually changed + if self.current_view != new_view || self.current_sub_route != new_sub_route { + self.current_view = new_view; + self.current_sub_route = new_sub_route; + log::info!( + "PopState: Updated to view {:?} with sub-route '{}'", + self.current_view, + self.current_sub_route + ); + true + } else { + false + } } Msg::Logout => { - log::info!("App: User logout"); self.auth_manager.logout(); - self.current_view = AppView::Login; true } } @@ -151,71 +331,94 @@ impl Component for App { fn view(&self, ctx: &Context) -> Html { let link = ctx.link(); + let active_context_urls: Vec = self + .active_context_circles + .iter() + .map(|c| c.ws_url.clone()) + .collect(); + let all_circles_map: HashMap = 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! { - - }; - } + 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! { -
+
{ self.render_header(link) } { match self.current_view { AppView::Login => { html! { - } }, AppView::Circles => { + let start_url = self.initial_context_url_from_query + .as_ref() + .cloned() + .unwrap_or_else(|| self.start_circle_ws_url.clone()); html!{ } }, AppView::Library => { + let on_route_change = link.callback(Msg::UpdateSubRoute); html! { - + } }, AppView::Intelligence => html! { }, AppView::Publishing => html! { }, AppView::Inspector => { html! { } }, - AppView::Customize => html! { - + AppView::Customize => { + let primary_circle = self.active_context_circles.first(); + let ws_url = primary_circle.map(|c| c.ws_url.clone()); + html! { + + } }, }} @@ -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 = 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) -> Html { if self.current_view == AppView::Login { return html! {}; } + let logo_html = if !self.theme.logo_url.is_empty() { + html! {
} + } else if !self.theme.logo_symbol.is_empty() { + html! { { &self.theme.logo_symbol } } + } else { + html! {} + }; + + let circle_name = self + .active_context_circles + .first() + .map_or("Circles".to_string(), |c| c.title.clone()); + html! {
- { "Circles" } + { logo_html } + { circle_name }
} diff --git a/src/app/src/auth/auth_manager.rs b/src/app/src/auth/auth_manager.rs index 93110c8..c22f049 100644 --- a/src/app/src/auth/auth_manager.rs +++ b/src/app/src/auth/auth_manager.rs @@ -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) { + 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 { - 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 { - if let Ok(marker) = LocalStorage::get::(AUTH_STATE_STORAGE_KEY) { - if marker == "private_key_auth_marker" { - if let Ok(private_key) = - SessionStorage::get::(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::(AUTH_STATE_STORAGE_KEY) { + // Try to load private key from session storage + if let Ok(private_key) = SessionStorage::get::(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 } diff --git a/src/app/src/auth/email_store.rs b/src/app/src/auth/email_store.rs deleted file mode 100644 index 8409db0..0000000 --- a/src/app/src/auth/email_store.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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")); - } -} diff --git a/src/app/src/auth/mod.rs b/src/app/src/auth/mod.rs index 5d068de..5516076 100644 --- a/src/app/src/auth/mod.rs +++ b/src/app/src/auth/mod.rs @@ -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; diff --git a/src/app/src/auth/types.rs b/src/app/src/auth/types.rs index b40ccd9..fb6c743 100644 --- a/src/app/src/auth/types.rs +++ b/src/app/src/auth/types.rs @@ -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 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), } } } diff --git a/src/app/src/components/asset_details_card.rs b/src/app/src/components/asset_details_card.rs index cce288e..dc9ce4a 100644 --- a/src/app/src/components/asset_details_card.rs +++ b/src/app/src/components/asset_details_card.rs @@ -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! {
-
{img.title.clone()}
@@ -53,9 +50,6 @@ impl Component for AssetDetailsCard { }, DisplayLibraryItem::Pdf(pdf) => html! {
-
@@ -76,9 +70,6 @@ impl Component for AssetDetailsCard { }, DisplayLibraryItem::Markdown(md) => html! {
-
@@ -95,9 +86,6 @@ impl Component for AssetDetailsCard { }, DisplayLibraryItem::Book(book) => html! {
-
@@ -120,11 +108,8 @@ impl Component for AssetDetailsCard {
}, - DisplayLibraryItem::Slides(slides) => html! { + DisplayLibraryItem::Slideshow(slides) => html! {
-
@@ -135,9 +120,9 @@ impl Component for AssetDetailsCard { } else { html! {} }}
diff --git a/src/app/src/components/auth_view.rs b/src/app/src/components/auth_view.rs deleted file mode 100644 index 04f9b7d..0000000 --- a/src/app/src/components/auth_view.rs +++ /dev/null @@ -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! { -
- { format!("PK: {}", pk_short) } - -
- } - } - AuthState::NotAuthenticated | AuthState::Failed(_) => { - let on_login = props.on_login.clone(); - let login_onclick = Callback::from(move |_| { - on_login.emit(()); - }); - - html! { -
- { "Not Authenticated" } - -
- } - } - AuthState::Authenticating => { - html! { -
- { "Authenticating..." } -
- } - } - } -} diff --git a/src/app/src/components/book_viewer.rs b/src/app/src/components/book_viewer.rs deleted file mode 100644 index 541bd7f..0000000 --- a/src/app/src/components/book_viewer.rs +++ /dev/null @@ -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 { current_page: 0 } - } - - fn update(&mut self, _ctx: &Context, 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) -> 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! { -
- -
-

{ &props.book.title }

-
- - - { format!("Page {} of {}", self.current_page + 1, total_pages) } - - -
-
-
-
- { if let Some(page_content) = props.book.pages.get(self.current_page) { - self.render_markdown(page_content) - } else { - html! {

{"Page not found"}

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

{ &line[2..] }

}); - } else if line.starts_with("## ") { - html_content.push(html! {

{ &line[3..] }

}); - } else if line.starts_with("### ") { - html_content.push(html! {

{ &line[4..] }

}); - } else if line.starts_with("- ") { - html_content.push(html! {
  • { &line[2..] }
  • }); - } else if line.starts_with("**") && line.ends_with("**") { - let text = &line[2..line.len() - 2]; - html_content.push(html! {

    { text }

    }); - } else if !line.trim().is_empty() { - html_content.push(html! {

    { line }

    }); - } else { - html_content.push(html! {
    }); - } - } - - html! {
    { for html_content }
    } - } - - #[allow(dead_code)] - pub fn render_toc(&self, ctx: &Context, toc: &[TocEntry]) -> Html { - html! { -
      - { toc.iter().map(|entry| { - let page = entry.page as usize; - let onclick = ctx.link().callback(move |_: MouseEvent| BookViewerMsg::GoToPage(page)); - html! { -
    • - - { if !entry.subsections.is_empty() { - self.render_toc(ctx, &entry.subsections) - } else { html! {} }} -
    • - } - }).collect::() } -
    - } - } -} diff --git a/src/app/src/components/customize_view.rs b/src/app/src/components/customize_view.rs deleted file mode 100644 index 6604d51..0000000 --- a/src/app/src/components/customize_view.rs +++ /dev/null @@ -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), // List of color hex values - PatternSelection(Vec), // List of pattern names/classes - LogoSelection(Vec), // 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>, - // 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` prop. - pub context_circle_ws_urls: Option>>, - pub app_callback: Callback, // For emitting update messages -} - -// --- Statically Defined Theme Settings --- -fn get_theme_setting_definitions() -> Vec { - 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 = props - .context_circle_ws_urls - .as_ref() - .and_then(|ws_urls| ws_urls.first().cloned()); - - let active_circle_theme: Option> = 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! { -
    -
    -

    {"Customize Appearance"}

    - { if active_circle_ws_url.is_none() { - html!{

    {"Select a circle context to customize its appearance."}

    } - } else { html!{} }} -
    - - { if let Some(current_circle_ws_url) = active_circle_ws_url { - html! { -
    - { 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() - ) - })} -
    - } - } else { - html!{} // Or a message indicating no circle is selected for customization - }} -
    - } -} - -fn render_setting_control( - setting_def: ThemeSettingDefinition, - current_value: String, - circle_ws_url: String, - _app_callback: Callback, -) -> 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! { -
    - { 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! { -
    - } - })} -
    - } - } - ThemeSettingControlType::PatternSelection(ref patterns) => { - let on_select = on_value_change.clone(); - html! { -
    - { 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! { -
    - } - })} -
    - } - } - ThemeSettingControlType::LogoSelection(ref logos) => { - let on_select = on_value_change.clone(); - html! { -
    - { 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! { -
    - { if logo_option == "custom_url" { "URL" } else { logo_option } } -
    - } - })} -
    - } - } - 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! { - - } - } - 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! { - - } - } - }; - - html! { -
    -
    - - { if !setting_def.description.is_empty() && setting_def.control_type != ThemeSettingControlType::TextInput { // Placeholder is used for TextInput desc - html!{

    { &setting_def.description }

    } - } else { html!{} }} -
    -
    - { control_html } -
    -
    - } -} diff --git a/src/app/src/components/library_item_cards/book.rs b/src/app/src/components/library_item_cards/book.rs new file mode 100644 index 0000000..38b3ba5 --- /dev/null +++ b/src/app/src/components/library_item_cards/book.rs @@ -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, +} + +#[function_component(BookCard)] +pub fn book_card(props: &BookCardProps) -> Html { + let item = &props.item; + let onclick = props.onclick.clone(); + + html! { +
    +
    + +
    +
    +

    { &item.title }

    + { if let Some(desc) = &item.description { + html! {

    { desc }

    } + } else { html! {} }} +

    { format!("{} pages", item.pages.len()) }

    +
    +
    + } +} + +// 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, +} + +impl Component for BookViewer { + type Message = BookViewerMsg; + type Properties = BookViewerProps; + + fn create(ctx: &Context) -> Self { + let link = ctx.link().clone(); + let keydown_listener = EventListener::new(&window(), "keydown", move |event| { + if let Ok(keyboard_event) = event.clone().dyn_into::() { + 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, 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) -> 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! { +
    +
    +
    +
    + +

    { "Table of Contents" }

    +
    + { self.render_toc(ctx, &props.book.table_of_contents) } +
    +
    +
    + { if let Some(page_content) = props.book.pages.get(self.current_page) { + self.render_markdown(page_content) + } else { + html! {

    {"Page not found"}

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

    { &line[2..] }

    }); + } else if line.starts_with("## ") { + html_content.push(html! {

    { &line[3..] }

    }); + } else if line.starts_with("### ") { + html_content.push(html! {

    { &line[4..] }

    }); + } else if line.starts_with("- ") { + html_content.push(html! {
  • { &line[2..] }
  • }); + } else if line.starts_with("**") && line.ends_with("**") { + let text = &line[2..line.len() - 2]; + html_content.push(html! {

    { text }

    }); + } else if !line.trim().is_empty() { + html_content.push(html! {

    { line }

    }); + } else { + html_content.push(html! {
    }); + } + } + + html! {
    { for html_content }
    } + } + + pub fn render_toc(&self, ctx: &Context, toc: &[TocEntry]) -> Html { + html! { +
      + { 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! { +
    • + + { if !entry.subsections.is_empty() { + self.render_toc(ctx, &entry.subsections) + } else { html! {} }} +
    • + } + } else { + html! { +
    • + + { format!("{} (invalid page)", &entry.title) } + +
    • + } + } + }).collect::() } +
    + } + } +} diff --git a/src/app/src/components/image_viewer.rs b/src/app/src/components/library_item_cards/image.rs similarity index 54% rename from src/app/src/components/image_viewer.rs rename to src/app/src/components/library_item_cards/image.rs index 679431f..2833d3a 100644 --- a/src/app/src/components/image_viewer.rs +++ b/src/app/src/components/library_item_cards/image.rs @@ -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, +} + +#[function_component(ImageCard)] +pub fn image_card(props: &ImageCardProps) -> Html { + let item = &props.item; + let onclick = props.onclick.clone(); + + html! { +
    +
    + {item.title.clone()} +
    +
    +

    { &item.title }

    + { if let Some(desc) = &item.description { + html! {

    { desc }

    } + } else { html! {} }} +
    +
    + } +} + +// Viewer Component #[derive(Clone, PartialEq, Properties)] pub struct ImageViewerProps { pub image: Image, @@ -29,12 +57,6 @@ impl Component for ImageViewer { html! {
    - -
    -

    { &props.image.title }

    -
    , +} + +#[function_component(MarkdownCard)] +pub fn markdown_card(props: &MarkdownCardProps) -> Html { + let item = &props.item; + let onclick = props.onclick.clone(); + + html! { +
    +
    + +
    +
    +

    { &item.title }

    + { if let Some(desc) = &item.description { + html! {

    { desc }

    } + } else { html! {} }} +
    +
    + } +} + +// Viewer Component #[derive(Clone, PartialEq, Properties)] pub struct MarkdownViewerProps { pub markdown: Markdown, @@ -29,12 +57,6 @@ impl Component for MarkdownViewer { html! {
    - -
    -

    { &props.markdown.title }

    -
    { self.render_markdown(&props.markdown.content) } diff --git a/src/app/src/components/library_item_cards/mod.rs b/src/app/src/components/library_item_cards/mod.rs new file mode 100644 index 0000000..ec6f1d0 --- /dev/null +++ b/src/app/src/components/library_item_cards/mod.rs @@ -0,0 +1,5 @@ +pub mod book; +pub mod image; +pub mod markdown; +pub mod pdf; +pub mod slides; diff --git a/src/app/src/components/pdf_viewer.rs b/src/app/src/components/library_item_cards/pdf.rs similarity index 54% rename from src/app/src/components/pdf_viewer.rs rename to src/app/src/components/library_item_cards/pdf.rs index b62ac6d..ca62546 100644 --- a/src/app/src/components/pdf_viewer.rs +++ b/src/app/src/components/library_item_cards/pdf.rs @@ -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, +} + +#[function_component(PdfCard)] +pub fn pdf_card(props: &PdfCardProps) -> Html { + let item = &props.item; + let onclick = props.onclick.clone(); + + html! { +
    +
    + +
    +
    +

    { &item.title }

    + { if let Some(desc) = &item.description { + html! {

    { desc }

    } + } else { html! {} }} +

    { format!("{} pages", item.page_count) }

    +
    +
    + } +} + +// Viewer Component #[derive(Clone, PartialEq, Properties)] pub struct PdfViewerProps { pub pdf: Pdf, @@ -29,12 +58,6 @@ impl Component for PdfViewer { html! {
    - -
    -

    { &props.pdf.title }

    -