add circles app and libraries
This commit is contained in:
		
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1 +1,5 @@ | ||||
| target | ||||
| target | ||||
| dump.rdb | ||||
| worker_rhai_temp_db | ||||
| launch_data | ||||
| .DS_Store | ||||
							
								
								
									
										201
									
								
								ARCHITECTURE.md
									
									
									
									
									
								
							
							
						
						
									
										201
									
								
								ARCHITECTURE.md
									
									
									
									
									
								
							| @@ -1,201 +0,0 @@ | ||||
| # Architecture: Circle Management System | ||||
|  | ||||
| ## 1. Introduction & Overview | ||||
|  | ||||
| This document outlines the architecture for a system that manages multiple "Circles." Each Circle is an independent entity comprising its own database, a Rhai scripting engine, a dedicated Rhai worker process, and a WebSocket (WS) server for external interaction. A central command-line orchestrator will be responsible for initializing, running, and monitoring these Circles based on a configuration file. | ||||
|  | ||||
| The primary goal is to allow users to define multiple isolated Rhai environments, each accessible via a unique WebSocket endpoint, and for scripts executed within a circle to interact with that circle's dedicated persistent storage. | ||||
|  | ||||
| ## 2. Goals | ||||
|  | ||||
| *   Create a command-line application (`circles_orchestrator`) to manage the lifecycle of multiple Circles. | ||||
| *   Each Circle will have: | ||||
|     *   An independent `OurDB` instance for data persistence. | ||||
|     *   A dedicated `Rhai Engine` configured with its `OurDB`. | ||||
|     *   A dedicated `Rhai Worker` processing scripts for that circle. | ||||
|     *   A dedicated `WebSocket Server` exposing an endpoint for that circle. | ||||
| *   Circle configurations (name, ID, port) will be loaded from a `circles.json` file. | ||||
| *   The orchestrator will display a status table of all running circles, including their worker queue and WS server URL. | ||||
| *   Utilize existing crates (`ourdb`, `rhai_engine`, `rhai_worker`, `rhai_client`, `server_ws`) with necessary refactoring to support library usage. | ||||
|  | ||||
| ## 3. System Components | ||||
|  | ||||
| *   **Orchestrator (`circles_orchestrator`)**: | ||||
|     *   Location: `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/cmd/src/main.rs` | ||||
|     *   Role: Parses `circles.json`, initializes, spawns, and monitors all components for each defined circle. Displays system status. | ||||
| *   **Circle Configuration (`circles.json`)**: | ||||
|     *   Location: e.g., `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/cmd/circles.json` | ||||
|     *   Role: Defines the set of circles to be managed, including their ID, name, and port. | ||||
| *   **OurDB (Per Circle)**: | ||||
|     *   Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/db/ourdb/src/lib.rs` | ||||
|     *   Role: Provides persistent key-value storage for each circle, configured in incremental mode. Instance data stored at `~/.hero/circles/{id}/`. | ||||
| *   **Rhai Engine (Per Circle)**: | ||||
|     *   Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/engine/src/lib.rs` | ||||
|     *   Role: Provides the Rhai scripting environment for a circle, configured with the circle's specific `OurDB` instance. | ||||
| *   **Rhai Worker (Per Circle)**: | ||||
|     *   Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/worker/src/lib.rs` | ||||
|     *   Role: Executes Rhai scripts for a specific circle. Listens on a dedicated Redis queue for tasks and uses the circle's `Rhai Engine`. | ||||
| *   **Rhai Client (Per Circle WS Server)**: | ||||
|     *   Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/client/src/lib.rs` | ||||
|     *   Role: Used by a `Circle WebSocket Server` to send script execution tasks to its corresponding `Rhai Worker` via Redis. | ||||
| *   **Circle WebSocket Server (Per Circle)**: | ||||
|     *   Library: `/Users/timurgordon/code/git.ourworld.tf/herocode/circles/server_ws/src/lib.rs` | ||||
|     *   Role: Exposes a WebSocket endpoint for a specific circle, allowing external clients to submit Rhai scripts for execution within that circle. | ||||
| *   **Redis**: | ||||
|     *   URL: `redis://127.0.0.1:6379` | ||||
|     *   Role: Acts as the message broker between `Rhai Clients` (within WS Servers) and `Rhai Workers`. | ||||
|  | ||||
| ## 4. High-Level Design Aspects | ||||
|  | ||||
| ### 4.1. Orchestrator Logic (Conceptual) | ||||
| The `circles_orchestrator` reads the `circles.json` configuration. For each defined circle, it: | ||||
| 1.  Determines the `OurDB` path based on the circle's ID. | ||||
| 2.  Initializes the `OurDB` instance. | ||||
| 3.  Creates a `Rhai Engine` configured with this `OurDB`. | ||||
| 4.  Spawns a `Rhai Worker` task, providing it with the engine and the circle's identity (for Redis queue naming). | ||||
| 5.  Spawns a `Circle WebSocket Server` task, providing it with the circle's identity and port. | ||||
| It then monitors these components and displays their status. | ||||
|  | ||||
| ### 4.2. Database Path Convention | ||||
| *   Base: System user's home directory (e.g., `/Users/username` or `/home/username`). | ||||
| *   Structure: `~/.hero/circles/{CIRCLE_ID}/` | ||||
| *   Example: For a circle with ID `1`, the database path would be `~/.hero/circles/1/`. | ||||
|  | ||||
| ### 4.3. Configuration File Format (`circles.json`) | ||||
| A JSON array of objects. Each object represents a circle: | ||||
| *   `id`: `u32` - Unique identifier. | ||||
| *   `name`: `String` - Human-readable name. | ||||
| *   `port`: `u16` - Port for the WebSocket server. | ||||
| ```json | ||||
| [ | ||||
|   { "id": 1, "name": "Alpha Circle", "port": 8081 }, | ||||
|   { "id": 2, "name": "Beta Circle", "port": 8082 } | ||||
| ] | ||||
| ``` | ||||
|  | ||||
| ### 4.4. Conceptual Output Table Format (Orchestrator) | ||||
| | Circle Name  | ID | Worker Status | Worker Queues                      | WS Server URL          | | ||||
| |--------------|----|---------------|------------------------------------|------------------------| | ||||
| | Alpha Circle | 1  | Running       | `rhai_tasks:alpha_circle`          | `ws://127.0.0.1:8081/ws` | | ||||
|  | ||||
| ### 4.5. Interaction Flow (Single Circle) | ||||
| 1.  An external client connects to a specific Circle's WebSocket Server. | ||||
| 2.  The client sends a Rhai script via a JSON-RPC message. | ||||
| 3.  The WS Server uses its embedded `Rhai Client` to publish the script and task details to a Redis queue specific to that circle (e.g., `rhai_tasks:alpha_circle`). | ||||
| 4.  The `Rhai Worker` for that circle picks up the task from its Redis queue. | ||||
| 5.  The Worker uses its `Rhai Engine` (which is configured with the circle's `OurDB`) to execute the script. | ||||
| 6.  Any database interactions within the script go through the circle's `OurDB`. | ||||
| 7.  The Worker updates the task status and result/error in Redis. | ||||
| 8.  The `Rhai Client` (in the WS Server), which has been polling Redis for the result, receives the update. | ||||
| 9.  The WS Server sends the script's result or error back to the external client via WebSocket. | ||||
|  | ||||
| ## 5. Diagrams | ||||
|  | ||||
| ### 5.1. Component Diagram | ||||
| ```mermaid | ||||
| graph TD | ||||
|     UserInterface[User/Admin] -- Manages --> OrchestratorCli{circles_orchestrator} | ||||
|     OrchestratorCli -- Reads --> CirclesJson[circles.json] | ||||
|  | ||||
|     subgraph Circle 1 | ||||
|         direction LR | ||||
|         OrchestratorCli -- Spawns/Manages --> C1_OurDB[(OurDB @ ~/.hero/circles/1)] | ||||
|         OrchestratorCli -- Spawns/Manages --> C1_RhaiEngine[Rhai Engine 1] | ||||
|         OrchestratorCli -- Spawns/Manages --> C1_RhaiWorker[Rhai Worker 1] | ||||
|         OrchestratorCli -- Spawns/Manages --> C1_WSServer[WS Server 1 @ Port 8081] | ||||
|  | ||||
|         C1_RhaiEngine -- Uses --> C1_OurDB | ||||
|         C1_RhaiWorker -- Uses --> C1_RhaiEngine | ||||
|         C1_WSServer -- Contains --> C1_RhaiClient[Rhai Client 1] | ||||
|     end | ||||
|  | ||||
|     subgraph Circle 2 | ||||
|         direction LR | ||||
|         OrchestratorCli -- Spawns/Manages --> C2_OurDB[(OurDB @ ~/.hero/circles/2)] | ||||
|         OrchestratorCli -- Spawns/Manages --> C2_RhaiEngine[Rhai Engine 2] | ||||
|         OrchestratorCli -- Spawns/Manages --> C2_RhaiWorker[Rhai Worker 2] | ||||
|         OrchestratorCli -- Spawns/Manages --> C2_WSServer[WS Server 2 @ Port 8082] | ||||
|  | ||||
|         C2_RhaiEngine -- Uses --> C2_OurDB | ||||
|         C2_RhaiWorker -- Uses --> C2_RhaiEngine | ||||
|         C2_WSServer -- Contains --> C2_RhaiClient[Rhai Client 2] | ||||
|     end | ||||
|  | ||||
|     C1_RhaiWorker -- Listens/Publishes --> Redis[(Redis @ 127.0.0.1:6379)] | ||||
|     C1_RhaiClient -- Publishes/Subscribes --> Redis | ||||
|     C2_RhaiWorker -- Listens/Publishes --> Redis | ||||
|     C2_RhaiClient -- Publishes/Subscribes --> Redis | ||||
|  | ||||
|     ExternalWSClient1[External WS Client] -- Connects --> C1_WSServer | ||||
|     ExternalWSClient2[External WS Client] -- Connects --> C2_WSServer | ||||
| ``` | ||||
|  | ||||
| ### 5.2. Sequence Diagram (Request Flow for one Circle) | ||||
| ```mermaid | ||||
| sequenceDiagram | ||||
|     participant ExtWSClient as External WS Client | ||||
|     participant CircleWSServer as Circle WS Server (e.g., Port 8081) | ||||
|     participant RhaiClientLib as Rhai Client Library (in WS Server) | ||||
|     participant RedisBroker as Redis | ||||
|     participant RhaiWorker as Rhai Worker (for the circle) | ||||
|     participant RhaiEngineLib as Rhai Engine Library (in Worker) | ||||
|     participant CircleOurDB as OurDB (for the circle) | ||||
|  | ||||
|     ExtWSClient ->>+ CircleWSServer: Send Rhai Script (JSON-RPC "play" over WS) | ||||
|     CircleWSServer ->>+ RhaiClientLib: submit_script_and_await_result(circle_name, script, ...) | ||||
|     RhaiClientLib ->>+ RedisBroker: LPUSH rhai_tasks:circle_name (task_id) | ||||
|     RhaiClientLib ->>+ RedisBroker: HSET rhai_task_details:task_id (script details) | ||||
|     RhaiClientLib -->>- CircleWSServer: Returns task_id (internally starts polling) | ||||
|  | ||||
|     RhaiWorker ->>+ RedisBroker: BLPOP rhai_tasks:circle_name (blocks) | ||||
|     RedisBroker -->>- RhaiWorker: Returns task_id | ||||
|     RhaiWorker ->>+ RedisBroker: HGETALL rhai_task_details:task_id | ||||
|     RedisBroker -->>- RhaiWorker: Returns script details | ||||
|     RhaiWorker ->>+ RhaiEngineLib: eval_with_scope(script) | ||||
|     RhaiEngineLib ->>+ CircleOurDB: DB Operations (if script interacts with DB) | ||||
|     CircleOurDB -->>- RhaiEngineLib: DB Results | ||||
|     RhaiEngineLib -->>- RhaiWorker: Script Result/Error | ||||
|     RhaiWorker ->>+ RedisBroker: HSET rhai_task_details:task_id (status="completed/error", output/error) | ||||
|  | ||||
|     RhaiClientLib ->>+ RedisBroker: HGETALL rhai_task_details:task_id (polling) | ||||
|     RedisBroker -->>- RhaiClientLib: Task details (status, output/error) | ||||
|     alt Task Completed | ||||
|         RhaiClientLib -->>- CircleWSServer: Result (output) | ||||
|         CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with result) | ||||
|     else Task Errored | ||||
|         RhaiClientLib -->>- CircleWSServer: Error (error message) | ||||
|         CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with error) | ||||
|     else Timeout | ||||
|          RhaiClientLib -->>- CircleWSServer: Timeout Error | ||||
|          CircleWSServer -->>- ExtWSClient: WS Response (JSON-RPC with timeout error) | ||||
|     end | ||||
| ``` | ||||
|  | ||||
| ### 5.3. Conceptual Directory Structure | ||||
| ``` | ||||
| /Users/timurgordon/code/git.ourworld.tf/herocode/ | ||||
| ├── circles/ | ||||
| │   ├── cmd/  <-- NEW Orchestrator Crate | ||||
| │   │   ├── Cargo.toml | ||||
| │   │   ├── circles.json (example config) | ||||
| │   │   └── src/ | ||||
| │   │       └── main.rs (Orchestrator logic) | ||||
| │   ├── server_ws/  <-- EXISTING, to be refactored to lib | ||||
| │   │   ├── Cargo.toml | ||||
| │   │   └── src/ | ||||
| │   │       └── lib.rs (WebSocket server library logic) | ||||
| │   ├── ARCHITECTURE.md (This document) | ||||
| │   └── README.md | ||||
| ├── db/ | ||||
| │   └── ourdb/ | ||||
| │       └── src/lib.rs (Existing OurDB library) | ||||
| └── rhailib/  <-- Current Workspace (contains other existing libs) | ||||
|     ├── src/ | ||||
|     │   ├── client/ | ||||
|     │   │   └── src/lib.rs (Existing Rhai Client library) | ||||
|     │   ├── engine/ | ||||
|     │   │   └── src/lib.rs (Existing Rhai Engine library) | ||||
|     │   └── worker/ | ||||
|     │       └── src/lib.rs (Existing Rhai Worker library, to be refactored) | ||||
|     ├── Cargo.toml | ||||
|     └── ... | ||||
							
								
								
									
										4484
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										4484
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										73
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| [package] | ||||
| name = "circles" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [workspace] | ||||
| resolver = "2" | ||||
| members = [ | ||||
|     "src/client_ws", | ||||
|     "src/server_ws", | ||||
|     "src/launcher", | ||||
|     "src/ui_repl", | ||||
|     "src/app", | ||||
| ] | ||||
|  | ||||
| [dependencies] | ||||
| circle_client_ws = { path = "src/client_ws", features = ["crypto"] } | ||||
| serde_json.workspace = true | ||||
|  | ||||
| # Define shared dependencies for the entire workspace | ||||
| [workspace.dependencies] | ||||
| actix = "0.13" | ||||
| actix-web = "4" | ||||
| circle_client_ws = { path = "src/client_ws", features = ["crypto"] } | ||||
| actix-web-actors = "4" | ||||
| async-trait = "0.1" | ||||
| chrono = { version = "0.4", features = ["serde"] } | ||||
| clap = { version = "4.0", features = ["derive"] } | ||||
| env_logger = "0.10" | ||||
| futures-channel = "0.3" | ||||
| futures-util = "0.3" | ||||
| hex = "0.4" | ||||
| log = "0.4" | ||||
| once_cell = "1.19" | ||||
| rand = "0.8" | ||||
| redis = { version = "0.25.0", features = ["tokio-comp"] } | ||||
| secp256k1 = { version = "0.29", features = ["recovery", "rand-std"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| sha3 = "0.10" | ||||
| tokio = { version = "1", features = ["full"] } | ||||
| tokio-tungstenite = "0.23.0" | ||||
| url = "2.5.0" | ||||
| urlencoding = "2.1" | ||||
| uuid = { version = "1.6", features = ["v4", "serde", "js"] } | ||||
| thiserror = "1.0" | ||||
| # Path dependencies to other local crates from outside this repo | ||||
| heromodels = { path = "../db/heromodels" } | ||||
| engine = { path = "../rhailib/src/engine" } | ||||
| rhailib_worker = { path = "../rhailib/src/worker" } | ||||
| circle_ws_lib = { path = "src/server_ws" } | ||||
|  | ||||
|  | ||||
| # Dev dependencies | ||||
| [dev-dependencies] | ||||
| env_logger = "0.10" | ||||
| tokio = { version = "1", features = ["full"] } | ||||
| tempfile = "3.10.1" | ||||
| log = "0.4" | ||||
| circle_ws_lib = { workspace = true } | ||||
| heromodels = { workspace = true } | ||||
| engine = { workspace = true } | ||||
| rhailib_worker = { workspace = true } | ||||
| redis = { workspace = true } | ||||
| secp256k1 = { workspace = true } | ||||
| hex = { workspace = true } | ||||
| launcher = { path = "src/launcher" } | ||||
|  | ||||
|  | ||||
|  | ||||
| [features] | ||||
| crypto = ["circle_client_ws/crypto"] | ||||
|  | ||||
							
								
								
									
										75
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										75
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,3 +1,74 @@ | ||||
| # Circles | ||||
| # Circles Project | ||||
|  | ||||
| Architecture around our digital selves. | ||||
| Welcome to the `circles` project, a full-stack system featuring a WebSocket server, a cross-platform client, and a launcher to manage multiple instances. This project is designed for executing Rhai scripts in isolated environments, with an optional layer of `secp256k1` cryptographic authentication. | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| The `circles` project provides two core library crates and a utility application: | ||||
|  | ||||
| - **`server_ws`**: The core WebSocket server library, built with `Actix`. It handles client connections, processes JSON-RPC messages, and executes Rhai scripts. | ||||
| - **`client_ws`**: The core cross-platform WebSocket client library, compatible with both native Rust and WebAssembly (WASM) environments. | ||||
| - **`launcher`**: A convenient command-line utility that uses the `server_ws` library to read a `circles.json` configuration file and spawn multiple, isolated "Circle" instances. | ||||
| - **`openrpc.json`**: An OpenRPC specification that formally defines the JSON-RPC 2.0 API used for client-server communication. | ||||
|  | ||||
| ## Architecture | ||||
|  | ||||
| The system is designed around a client-server model, with `client_ws` and `server_ws` as the core components. The `launcher` is provided as a utility for orchestrating multiple server instances, each configured as an isolated "Circle" environment. | ||||
|  | ||||
| Clients connect to a `server_ws` instance via WebSocket and interact with it using the JSON-RPC protocol. The server can be configured to require authentication, in which case the client must complete a signature-based challenge-response flow over the WebSocket connection before it can execute protected methods like `play`. | ||||
|  | ||||
| For a more detailed explanation of the system's design, please see the [ARCHITECTURE.md](ARCHITECTURE.md) file. | ||||
|  | ||||
| ## Getting Started | ||||
|  | ||||
| To run the system, you will need to use the `launcher`. | ||||
|  | ||||
| 1.  **Configure Your Circles**: Create a `circles.json` file at the root of the project to define the instances you want to run. Each object in the top-level array defines a "Circle" with a unique `id`, `port`, and associated database and script paths. | ||||
|  | ||||
|     ```json | ||||
|     [ | ||||
|       { | ||||
|         "id": "circle-1", | ||||
|         "port": 9001, | ||||
|         "db_path": "/tmp/circle-1.db", | ||||
|         "rhai_path": "/path/to/your/scripts" | ||||
|       } | ||||
|     ] | ||||
|     ``` | ||||
|  | ||||
| 2.  **Run the Launcher**: | ||||
|     ```bash | ||||
|     cargo run --package launcher | ||||
|     ``` | ||||
|  | ||||
| The launcher will start a WebSocket server for each configured circle on its specified port. | ||||
|  | ||||
| ## API | ||||
|  | ||||
| The client-server communication is handled via JSON-RPC 2.0 over WebSocket. The available methods are: | ||||
|  | ||||
| - `play`: Executes a Rhai script. | ||||
| - `authenticate`: Authenticates the client. | ||||
|  | ||||
| For a complete definition of the API, including request parameters and response objects, please refer to the [openrpc.json](openrpc.json) file. | ||||
|  | ||||
| ## Crates | ||||
|  | ||||
| - **[server_ws](server_ws/README.md)**: Detailed documentation for the server library. | ||||
| - **[client_ws](client_ws/README.md)**: Detailed documentation for the client library. | ||||
| - **[launcher](launcher/README.md)**: Detailed documentation for the launcher utility. | ||||
| - **[app](src/app/README.md)**: A Yew frontend application that uses the `client_ws` to interact with the `server_ws`. | ||||
|  | ||||
| ## Running the App | ||||
|  | ||||
| To run the `circles-app`, you'll need to have `trunk` installed. If you don't have it, you can install it with: | ||||
|  | ||||
| ```bash | ||||
| cargo install trunk wasm-bindgen-cli | ||||
| ``` | ||||
|  | ||||
| Once `trunk` is installed, you can serve the app with: | ||||
|  | ||||
| ```bash | ||||
| cd src/app && trunk serve | ||||
| ``` | ||||
| @@ -1,31 +0,0 @@ | ||||
| [package] | ||||
| name = "circle_client_ws" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [dependencies] | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| uuid = { version = "1.6", features = ["v4", "serde", "js"] } | ||||
| log = "0.4" | ||||
| futures-channel = { version = "0.3", features = ["sink"] } # For mpsc | ||||
| futures-util = { version = "0.3", features = ["sink"] }    # For StreamExt, SinkExt | ||||
| thiserror = "1.0" | ||||
| async-trait = "0.1" # May be needed for abstracting WS connection | ||||
|  | ||||
| # WASM-specific dependencies | ||||
| [target.'cfg(target_arch = "wasm32")'.dependencies] | ||||
| gloo-net = { version = "0.4.0", features = ["websocket"] } | ||||
| wasm-bindgen-futures = "0.4" | ||||
| gloo-console = "0.3.0" # For wasm logging if needed, or use `log` with wasm_logger | ||||
|  | ||||
| # Native-specific dependencies | ||||
| [target.'cfg(not(target_arch = "wasm32"))'.dependencies] | ||||
| tokio-tungstenite = { version = "0.23.0", features = ["native-tls"] } | ||||
| tokio = { version = "1", features = ["rt", "macros"] } # For tokio::spawn on native | ||||
| url = "2.5.0" # For native WebSocket connection | ||||
|  | ||||
| [dev-dependencies] | ||||
| # For examples within this crate, if any, or for testing | ||||
| env_logger = "0.10" | ||||
| # tokio = { version = "1", features = ["full"] } # If examples need full tokio runtime | ||||
| @@ -1,86 +0,0 @@ | ||||
| # Circle WebSocket Client (`circle_client_ws`) | ||||
|  | ||||
| This crate provides a WebSocket client (`CircleWsClient`) designed to interact with a server that expects JSON-RPC messages, specifically for executing Rhai scripts. | ||||
|  | ||||
| It is designed to be compatible with both WebAssembly (WASM) environments (e.g., web browsers) and native Rust applications. | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| -   **Cross-Platform:** Works in WASM and native environments. | ||||
|     -   Uses `gloo-net` for WebSockets in WASM. | ||||
|     -   Uses `tokio-tungstenite` for WebSockets in native applications. | ||||
| -   **JSON-RPC Communication:** Implements client-side JSON-RPC 2.0 request and response handling. | ||||
| -   **Rhai Script Execution:** Provides a `play(script: String)` method to send Rhai scripts to the server for execution and receive their output. | ||||
| -   **Asynchronous Operations:** Leverages `async/await` and `futures` for non-blocking communication. | ||||
| -   **Connection Management:** Supports connecting to and disconnecting from a WebSocket server. | ||||
| -   **Error Handling:** Defines a comprehensive `CircleWsClientError` enum for various client-side errors. | ||||
|  | ||||
| ## Core Component | ||||
|  | ||||
| -   **`CircleWsClient`**: The main client struct. | ||||
|     -   `new(ws_url: String)`: Creates a new client instance targeting the given WebSocket URL. | ||||
|     -   `connect()`: Establishes the WebSocket connection. | ||||
|     -   `play(script: String)`: Sends a Rhai script to the server for execution and returns the result. | ||||
|     -   `disconnect()`: Closes the WebSocket connection. | ||||
|  | ||||
| ## Usage Example (Conceptual) | ||||
|  | ||||
| ```rust | ||||
| use circle_client_ws::CircleWsClient; | ||||
|  | ||||
| async fn run_client() { | ||||
|     let mut client = CircleWsClient::new("ws://localhost:8080/ws".to_string()); | ||||
|  | ||||
|     if let Err(e) = client.connect().await { | ||||
|         eprintln!("Failed to connect: {}", e); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     let script = "print(\"Hello from Rhai via WebSocket!\"); 40 + 2".to_string(); | ||||
|  | ||||
|     match client.play(script).await { | ||||
|         Ok(result) => { | ||||
|             println!("Script output: {}", result.output); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             eprintln!("Error during play: {}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     client.disconnect().await; | ||||
| } | ||||
|  | ||||
| // To run this example, you'd need an async runtime like tokio for native | ||||
| // or wasm-bindgen-test for WASM. | ||||
| ``` | ||||
|  | ||||
| ## Building | ||||
|  | ||||
| ### Native | ||||
| ```bash | ||||
| cargo build | ||||
| ``` | ||||
|  | ||||
| ### WASM | ||||
| ```bash | ||||
| cargo build --target wasm32-unknown-unknown | ||||
| ``` | ||||
|  | ||||
| ## Dependencies | ||||
|  | ||||
| Key dependencies include: | ||||
|  | ||||
| -   `serde`, `serde_json`: For JSON serialization/deserialization. | ||||
| -   `futures-channel`, `futures-util`: For asynchronous stream and sink handling. | ||||
| -   `uuid`: For generating unique request IDs. | ||||
| -   `log`: For logging. | ||||
| -   `thiserror`: For error type definitions. | ||||
|  | ||||
| **WASM-specific:** | ||||
| -   `gloo-net`: For WebSocket communication in WASM. | ||||
| -   `wasm-bindgen-futures`: To bridge Rust futures with JavaScript promises. | ||||
|  | ||||
| **Native-specific:** | ||||
| -   `tokio-tungstenite`: For WebSocket communication in native environments. | ||||
| -   `tokio`: Asynchronous runtime for native applications. | ||||
| -   `url`: For URL parsing. | ||||
| @@ -1,23 +0,0 @@ | ||||
| [package] | ||||
| name = "circles_orchestrator" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
|  | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["full"] } | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| # clap = { version = "4.0", features = ["derive"], optional = true } # Optional for future args | ||||
| dirs = "5.0" | ||||
| log = "0.4" | ||||
| env_logger = "0.10" | ||||
| comfy-table = "7.0" # For table display | ||||
|  | ||||
| # Path dependencies to other local crates | ||||
| heromodels = { path = "../../db/heromodels" } # Changed from ourdb | ||||
| rhai_engine = { path = "../../rhailib/src/engine" } | ||||
| rhai_worker = { path = "../../rhailib/src/worker" } | ||||
| # rhai_client is used by circle_ws_lib, not directly by orchestrator usually | ||||
| circle_ws_lib = { path = "../server_ws" } | ||||
| @@ -1,5 +0,0 @@ | ||||
| [ | ||||
|   { "id": 1, "name": "Alpha Circle", "port": 8091 }, | ||||
|   { "id": 2, "name": "Alpha Circle", "port": 8082 }, | ||||
|   { "id": 3, "name": "Beta Circle", "port": 8083 } | ||||
| ] | ||||
							
								
								
									
										245
									
								
								cmd/src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										245
									
								
								cmd/src/main.rs
									
									
									
									
									
								
							| @@ -1,245 +0,0 @@ | ||||
| use std::fs; | ||||
| use std::path::PathBuf; | ||||
| use std::sync::{Arc, Mutex}; | ||||
| use serde::Deserialize; | ||||
| use tokio::task::JoinHandle; | ||||
| use tokio::sync::{oneshot, mpsc}; // For server handles and worker shutdown | ||||
| use tokio::signal; | ||||
| use std::time::Duration; | ||||
|  | ||||
| use comfy_table::{Table, Row, Cell, ContentArrangement}; | ||||
| use log::{info, error, warn, debug}; | ||||
|  | ||||
| use heromodels::db::hero::{OurDB as HeroOurDB}; // Renamed to avoid conflict if OurDB is used from elsewhere | ||||
| use rhai_engine::create_heromodels_engine; | ||||
| use worker_lib::spawn_rhai_worker; // This now takes a shutdown_rx | ||||
| use circle_ws_lib::spawn_circle_ws_server; // This now takes a server_handle_tx | ||||
|  | ||||
| const DEFAULT_REDIS_URL: &str = "redis://127.0.0.1:6379"; | ||||
|  | ||||
| #[derive(Deserialize, Debug, Clone)] | ||||
| struct CircleConfig { | ||||
|     id: u32, | ||||
|     name: String, | ||||
|     port: u16, | ||||
| } | ||||
|  | ||||
| struct RunningCircleInfo { | ||||
|     config: CircleConfig, | ||||
|     db_path: PathBuf, | ||||
|     worker_queue: String, | ||||
|     ws_url: String, | ||||
|      | ||||
|     worker_handle: JoinHandle<Result<(), Box<dyn std::error::Error + Send + Sync>>>, | ||||
|     worker_shutdown_tx: mpsc::Sender<()>, // To signal worker to stop | ||||
|  | ||||
|     // Store the server handle for graceful shutdown, and its JoinHandle | ||||
|     ws_server_instance_handle: Arc<Mutex<Option<actix_web::dev::Server>>>,  | ||||
|     ws_server_task_join_handle: JoinHandle<std::io::Result<()>>, | ||||
|     status: Arc<Mutex<String>>,  | ||||
| } | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     std::env::set_var("RUST_LOG", "info,circles_orchestrator=debug,worker_lib=debug,circle_ws_lib=debug,rhai_client=debug,actix_server=info"); | ||||
|     env_logger::init(); | ||||
|  | ||||
|     info!("Starting Circles Orchestrator..."); | ||||
|     info!("Press Ctrl+C to initiate graceful shutdown."); | ||||
|  | ||||
|     let config_path = PathBuf::from("./circles.json"); | ||||
|     if !config_path.exists() { | ||||
|         error!("Configuration file not found at {:?}. Please create circles.json.", config_path); | ||||
|         return Err("circles.json not found".into()); | ||||
|     } | ||||
|  | ||||
|     let config_content = fs::read_to_string(config_path)?; | ||||
|     let circle_configs: Vec<CircleConfig> = serde_json::from_str(&config_content)?; | ||||
|  | ||||
|     if circle_configs.is_empty() { | ||||
|         warn!("No circle configurations found in circles.json. Exiting."); | ||||
|         return Ok(()); | ||||
|     } | ||||
|     info!("Loaded {} circle configurations.", circle_configs.len()); | ||||
|  | ||||
|     let mut running_circles_store: Vec<Arc<Mutex<RunningCircleInfo>>> = Vec::new(); | ||||
|  | ||||
|     for config in circle_configs { | ||||
|         info!("Initializing Circle ID: {}, Name: '{}', Port: {}", config.id, config.name, config.port); | ||||
|         let current_status = Arc::new(Mutex::new(format!("Initializing Circle {}", config.id))); | ||||
|  | ||||
|         let db_base_path = match dirs::home_dir() { | ||||
|             Some(path) => path.join(".hero").join("circles"), | ||||
|             None => { | ||||
|                 error!("Failed to get user home directory for Circle ID {}.", config.id); | ||||
|                 *current_status.lock().unwrap() = "Error: DB Path".to_string(); | ||||
|                 // Not pushing to running_circles_store as it can't fully initialize | ||||
|                 continue; | ||||
|             } | ||||
|         }; | ||||
|         let circle_db_path = db_base_path.join(config.id.to_string()); | ||||
|         if !circle_db_path.exists() { | ||||
|             if let Err(e) = fs::create_dir_all(&circle_db_path) { | ||||
|                 error!("Failed to create database directory for Circle {}: {:?}. Error: {}", config.id, circle_db_path, e); | ||||
|                 *current_status.lock().unwrap() = "Error: DB Create".to_string(); | ||||
|                 continue; | ||||
|             } | ||||
|             info!("Created database directory for Circle {}: {:?}", config.id, circle_db_path); | ||||
|         } | ||||
|  | ||||
|         let db = match HeroOurDB::new(circle_db_path.clone(), false) { | ||||
|             Ok(db_instance) => Arc::new(db_instance), | ||||
|             Err(e) => { | ||||
|                 error!("Failed to initialize heromodels::OurDB for Circle {}: {:?}", config.id, e); | ||||
|                 *current_status.lock().unwrap() = "Error: DB Init".to_string(); | ||||
|                 continue; | ||||
|             } | ||||
|         }; | ||||
|         info!("OurDB initialized for Circle {}", config.id); | ||||
|         *current_status.lock().unwrap() = format!("DB Ok for Circle {}", config.id); | ||||
|  | ||||
|         let engine = create_heromodels_engine(db.clone()); | ||||
|         info!("Rhai Engine created for Circle {}", config.id); | ||||
|         *current_status.lock().unwrap() = format!("Engine Ok for Circle {}", config.id); | ||||
|          | ||||
|         // Channel for worker shutdown | ||||
|         let (worker_shutdown_tx, worker_shutdown_rx) = mpsc::channel(1); // Buffer of 1 is fine | ||||
|  | ||||
|         let worker_handle = spawn_rhai_worker( | ||||
|             config.id, | ||||
|             config.name.clone(), | ||||
|             engine, // engine is Clone | ||||
|             DEFAULT_REDIS_URL.to_string(), | ||||
|             worker_shutdown_rx, // Pass the receiver | ||||
|         ); | ||||
|         info!("Rhai Worker spawned for Circle {}", config.id); | ||||
|         let worker_queue_name = format!("rhai_tasks:{}", config.name.replace(" ", "_").to_lowercase()); | ||||
|         *current_status.lock().unwrap() = format!("Worker Spawning for Circle {}", config.id); | ||||
|  | ||||
|         let (server_handle_tx, server_handle_rx) = oneshot::channel(); | ||||
|         let ws_server_task_join_handle = spawn_circle_ws_server( | ||||
|             config.id, | ||||
|             config.name.clone(), | ||||
|             config.port, | ||||
|             DEFAULT_REDIS_URL.to_string(), | ||||
|             server_handle_tx, | ||||
|         ); | ||||
|         info!("Circle WebSocket Server task spawned for Circle {} on port {}", config.id, config.port); | ||||
|         let ws_url = format!("ws://127.0.0.1:{}/ws", config.port); | ||||
|         *current_status.lock().unwrap() = format!("WS Server Spawning for Circle {}", config.id); | ||||
|  | ||||
|         let server_instance_handle_arc = Arc::new(Mutex::new(None)); | ||||
|         let server_instance_handle_clone = server_instance_handle_arc.clone(); | ||||
|         let status_clone_for_server_handle = current_status.clone(); | ||||
|         let circle_id_for_server_handle = config.id; | ||||
|  | ||||
|         tokio::spawn(async move { | ||||
|             match server_handle_rx.await { | ||||
|                 Ok(handle) => { | ||||
|                     *server_instance_handle_clone.lock().unwrap() = Some(handle); | ||||
|                     *status_clone_for_server_handle.lock().unwrap() = format!("Running Circle {}", circle_id_for_server_handle); | ||||
|                     info!("Received server handle for Circle {}", circle_id_for_server_handle); | ||||
|                 } | ||||
|                 Err(_) => { | ||||
|                     *status_clone_for_server_handle.lock().unwrap() = format!("Error: No Server Handle for Circle {}", circle_id_for_server_handle); | ||||
|                     error!("Failed to receive server handle for Circle {}", circle_id_for_server_handle); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         running_circles_store.push(Arc::new(Mutex::new(RunningCircleInfo { | ||||
|             config, | ||||
|             db_path: circle_db_path, | ||||
|             worker_queue: worker_queue_name, | ||||
|             ws_url, | ||||
|             worker_handle, | ||||
|             worker_shutdown_tx, | ||||
|             ws_server_instance_handle: server_instance_handle_arc, | ||||
|             ws_server_task_join_handle, | ||||
|             status: current_status, // This is an Arc<Mutex<String>> | ||||
|         }))); | ||||
|     } | ||||
|  | ||||
|     info!("All configured circles have been processed. Initializing status table display loop."); | ||||
|  | ||||
|     let display_running_circles = running_circles_store.clone(); | ||||
|     let display_task = tokio::spawn(async move { | ||||
|         loop { | ||||
|             { // Scope for MutexGuard | ||||
|                 let circles = display_running_circles.iter() | ||||
|                                 .map(|arc_info| arc_info.lock().unwrap()) | ||||
|                                 .collect::<Vec<_>>(); // Collect locked guards | ||||
|  | ||||
|                 let mut table = Table::new(); | ||||
|                 table.set_content_arrangement(ContentArrangement::Dynamic); | ||||
|                 table.set_header(vec!["Name", "ID", "Port", "Status", "DB Path", "Worker Queue", "WS URL"]); | ||||
|  | ||||
|                 for circle_info in circles.iter() { | ||||
|                     let mut row = Row::new(); | ||||
|                     row.add_cell(Cell::new(&circle_info.config.name)); | ||||
|                     row.add_cell(Cell::new(circle_info.config.id)); | ||||
|                     row.add_cell(Cell::new(circle_info.config.port)); | ||||
|                     row.add_cell(Cell::new(&*circle_info.status.lock().unwrap())); // Deref and lock status | ||||
|                     row.add_cell(Cell::new(circle_info.db_path.to_string_lossy())); | ||||
|                     row.add_cell(Cell::new(&circle_info.worker_queue)); | ||||
|                     row.add_cell(Cell::new(&circle_info.ws_url)); | ||||
|                     table.add_row(row); | ||||
|                 } | ||||
|                 // Clear terminal before printing (basic, might flicker) | ||||
|                 // print!("\x1B[2J\x1B[1;1H");  | ||||
|                 println!("\n--- Circles Status (updated every 5s, Ctrl+C to stop) ---\n{table}"); | ||||
|             } | ||||
|             tokio::time::sleep(Duration::from_secs(5)).await; | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|  | ||||
|     signal::ctrl_c().await?; | ||||
|     info!("Ctrl-C received. Initiating graceful shutdown of all circles..."); | ||||
|     display_task.abort(); // Stop the display task | ||||
|  | ||||
|     for circle_arc in running_circles_store { | ||||
|         let mut circle_info = circle_arc.lock().unwrap(); | ||||
|         info!("Shutting down Circle ID: {}, Name: '{}'", circle_info.config.id, circle_info.config.name); | ||||
|         *circle_info.status.lock().unwrap() = "Shutting down".to_string(); | ||||
|  | ||||
|         // Signal worker to shut down | ||||
|         if circle_info.worker_shutdown_tx.send(()).await.is_err() { | ||||
|             warn!("Failed to send shutdown signal to worker for Circle {}. It might have already stopped.", circle_info.config.id); | ||||
|         } | ||||
|  | ||||
|         // Stop WS server | ||||
|         if let Some(server_handle) = circle_info.ws_server_instance_handle.lock().unwrap().take() { | ||||
|             info!("Stopping WebSocket server for Circle {}...", circle_info.config.id); | ||||
|             server_handle.stop(true).await; // Graceful stop | ||||
|             info!("WebSocket server for Circle {} stop signal sent.", circle_info.config.id); | ||||
|         } else { | ||||
|             warn!("No server handle to stop WebSocket server for Circle {}. It might not have started properly or already stopped.", circle_info.config.id); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     info!("Waiting for all tasks to complete..."); | ||||
|     for circle_arc in running_circles_store { | ||||
|         // We need to take ownership of handles to await them, or await mutable refs. | ||||
|         // This part is tricky if the MutexGuard is held. | ||||
|         // For simplicity, we'll just log that we've signaled them. | ||||
|         // Proper awaiting would require more careful structuring of JoinHandles. | ||||
|         let circle_id; | ||||
|         let circle_name; | ||||
|         { // Short scope for the lock | ||||
|             let circle_info = circle_arc.lock().unwrap(); | ||||
|             circle_id = circle_info.config.id; | ||||
|             circle_name = circle_info.config.name.clone(); | ||||
|         } | ||||
|         debug!("Orchestrator has signaled shutdown for Circle {} ({}). Main loop will await join handles if structured for it.", circle_name, circle_id); | ||||
|         // Actual awaiting of join handles would happen here if they were collected outside the Mutex. | ||||
|         // For now, the main function will exit after this loop. | ||||
|     } | ||||
|  | ||||
|     // Give some time for tasks to shut down before the main process exits. | ||||
|     // This is a simplified approach. A more robust solution would involve awaiting all JoinHandles. | ||||
|     tokio::time::sleep(Duration::from_secs(2)).await;  | ||||
|  | ||||
|     info!("Orchestrator shut down complete."); | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										137
									
								
								docs/ARCHITECTURE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								docs/ARCHITECTURE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| # System Architecture | ||||
|  | ||||
| This document provides a detailed overview of the `circles` project architecture. The project is composed of two core library crates, `server_ws` and `client_ws`, and a convenient `launcher` utility. | ||||
|  | ||||
| ## 1. High-Level Overview | ||||
|  | ||||
| The `circles` project provides the core components for a client-server system designed to execute Rhai scripts in isolated environments. The `launcher` application is a utility that demonstrates how to use the `server_ws` and `client_ws` libraries to manage multiple server instances, but the libraries themselves are the fundamental building blocks. | ||||
|  | ||||
| The core functionality revolves around: | ||||
| - **Orchestration**: The `launcher` starts and stops multiple, independent WebSocket servers. | ||||
| - **Client-Server Communication**: A JSON-RPC 2.0 API over WebSockets allows clients to execute scripts and authenticate. | ||||
| - **Authentication**: An optional, robust `secp256k1` signature-based authentication mechanism secures the script execution endpoint. | ||||
|  | ||||
| ## 2. Component Architecture | ||||
|  | ||||
| ### 2.1. `server_ws` (Library) | ||||
|  | ||||
| The `server_ws` crate provides the WebSocket server that handles client connections and API requests. Its key features include: | ||||
| - **Web Framework**: Built using `Actix`, a powerful actor-based web framework for Rust. | ||||
| - **WebSocket Handling**: Uses `actix-web-actors` to manage individual WebSocket sessions. Each client connection is handled by a `CircleWs` actor, ensuring that sessions are isolated from one another. | ||||
| - **JSON-RPC API**: Exposes a JSON-RPC 2.0 API with methods for script execution (`play`) and authentication (`fetch_nonce`, `authenticate`). | ||||
| - **Authentication Service**: The authentication flow is handled entirely within the WebSocket connection using the dedicated JSON-RPC methods. | ||||
|  | ||||
| ### 2.2. `client_ws` (Library) | ||||
|  | ||||
| The `client_ws` crate is a WebSocket client library designed for interacting with the `server_ws`. It is engineered to be cross-platform: | ||||
| - **Native**: For native Rust applications, it uses `tokio-tungstenite` for WebSocket communication. | ||||
| - **WebAssembly (WASM)**: For browser-based applications, it uses `gloo-net` to integrate with the browser's native WebSocket API. | ||||
| - **API**: Provides a flexible builder pattern for client construction and a high-level API (`CircleWsClient`) that abstracts the complexities of the WebSocket connection and the JSON-RPC protocol. | ||||
|  | ||||
| ### 2.3. `launcher` (Utility) | ||||
|  | ||||
| The `launcher` is a command-line utility that demonstrates how to use the `server_ws` library. It is responsible for: | ||||
| - **Configuration**: Reading a `circles.json` file that defines a list of Circle instances to run. | ||||
| - **Orchestration**: Spawning a dedicated `server_ws` instance for each configured circle. | ||||
| - **Lifecycle Management**: Managing the lifecycle of all spawned servers and their associated Rhai workers. | ||||
|  | ||||
| ### 2.2. `server_ws` | ||||
|  | ||||
| The `server_ws` crate provides the WebSocket server that handles client connections and API requests. Its key features include: | ||||
| - **Web Framework**: Built using `Actix`, a powerful actor-based web framework for Rust. | ||||
| - **WebSocket Handling**: Uses `actix-web-actors` to manage individual WebSocket sessions. Each client connection is handled by a `CircleWs` actor, ensuring that sessions are isolated from one another. | ||||
| - **JSON-RPC API**: Exposes a JSON-RPC 2.0 API with methods for script execution (`play`) and authentication (`fetch_nonce`, `authenticate`). | ||||
| - **Authentication Service**: The authentication flow is handled entirely within the WebSocket connection using the dedicated JSON-RPC methods. | ||||
|  | ||||
| ### 2.3. `client_ws` | ||||
|  | ||||
| The `client_ws` crate is a WebSocket client library designed for interacting with the `server_ws`. It is engineered to be cross-platform: | ||||
| - **Native**: For native Rust applications, it uses `tokio-tungstenite` for WebSocket communication. | ||||
| - **WebAssembly (WASM)**: For browser-based applications, it uses `gloo-net` to integrate with the browser's native WebSocket API. | ||||
| - **API**: Provides a flexible builder pattern for client construction and a high-level API (`CircleWsClient`) that abstracts the complexities of the WebSocket connection and the JSON-RPC protocol. | ||||
|  | ||||
| ## 3. Communication and Protocols | ||||
|  | ||||
| ### 3.1. JSON-RPC 2.0 | ||||
|  | ||||
| All client-server communication, including authentication, uses the JSON-RPC 2.0 protocol over the WebSocket connection. This provides a unified, lightweight, and well-defined structure for all interactions. The formal API contract is defined in the [openrpc.json](openrpc.json) file. | ||||
|  | ||||
| ### 3.2. Authentication Flow | ||||
|  | ||||
| The authentication mechanism is designed to verify that a client possesses the private key corresponding to a given public key, without ever exposing the private key. The entire flow happens over the established WebSocket connection. | ||||
|  | ||||
| **Sequence of Events:** | ||||
| 1.  **Keypair**: The client is instantiated with a `secp256k1` keypair. | ||||
| 2.  **Nonce Request**: The client sends a `fetch_nonce` JSON-RPC request containing its public key. | ||||
| 3.  **Nonce Issuance**: The server generates a unique, single-use nonce, stores it in the actor's state, and returns it to the client in a JSON-RPC response. | ||||
| 4.  **Signature Creation**: The client signs the received nonce with its private key. | ||||
| 5.  **Authentication Request**: The client sends an `authenticate` JSON-RPC message, containing the public key and the generated signature. | ||||
| 6.  **Signature Verification**: The server's WebSocket actor retrieves the stored nonce for the given public key and cryptographically verifies the signature. | ||||
| 7.  **Session Update**: If verification is successful, the server marks the client's WebSocket session as "authenticated," granting it access to protected methods like `play`. | ||||
|  | ||||
| ## 4. Diagrams | ||||
|  | ||||
| ### 4.1. System Component Diagram | ||||
|  | ||||
| ```mermaid | ||||
| graph TD | ||||
|     subgraph "User Machine" | ||||
|         Launcher[🚀 launcher] | ||||
|         CirclesConfig[circles.json] | ||||
|         Launcher -- Reads --> CirclesConfig | ||||
|     end | ||||
|  | ||||
|     subgraph "Spawned Processes" | ||||
|         direction LR | ||||
|         subgraph "Circle 1" | ||||
|             Server1[🌐 server_ws on port 9001] | ||||
|         end | ||||
|         subgraph "Circle 2" | ||||
|             Server2[🌐 server_ws on port 9002] | ||||
|         end | ||||
|     end | ||||
|  | ||||
|     Launcher -- Spawns & Manages --> Server1 | ||||
|     Launcher -- Spawns & Manages --> Server2 | ||||
|  | ||||
|     subgraph "Clients" | ||||
|         Client1[💻 client_ws] | ||||
|         Client2[💻 client_ws] | ||||
|     end | ||||
|  | ||||
|     Client1 -- Connects via WebSocket --> Server1 | ||||
|     Client2 -- Connects via WebSocket --> Server2 | ||||
| ``` | ||||
|  | ||||
| ### 4.2. Authentication Sequence Diagram | ||||
|  | ||||
| ```mermaid | ||||
| sequenceDiagram | ||||
|     participant Client as client_ws | ||||
|     participant WsActor as CircleWs Actor (WebSocket) | ||||
|  | ||||
|     Client->>Client: Instantiate with keypair | ||||
|  | ||||
|     Note over Client: Has public_key, private_key | ||||
|  | ||||
|     Client->>+WsActor: JSON-RPC "fetch_nonce" (pubkey) | ||||
|     WsActor->>WsActor: generate_nonce() | ||||
|     WsActor->>WsActor: store_nonce(pubkey, nonce) | ||||
|     WsActor-->>-Client: JSON-RPC Response ({"nonce": "..."}) | ||||
|  | ||||
|     Client->>Client: sign(nonce, private_key) | ||||
|  | ||||
|     Note over Client: Has signature | ||||
|  | ||||
|     Client->>+WsActor: JSON-RPC "authenticate" (pubkey, signature) | ||||
|     WsActor->>WsActor: retrieve_nonce(pubkey) | ||||
|     WsActor->>WsActor: verify_signature(nonce, signature, pubkey) | ||||
|  | ||||
|     alt Signature is Valid | ||||
|         WsActor->>WsActor: Set session as authenticated | ||||
|         WsActor-->>-Client: JSON-RPC Response ({"authenticated": true}) | ||||
|     else Signature is Invalid | ||||
|         WsActor-->>-Client: JSON-RPC Error (Invalid Credentials) | ||||
|     end | ||||
|  | ||||
|     Note over WsActor: Subsequent "play" requests will include the authenticated public key. | ||||
							
								
								
									
										126
									
								
								docs/openrpc.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								docs/openrpc.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | ||||
| { | ||||
|   "openrpc": "1.2.6", | ||||
|   "info": { | ||||
|     "title": "Circles RPC", | ||||
|     "description": "A JSON-RPC API for interacting with a Circle, allowing script execution and authentication.", | ||||
|     "version": "1.0.0" | ||||
|   }, | ||||
|   "methods": [ | ||||
|     { | ||||
|       "name": "fetch_nonce", | ||||
|       "summary": "Fetches a cryptographic nonce for a given public key.", | ||||
|       "params": [ | ||||
|         { | ||||
|           "name": "pubkey", | ||||
|           "description": "The client's public key.", | ||||
|           "required": true, | ||||
|           "schema": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|       "result": { | ||||
|         "name": "nonce_response", | ||||
|         "description": "The cryptographic nonce to be signed.", | ||||
|         "schema": { | ||||
|           "$ref": "#/components/schemas/NonceResponse" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     { | ||||
|       "name": "authenticate", | ||||
|       "summary": "Authenticates the client using a signed nonce.", | ||||
|       "params": [ | ||||
|         { | ||||
|           "name": "credentials", | ||||
|           "description": "The authentication credentials, including the public key and the signed nonce.", | ||||
|           "required": true, | ||||
|           "schema": { | ||||
|             "$ref": "#/components/schemas/AuthCredentials" | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|       "result": { | ||||
|         "name": "authentication_status", | ||||
|         "description": "The result of the authentication attempt.", | ||||
|         "schema": { | ||||
|           "type": "object", | ||||
|           "properties": { | ||||
|             "authenticated": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           "required": ["authenticated"] | ||||
|         } | ||||
|       }, | ||||
|       "errors": [ | ||||
|         { | ||||
|           "code": -32002, | ||||
|           "message": "Invalid Credentials", | ||||
|           "description": "The provided credentials were not valid." | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "name": "play", | ||||
|       "summary": "Executes a Rhai script and returns the result.", | ||||
|       "params": [ | ||||
|         { | ||||
|           "name": "script", | ||||
|           "description": "The Rhai script to execute.", | ||||
|           "required": true, | ||||
|           "schema": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         } | ||||
|       ], | ||||
|       "result": { | ||||
|         "name": "play_result", | ||||
|         "description": "The output of the executed script.", | ||||
|         "schema": { | ||||
|           "type": "string" | ||||
|         } | ||||
|       }, | ||||
|       "errors": [ | ||||
|         { | ||||
|           "code": -32000, | ||||
|           "message": "Execution Error", | ||||
|           "description": "The script failed to execute." | ||||
|         }, | ||||
|         { | ||||
|           "code": -32001, | ||||
|           "message": "Authentication Required", | ||||
|           "description": "The client must be authenticated to use this method." | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   ], | ||||
|   "components": { | ||||
|     "schemas": { | ||||
|       "AuthCredentials": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "pubkey": { | ||||
|             "type": "string", | ||||
|             "description": "The public key of the client." | ||||
|           }, | ||||
|           "signature": { | ||||
|             "type": "string", | ||||
|             "description": "The nonce signed with the client's private key." | ||||
|           } | ||||
|         }, | ||||
|         "required": ["pubkey", "signature"] | ||||
|       }, | ||||
|       "NonceResponse": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "nonce": { | ||||
|             "type": "string", | ||||
|             "description": "The single-use cryptographic nonce." | ||||
|           } | ||||
|         }, | ||||
|         "required": ["nonce"] | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										0
									
								
								examples/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								examples/.gitkeep
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										146
									
								
								examples/client_auth_example.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								examples/client_auth_example.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| //! End-to-end authentication example | ||||
| //! | ||||
| //! This example demonstrates the complete authentication flow with the simplified approach: | ||||
| //! 1. Create a WebSocket client with authentication configuration | ||||
| //! 2. Authenticate using private key | ||||
| //! 3. Connect to WebSocket with authentication | ||||
| //! 4. Send authenticated requests | ||||
| //! | ||||
| //! To run this example: | ||||
| //! ```bash | ||||
| //! cargo run --example client_auth_example --features "crypto" | ||||
| //! ``` | ||||
|  | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| use log::{info, error}; | ||||
| use std::time::Duration; | ||||
| use tokio::time::sleep; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Initialize logging | ||||
|     env_logger::init(); | ||||
|  | ||||
|     info!("Starting simplified authentication example"); | ||||
|  | ||||
|     // Configuration | ||||
|     let ws_url = "ws://localhost:8080/ws".to_string(); | ||||
|  | ||||
|     // Example 1: Authenticate with private key | ||||
|     info!("=== Example 1: Private Key Authentication ==="); | ||||
|     let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; | ||||
|      | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.clone()) | ||||
|         .with_keypair(private_key.to_string()) | ||||
|         .build(); | ||||
|      | ||||
|     match client.connect().await { | ||||
|         Ok(_) => { | ||||
|             info!("Successfully connected to WebSocket"); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("WebSocket connection failed: {}", e); | ||||
|             return Err(e.into()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     match client.authenticate().await { | ||||
|         Ok(true) => { | ||||
|             info!("Successfully authenticated with private key"); | ||||
|         } | ||||
|         Ok(false) => { | ||||
|             error!("Authentication failed"); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("Private key authentication failed: {}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Example 2: Send authenticated request | ||||
|     info!("=== Example 2: Send Authenticated Request ==="); | ||||
|     let script = "print('Hello from authenticated client!');".to_string(); | ||||
|     match client.play(script).await { | ||||
|         Ok(result) => { | ||||
|             info!("Play request successful: {}", result.output); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("Play request failed: {}", e); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Keep connection alive for a moment | ||||
|     sleep(Duration::from_secs(2)).await; | ||||
|      | ||||
|     // Disconnect | ||||
|     client.disconnect().await; | ||||
|     info!("Disconnected from WebSocket"); | ||||
|  | ||||
|  | ||||
|     // Example 3: Different private key authentication | ||||
|     info!("=== Example 3: Different Private Key Authentication ==="); | ||||
|     let private_key2 = "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321"; | ||||
|      | ||||
|     let mut client2 = CircleWsClientBuilder::new(ws_url.clone()) | ||||
|         .with_keypair(private_key2.to_string()) | ||||
|         .build(); | ||||
|      | ||||
|     match client2.connect().await { | ||||
|         Ok(_) => { | ||||
|             info!("Connected with second private key authentication"); | ||||
|              | ||||
|             match client2.authenticate().await { | ||||
|                 Ok(true) => { | ||||
|                     info!("Successfully authenticated with second private key"); | ||||
|                     let script = "print('Hello from second auth!');".to_string(); | ||||
|                     match client2.play(script).await { | ||||
|                         Ok(result) => { | ||||
|                             info!("Second auth request successful: {}", result.output); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             error!("Second auth request failed: {}", e); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 Ok(false) => { | ||||
|                     error!("Second private key authentication failed"); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     error!("Second private key authentication failed: {}", e); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             client2.disconnect().await; | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("Second auth connection failed: {}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Example 4: Non-authenticated connection (fallback) | ||||
|     info!("=== Example 4: Non-Authenticated Connection ==="); | ||||
|     let mut client3 = CircleWsClientBuilder::new(ws_url).build(); | ||||
|      | ||||
|     match client3.connect().await { | ||||
|         Ok(()) => { | ||||
|             info!("Connected without authentication (fallback mode)"); | ||||
|              | ||||
|             let script = "print('Hello from non-auth client!');".to_string(); | ||||
|             match client3.play(script).await { | ||||
|                 Ok(result) => { | ||||
|                     info!("Non-auth request successful: {}", result.output); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     error!("Non-auth request failed: {}", e); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             client3.disconnect().await; | ||||
|         } | ||||
|         Err(e) => { | ||||
|             error!("Non-auth connection failed: {}", e); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     info!("Simplified authentication example completed"); | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										261
									
								
								examples/client_auth_simulation_example.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										261
									
								
								examples/client_auth_simulation_example.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,261 @@ | ||||
| //! Authentication simulation example | ||||
| //!  | ||||
| //! This example simulates the authentication flow without requiring a running server. | ||||
| //! It demonstrates: | ||||
| //! 1. Key generation and management | ||||
| //! 2. Nonce request simulation | ||||
| //! 3. Message signing and verification | ||||
| //! 4. Credential management | ||||
| //! 5. Authentication state checking | ||||
|  | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
| use log::info; | ||||
|  | ||||
| // Import authentication modules | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
|  | ||||
| #[cfg(feature = "crypto")] | ||||
| use circle_client_ws::auth::{ | ||||
|     generate_private_key,  | ||||
|     derive_public_key,  | ||||
|     sign_message,  | ||||
|     verify_signature, | ||||
|     AuthCredentials, | ||||
|     NonceResponse | ||||
| }; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     // Initialize logging | ||||
|     env_logger::init(); | ||||
|      | ||||
|     info!("🔐 Starting authentication simulation example"); | ||||
|      | ||||
|     // Step 1: Generate cryptographic keys | ||||
|     info!("🔑 Generating cryptographic keys..."); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let (private_key, public_key) = { | ||||
|         let private_key = generate_private_key()?; | ||||
|         let public_key = derive_public_key(&private_key)?; | ||||
|         info!("✅ Generated private key: {}...", &private_key[..10]); | ||||
|         info!("✅ Derived public key: {}...", &public_key[..20]); | ||||
|         (private_key, public_key) | ||||
|     }; | ||||
|      | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     let (private_key, _public_key) = { | ||||
|         let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string(); | ||||
|         let public_key = "04abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(); | ||||
|         info!("📝 Using fallback keys (crypto feature disabled)"); | ||||
|         (private_key, public_key) | ||||
|     }; | ||||
|      | ||||
|     // Step 2: Simulate nonce request and response | ||||
|     info!("📡 Simulating nonce request..."); | ||||
|     let current_time = SystemTime::now() | ||||
|         .duration_since(UNIX_EPOCH) | ||||
|         .unwrap() | ||||
|         .as_secs(); | ||||
|      | ||||
|     let simulated_nonce = format!("nonce_{}_{}", current_time, "abcdef123456"); | ||||
|     let expires_at = current_time + 300; // 5 minutes from now | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let nonce_response = NonceResponse { | ||||
|         nonce: simulated_nonce.clone(), | ||||
|         expires_at, | ||||
|     }; | ||||
|      | ||||
|     info!("✅ Simulated nonce response:"); | ||||
|     info!("   Nonce: {}", simulated_nonce); | ||||
|     info!("   Expires at: {}", expires_at); | ||||
|      | ||||
|     // Step 3: Sign the nonce | ||||
|     info!("✍️  Signing nonce with private key..."); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let signature = { | ||||
|         match sign_message(&private_key, &simulated_nonce) { | ||||
|             Ok(sig) => { | ||||
|                 info!("✅ Signature created: {}...", &sig[..20]); | ||||
|                 sig | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 error!("❌ Failed to sign message: {}", e); | ||||
|                 return Err(e.into()); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     let _signature = { | ||||
|         info!("📝 Using fallback signature (crypto feature disabled)"); | ||||
|         "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string() | ||||
|     }; | ||||
|      | ||||
|     // Step 4: Verify the signature | ||||
|     info!("🔍 Verifying signature..."); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         match verify_signature(&public_key, &simulated_nonce, &signature) { | ||||
|             Ok(true) => info!("✅ Signature verification successful!"), | ||||
|             Ok(false) => { | ||||
|                 error!("❌ Signature verification failed!"); | ||||
|                 return Err("Signature verification failed".into()); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 error!("❌ Signature verification error: {}", e); | ||||
|                 return Err(e.into()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     { | ||||
|         info!("📝 Skipping signature verification (crypto feature disabled)"); | ||||
|     } | ||||
|      | ||||
|     // Step 5: Create authentication credentials | ||||
|     info!("📋 Creating authentication credentials..."); | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let credentials = AuthCredentials::new( | ||||
|         public_key.clone(), | ||||
|         signature.clone(), | ||||
|         nonce_response.nonce.clone(), | ||||
|         expires_at | ||||
|     ); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         info!("✅ Credentials created:"); | ||||
|         info!("   Public key: {}...", &credentials.public_key()[..20]); | ||||
|         info!("   Signature: {}...", &credentials.signature()[..20]); | ||||
|         info!("   Nonce: {}", credentials.nonce()); | ||||
|         info!("   Expires at: {}", credentials.expires_at); | ||||
|         info!("   Is expired: {}", credentials.is_expired()); | ||||
|         info!("   Expires within 60s: {}", credentials.expires_within(60)); | ||||
|         info!("   Expires within 400s: {}", credentials.expires_within(400)); | ||||
|     } | ||||
|      | ||||
|     // Step 6: Create client with authentication | ||||
|     info!("🔌 Creating WebSocket client with authentication..."); | ||||
|     let _client = CircleWsClientBuilder::new("ws://localhost:8080/ws".to_string()) | ||||
|         .with_keypair(private_key.clone()) | ||||
|         .build(); | ||||
|      | ||||
|     info!("✅ Client created"); | ||||
|      | ||||
|     // Step 7: Demonstrate key rotation | ||||
|     info!("🔄 Demonstrating key rotation..."); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         let new_private_key = generate_private_key()?; | ||||
|         let new_public_key = derive_public_key(&new_private_key)?; | ||||
|          | ||||
|         info!("✅ Generated new keys:"); | ||||
|         info!("   New private key: {}...", &new_private_key[..10]); | ||||
|         info!("   New public key: {}...", &new_public_key[..20]); | ||||
|          | ||||
|         // Create new client with rotated keys | ||||
|         let _new_client = CircleWsClientBuilder::new("ws://localhost:8080/ws".to_string()) | ||||
|             .with_keypair(new_private_key) | ||||
|             .build(); | ||||
|          | ||||
|         info!("✅ Created client with rotated keys"); | ||||
|     } | ||||
|      | ||||
|     #[cfg(not(feature = "crypto"))] | ||||
|     { | ||||
|         info!("📝 Skipping key rotation (crypto feature disabled)"); | ||||
|     } | ||||
|      | ||||
|     // Step 8: Demonstrate credential expiration | ||||
|     info!("⏰ Demonstrating credential expiration..."); | ||||
|      | ||||
|     // Create credentials that expire soon | ||||
|     #[cfg(feature = "crypto")] | ||||
|     let short_lived_credentials = AuthCredentials::new( | ||||
|         public_key, | ||||
|         signature, | ||||
|         nonce_response.nonce, | ||||
|         current_time + 5 // Expires in 5 seconds | ||||
|     ); | ||||
|      | ||||
|     #[cfg(feature = "crypto")] | ||||
|     { | ||||
|         info!("✅ Created short-lived credentials:"); | ||||
|         info!("   Expires at: {}", short_lived_credentials.expires_at); | ||||
|         info!("   Is expired: {}", short_lived_credentials.is_expired()); | ||||
|         info!("   Expires within 10s: {}", short_lived_credentials.expires_within(10)); | ||||
|          | ||||
|         // Wait a moment and check again | ||||
|         tokio::time::sleep(std::time::Duration::from_secs(1)).await; | ||||
|         info!("⏳ After 1 second:"); | ||||
|         info!("   Is expired: {}", short_lived_credentials.is_expired()); | ||||
|         info!("   Expires within 5s: {}", short_lived_credentials.expires_within(5)); | ||||
|     } | ||||
|      | ||||
|     info!("🎉 Authentication simulation completed successfully!"); | ||||
|      | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|      | ||||
|     #[tokio::test] | ||||
|     async fn test_key_generation() { | ||||
|         #[cfg(feature = "crypto")] | ||||
|         { | ||||
|             let private_key = generate_private_key().unwrap(); | ||||
|             assert!(private_key.starts_with("0x")); | ||||
|             assert_eq!(private_key.len(), 66); // 0x + 64 hex chars | ||||
|              | ||||
|             let public_key = derive_public_key(&private_key).unwrap(); | ||||
|             assert!(public_key.starts_with("04")); | ||||
|             assert_eq!(public_key.len(), 130); // 04 + 128 hex chars (uncompressed) | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     #[tokio::test] | ||||
|     async fn test_signature_flow() { | ||||
|         #[cfg(feature = "crypto")] | ||||
|         { | ||||
|             let private_key = generate_private_key().unwrap(); | ||||
|             let public_key = derive_public_key(&private_key).unwrap(); | ||||
|             let message = "test_nonce_12345"; | ||||
|              | ||||
|             let signature = sign_message(&private_key, message).unwrap(); | ||||
|             let is_valid = verify_signature(&public_key, message, &signature).unwrap(); | ||||
|              | ||||
|             assert!(is_valid); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     #[test] | ||||
|     fn test_credentials() { | ||||
|         let current_time = SystemTime::now() | ||||
|             .duration_since(UNIX_EPOCH) | ||||
|             .unwrap() | ||||
|             .as_secs(); | ||||
|          | ||||
|         #[cfg(feature = "crypto")] | ||||
|         let credentials = AuthCredentials::new( | ||||
|             "04abcdef...".to_string(), | ||||
|             "0x123456...".to_string(), | ||||
|             "nonce_123".to_string(), | ||||
|             current_time + 300 | ||||
|         ); | ||||
|          | ||||
|         #[cfg(feature = "crypto")] | ||||
|         { | ||||
|             assert!(!credentials.is_expired()); | ||||
|             assert!(credentials.expires_within(400)); | ||||
|             assert!(!credentials.expires_within(100)); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										68
									
								
								examples/ourworld/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								examples/ourworld/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| # OurWorld Example | ||||
|  | ||||
| This directory contains a complete example demonstrating a simulated "OurWorld" network, consisting of multiple interconnected "circles" (nodes). Each circle runs its own WebSocket server and a Rhai script worker, all managed by a central launcher. | ||||
|  | ||||
| This example is designed to showcase: | ||||
| 1.  **Multi-Circle Configuration**: How to define and configure multiple circles in a single `circles.json` file. | ||||
| 2.  **Programmatic Launching**: How to use the `launcher` library to start, manage, and monitor these circles from within a Rust application. | ||||
| 3.  **Dynamic Key Generation**: The launcher generates unique cryptographic keypairs for each circle upon startup. | ||||
| 4.  **Output Generation**: How to use the `--output` functionality to get a JSON file containing the connection details (public keys, WebSocket URLs, etc.) for each running circle. | ||||
| 5.  **Graceful Shutdown**: How the launcher handles a `Ctrl+C` signal to shut down all running circles cleanly. | ||||
|  | ||||
| ## Directory Contents | ||||
|  | ||||
| -   `circles.json`: The main configuration file that defines the 7 circles in the OurWorld network, including their names, ports, and associated Rhai scripts. | ||||
| -   `scripts/`: This directory contains the individual Rhai scripts that define the behavior of each circle. | ||||
| -   `ourworld_output.json` (Generated): This file is created after running the example and contains the runtime details of each circle. | ||||
|  | ||||
| ## How to Run the Example | ||||
|  | ||||
| There are two ways to run this example, each demonstrating a different way to use the launcher. | ||||
|  | ||||
| ### 1. As a Root Example (Recommended) | ||||
|  | ||||
| This method runs the launcher programmatically from the root of the workspace and is the simplest way to see the system in action. It uses the `examples/ourworld.rs` file. | ||||
|  | ||||
| ```sh | ||||
| # From the root of the workspace | ||||
| cargo run --example ourworld | ||||
| ``` | ||||
|  | ||||
| ### 2. As a Crate-Level Example | ||||
|  | ||||
| This method runs a similar launcher, but as an example *within* the `launcher` crate itself. It uses the `src/launcher/examples/ourworld/main.rs` file. This is useful for testing the launcher in a more isolated context. | ||||
|  | ||||
| ```sh | ||||
| # Navigate to the launcher's crate directory | ||||
| cd src/launcher | ||||
|  | ||||
| # Run the 'ourworld' example using cargo | ||||
| cargo run --example ourworld | ||||
| ``` | ||||
|  | ||||
| ### 3. Using the Launcher Binary | ||||
|  | ||||
| This method uses the main `launcher` binary to run the configuration, which is useful for testing the command-line interface. | ||||
|  | ||||
| ```sh | ||||
| # From the root of the workspace | ||||
| cargo run -p launcher -- --config examples/ourworld/circles.json --output examples/ourworld/ourworld_output.json | ||||
| ``` | ||||
|  | ||||
| ## What to Expect | ||||
|  | ||||
| When you run the example, you will see log output indicating that the launcher is starting up, followed by a table summarizing the running circles: | ||||
|  | ||||
| ``` | ||||
| +-----------------+------------------------------------------------------------------+------------------------------------------+-----------------------+ | ||||
| | Name            | Public Key                                                       | Worker Queue                             | WS URL                | | ||||
| +=================+==================================================================+==========================================+=======================+ | ||||
| | OurWorld        | 02...                                                            | rhai_tasks:02...                          | ws://127.0.0.1:9000/ws| | ||||
| +-----------------+------------------------------------------------------------------+------------------------------------------+-----------------------+ | ||||
| | Dunia Cybercity | 03...                                                            | rhai_tasks:03...                          | ws://127.0.0.1:9001/ws| | ||||
| +-----------------+------------------------------------------------------------------+------------------------------------------+-----------------------+ | ||||
| | ... (and so on for all 7 circles)                                                                                                                     | | ||||
| +-----------------+------------------------------------------------------------------+------------------------------------------+-----------------------+ | ||||
| ``` | ||||
|  | ||||
| The launcher will then wait for you to press `Ctrl+C` to initiate a graceful shutdown of all services. | ||||
							
								
								
									
										37
									
								
								examples/ourworld/circles.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								examples/ourworld/circles.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| [ | ||||
|     { | ||||
|         "name": "OurWorld", | ||||
|         "port": 9000, | ||||
|         "script_path": "scripts/ourworld.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Dunia Cybercity", | ||||
|         "port": 9001, | ||||
|         "script_path": "scripts/dunia_cybercity.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Sikana", | ||||
|         "port": 9002, | ||||
|         "script_path": "scripts/sikana.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Threefold", | ||||
|         "port": 9003, | ||||
|         "script_path": "scripts/threefold.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Mbweni", | ||||
|         "port": 9004, | ||||
|         "script_path": "scripts/mbweni.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Geomind", | ||||
|         "port": 9005, | ||||
|         "script_path": "scripts/geomind.rhai" | ||||
|     }, | ||||
|     { | ||||
|         "name": "Freezone", | ||||
|         "port": 9006, | ||||
|         "script_path": "scripts/freezone.rhai" | ||||
|     } | ||||
| ] | ||||
							
								
								
									
										83
									
								
								examples/ourworld/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								examples/ourworld/main.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| //! Example of launching multiple circles and outputting their details to a file. | ||||
| //! | ||||
| //! This example demonstrates how to use the launcher library to start circles | ||||
| //! programmatically, similar to how the `launcher` binary works. | ||||
| //! | ||||
| //! # Usage | ||||
| //! | ||||
| //! ```sh | ||||
| //! cargo run --example ourworld | ||||
| //! ``` | ||||
| //! | ||||
| //! This will: | ||||
| //! 1. Read the `circles.json` file in the `examples/ourworld` directory. | ||||
| //! 2. Launch all 7 circles defined in the config. | ||||
| //! 3. Create a `ourworld_output.json` file in the same directory with the details. | ||||
| //! 4. The launcher will run until you stop it with Ctrl+C. | ||||
|  | ||||
| use launcher::{run_launcher, Args, CircleConfig}; | ||||
| use std::fs; | ||||
| use std::path::PathBuf; | ||||
| use std::error::Error as StdError; | ||||
| use log::{error, info}; | ||||
|  | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<(), Box<dyn StdError>> { | ||||
|     println!("--- Launching OurWorld Example Programmatically ---"); | ||||
|  | ||||
|     // The example is now at the root of the `examples` directory, | ||||
|     // so we can reference its assets directly. | ||||
|     let example_dir = PathBuf::from("./examples/ourworld"); | ||||
|     let config_path = example_dir.join("circles.json"); | ||||
|     let output_path = example_dir.join("ourworld_output.json"); | ||||
|  | ||||
|     println!("Using config file: {:?}", config_path); | ||||
|     println!("Output will be written to: {:?}", output_path); | ||||
|  | ||||
|     // Manually construct the arguments instead of parsing from command line. | ||||
|     // This is useful when embedding the launcher logic in another application. | ||||
|     let args = Args { | ||||
|         config_path: config_path.clone(), | ||||
|         output: Some(output_path), | ||||
|         debug: true, // Enable debug logging for the example | ||||
|         verbose: 2,  // Set verbosity to max | ||||
|     }; | ||||
|  | ||||
|     if !config_path.exists() { | ||||
|         let msg = format!("Configuration file not found at {:?}", config_path); | ||||
|         error!("{}", msg); | ||||
|         return Err(msg.into()); | ||||
|     } | ||||
|  | ||||
|     let config_content = fs::read_to_string(&config_path)?; | ||||
|  | ||||
|     let mut circle_configs: Vec<CircleConfig> = match serde_json::from_str(&config_content) { | ||||
|         Ok(configs) => configs, | ||||
|         Err(e) => { | ||||
|             error!("Failed to parse {}: {}. Ensure it's a valid JSON array of CircleConfig.", config_path.display(), e); | ||||
|             return Err(Box::new(e) as Box<dyn StdError>); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Make script paths relative to the project root by prepending the example directory path. | ||||
|     for config in &mut circle_configs { | ||||
|         if let Some(script_path) = &config.script_path { | ||||
|             let full_script_path = example_dir.join(script_path); | ||||
|             config.script_path = Some(full_script_path.to_string_lossy().into_owned()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     if circle_configs.is_empty() { | ||||
|         info!("No circle configurations found in {}. Exiting.", config_path.display()); | ||||
|         return Ok(()); | ||||
|     } | ||||
|  | ||||
|     println!("Starting launcher... Press Ctrl+C to exit."); | ||||
|  | ||||
|     // The run_launcher function will setup logging, spawn circles, print the table, | ||||
|     // and wait for a shutdown signal (Ctrl+C). | ||||
|     run_launcher(args, circle_configs).await?; | ||||
|  | ||||
|     println!("--- OurWorld Example Finished ---"); | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										51
									
								
								examples/ourworld/ourworld_output.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								examples/ourworld/ourworld_output.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| [ | ||||
|   { | ||||
|     "name": "OurWorld", | ||||
|     "public_key": "02acbca22369b7f10584348056ae48779e04534cd34d37b7db0f4996f4d9d5e2a5", | ||||
|     "secret_key": "0c75df7425c799eb769049cf48891299761660396d772c687fa84cac5ec62570", | ||||
|     "worker_queue": "rhai_tasks:02acbca22369b7f10584348056ae48779e04534cd34d37b7db0f4996f4d9d5e2a5", | ||||
|     "ws_url": "ws://127.0.0.1:9000" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Dunia Cybercity", | ||||
|     "public_key": "03d97b1a357c3ceb2f0eb78f8e2c71beda9190db5cb7e5112150105132effb35e0", | ||||
|     "secret_key": "4fad664608e8de55f0e5e1712241e71dc0864be125bc8633e50601fca8040791", | ||||
|     "worker_queue": "rhai_tasks:03d97b1a357c3ceb2f0eb78f8e2c71beda9190db5cb7e5112150105132effb35e0", | ||||
|     "ws_url": "ws://127.0.0.1:9001" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Sikana", | ||||
|     "public_key": "0389595b28cfa98b45fa3c222db79892f3face65e7ef06d44e35d642967e45ed6e", | ||||
|     "secret_key": "fd59ddbf0d0bada725c911dc7e3317754ac552aa1ac84cfcb899bdfe3591e1f4", | ||||
|     "worker_queue": "rhai_tasks:0389595b28cfa98b45fa3c222db79892f3face65e7ef06d44e35d642967e45ed6e", | ||||
|     "ws_url": "ws://127.0.0.1:9002" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Threefold", | ||||
|     "public_key": "03270f06ee4a7d42a9f6c22c9a7d6d0138cd15d4fa659026e2e6572fc6c6a6ea18", | ||||
|     "secret_key": "e204c0215bec80f74df49ea5b1592de3c6739cced339ace801bb7e158eb62231", | ||||
|     "worker_queue": "rhai_tasks:03270f06ee4a7d42a9f6c22c9a7d6d0138cd15d4fa659026e2e6572fc6c6a6ea18", | ||||
|     "ws_url": "ws://127.0.0.1:9003" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Mbweni", | ||||
|     "public_key": "02724cf23e4ac95d0f14984f55c6955b3ca5ab2275d7ac2a2e4baf3596caf8606c", | ||||
|     "secret_key": "3c013e2e5f64692f044d17233e5fabdb0577629f898359115e69c3e594d5f43e", | ||||
|     "worker_queue": "rhai_tasks:02724cf23e4ac95d0f14984f55c6955b3ca5ab2275d7ac2a2e4baf3596caf8606c", | ||||
|     "ws_url": "ws://127.0.0.1:9004" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Geomind", | ||||
|     "public_key": "030d8ceb47d445c92b7c3f13e9e134eebcb1d83beed424425f734164544eb58eed", | ||||
|     "secret_key": "dbd6dd383a6f56042710f72ce2ac68266650bbfb61432cdd139e98043b693e7c", | ||||
|     "worker_queue": "rhai_tasks:030d8ceb47d445c92b7c3f13e9e134eebcb1d83beed424425f734164544eb58eed", | ||||
|     "ws_url": "ws://127.0.0.1:9005" | ||||
|   }, | ||||
|   { | ||||
|     "name": "Freezone", | ||||
|     "public_key": "02dd21025c1d47421eccc2264c87538d41126da772a9a3f0e7226807fed89c9971", | ||||
|     "secret_key": "0c0c6b02c20fcd4ccfb2afeae249979ddd623e6f6edd17af4a9a5a19bc1b15ae", | ||||
|     "worker_queue": "rhai_tasks:02dd21025c1d47421eccc2264c87538d41126da772a9a3f0e7226807fed89c9971", | ||||
|     "ws_url": "ws://127.0.0.1:9006" | ||||
|   } | ||||
| ] | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/dunia_cybercity.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/dunia_cybercity.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Dunia Cybercity") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("ws://localhost:8091/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/freezone.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/freezone.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Zanzibar Digital Freezone") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("ws://localhost:8096/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/geomind.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/geomind.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Geomind") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("ws://localhost:8095/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/mbweni.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/mbweni.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Mbweni Ruins & Gardens") | ||||
|     .description("Mbweni ruins and Gardens") | ||||
|     .ws_url("ws://localhost:8094/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										255
									
								
								examples/ourworld/scripts/ourworld.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								examples/ourworld/scripts/ourworld.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Ourworld") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("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(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/sikana.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/sikana.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Sikana") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("ws://localhost:8092/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
							
								
								
									
										249
									
								
								examples/ourworld/scripts/threefold.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								examples/ourworld/scripts/threefold.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,249 @@ | ||||
| // OurWorld Circle and Library Data | ||||
|  | ||||
| new_circle() | ||||
|     .title("Threefold DMCC") | ||||
|     .description("Creating a better world.") | ||||
|     .ws_url("ws://localhost:8093/ws") | ||||
|     .logo("🌍") | ||||
|     .save_circle(); | ||||
|  | ||||
| let circle = get_circle(); | ||||
|  | ||||
| print("--- Creating OurWorld Library ---"); | ||||
|  | ||||
| // === IMAGES === | ||||
| print("Creating images..."); | ||||
|  | ||||
| let nature1 = save_image(new_image() | ||||
|     .title("Mountain Sunrise") | ||||
|     .description("Breathtaking sunrise over mountain peaks") | ||||
|     .url("https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature2 = save_image(new_image() | ||||
|     .title("Ocean Waves") | ||||
|     .description("Powerful ocean waves crashing on rocks") | ||||
|     .url("https://images.unsplash.com/photo-1505142468610-359e7d316be0?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let nature3 = save_image(new_image() | ||||
|     .title("Forest Path") | ||||
|     .description("Peaceful path through ancient forest") | ||||
|     .url("https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech1 = save_image(new_image() | ||||
|     .title("Solar Panels") | ||||
|     .description("Modern solar panel installation") | ||||
|     .url("https://images.unsplash.com/photo-1509391366360-2e959784a276?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let tech2 = save_image(new_image() | ||||
|     .title("Wind Turbines") | ||||
|     .description("Wind turbines generating clean energy") | ||||
|     .url("https://images.unsplash.com/photo-1466611653911-95081537e5b7?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space1 = save_image(new_image() | ||||
|     .title("Earth from Space") | ||||
|     .description("Our beautiful planet from orbit") | ||||
|     .url("https://images.unsplash.com/photo-1446776877081-d282a0f896e2?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let space2 = save_image(new_image() | ||||
|     .title("Galaxy Spiral") | ||||
|     .description("Stunning spiral galaxy in deep space") | ||||
|     .url("https://images.unsplash.com/photo-1502134249126-9f3755a50d78?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| let city1 = save_image(new_image() | ||||
|     .title("Smart City") | ||||
|     .description("Futuristic smart city at night") | ||||
|     .url("https://images.unsplash.com/photo-1480714378408-67cf0d13bc1f?w=800") | ||||
|     .width(800).height(600)); | ||||
|  | ||||
| // === PDFs === | ||||
| print("Creating PDFs..."); | ||||
|  | ||||
| let pdf1 = save_pdf(new_pdf() | ||||
|     .title("Climate Action Report 2024") | ||||
|     .description("Comprehensive analysis of global climate initiatives") | ||||
|     .url("https://www.ipcc.ch/site/assets/uploads/2018/02/ipcc_wg3_ar5_summary-for-policymakers.pdf") | ||||
|     .page_count(42)); | ||||
|  | ||||
| let pdf2 = save_pdf(new_pdf() | ||||
|     .title("Sustainable Development Goals") | ||||
|     .description("UN SDG implementation guide") | ||||
|     .url("https://sdgs.un.org/sites/default/files/publications/21252030%20Agenda%20for%20Sustainable%20Development%20web.pdf") | ||||
|     .page_count(35)); | ||||
|  | ||||
| let pdf3 = save_pdf(new_pdf() | ||||
|     .title("Renewable Energy Handbook") | ||||
|     .description("Technical guide to renewable energy systems") | ||||
|     .url("https://www.irena.org/-/media/Files/IRENA/Agency/Publication/2019/Oct/IRENA_Renewable-Energy-Statistics-2019.pdf") | ||||
|     .page_count(280)); | ||||
|  | ||||
| let pdf4 = save_pdf(new_pdf() | ||||
|     .title("Blockchain for Good") | ||||
|     .description("How blockchain technology can solve global challenges") | ||||
|     .url("https://www.weforum.org/whitepapers/blockchain-beyond-the-hype") | ||||
|     .page_count(24)); | ||||
|  | ||||
| let pdf5 = save_pdf(new_pdf() | ||||
|     .title("Future of Work Report") | ||||
|     .description("Analysis of changing work patterns and remote collaboration") | ||||
|     .url("https://www.mckinsey.com/featured-insights/future-of-work") | ||||
|     .page_count(156)); | ||||
|  | ||||
| // === MARKDOWN DOCUMENTS === | ||||
| print("Creating markdown documents..."); | ||||
|  | ||||
| let md1 = save_markdown(new_markdown() | ||||
|     .title("OurWorld Mission Statement") | ||||
|     .description("Our vision for a better world") | ||||
|     .content("# OurWorld Mission\n\n## Vision\nTo create a more sustainable, equitable, and connected world through technology and collaboration.\n\n## Values\n- **Sustainability**: Every decision considers environmental impact\n- **Inclusivity**: Technology that serves everyone\n- **Transparency**: Open source and open governance\n- **Innovation**: Pushing boundaries for positive change\n\n## Goals\n1. Reduce global carbon footprint by 50% by 2030\n2. Provide internet access to 1 billion underserved people\n3. Create 10 million green jobs worldwide\n4. Establish 1000 sustainable communities")); | ||||
|  | ||||
| let md2 = save_markdown(new_markdown() | ||||
|     .title("Getting Started Guide") | ||||
|     .description("How to join the OurWorld movement") | ||||
|     .content("# Getting Started with OurWorld\n\n## Welcome!\nThank you for joining our mission to create a better world.\n\n## First Steps\n1. **Explore**: Browse our projects and initiatives\n2. **Connect**: Join our community forums\n3. **Contribute**: Find ways to get involved\n4. **Learn**: Access our educational resources\n\n## Ways to Contribute\n- **Developers**: Contribute to open source projects\n- **Activists**: Organize local initiatives\n- **Educators**: Share knowledge and skills\n- **Investors**: Support sustainable ventures\n\n## Resources\n- [Community Forum](https://forum.ourworld.tf)\n- [Developer Portal](https://dev.ourworld.tf)\n- [Learning Hub](https://learn.ourworld.tf)")); | ||||
|  | ||||
| let md3 = save_markdown(new_markdown() | ||||
|     .title("Technology Roadmap 2024") | ||||
|     .description("Our technical development plans") | ||||
|     .content("# Technology Roadmap 2024\n\n## Q1 Objectives\n- Launch decentralized identity system\n- Deploy carbon tracking blockchain\n- Release mobile app v2.0\n\n## Q2 Objectives\n- Implement AI-powered resource optimization\n- Launch peer-to-peer energy trading platform\n- Deploy IoT sensor network\n\n## Q3 Objectives\n- Release virtual collaboration spaces\n- Launch digital twin cities pilot\n- Implement quantum-safe encryption\n\n## Q4 Objectives\n- Deploy autonomous governance systems\n- Launch global impact measurement platform\n- Release AR/VR sustainability training")); | ||||
|  | ||||
| let md4 = save_markdown(new_markdown() | ||||
|     .title("Community Guidelines") | ||||
|     .description("How we work together") | ||||
|     .content("# Community Guidelines\n\n## Our Principles\n- **Respect**: Treat everyone with dignity\n- **Collaboration**: Work together towards common goals\n- **Constructive**: Focus on solutions, not problems\n- **Inclusive**: Welcome diverse perspectives\n\n## Communication Standards\n- Use clear, respectful language\n- Listen actively to others\n- Provide constructive feedback\n- Share knowledge freely\n\n## Conflict Resolution\n1. Address issues directly and respectfully\n2. Seek to understand different viewpoints\n3. Involve mediators when needed\n4. Focus on solutions that benefit everyone")); | ||||
|  | ||||
|  | ||||
| let investor = new_contact() | ||||
|     .name("Example Investor") | ||||
|     .save_contact(); | ||||
|  | ||||
| let investors = new_group() | ||||
|     .name("Investors") | ||||
|     .description("A group for example inverstors of ourworld"); | ||||
|  | ||||
| investors.add_contact(investor.id) | ||||
|     .save_group(); | ||||
|  | ||||
| // === BOOKS === | ||||
| print("Creating books..."); | ||||
|  | ||||
| let sustainability_book = save_book(new_book() | ||||
|     .title("Sustainability Handbook") | ||||
|     .description("Complete guide to sustainable living and practices") | ||||
|     .add_page("# Introduction to Sustainability\n\nSustainability is about meeting our present needs without compromising the ability of future generations to meet their own needs.\n\n## Key Principles\n- Environmental stewardship\n- Social equity\n- Economic viability\n\n## Why It Matters\nOur planet faces unprecedented challenges from climate change, resource depletion, and environmental degradation.") | ||||
|     .add_page("# Energy Efficiency\n\n## Home Energy Savings\n- LED lighting reduces energy consumption by 75%\n- Smart thermostats can save 10-15% on heating/cooling\n- Energy-efficient appliances make a significant difference\n\n## Renewable Energy\n- Solar panels: Clean electricity from sunlight\n- Wind power: Harnessing natural wind currents\n- Hydroelectric: Using water flow for energy\n\n## Transportation\n- Electric vehicles reduce emissions\n- Public transit decreases individual carbon footprint\n- Cycling and walking for short distances") | ||||
|     .add_page("# Waste Reduction\n\n## The 5 R's\n1. **Refuse**: Say no to unnecessary items\n2. **Reduce**: Use less of what you need\n3. **Reuse**: Find new purposes for items\n4. **Recycle**: Process materials into new products\n5. **Rot**: Compost organic waste\n\n## Practical Tips\n- Use reusable bags and containers\n- Buy products with minimal packaging\n- Repair instead of replacing\n- Donate items you no longer need") | ||||
|     .add_page("# Sustainable Food\n\n## Local and Seasonal\n- Support local farmers and reduce transport emissions\n- Eat seasonal produce for better nutrition and taste\n- Visit farmers markets and join CSAs\n\n## Plant-Based Options\n- Reduce meat consumption for environmental benefits\n- Explore diverse plant proteins\n- Grow your own herbs and vegetables\n\n## Food Waste Prevention\n- Plan meals and make shopping lists\n- Store food properly to extend freshness\n- Use leftovers creatively") | ||||
|     .add_toc_entry(new_toc_entry().title("Introduction to Sustainability").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Energy Efficiency").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Waste Reduction").page(2)) | ||||
|     .add_toc_entry(new_toc_entry().title("Sustainable Food").page(3))); | ||||
|  | ||||
| let tech_guide_book = save_book(new_book() | ||||
|     .title("Green Technology Guide") | ||||
|     .description("Understanding and implementing green technologies") | ||||
|     .add_page("# Green Technology Overview\n\nGreen technology, also known as clean technology, refers to the use of science and technology to create products and services that are environmentally friendly.\n\n## Categories\n- Renewable energy systems\n- Energy efficiency technologies\n- Pollution prevention and cleanup\n- Sustainable materials and manufacturing\n\n## Benefits\n- Reduced environmental impact\n- Lower operating costs\n- Improved public health\n- Economic opportunities") | ||||
|     .add_page("# Solar Technology\n\n## How Solar Works\nSolar panels convert sunlight directly into electricity using photovoltaic cells.\n\n## Types of Solar Systems\n- **Grid-tied**: Connected to the electrical grid\n- **Off-grid**: Standalone systems with battery storage\n- **Hybrid**: Combination of grid-tied and battery backup\n\n## Installation Considerations\n- Roof orientation and shading\n- Local climate and sun exposure\n- Energy consumption patterns\n- Available incentives and rebates") | ||||
|     .add_page("# Smart Home Technology\n\n## Automation Benefits\n- Optimized energy usage\n- Enhanced comfort and convenience\n- Remote monitoring and control\n- Predictive maintenance\n\n## Key Technologies\n- Smart thermostats\n- Automated lighting systems\n- Energy monitoring devices\n- Smart appliances\n- Home energy management systems") | ||||
|     .add_toc_entry(new_toc_entry().title("Green Technology Overview").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Solar Technology").page(1)) | ||||
|     .add_toc_entry(new_toc_entry().title("Smart Home Technology").page(2))); | ||||
|  | ||||
| let community_book = save_book(new_book() | ||||
|     .title("Building Communities") | ||||
|     .description("Guide to creating sustainable and inclusive communities") | ||||
|     .add_page("# Community Building Fundamentals\n\n## What Makes a Strong Community?\n- Shared values and vision\n- Open communication channels\n- Mutual support and cooperation\n- Inclusive decision-making processes\n\n## Benefits of Strong Communities\n- Enhanced quality of life\n- Economic resilience\n- Social cohesion\n- Environmental stewardship") | ||||
|     .add_page("# Governance and Leadership\n\n## Collaborative Leadership\n- Distributed decision-making\n- Transparent processes\n- Accountability mechanisms\n- Conflict resolution systems\n\n## Community Engagement\n- Regular town halls and meetings\n- Digital participation platforms\n- Volunteer coordination\n- Feedback and improvement cycles") | ||||
|     .add_toc_entry(new_toc_entry().title("Community Building Fundamentals").page(0)) | ||||
|     .add_toc_entry(new_toc_entry().title("Governance and Leadership").page(1))); | ||||
|  | ||||
| // === SLIDES === | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| 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")); | ||||
|  | ||||
| // === COLLECTIONS === | ||||
| print("Creating collections..."); | ||||
|  | ||||
| let nature_collection = save_collection(new_collection() | ||||
|     .title("Nature & Environment") | ||||
|     .description("Beautiful images and resources about our natural world") | ||||
|     .add_image(nature1.id) | ||||
|     .add_image(nature2.id) | ||||
|     .add_image(nature3.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id) | ||||
|     .add_book(sustainability_book.id) | ||||
|     .add_slides(nature_slides.id)); | ||||
|  | ||||
| let technology_collection = save_collection(new_collection() | ||||
|     .title("Sustainable Technology") | ||||
|     .description("Innovations driving positive change") | ||||
|     .add_image(tech1.id) | ||||
|     .add_image(tech2.id) | ||||
|     .add_pdf(pdf3.id) | ||||
|     .add_pdf(pdf4.id) | ||||
|     .add_markdown(md3.id) | ||||
|     .add_book(tech_guide_book.id) | ||||
|     .add_slides(innovation_slides.id)); | ||||
|  | ||||
| let space_collection = save_collection(new_collection() | ||||
|     .title("Space & Cosmos") | ||||
|     .description("Exploring the universe and our place in it") | ||||
|     .add_image(space1.id) | ||||
|     .add_image(space2.id) | ||||
|     .add_pdf(pdf2.id) | ||||
|     .add_markdown(md2.id)); | ||||
|  | ||||
| let community_collection = save_collection(new_collection() | ||||
|     .title("Community & Collaboration") | ||||
|     .description("Building better communities together") | ||||
|     .add_image(city1.id) | ||||
|     .add_pdf(pdf5.id) | ||||
|     .add_markdown(md4.id) | ||||
|     .add_book(community_book.id)); | ||||
|  | ||||
| let climate_collection = save_collection(new_collection() | ||||
|     .title("Climate Action") | ||||
|     .description("Understanding and addressing climate change") | ||||
|     .add_slides(climate_slides.id) | ||||
|     .add_pdf(pdf1.id) | ||||
|     .add_markdown(md1.id)); | ||||
|  | ||||
| print("✅ OurWorld library created successfully!"); | ||||
| print("📚 Collections: 5"); | ||||
| print("🖼️  Images: 8"); | ||||
| print("📄 PDFs: 5"); | ||||
| print("📝 Markdown docs: 4"); | ||||
| print("📖 Books: 3"); | ||||
| print("🎞️  Slide shows: 3"); | ||||
| @@ -8,15 +8,15 @@ use tokio::time::sleep; | ||||
| // use serde_json::Value; // No longer needed as CircleWsClient::play takes String
 | ||||
| // Uuid is handled by CircleWsClient internally for requests.
 | ||||
| // use uuid::Uuid;
 | ||||
| use circle_client_ws::CircleWsClient; | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| // PlayResultClient and CircleWsClientError will be resolved via the client methods if needed,
 | ||||
| // or this indicates they were not actually needed in the scope of this file directly.
 | ||||
| // The compiler warning suggests they are unused from this specific import.
 | ||||
| 
 | ||||
| const TEST_CIRCLE_NAME: &str = "e2e_test_circle"; | ||||
| const TEST_SERVER_PORT: u16 = 9876; // Choose a unique port for the test
 | ||||
| const RHAI_WORKER_BIN_NAME: &str = "rhai_worker"; | ||||
| const CIRCLE_SERVER_WS_BIN_NAME: &str = "circle_server_ws"; | ||||
| const RHAI_WORKER_BIN_NAME: &str = "worker"; | ||||
| const CIRCLE_SERVER_WS_BIN_NAME: &str = "server_ws"; | ||||
| 
 | ||||
| // RAII guard for cleaning up child processes
 | ||||
| struct ChildProcessGuard { | ||||
| @@ -48,20 +48,9 @@ impl Drop for ChildProcessGuard { | ||||
| } | ||||
| 
 | ||||
| fn find_target_dir() -> Result<PathBuf, String> { | ||||
|     // Try to find the cargo target directory relative to current exe or manifest
 | ||||
|     let mut current_exe = std::env::current_exe().map_err(|e| format!("Failed to get current exe path: {}", e))?; | ||||
|     // current_exe is target/debug/examples/e2e_rhai_flow
 | ||||
|     // want target/debug/
 | ||||
|     if current_exe.ends_with("examples/e2e_rhai_flow") { // Adjust if example name changes
 | ||||
|         current_exe.pop(); // remove e2e_rhai_flow
 | ||||
|         current_exe.pop(); // remove examples
 | ||||
|         Ok(current_exe) | ||||
|     } else { | ||||
|          // Fallback: Assume 'target/debug' relative to workspace root if CARGO_MANIFEST_DIR is set
 | ||||
|         let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|         let workspace_root = PathBuf::from(manifest_dir).parent().ok_or("Failed to get workspace root")?.to_path_buf(); | ||||
|         Ok(workspace_root.join("target").join("debug")) | ||||
|     } | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir).parent().ok_or("Failed to get workspace root")?.to_path_buf(); | ||||
|     Ok(workspace_root.join("target").join("debug")) | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| @@ -108,7 +97,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     let ws_url_str = format!("ws://127.0.0.1:{}/ws", TEST_SERVER_PORT); | ||||
|     
 | ||||
|     log::info!("Creating CircleWsClient for {}...", ws_url_str); | ||||
|     let mut client = CircleWsClient::new(ws_url_str.clone()); | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url_str.clone()).build(); | ||||
|     
 | ||||
|     log::info!("Connecting CircleWsClient..."); | ||||
|     client.connect().await.map_err(|e| { | ||||
| @@ -6,7 +6,7 @@ | ||||
| // This example will attempt to start its own instance of circle_server_ws.
 | ||||
| // Ensure circle_server_ws is compiled (cargo build --bin circle_server_ws).
 | ||||
| 
 | ||||
| use circle_client_ws::CircleWsClient; | ||||
| use circle_client_ws::CircleWsClientBuilder; | ||||
| use tokio::time::{sleep, Duration}; | ||||
| use std::process::{Command, Child, Stdio}; | ||||
| use std::path::PathBuf; | ||||
| @@ -14,7 +14,7 @@ use std::path::PathBuf; | ||||
| const EXAMPLE_SERVER_PORT: u16 = 8089; // Using a specific port for this example
 | ||||
| const WS_URL: &str = "ws://127.0.0.1:8089/ws"; | ||||
| const CIRCLE_NAME_FOR_EXAMPLE: &str = "timeout_example_circle"; | ||||
| const CIRCLE_SERVER_WS_BIN_NAME: &str = "circle_server_ws"; | ||||
| const CIRCLE_SERVER_WS_BIN_NAME: &str = "server_ws"; | ||||
| const SCRIPT_TIMEOUT_SECONDS: u64 = 30; // This is the server-side timeout we expect to hit
 | ||||
| 
 | ||||
| // RAII guard for cleaning up child processes
 | ||||
| @@ -47,28 +47,10 @@ impl Drop for ChildProcessGuard { | ||||
| } | ||||
| 
 | ||||
| fn find_target_bin_path(bin_name: &str) -> Result<PathBuf, String> { | ||||
|     let mut current_exe = std::env::current_exe().map_err(|e| format!("Failed to get current exe path: {}", e))?; | ||||
|     // current_exe is typically target/debug/examples/timeout_demonstration
 | ||||
|     // We want to find target/debug/[bin_name]
 | ||||
|     current_exe.pop(); // remove executable name
 | ||||
|     current_exe.pop(); // remove examples directory
 | ||||
|     let target_debug_dir = current_exe; | ||||
|     let bin_path = target_debug_dir.join(bin_name); | ||||
|     let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| "CARGO_MANIFEST_DIR not set".to_string())?; | ||||
|     let workspace_root = PathBuf::from(manifest_dir).parent().ok_or("Failed to get workspace root")?.to_path_buf(); | ||||
|     let bin_path = workspace_root.join("target").join("debug").join(bin_name); | ||||
|     if !bin_path.exists() { | ||||
|         // Fallback: try CARGO_BIN_EXE_[bin_name] if running via `cargo run --example` which sets these
 | ||||
|         if let Ok(cargo_bin_path_str) = std::env::var(format!("CARGO_BIN_EXE_{}", bin_name.to_uppercase())) { | ||||
|             let cargo_bin_path = PathBuf::from(cargo_bin_path_str); | ||||
|             if cargo_bin_path.exists() { | ||||
|                 return Ok(cargo_bin_path); | ||||
|             } | ||||
|         } | ||||
|         // Fallback: try target/debug/[bin_name] relative to CARGO_MANIFEST_DIR (crate root)
 | ||||
|         if let Ok(manifest_dir_str) = std::env::var("CARGO_MANIFEST_DIR") { | ||||
|             let bin_path_rel_manifest = PathBuf::from(manifest_dir_str).join("target").join("debug").join(bin_name); | ||||
|             if bin_path_rel_manifest.exists() { | ||||
|                 return Ok(bin_path_rel_manifest); | ||||
|             } | ||||
|         } | ||||
|         return Err(format!("Binary '{}' not found at {:?}. Ensure it's built.", bin_name, bin_path)); | ||||
|     } | ||||
|     Ok(bin_path) | ||||
| @@ -98,7 +80,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     sleep(Duration::from_secs(3)).await; // Wait for server to initialize
 | ||||
| 
 | ||||
|     log::info!("Attempting to connect to WebSocket server at: {}", WS_URL); | ||||
|     let mut client = CircleWsClient::new(WS_URL.to_string()); | ||||
|     let mut client = CircleWsClientBuilder::new(WS_URL.to_string()).build(); | ||||
| 
 | ||||
|     log::info!("Connecting client..."); | ||||
|     if let Err(e) = client.connect().await { | ||||
| @@ -110,16 +92,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
| 
 | ||||
|     // This Rhai script is designed to run for much longer than the typical server timeout.
 | ||||
|     let long_running_script = " | ||||
|         log(\"Rhai: Starting long-running script...\");
 | ||||
|         let mut x = 0; | ||||
|         for i in 0..9999999999 { // Extremely large loop
 | ||||
|             x = x + i; | ||||
|             if i % 100000000 == 0 { | ||||
|                 // log(\"Rhai: Loop iteration \" + i);
 | ||||
|             } | ||||
|         } | ||||
|         // This part should not be reached if timeout works correctly.
 | ||||
|         log(\"Rhai: Long-running script finished calculation (x = \" + x + \").\");
 | ||||
|         print(x); | ||||
|         x | ||||
|     ".to_string();
 | ||||
| @@ -135,7 +112,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|             log::info!("Received expected error from play request: {}", e); | ||||
|             log::info!("This demonstrates the server timing out the script execution."); | ||||
|             // You can further inspect the error details if CircleWsClientError provides them.
 | ||||
|             // For example, if e.to_string() contains 'code: -32002' or 'timed out'.
 | ||||
|             // For example, if e.to_string().contains('code: -32002' or 'timed out'.
 | ||||
|             if e.to_string().contains("timed out") || e.to_string().contains("-32002") { | ||||
|                 log::info!("Successfully received timeout error from the server!"); | ||||
|             } else { | ||||
| @@ -150,4 +127,4 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     log::info!("Timeout demonstration example finished."); | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| } | ||||
| @@ -1,17 +0,0 @@ | ||||
| [package] | ||||
| name = "rhai_repl_cli" | ||||
| version = "0.1.0" | ||||
| edition = "2024" # Keep 2024 unless issues arise | ||||
|  | ||||
| [dependencies] | ||||
| tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } # Added "time" for potential timeouts | ||||
| tokio-tungstenite = { version = "0.21", features = ["native-tls"] } # May be removed if client_ws handles all | ||||
| futures-util = "0.3" | ||||
| url = "2" | ||||
| tracing = "0.1" # For logging | ||||
| tracing-subscriber = { version = "0.3", features = ["env-filter"] } | ||||
| log = "0.4" # circle_client_ws uses log crate | ||||
| rustyline = { version = "13.0.0", features = ["derive"] } # For enhanced REPL input | ||||
| tempfile = "3.8" # For creating temporary files for editing | ||||
|  | ||||
| circle_client_ws = { path = "../client_ws" } | ||||
| @@ -1,32 +0,0 @@ | ||||
| [package] | ||||
| name = "circle_ws_lib" # Renamed to reflect library nature | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [lib] | ||||
| name = "circle_ws_lib" | ||||
| path = "src/lib.rs" | ||||
|  | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
|  | ||||
| [dependencies] | ||||
| actix-web = "4" | ||||
| actix-web-actors = "4" | ||||
| actix = "0.13" | ||||
| env_logger = "0.10" # Keep for logging within the lib | ||||
| log = "0.4" | ||||
| # clap is removed as CLI parsing moves to the orchestrator bin | ||||
| serde = { version = "1.0", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| redis = { version = "0.25.0", features = ["tokio-comp"] } # For async Redis with Actix | ||||
| uuid = { version = "1.6", features = ["v4", "serde"] } # Still used by RhaiClient or for task details | ||||
| tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } # Added "time" for Duration | ||||
| chrono = { version = "0.4", features = ["serde"] } # For timestamps | ||||
| rhai_client = { path = "../../rhailib/src/client" } # Corrected relative path | ||||
|  | ||||
| [dev-dependencies] | ||||
| tokio-tungstenite = { version = "0.23.0", features = ["native-tls"] } | ||||
| futures-util = "0.3" # For StreamExt and SinkExt on WebSocket stream | ||||
| url = "2.5.0" # For parsing WebSocket URL | ||||
| # circle_client_ws = { path = "../client_ws" } # This might need adjustment if it's a test client for the old binary | ||||
| # uuid = { version = "1.6", features = ["v4", "serde"] } # Already in dependencies | ||||
| @@ -1,52 +0,0 @@ | ||||
| # Circle Server WebSocket (`server_ws`) | ||||
|  | ||||
| ## Overview | ||||
|  | ||||
| The `server_ws` component is an Actix-based WebSocket server designed to handle client connections and execute Rhai scripts. It acts as a bridge between WebSocket clients and a Rhai scripting engine, facilitating remote script execution and result retrieval. | ||||
|  | ||||
| ## Key Features | ||||
|  | ||||
| *   **WebSocket Communication:** Establishes and manages WebSocket connections with clients. | ||||
| *   **Rhai Script Execution:** Receives Rhai scripts from clients, submits them for execution via `rhai_client`, and returns the results. | ||||
| *   **Timeout Management:** Implements timeouts for Rhai script execution to prevent indefinite blocking, returning specific error codes on timeout. | ||||
| *   **Asynchronous Processing:** Leverages Actix actors for concurrent handling of multiple client connections and script executions. | ||||
|  | ||||
| ## Core Components | ||||
|  | ||||
| *   **`CircleWs` Actor:** The primary Actix actor responsible for handling individual WebSocket sessions. It manages the lifecycle of a client connection, processes incoming messages (Rhai scripts), and sends back results or errors. | ||||
| *   **`rhai_client` Integration:** Utilizes the `rhai_client` crate to submit scripts to a shared Rhai processing service (likely Redis-backed for task queuing and result storage) and await their completion. | ||||
|  | ||||
| ## Dependencies | ||||
|  | ||||
| *   `actix`: Actor framework for building concurrent applications. | ||||
| *   `actix-web-actors`: WebSocket support for Actix. | ||||
| *   `rhai_client`: Client library for interacting with the Rhai scripting service. | ||||
| *   `serde_json`: For serializing and deserializing JSON messages exchanged over WebSockets. | ||||
| *   `uuid`: For generating unique task identifiers. | ||||
|  | ||||
| ## Workflow | ||||
|  | ||||
| 1.  A client establishes a WebSocket connection to the `/ws/` endpoint. | ||||
| 2.  The server upgrades the connection and spawns a `CircleWs` actor instance for that session. | ||||
| 3.  The client sends a JSON-RPC formatted message containing the Rhai script to be executed. | ||||
| 4.  The `CircleWs` actor parses the message and uses `rhai_client::RhaiClient::submit_script_and_await_result` to send the script for execution. This method handles the interaction with the underlying task queue (e.g., Redis) and waits for the script's outcome. | ||||
| 5.  The `rhai_client` will return the script's result or an error (e.g., timeout, script error). | ||||
| 6.  `CircleWs` formats the result/error into a JSON-RPC response and sends it back to the client over the WebSocket. | ||||
|  | ||||
| ## Configuration | ||||
|  | ||||
| *   **`REDIS_URL`**: The `rhai_client` component (and thus `server_ws` indirectly) relies on a Redis instance. The connection URL for this Redis instance is typically configured via an environment variable or a constant that `rhai_client` uses. | ||||
| *   **Timeout Durations**: | ||||
|     *   `TASK_TIMEOUT_DURATION` (e.g., 30 seconds): The maximum time the server will wait for a Rhai script to complete execution. | ||||
|     *   `TASK_POLL_INTERVAL_DURATION` (e.g., 200 milliseconds): The interval at which the `rhai_client` polls for task completion (this is an internal detail of `rhai_client` but relevant to understanding its behavior). | ||||
|  | ||||
| ## Error Handling | ||||
|  | ||||
| The server implements specific JSON-RPC error responses for various scenarios: | ||||
| *   **Script Execution Timeout:** If a script exceeds `TASK_TIMEOUT_DURATION`, a specific error (e.g., code -32002) is returned. | ||||
| *   **Other `RhaiClientError`s:** Other errors originating from `rhai_client` (e.g., issues with the Redis connection, script compilation errors detected by the remote Rhai engine) are also translated into appropriate JSON-RPC error responses. | ||||
| *   **Message Parsing Errors:** Invalid incoming messages will result in error responses. | ||||
|  | ||||
| ## How to Run | ||||
|  | ||||
| (Instructions on how to build and run the server would typically go here, e.g., `cargo run --bin circle_server_ws`) | ||||
| @@ -1,302 +0,0 @@ | ||||
| use actix_web::{web, App, HttpRequest, HttpServer, HttpResponse, Error}; | ||||
| use actix_web_actors::ws; | ||||
| use actix::{Actor, ActorContext, StreamHandler, AsyncContext, WrapFuture, ActorFutureExt}; | ||||
| // clap::Parser removed | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::Value; | ||||
| use std::time::Duration; | ||||
| use rhai_client::RhaiClientError; | ||||
| use rhai_client::RhaiClient; | ||||
| use tokio::task::JoinHandle; | ||||
| use tokio::sync::oneshot; // For sending the server handle back | ||||
|  | ||||
| // Newtype wrappers for distinct app_data types | ||||
| #[derive(Clone)] | ||||
| struct AppCircleName(String); | ||||
|  | ||||
| #[derive(Clone)] | ||||
| struct AppRedisUrl(String); | ||||
|  | ||||
| // JSON-RPC 2.0 Structures (remain the same) | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct JsonRpcRequest { | ||||
|     jsonrpc: String, | ||||
|     method: String, | ||||
|     params: Value, | ||||
|     id: Option<Value>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct JsonRpcResponse { | ||||
|     jsonrpc: String, | ||||
|     result: Option<Value>, | ||||
|     error: Option<JsonRpcError>, | ||||
|     id: Value, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct JsonRpcError { | ||||
|     code: i32, | ||||
|     message: String, | ||||
|     data: Option<Value>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct PlayParams { | ||||
|     script: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] | ||||
| struct PlayResult { | ||||
|     output: String, | ||||
| } | ||||
|  | ||||
| // WebSocket Actor | ||||
| struct CircleWs { | ||||
|     server_circle_name: String, | ||||
|     redis_url_for_client: String, | ||||
| } | ||||
|  | ||||
| const TASK_TIMEOUT_DURATION: Duration = Duration::from_secs(30); | ||||
| const TASK_POLL_INTERVAL_DURATION: Duration = Duration::from_millis(200); | ||||
|  | ||||
| impl CircleWs { | ||||
|     fn new(name: String, redis_url: String) -> Self { | ||||
|         Self { | ||||
|             server_circle_name: name, | ||||
|             redis_url_for_client: redis_url, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Actor for CircleWs { | ||||
|     type Context = ws::WebsocketContext<Self>; | ||||
|  | ||||
|     fn started(&mut self, _ctx: &mut Self::Context) { | ||||
|         log::info!("WebSocket session started for server dedicated to: {}", self.server_circle_name); | ||||
|     } | ||||
|  | ||||
|     fn stopping(&mut self, _ctx: &mut Self::Context) -> actix::Running { | ||||
|         log::info!("WebSocket session stopping for server dedicated to: {}", self.server_circle_name); | ||||
|         actix::Running::Stop | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for CircleWs { | ||||
|     fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) { | ||||
|         match msg { | ||||
|             Ok(ws::Message::Text(text)) => { | ||||
|                 log::debug!("WS Text for {}: {}", self.server_circle_name, text); // Changed to debug for less noise | ||||
|                 match serde_json::from_str::<JsonRpcRequest>(&text) { | ||||
|                     Ok(req) => { | ||||
|                         let client_rpc_id = req.id.clone().unwrap_or(Value::Null); | ||||
|                         if req.method == "play" { | ||||
|                             match serde_json::from_value::<PlayParams>(req.params.clone()) { | ||||
|                                 Ok(play_params) => { | ||||
|                                     let script_content = play_params.script; | ||||
|                                     // Use the server_circle_name which should be correctly set now | ||||
|                                     let current_circle_name_for_rhai_client = self.server_circle_name.clone(); | ||||
|                                     let rpc_id_for_client = client_rpc_id.clone(); | ||||
|                                     let redis_url_clone = self.redis_url_for_client.clone(); | ||||
|  | ||||
|                                     log::info!("Circle '{}' WS: Received 'play' request, ID: {:?}, for RhaiClient target circle: '{}'", self.server_circle_name, rpc_id_for_client, current_circle_name_for_rhai_client); | ||||
|  | ||||
|                                     let fut = async move { | ||||
|                                         match RhaiClient::new(&redis_url_clone) { | ||||
|                                             Ok(rhai_task_client) => { | ||||
|                                                 rhai_task_client.submit_script_and_await_result( | ||||
|                                                     ¤t_circle_name_for_rhai_client, // This name is used for Redis queue | ||||
|                                                     script_content, | ||||
|                                                     Some(rpc_id_for_client.clone()), | ||||
|                                                     TASK_TIMEOUT_DURATION, | ||||
|                                                     TASK_POLL_INTERVAL_DURATION, | ||||
|                                                 ).await | ||||
|                                             } | ||||
|                                             Err(e) => { | ||||
|                                                 log::error!("Circle '{}' WS: Failed to create RhaiClient for Redis URL {}: {}", current_circle_name_for_rhai_client, redis_url_clone, e); | ||||
|                                                 Err(e) | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }; | ||||
|  | ||||
|                                     ctx.spawn(fut.into_actor(self).map(move |result, _act, ws_ctx| { | ||||
|                                         let response = match result { | ||||
|                                             Ok(task_details) => { | ||||
|                                                 if task_details.status == "completed" { | ||||
|                                                     // task_details itself doesn't have a task_id field. | ||||
|                                                     // The task_id is known by the client that initiated the poll. | ||||
|                                                     // We log with client_rpc_id which is the JSON-RPC request ID. | ||||
|                                                     log::info!("Circle '{}' WS: Request ID {:?} completed successfully. Output: {:?}", _act.server_circle_name, client_rpc_id, task_details.output); | ||||
|                                                     JsonRpcResponse { | ||||
|                                                         jsonrpc: "2.0".to_string(), | ||||
|                                                         result: Some(serde_json::to_value(PlayResult { | ||||
|                                                             output: task_details.output.unwrap_or_default() | ||||
|                                                         }).unwrap()), | ||||
|                                                         error: None, | ||||
|                                                         id: client_rpc_id, | ||||
|                                                     } | ||||
|                                                 } else { // status == "error" | ||||
|                                                     log::warn!("Circle '{}' WS: Request ID {:?} execution failed. Error: {:?}", _act.server_circle_name, client_rpc_id, task_details.error); | ||||
|                                                     JsonRpcResponse { | ||||
|                                                         jsonrpc: "2.0".to_string(), | ||||
|                                                         result: None, | ||||
|                                                         error: Some(JsonRpcError { | ||||
|                                                             code: -32004, | ||||
|                                                             message: task_details.error.unwrap_or_else(|| "Script execution failed".to_string()), | ||||
|                                                             data: None, | ||||
|                                                         }), | ||||
|                                                         id: client_rpc_id, | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                             Err(rhai_err) => { | ||||
|                                                 log::error!("Circle '{}' WS: RhaiClient operation failed for req ID {:?}: {}", _act.server_circle_name, client_rpc_id, rhai_err); | ||||
|                                                 let (code, message) = match rhai_err { | ||||
|                                                     RhaiClientError::Timeout(task_id) => (-32002, format!("Timeout: {}", task_id)), | ||||
|                                                     RhaiClientError::RedisError(e) => (-32003, format!("Redis error: {}", e)), | ||||
|                                                     RhaiClientError::SerializationError(e) => (-32003, format!("Serialization error: {}", e)), | ||||
|                                                     RhaiClientError::TaskNotFound(task_id) => (-32005, format!("Task not found: {}", task_id)), | ||||
|                                                 }; | ||||
|                                                 JsonRpcResponse { | ||||
|                                                     jsonrpc: "2.0".to_string(), | ||||
|                                                     result: None, | ||||
|                                                     id: client_rpc_id, | ||||
|                                                     error: Some(JsonRpcError { code, message, data: None }), | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         }; | ||||
|                                         ws_ctx.text(serde_json::to_string(&response).unwrap()); | ||||
|                                     })); | ||||
|                                 } | ||||
|                                 Err(e) => { | ||||
|                                     log::error!("Circle '{}' WS: Invalid params for 'play' method: {}", self.server_circle_name, e); | ||||
|                                     let err_resp = JsonRpcResponse { | ||||
|                                         jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id, | ||||
|                                         error: Some(JsonRpcError { code: -32602, message: "Invalid params".to_string(), data: Some(Value::String(e.to_string())) }), | ||||
|                                     }; | ||||
|                                     ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                                 } | ||||
|                             } | ||||
|                         } else { | ||||
|                             log::warn!("Circle '{}' WS: Method not found: {}", self.server_circle_name, req.method); | ||||
|                              let err_resp = JsonRpcResponse { | ||||
|                                 jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id, | ||||
|                                 error: Some(JsonRpcError { code: -32601, message: "Method not found".to_string(), data: None }), | ||||
|                             }; | ||||
|                             ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                         } | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         log::error!("Circle '{}' WS: Failed to parse JSON-RPC request: {}", self.server_circle_name, e); | ||||
|                         let err_resp = JsonRpcResponse { | ||||
|                             jsonrpc: "2.0".to_string(), result: None, id: Value::Null, | ||||
|                             error: Some(JsonRpcError { code: -32700, message: "Parse error".to_string(), data: Some(Value::String(e.to_string())) }), | ||||
|                         }; | ||||
|                         ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             Ok(ws::Message::Ping(msg)) => ctx.pong(&msg), | ||||
|             Ok(ws::Message::Pong(_)) => {}, | ||||
|             Ok(ws::Message::Binary(_bin)) => log::warn!("Circle '{}' WS: Binary messages not supported.", self.server_circle_name), | ||||
|             Ok(ws::Message::Close(reason)) => { | ||||
|                 log::info!("Circle '{}' WS: Close message received. Reason: {:?}", self.server_circle_name, reason); | ||||
|                 ctx.close(reason); | ||||
|                 ctx.stop(); | ||||
|             } | ||||
|             Ok(ws::Message::Continuation(_)) => ctx.stop(), | ||||
|             Ok(ws::Message::Nop) => (), | ||||
|             Err(e) => { | ||||
|                 log::error!("Circle '{}' WS: Error: {:?}", self.server_circle_name, e); | ||||
|                 ctx.stop(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Modified ws_handler to accept newtype wrapped app_data | ||||
| async fn ws_handler_modified( | ||||
|     req: HttpRequest, | ||||
|     stream: web::Payload, | ||||
|     app_circle_name: web::Data<AppCircleName>, // Use wrapped type | ||||
|     app_redis_url: web::Data<AppRedisUrl>,     // Use wrapped type | ||||
| ) -> Result<HttpResponse, Error> { | ||||
|     let circle_name_str = app_circle_name.0.clone(); | ||||
|     let redis_url_str = app_redis_url.0.clone(); | ||||
|  | ||||
|     log::info!("WebSocket handshake attempt for server: '{}' with redis: '{}'", circle_name_str, redis_url_str); | ||||
|     let resp = ws::start( | ||||
|         CircleWs::new(circle_name_str, redis_url_str), // Pass unwrapped strings | ||||
|         &req, | ||||
|         stream | ||||
|     )?; | ||||
|     Ok(resp) | ||||
| } | ||||
|  | ||||
| // Public factory function to spawn the server | ||||
| pub fn spawn_circle_ws_server( | ||||
|     _circle_id: u32, | ||||
|     circle_name: String, | ||||
|     port: u16, | ||||
|     redis_url: String, | ||||
|     // Sender to send the server handle back to the orchestrator | ||||
|     server_handle_tx: oneshot::Sender<actix_web::dev::Server>, | ||||
| ) -> JoinHandle<std::io::Result<()>> { | ||||
|     let circle_name_for_log = circle_name.clone(); | ||||
|     // redis_url_for_log is not used, but kept for consistency if needed later | ||||
|  | ||||
|     tokio::spawn(async move { | ||||
|         let circle_name_outer = circle_name; | ||||
|         let redis_url_outer = redis_url; | ||||
|  | ||||
|         let app_factory = move || { | ||||
|             App::new() | ||||
|                 .app_data(web::Data::new(AppCircleName(circle_name_outer.clone()))) | ||||
|                 .app_data(web::Data::new(AppRedisUrl(redis_url_outer.clone()))) | ||||
|                 .route("/ws", web::get().to(ws_handler_modified)) | ||||
|                 .default_service(web::route().to(|| async { HttpResponse::NotFound().body("404 Not Found") })) | ||||
|         }; | ||||
|  | ||||
|         let server_builder = HttpServer::new(app_factory); | ||||
|  | ||||
|         let bound_server = match server_builder.bind(("127.0.0.1", port)) { | ||||
|             Ok(srv) => { | ||||
|                 log::info!( | ||||
|                     "Successfully bound WebSocket server for Circle: '{}' on port {}. Starting...", | ||||
|                     circle_name_for_log, port | ||||
|                 ); | ||||
|                 srv | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 log::error!( | ||||
|                     "Failed to bind WebSocket server for Circle '{}' on port {}: {}", | ||||
|                     circle_name_for_log, port, e | ||||
|                 ); | ||||
|                 // If binding fails, we can't send a server handle. | ||||
|                 // The orchestrator will see the JoinHandle error out or the oneshot::Sender drop. | ||||
|                 return Err(e); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         let server_runnable: actix_web::dev::Server = bound_server.run(); | ||||
|          | ||||
|         // Send the server handle back to the orchestrator | ||||
|         if server_handle_tx.send(server_runnable.clone()).is_err() { | ||||
|             log::error!( | ||||
|                 "Failed to send server handle back to orchestrator for Circle '{}'. Orchestrator might have shut down.", | ||||
|                 circle_name_for_log | ||||
|             ); | ||||
|             // Server might still run, but orchestrator can't stop it gracefully via this handle. | ||||
|             // Consider stopping it here if sending the handle is critical. | ||||
|             // For now, let it proceed, but log the error. | ||||
|         } | ||||
|          | ||||
|         // Now await the server_runnable (which is the Server handle itself) | ||||
|         if let Err(e) = server_runnable.await { | ||||
|             log::error!("WebSocket server for Circle '{}' on port {} failed during run: {}", circle_name_for_log, port, e); | ||||
|             return Err(e); | ||||
|         } | ||||
|         log::info!("WebSocket server for Circle '{}' on port {} shut down gracefully.", circle_name_for_log, port); | ||||
|         Ok(()) | ||||
|     }) | ||||
| } | ||||
| @@ -1,260 +0,0 @@ | ||||
| use actix_web::{web, App, HttpRequest, HttpServer, HttpResponse, Error}; | ||||
| use actix_web_actors::ws; | ||||
| use actix::{Actor, ActorContext, StreamHandler, AsyncContext, WrapFuture, ActorFutureExt}; | ||||
| // HashMap no longer needed | ||||
| use clap::Parser; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use serde_json::Value; | ||||
| use std::time::Duration; | ||||
| // AsyncCommands no longer directly used here | ||||
| use rhai_client::RhaiClientError; // Import RhaiClientError for matching | ||||
| // Uuid is not directly used here anymore for task_id generation, RhaiClient handles it. | ||||
| // Utc no longer directly used here | ||||
| // RhaiClientError is not directly handled here, errors from RhaiClient are strings or RhaiClient's own error type. | ||||
| use rhai_client::RhaiClient; // ClientRhaiTaskDetails is used via rhai_client::RhaiTaskDetails | ||||
|  | ||||
| const REDIS_URL: &str = "redis://127.0.0.1/"; // Make this configurable if needed | ||||
|  | ||||
| // JSON-RPC 2.0 Structures | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] // Added Clone | ||||
| struct JsonRpcRequest { | ||||
|     jsonrpc: String, | ||||
|     method: String, | ||||
|     params: Value, | ||||
|     id: Option<Value>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] // Added Clone | ||||
| struct JsonRpcResponse { | ||||
|     jsonrpc: String, | ||||
|     result: Option<Value>, | ||||
|     error: Option<JsonRpcError>, | ||||
|     id: Value, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] // Added Clone | ||||
| struct JsonRpcError { | ||||
|     code: i32, | ||||
|     message: String, | ||||
|     data: Option<Value>, | ||||
| } | ||||
|  | ||||
| // Specific params and result for "play" method | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] // Added Clone | ||||
| struct PlayParams { | ||||
|     script: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Debug, Clone)] // Added Clone | ||||
| struct PlayResult { | ||||
|     output: String, | ||||
| } | ||||
|  | ||||
| // Local RhaiTaskDetails struct is removed, will use ClientRhaiTaskDetails from rhai_client crate. | ||||
| // Ensure field names used in polling logic (e.g. error_message) are updated if they differ. | ||||
| // rhai_client::RhaiTaskDetails uses 'error' and 'client_rpc_id'. | ||||
|  | ||||
| #[derive(Parser, Debug)] | ||||
| #[clap(author, version, about, long_about = None)] | ||||
| struct Args { | ||||
|     #[clap(short, long, value_parser, default_value_t = 8080)] | ||||
|     port: u16, | ||||
|  | ||||
|     #[clap(short, long, value_parser)] | ||||
|     circle_name: String, | ||||
| } | ||||
|  | ||||
| // WebSocket Actor | ||||
| struct CircleWs { | ||||
|     server_circle_name: String, | ||||
|     // redis_client field removed as RhaiClient handles its own connection | ||||
| } | ||||
|  | ||||
| const TASK_TIMEOUT_DURATION: Duration = Duration::from_secs(30); // 30 seconds timeout | ||||
| const TASK_POLL_INTERVAL_DURATION: Duration = Duration::from_millis(200); // 200 ms poll interval | ||||
|  | ||||
| impl CircleWs { | ||||
|     fn new(name: String) -> Self { | ||||
|         Self { | ||||
|             server_circle_name: name, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Actor for CircleWs { | ||||
|     type Context = ws::WebsocketContext<Self>; | ||||
|  | ||||
|     fn started(&mut self, _ctx: &mut Self::Context) { | ||||
|         log::info!("WebSocket session started for server dedicated to: {}", self.server_circle_name); | ||||
|     } | ||||
|  | ||||
|     fn stopping(&mut self, _ctx: &mut Self::Context) -> actix::Running { | ||||
|         log::info!("WebSocket session stopping for server dedicated to: {}", self.server_circle_name); | ||||
|         actix::Running::Stop | ||||
|     } | ||||
| } | ||||
|  | ||||
| // WebSocket message handler | ||||
| impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for CircleWs { | ||||
|     fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) { | ||||
|         match msg { | ||||
|             Ok(ws::Message::Text(text)) => { | ||||
|                 log::info!("WS Text for {}: {}", self.server_circle_name, text); | ||||
|                 match serde_json::from_str::<JsonRpcRequest>(&text) { | ||||
|                     Ok(req) => { | ||||
|                         let client_rpc_id = req.id.clone().unwrap_or(Value::Null); | ||||
|                         if req.method == "play" { | ||||
|                             match serde_json::from_value::<PlayParams>(req.params.clone()) { | ||||
|                                 Ok(play_params) => { | ||||
|                                     // Use RhaiClient to submit the script | ||||
|                                     let script_content = play_params.script; | ||||
|                                     let current_circle_name = self.server_circle_name.clone(); | ||||
|                                     let rpc_id_for_client = client_rpc_id.clone(); // client_rpc_id is already Value | ||||
|  | ||||
|                                     let fut = async move { | ||||
|                                         match RhaiClient::new(REDIS_URL) { | ||||
|                                             Ok(rhai_task_client) => { | ||||
|                                                 rhai_task_client.submit_script_and_await_result( | ||||
|                                                     ¤t_circle_name, | ||||
|                                                     script_content, | ||||
|                                                     Some(rpc_id_for_client.clone()), | ||||
|                                                     TASK_TIMEOUT_DURATION, | ||||
|                                                     TASK_POLL_INTERVAL_DURATION, | ||||
|                                                 ).await // This returns Result<rhai_client::RhaiTaskDetails, RhaiClientError> | ||||
|                                             } | ||||
|                                             Err(e) => { | ||||
|                                                 log::error!("Failed to create RhaiClient: {}", e); | ||||
|                                                 Err(e) // Convert the error from RhaiClient::new into the type expected by the map function's error path. | ||||
|                                             } | ||||
|                                         } | ||||
|                                     }; | ||||
|  | ||||
|                                     ctx.spawn(fut.into_actor(self).map(move |result, _act, ws_ctx| { | ||||
|                                         let response = match result { | ||||
|                                             Ok(task_details) => { // ClientRhaiTaskDetails | ||||
|                                                 if task_details.status == "completed" { | ||||
|                                                     log::info!("Task completed successfully. Client RPC ID: {:?}, Output: {:?}", task_details.client_rpc_id, task_details.output); | ||||
|                                                     JsonRpcResponse { | ||||
|                                                         jsonrpc: "2.0".to_string(), | ||||
|                                                         result: Some(serde_json::to_value(PlayResult {  | ||||
|                                                             output: task_details.output.unwrap_or_default()  | ||||
|                                                         }).unwrap()), | ||||
|                                                         error: None, | ||||
|                                                         id: client_rpc_id, // Use the original client_rpc_id from the request | ||||
|                                                     } | ||||
|                                                 } else { // status == "error" | ||||
|                                                     log::warn!("Task execution failed. Client RPC ID: {:?}, Error: {:?}", task_details.client_rpc_id, task_details.error); | ||||
|                                                     JsonRpcResponse { | ||||
|                                                         jsonrpc: "2.0".to_string(), | ||||
|                                                         result: None, | ||||
|                                                         error: Some(JsonRpcError { | ||||
|                                                             code: -32004, // Script execution error | ||||
|                                                             message: task_details.error.unwrap_or_else(|| "Script execution failed with no specific error message".to_string()), | ||||
|                                                             data: None, | ||||
|                                                         }), | ||||
|                                                         id: client_rpc_id, | ||||
|                                                     } | ||||
|                                                 } | ||||
|                                             } | ||||
|                                             Err(rhai_err) => { // RhaiClientError | ||||
|                                                 log::error!("RhaiClient operation failed: {}", rhai_err); | ||||
|                                                 let (code, message) = match rhai_err { | ||||
|                                                     RhaiClientError::Timeout(task_id) => (-32002, format!("Timeout waiting for task {} to complete", task_id)), | ||||
|                                                     RhaiClientError::RedisError(e) => (-32003, format!("Redis communication error: {}", e)), | ||||
|                                                     RhaiClientError::SerializationError(e) => (-32003, format!("Serialization error: {}", e)), | ||||
|                                                     RhaiClientError::TaskNotFound(task_id) => (-32005, format!("Task {} not found after submission", task_id)), | ||||
|                                                 }; | ||||
|                                                 JsonRpcResponse { | ||||
|                                                     jsonrpc: "2.0".to_string(), | ||||
|                                                     result: None, | ||||
|                                                     id: client_rpc_id, | ||||
|                                                     error: Some(JsonRpcError { code, message, data: None }), | ||||
|                                                 } | ||||
|                                             } | ||||
|                                         }; | ||||
|                                         ws_ctx.text(serde_json::to_string(&response).unwrap()); | ||||
|                                     })); | ||||
|                                 } | ||||
|                                 Err(e) => { // Invalid params for 'play' | ||||
|                                     log::error!("Invalid params for 'play' method: {}", e); | ||||
|                                     let err_resp = JsonRpcResponse { | ||||
|                                         jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id, | ||||
|                                         error: Some(JsonRpcError { code: -32602, message: "Invalid params".to_string(), data: Some(Value::String(e.to_string())) }), | ||||
|                                     }; | ||||
|                                     ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                                 } | ||||
|                             } | ||||
|                         } else { // Method not found | ||||
|                             log::warn!("Method not found: {}", req.method); | ||||
|                              let err_resp = JsonRpcResponse { | ||||
|                                 jsonrpc: "2.0".to_string(), result: None, id: client_rpc_id, | ||||
|                                 error: Some(JsonRpcError { code: -32601, message: "Method not found".to_string(), data: None }), | ||||
|                             }; | ||||
|                             ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                         } | ||||
|                     } | ||||
|                     Err(e) => { // Parse error | ||||
|                         log::error!("Failed to parse JSON-RPC request: {}", e); | ||||
|                         let err_resp = JsonRpcResponse { | ||||
|                             jsonrpc: "2.0".to_string(), result: None, id: Value::Null, // No ID if request couldn't be parsed | ||||
|                             error: Some(JsonRpcError { code: -32700, message: "Parse error".to_string(), data: Some(Value::String(e.to_string())) }), | ||||
|                         }; | ||||
|                         ctx.text(serde_json::to_string(&err_resp).unwrap()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             Ok(ws::Message::Ping(msg)) => ctx.pong(&msg), | ||||
|             Ok(ws::Message::Pong(_)) => {}, | ||||
|             Ok(ws::Message::Binary(_bin)) => log::warn!("Binary messages not supported."), | ||||
|             Ok(ws::Message::Close(reason)) => { | ||||
|                 ctx.close(reason); | ||||
|                 ctx.stop(); | ||||
|             } | ||||
|             Ok(ws::Message::Continuation(_)) => ctx.stop(), | ||||
|             Ok(ws::Message::Nop) => (), | ||||
|             Err(e) => { | ||||
|                 log::error!("WS Error: {:?}", e); | ||||
|                 ctx.stop(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // WebSocket handshake and actor start | ||||
| async fn ws_handler( | ||||
|     req: HttpRequest, | ||||
|     stream: web::Payload, | ||||
|     server_name: web::Data<String>, | ||||
|     // redis_client: web::Data<redis::Client>, // No longer passed to CircleWs actor directly | ||||
| ) -> Result<HttpResponse, Error> { | ||||
|     log::info!("WebSocket handshake attempt for server: {}", server_name.get_ref()); | ||||
|     let resp = ws::start( | ||||
|         CircleWs::new(server_name.get_ref().clone()), // Pass only the server name | ||||
|         &req, | ||||
|         stream | ||||
|     )?; | ||||
|     Ok(resp) | ||||
| } | ||||
|  | ||||
| #[actix_web::main] | ||||
| async fn main() -> std::io::Result<()> { | ||||
|     let args = Args::parse(); | ||||
|      | ||||
|     std::env::set_var("RUST_LOG", "info,circle_server_ws=debug"); | ||||
|     env_logger::init(); | ||||
|      | ||||
|     log::info!( | ||||
|         "Starting WebSocket server for Circle: '{}' on port {}...", | ||||
|         args.circle_name, args.port | ||||
|     ); | ||||
|  | ||||
|     HttpServer::new(move || { | ||||
|         App::new() | ||||
|             .app_data(web::Data::new(args.circle_name.clone())) | ||||
|             .route("/ws", web::get().to(ws_handler)) | ||||
|             .default_service(web::route().to(|| async { HttpResponse::NotFound().body("404 Not Found - This is a WebSocket-only server for a specific circle.") })) | ||||
|     }) | ||||
|     .bind(("127.0.0.1", args.port))? | ||||
|     .run() | ||||
|     .await | ||||
| } | ||||
| @@ -1,135 +0,0 @@ | ||||
| use tokio::time::Duration; // Removed unused sleep | ||||
| use futures_util::{sink::SinkExt, stream::StreamExt}; | ||||
| use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; | ||||
| use serde_json::Value; // Removed unused json macro import | ||||
| use std::process::Command; | ||||
| use std::thread; | ||||
| use std::sync::Once; | ||||
|  | ||||
| // Define a simple JSON-RPC request structure for sending scripts | ||||
| #[derive(serde::Serialize, Debug)] | ||||
| struct JsonRpcRequest { | ||||
|     jsonrpc: String, | ||||
|     method: String, | ||||
|     params: ScriptParams, | ||||
|     id: u64, | ||||
| } | ||||
|  | ||||
| #[derive(serde::Serialize, Debug)] | ||||
| struct ScriptParams { | ||||
|     script: String, | ||||
| } | ||||
|  | ||||
| // Define a simple JSON-RPC error response structure for assertion | ||||
| #[derive(serde::Deserialize, Debug)] | ||||
| struct JsonRpcErrorResponse { | ||||
|     _jsonrpc: String, // Field is present in response, but not used in assert | ||||
|     error: JsonRpcErrorDetails, | ||||
|     _id: Option<Value>, // Field is present in response, but not used in assert | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize, Debug)] | ||||
| struct JsonRpcErrorDetails { | ||||
|     code: i32, | ||||
|     message: String, | ||||
| } | ||||
|  | ||||
| const SERVER_ADDRESS: &str = "ws://127.0.0.1:8088/ws"; // Match port in main.rs or make configurable | ||||
| const TEST_CIRCLE_NAME: &str = "test_timeout_circle"; | ||||
| const SERVER_STARTUP_TIME: Duration = Duration::from_secs(5); // Time to wait for server to start | ||||
| const RHAI_TIMEOUT_SECONDS: u64 = 30; // Should match TASK_TIMEOUT_DURATION in circle_server_ws | ||||
|  | ||||
| static START_SERVER: Once = Once::new(); | ||||
|  | ||||
| fn ensure_server_is_running() { | ||||
|     START_SERVER.call_once(|| { | ||||
|         println!("Attempting to start circle_server_ws for integration tests..."); | ||||
|         // The server executable will be in target/debug relative to the crate root | ||||
|         let server_executable = "target/debug/circle_server_ws";  | ||||
|          | ||||
|         thread::spawn(move || { | ||||
|             let mut child = Command::new(server_executable) | ||||
|                 .arg("--port=8088") // Use a specific port for testing | ||||
|                 .arg(format!("--circle-name={}", TEST_CIRCLE_NAME)) | ||||
|                 .spawn() | ||||
|                 .expect("Failed to start circle_server_ws. Make sure it's compiled (cargo build)."); | ||||
|              | ||||
|             let status = child.wait().expect("Failed to wait on server process."); | ||||
|             println!("Server process exited with status: {}", status); | ||||
|         }); | ||||
|         println!("Server start command issued. Waiting for {}s...", SERVER_STARTUP_TIME.as_secs()); | ||||
|         thread::sleep(SERVER_STARTUP_TIME); | ||||
|         println!("Presumed server started."); | ||||
|     }); | ||||
| } | ||||
|  | ||||
| #[tokio::test] | ||||
| async fn test_rhai_script_timeout() { | ||||
|     ensure_server_is_running(); | ||||
|  | ||||
|     println!("Connecting to WebSocket server: {}", SERVER_ADDRESS); | ||||
|     let (mut ws_stream, _response) = connect_async(SERVER_ADDRESS) | ||||
|         .await | ||||
|         .expect("Failed to connect to WebSocket server"); | ||||
|     println!("Connected to WebSocket server."); | ||||
|  | ||||
|     // Rhai script designed to run longer than RHAI_TIMEOUT_SECONDS | ||||
|     // A large loop should cause a timeout. | ||||
|     let long_running_script = format!(" | ||||
|         let mut x = 0; | ||||
|         for i in 0..999999999 {{ | ||||
|             x = x + i; | ||||
|             if i % 10000000 == 0 {{ | ||||
|                 // debug(\"Looping: \" + i); // Optional: for server-side logging if enabled | ||||
|             }} | ||||
|         }} | ||||
|         print(x); // This line will likely not be reached due to timeout | ||||
|     "); | ||||
|  | ||||
|     let request = JsonRpcRequest { | ||||
|         jsonrpc: "2.0".to_string(), | ||||
|         method: "execute_script".to_string(), | ||||
|         params: ScriptParams { script: long_running_script }, | ||||
|         id: 1, | ||||
|     }; | ||||
|  | ||||
|     let request_json = serde_json::to_string(&request).expect("Failed to serialize request"); | ||||
|     println!("Sending long-running script request: {}", request_json); | ||||
|     ws_stream.send(Message::Text(request_json)).await.expect("Failed to send message"); | ||||
|  | ||||
|     println!("Waiting for response (expecting timeout after ~{}s)..", RHAI_TIMEOUT_SECONDS); | ||||
|  | ||||
|     // Wait for a response, expecting a timeout error | ||||
|     // The server's timeout is RHAI_TIMEOUT_SECONDS, client should wait a bit longer. | ||||
|     match tokio::time::timeout(Duration::from_secs(RHAI_TIMEOUT_SECONDS + 15), ws_stream.next()).await { | ||||
|         Ok(Some(Ok(Message::Text(text)))) => { | ||||
|             println!("Received response: {}", text); | ||||
|             let response: Result<JsonRpcErrorResponse, _> = serde_json::from_str(&text); | ||||
|             match response { | ||||
|                 Ok(err_resp) => { | ||||
|                     assert_eq!(err_resp.error.code, -32002, "Error code should indicate timeout."); | ||||
|                     assert!(err_resp.error.message.contains("timed out"), "Error message should indicate timeout."); | ||||
|                     println!("Timeout test passed! Received correct timeout error."); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     panic!("Failed to deserialize error response: {}. Raw: {}", e, text); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Ok(Some(Ok(other_msg))) => { | ||||
|             panic!("Received unexpected message type: {:?}", other_msg); | ||||
|         } | ||||
|         Ok(Some(Err(e))) => { | ||||
|             panic!("WebSocket error: {}", e); | ||||
|         } | ||||
|         Ok(None) => { | ||||
|             panic!("WebSocket stream closed unexpectedly."); | ||||
|         } | ||||
|         Err(_) => { | ||||
|             panic!("Test timed out waiting for server response. Server might not have sent timeout error or took too long."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     ws_stream.close(None).await.ok(); | ||||
|     println!("Test finished, WebSocket closed."); | ||||
| } | ||||
							
								
								
									
										5
									
								
								src/app/.cargo/config.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/app/.cargo/config.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # This configuration is picked up by Cargo when building the `circles-app` crate. | ||||
| # It sets the required RUSTFLAGS to enable the JavaScript backend for the `getrandom` crate, | ||||
| # which is necessary for WebAssembly compilation. | ||||
| [target.wasm32-unknown-unknown] | ||||
| rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] | ||||
							
								
								
									
										2
									
								
								src/app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| /dist/ | ||||
| /target/ | ||||
							
								
								
									
										1387
									
								
								src/app/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1387
									
								
								src/app/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										45
									
								
								src/app/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| [package] | ||||
| name = "circles-app" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
|  | ||||
| [lib] | ||||
| crate-type = ["cdylib"] | ||||
|  | ||||
| [dependencies] | ||||
| heromodels = { path = "/Users/timurgordon/code/git.ourworld.tf/herocode/db/heromodels" } | ||||
| circle_client_ws = { path = "../client_ws" } | ||||
|  | ||||
| futures = "0.3" | ||||
| yew-router = "0.18" | ||||
| yew = { version = "0.21", features = ["csr"] } | ||||
| wasm-bindgen = "0.2" | ||||
| 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"] } | ||||
| 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 | ||||
| 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 | ||||
| common_models = { path = "/Users/timurgordon/code/playground/yew/common_models" } | ||||
| engine = { path = "/Users/timurgordon/code/git.ourworld.tf/herocode/rhailib/src/engine" } | ||||
| rhai = "1.17" | ||||
| js-sys = "0.3" | ||||
| getrandom = { version = "0.3", features = ["wasm_js"] } | ||||
|  | ||||
| # Authentication dependencies | ||||
| secp256k1 = { workspace = true, features = ["rand", "recovery", "hashes"] } | ||||
| hex = "0.4" | ||||
| sha3 = "0.10" | ||||
| gloo-storage = "0.3" | ||||
| urlencoding = "2.1" | ||||
| thiserror = "1.0" | ||||
|  | ||||
| [dev-dependencies] | ||||
| wasm-bindgen-test = "0.3" | ||||
							
								
								
									
										177
									
								
								src/app/LICENSE-APACHE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								src/app/LICENSE-APACHE
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
|  | ||||
|                                 Apache License | ||||
|                         Version 2.0, January 2004 | ||||
|                     http://www.apache.org/licenses/ | ||||
|  | ||||
| TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | ||||
|  | ||||
| 1. Definitions. | ||||
|  | ||||
|     "License" shall mean the terms and conditions for use, reproduction, | ||||
|     and distribution as defined by Sections 1 through 9 of this document. | ||||
|  | ||||
|     "Licensor" shall mean the copyright owner or entity authorized by | ||||
|     the copyright owner that is granting the License. | ||||
|  | ||||
|     "Legal Entity" shall mean the union of the acting entity and all | ||||
|     other entities that control, are controlled by, or are under common | ||||
|     control with that entity. For the purposes of this definition, | ||||
|     "control" means (i) the power, direct or indirect, to cause the | ||||
|     direction or management of such entity, whether by contract or | ||||
|     otherwise, or (ii) ownership of fifty percent (50%) or more of the | ||||
|     outstanding shares, or (iii) beneficial ownership of such entity. | ||||
|  | ||||
|     "You" (or "Your") shall mean an individual or Legal Entity | ||||
|     exercising permissions granted by this License. | ||||
|  | ||||
|     "Source" form shall mean the preferred form for making modifications, | ||||
|     including but not limited to software source code, documentation | ||||
|     source, and configuration files. | ||||
|  | ||||
|     "Object" form shall mean any form resulting from mechanical | ||||
|     transformation or translation of a Source form, including but | ||||
|     not limited to compiled object code, generated documentation, | ||||
|     and conversions to other media types. | ||||
|  | ||||
|     "Work" shall mean the work of authorship, whether in Source or | ||||
|     Object form, made available under the License, as indicated by a | ||||
|     copyright notice that is included in or attached to the work | ||||
|     (an example is provided in the Appendix below). | ||||
|  | ||||
|     "Derivative Works" shall mean any work, whether in Source or Object | ||||
|     form, that is based on (or derived from) the Work and for which the | ||||
|     editorial revisions, annotations, elaborations, or other modifications | ||||
|     represent, as a whole, an original work of authorship. For the purposes | ||||
|     of this License, Derivative Works shall not include works that remain | ||||
|     separable from, or merely link (or bind by name) to the interfaces of, | ||||
|     the Work and Derivative Works thereof. | ||||
|  | ||||
|     "Contribution" shall mean any work of authorship, including | ||||
|     the original version of the Work and any modifications or additions | ||||
|     to that Work or Derivative Works thereof, that is intentionally | ||||
|     submitted to Licensor for inclusion in the Work by the copyright owner | ||||
|     or by an individual or Legal Entity authorized to submit on behalf of | ||||
|     the copyright owner. For the purposes of this definition, "submitted" | ||||
|     means any form of electronic, verbal, or written communication sent | ||||
|     to the Licensor or its representatives, including but not limited to | ||||
|     communication on electronic mailing lists, source code control systems, | ||||
|     and issue tracking systems that are managed by, or on behalf of, the | ||||
|     Licensor for the purpose of discussing and improving the Work, but | ||||
|     excluding communication that is conspicuously marked or otherwise | ||||
|     designated in writing by the copyright owner as "Not a Contribution." | ||||
|  | ||||
|     "Contributor" shall mean Licensor and any individual or Legal Entity | ||||
|     on behalf of whom a Contribution has been received by Licensor and | ||||
|     subsequently incorporated within the Work. | ||||
|  | ||||
| 2. Grant of Copyright License. Subject to the terms and conditions of | ||||
|     this License, each Contributor hereby grants to You a perpetual, | ||||
|     worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||
|     copyright license to reproduce, prepare Derivative Works of, | ||||
|     publicly display, publicly perform, sublicense, and distribute the | ||||
|     Work and such Derivative Works in Source or Object form. | ||||
|  | ||||
| 3. Grant of Patent License. Subject to the terms and conditions of | ||||
|     this License, each Contributor hereby grants to You a perpetual, | ||||
|     worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||
|     (except as stated in this section) patent license to make, have made, | ||||
|     use, offer to sell, sell, import, and otherwise transfer the Work, | ||||
|     where such license applies only to those patent claims licensable | ||||
|     by such Contributor that are necessarily infringed by their | ||||
|     Contribution(s) alone or by combination of their Contribution(s) | ||||
|     with the Work to which such Contribution(s) was submitted. If You | ||||
|     institute patent litigation against any entity (including a | ||||
|     cross-claim or counterclaim in a lawsuit) alleging that the Work | ||||
|     or a Contribution incorporated within the Work constitutes direct | ||||
|     or contributory patent infringement, then any patent licenses | ||||
|     granted to You under this License for that Work shall terminate | ||||
|     as of the date such litigation is filed. | ||||
|  | ||||
| 4. Redistribution. You may reproduce and distribute copies of the | ||||
|     Work or Derivative Works thereof in any medium, with or without | ||||
|     modifications, and in Source or Object form, provided that You | ||||
|     meet the following conditions: | ||||
|  | ||||
|     (a) You must give any other recipients of the Work or | ||||
|         Derivative Works a copy of this License; and | ||||
|  | ||||
|     (b) You must cause any modified files to carry prominent notices | ||||
|         stating that You changed the files; and | ||||
|  | ||||
|     (c) You must retain, in the Source form of any Derivative Works | ||||
|         that You distribute, all copyright, patent, trademark, and | ||||
|         attribution notices from the Source form of the Work, | ||||
|         excluding those notices that do not pertain to any part of | ||||
|         the Derivative Works; and | ||||
|  | ||||
|     (d) If the Work includes a "NOTICE" text file as part of its | ||||
|         distribution, then any Derivative Works that You distribute must | ||||
|         include a readable copy of the attribution notices contained | ||||
|         within such NOTICE file, excluding those notices that do not | ||||
|         pertain to any part of the Derivative Works, in at least one | ||||
|         of the following places: within a NOTICE text file distributed | ||||
|         as part of the Derivative Works; within the Source form or | ||||
|         documentation, if provided along with the Derivative Works; or, | ||||
|         within a display generated by the Derivative Works, if and | ||||
|         wherever such third-party notices normally appear. The contents | ||||
|         of the NOTICE file are for informational purposes only and | ||||
|         do not modify the License. You may add Your own attribution | ||||
|         notices within Derivative Works that You distribute, alongside | ||||
|         or as an addendum to the NOTICE text from the Work, provided | ||||
|         that such additional attribution notices cannot be construed | ||||
|         as modifying the License. | ||||
|  | ||||
|     You may add Your own copyright statement to Your modifications and | ||||
|     may provide additional or different license terms and conditions | ||||
|     for use, reproduction, or distribution of Your modifications, or | ||||
|     for any such Derivative Works as a whole, provided Your use, | ||||
|     reproduction, and distribution of the Work otherwise complies with | ||||
|     the conditions stated in this License. | ||||
|  | ||||
| 5. Submission of Contributions. Unless You explicitly state otherwise, | ||||
|     any Contribution intentionally submitted for inclusion in the Work | ||||
|     by You to the Licensor shall be under the terms and conditions of | ||||
|     this License, without any additional terms or conditions. | ||||
|     Notwithstanding the above, nothing herein shall supersede or modify | ||||
|     the terms of any separate license agreement you may have executed | ||||
|     with Licensor regarding such Contributions. | ||||
|  | ||||
| 6. Trademarks. This License does not grant permission to use the trade | ||||
|     names, trademarks, service marks, or product names of the Licensor, | ||||
|     except as required for reasonable and customary use in describing the | ||||
|     origin of the Work and reproducing the content of the NOTICE file. | ||||
|  | ||||
| 7. Disclaimer of Warranty. Unless required by applicable law or | ||||
|     agreed to in writing, Licensor provides the Work (and each | ||||
|     Contributor provides its Contributions) on an "AS IS" BASIS, | ||||
|     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | ||||
|     implied, including, without limitation, any warranties or conditions | ||||
|     of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | ||||
|     PARTICULAR PURPOSE. You are solely responsible for determining the | ||||
|     appropriateness of using or redistributing the Work and assume any | ||||
|     risks associated with Your exercise of permissions under this License. | ||||
|  | ||||
| 8. Limitation of Liability. In no event and under no legal theory, | ||||
|     whether in tort (including negligence), contract, or otherwise, | ||||
|     unless required by applicable law (such as deliberate and grossly | ||||
|     negligent acts) or agreed to in writing, shall any Contributor be | ||||
|     liable to You for damages, including any direct, indirect, special, | ||||
|     incidental, or consequential damages of any character arising as a | ||||
|     result of this License or out of the use or inability to use the | ||||
|     Work (including but not limited to damages for loss of goodwill, | ||||
|     work stoppage, computer failure or malfunction, or any and all | ||||
|     other commercial damages or losses), even if such Contributor | ||||
|     has been advised of the possibility of such damages. | ||||
|  | ||||
| 9. Accepting Warranty or Additional Liability. While redistributing | ||||
|     the Work or Derivative Works thereof, You may choose to offer, | ||||
|     and charge a fee for, acceptance of support, warranty, indemnity, | ||||
|     or other liability obligations and/or rights consistent with this | ||||
|     License. However, in accepting such obligations, You may act only | ||||
|     on Your own behalf and on Your sole responsibility, not on behalf | ||||
|     of any other Contributor, and only if You agree to indemnify, | ||||
|     defend, and hold each Contributor harmless for any liability | ||||
|     incurred by, or claims asserted against, such Contributor by reason | ||||
|     of your accepting any such warranty or additional liability. | ||||
|  | ||||
| END OF TERMS AND CONDITIONS | ||||
							
								
								
									
										25
									
								
								src/app/LICENSE-MIT
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/app/LICENSE-MIT
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| Copyright (c)  timurgordon <timurgordon@gmail.com> | ||||
|  | ||||
| Permission is hereby granted, free of charge, to any | ||||
| person obtaining a copy of this software and associated | ||||
| documentation files (the "Software"), to deal in the | ||||
| Software without restriction, including without | ||||
| limitation the rights to use, copy, modify, merge, | ||||
| publish, distribute, sublicense, and/or sell copies of | ||||
| the Software, and to permit persons to whom the Software | ||||
| is furnished to do so, subject to the following | ||||
| conditions: | ||||
|  | ||||
| The above copyright notice and this permission notice | ||||
| shall be included in all copies or substantial portions | ||||
| of the Software. | ||||
|  | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF | ||||
| ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED | ||||
| TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | ||||
| PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT | ||||
| SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY | ||||
| CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION | ||||
| OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR | ||||
| IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER | ||||
| DEALINGS IN THE SOFTWARE. | ||||
							
								
								
									
										75
									
								
								src/app/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/app/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| # Yew Trunk Template | ||||
|  | ||||
| This is a fairly minimal template for a Yew app that's built with [Trunk]. | ||||
|  | ||||
| ## Usage | ||||
|  | ||||
| For a more thorough explanation of Trunk and its features, please head over to the [repository][trunk]. | ||||
|  | ||||
| ### Installation | ||||
|  | ||||
| If you don't already have it installed, it's time to install Rust: <https://www.rust-lang.org/tools/install>. | ||||
| The rest of this guide assumes a typical Rust installation which contains both `rustup` and Cargo. | ||||
|  | ||||
| To compile Rust to WASM, we need to have the `wasm32-unknown-unknown` target installed. | ||||
| If you don't already have it, install it with the following command: | ||||
|  | ||||
| ```bash | ||||
| rustup target add wasm32-unknown-unknown | ||||
| ``` | ||||
|  | ||||
| Now that we have our basics covered, it's time to install the star of the show: [Trunk]. | ||||
| Simply run the following command to install it: | ||||
|  | ||||
| ```bash | ||||
| cargo install trunk wasm-bindgen-cli | ||||
| ``` | ||||
|  | ||||
| That's it, we're done! | ||||
|  | ||||
| ### Running | ||||
|  | ||||
| ```bash | ||||
| trunk serve | ||||
| ``` | ||||
|  | ||||
| Rebuilds the app whenever a change is detected and runs a local server to host it. | ||||
|  | ||||
| There's also the `trunk watch` command which does the same thing but without hosting it. | ||||
|  | ||||
| ### Release | ||||
|  | ||||
| ```bash | ||||
| trunk build --release | ||||
| ``` | ||||
|  | ||||
| This builds the app in release mode similar to `cargo build --release`. | ||||
| You can also pass the `--release` flag to `trunk serve` if you need to get every last drop of performance. | ||||
|  | ||||
| Unless overwritten, the output will be located in the `dist` directory. | ||||
|  | ||||
| ## Using this template | ||||
|  | ||||
| There are a few things you have to adjust when adopting this template. | ||||
|  | ||||
| ### Remove example code | ||||
|  | ||||
| The code in [src/main.rs](src/main.rs) specific to the example is limited to only the `view` method. | ||||
| There is, however, a fair bit of Sass in [index.scss](index.scss) you can remove. | ||||
|  | ||||
| ### Update metadata | ||||
|  | ||||
| Update the `version`, `description` and `repository` fields in the [Cargo.toml](Cargo.toml) file. | ||||
| The [index.html](index.html) file also contains a `<title>` tag that needs updating. | ||||
|  | ||||
|  | ||||
| Finally, you should update this very `README` file to be about your app. | ||||
|  | ||||
| ### License | ||||
|  | ||||
| The template ships with both the Apache and MIT license. | ||||
| If you don't want to have your app dual licensed, just remove one (or both) of the files and update the `license` field in `Cargo.toml`. | ||||
|  | ||||
| There are two empty spaces in the MIT license you need to fill out: `` and `timurgordon <timurgordon@gmail.com>`. | ||||
|  | ||||
| [trunk]: https://github.com/thedodd/trunk | ||||
							
								
								
									
										2
									
								
								src/app/Trunk.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/Trunk.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| [build] | ||||
| target = "index.html" | ||||
							
								
								
									
										215
									
								
								src/app/auth_system_plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								src/app/auth_system_plan.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,215 @@ | ||||
| # Authentication System Architecture Plan (Clean Separation) | ||||
|  | ||||
| ## Overview | ||||
| A comprehensive authentication system for a standalone WASM Yew application that uses the `client_ws` library for WebSocket authentication with secp256k1 cryptographic signatures. The system maintains clear separation between generic WebSocket/crypto functionality and app-specific user management. | ||||
|  | ||||
| ## 🏗️ Clean System Architecture | ||||
|  | ||||
| ```mermaid | ||||
| graph TB | ||||
|     subgraph "Demo App (Application Layer)" | ||||
|         A[Login Component] --> B[Auth Manager] | ||||
|         B --> C[Email Store - Hardcoded Mapping] | ||||
|         B --> D[App-Specific Auth State] | ||||
|         C --> E[Email-to-Private-Key Lookup] | ||||
|     end | ||||
|      | ||||
|     subgraph "client_ws Library (Generic Layer)" | ||||
|         F[CircleWsClient] --> G[Crypto Utils] | ||||
|         F --> H[Nonce Client] | ||||
|         G --> I[secp256k1 Signing] | ||||
|         H --> J[REST Nonce Requests] | ||||
|     end | ||||
|      | ||||
|     subgraph "External Services" | ||||
|         K[WebSocket Server] --> L[Auth Middleware] | ||||
|         M[Auth Server] --> N[Nonce Endpoint] | ||||
|         L --> O[Signature Verification] | ||||
|     end | ||||
|      | ||||
|     B --> F | ||||
|     F --> K | ||||
|     H --> M | ||||
|      | ||||
|     subgraph "Authentication Flow" | ||||
|         P[1. Email/Private Key Input] --> Q[2. App Layer Lookup] | ||||
|         Q --> R[3. Create Authenticated Client] | ||||
|         R --> S[4. Client Fetches Nonce] | ||||
|         S --> T[5. Client Signs Nonce] | ||||
|         T --> U[6. WebSocket Connection] | ||||
|         U --> V[7. Server Verification] | ||||
|     end | ||||
| ``` | ||||
|  | ||||
| ## 📁 Clean File Structure | ||||
|  | ||||
| ``` | ||||
| app/src/auth/ | ||||
| ├── mod.rs                   # App auth module exports + client_ws re-exports | ||||
| ├── auth_manager.rs          # App-specific auth coordination | ||||
| ├── email_store.rs           # Hardcoded email-to-key mappings (app-specific) | ||||
| └── types.rs                 # App-specific auth types + client_ws re-exports | ||||
|  | ||||
| client_ws/src/auth/ | ||||
| ├── mod.rs                   # Generic auth module | ||||
| ├── crypto_utils.rs          # secp256k1 operations (generic) | ||||
| ├── nonce_client.rs          # REST nonce client (generic) | ||||
| └── types.rs                 # Core auth types (generic) | ||||
|  | ||||
| client_ws/src/ | ||||
| └── lib.rs                   # WebSocket client with auth support | ||||
| ``` | ||||
|  | ||||
| ## 🔐 Clean Authentication Flow | ||||
|  | ||||
| ### 1. Email Authentication (App-Specific) | ||||
| 1. **User enters email** in app app login component | ||||
| 2. **App looks up email** in hardcoded email_store.rs | ||||
| 3. **App retrieves private key** from hardcoded mapping | ||||
| 4. **App creates CircleWsClient** with private key | ||||
| 5. **Client library handles** nonce fetching, signing, WebSocket connection | ||||
|  | ||||
| ### 2. Private Key Authentication (Generic) | ||||
| 1. **User enters private key** directly | ||||
| 2. **App creates CircleWsClient** with private key | ||||
| 3. **Client library handles** nonce fetching, signing, WebSocket connection | ||||
|  | ||||
| ## 🛠️ Key Separation Principles | ||||
|  | ||||
| ### App Layer Responsibilities (app/src/auth/) | ||||
| - ✅ **Email-to-private-key mappings** (email_store.rs) | ||||
| - ✅ **User interface logic** (login components) | ||||
| - ✅ **App-specific auth state** (AuthState, AuthMethod with Email) | ||||
| - ✅ **Session management** (local storage, UI state) | ||||
| - ✅ **Business logic** (user management, app data) | ||||
|  | ||||
| ### Client Library Responsibilities (client_ws/) | ||||
| - ✅ **WebSocket connection management** | ||||
| - ✅ **Cryptographic operations** (secp256k1 signing/verification) | ||||
| - ✅ **Nonce fetching** from REST endpoints | ||||
| - ✅ **Private key authentication** (generic) | ||||
| - ✅ **Cross-platform support** (WASM + Native) | ||||
|  | ||||
| ### What Was Removed (Duplicated Code) | ||||
| - ❌ **crypto_utils.rs** from app (use client_ws instead) | ||||
| - ❌ **nonce_client.rs** from app (use client_ws instead) | ||||
| - ❌ **Duplicated auth types** (use client_ws types) | ||||
| - ❌ **Email authentication** from client_ws (app-specific) | ||||
|  | ||||
| ## 📋 Implementation Status | ||||
|  | ||||
| ### ✅ Completed | ||||
| 1. **Cleaned client_ws library** | ||||
|    - Removed app-specific email authentication | ||||
|    - Kept only private key authentication | ||||
|    - Updated documentation with clear separation | ||||
|  | ||||
| 2. **Updated app app** | ||||
|    - Removed duplicated crypto_utils.rs | ||||
|    - Removed duplicated nonce_client.rs | ||||
|    - Updated auth_manager.rs to use client_ws | ||||
|    - Updated types.rs to re-export client_ws types | ||||
|    - Kept app-specific email_store.rs | ||||
|  | ||||
| 3. **Clear integration pattern** | ||||
|    - App handles email-to-key lookup | ||||
|    - App creates CircleWsClient with private key | ||||
|    - Client library handles all WebSocket/crypto operations | ||||
|  | ||||
| ## 🔧 Usage Examples | ||||
|  | ||||
| ### App-Level Authentication | ||||
| ```rust | ||||
| // In app app auth_manager.rs | ||||
| impl AuthManager { | ||||
|     pub async fn authenticate_with_email(&self, email: String) -> AuthResult<()> { | ||||
|         // 1. App-specific: Look up email in hardcoded store | ||||
|         let key_pair = get_key_pair_for_email(&email)?; | ||||
|          | ||||
|         // 2. Generic: Validate using client_ws | ||||
|         validate_private_key(&key_pair.private_key)?; | ||||
|          | ||||
|         // 3. App-specific: Update app auth state | ||||
|         self.set_state(AuthState::Authenticated { | ||||
|             public_key: key_pair.public_key, | ||||
|             private_key: key_pair.private_key, | ||||
|             method: AuthMethod::Email(email), | ||||
|         }); | ||||
|          | ||||
|         Ok(()) | ||||
|     } | ||||
|      | ||||
|     pub async fn create_authenticated_client(&self, ws_url: &str, auth_server_url: &str) -> Result<CircleWsClient, CircleWsClientError> { | ||||
|         // 1. App-specific: Get private key from app state | ||||
|         let private_key = match self.state.borrow().clone() { | ||||
|             AuthState::Authenticated { private_key, .. } => private_key, | ||||
|             _ => return Err(CircleWsClientError::NotConnected), | ||||
|         }; | ||||
|  | ||||
|         // 2. Generic: Create and authenticate client using client_ws | ||||
|         let mut client = CircleWsClient::new_with_auth( | ||||
|             ws_url.to_string(), | ||||
|             auth_server_url.to_string(), | ||||
|             private_key | ||||
|         ); | ||||
|          | ||||
|         client.authenticate().await?; | ||||
|         Ok(client) | ||||
|     } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Client Library Usage | ||||
| ```rust | ||||
| // Using client_ws directly (no app-specific logic) | ||||
| use circle_client_ws::CircleWsClient; | ||||
|  | ||||
| let mut client = CircleWsClient::new_with_auth( | ||||
|     "ws://localhost:8080/ws".to_string(), | ||||
|     "http://localhost:8080".to_string(), | ||||
|     private_key | ||||
| ); | ||||
|  | ||||
| client.authenticate().await?; | ||||
| client.connect().await?; | ||||
|  | ||||
| let result = client.play("console.log('Hello, authenticated world!');".to_string()).await?; | ||||
| ``` | ||||
|  | ||||
| ## 🎯 Benefits of Clean Separation | ||||
|  | ||||
| 1. **Reusability**: client_ws can be used by any Rust application | ||||
| 2. **Maintainability**: Clear boundaries between WebSocket/crypto and user management | ||||
| 3. **Testability**: Each layer can be tested independently | ||||
| 4. **Security**: Consistent crypto handling at the library level | ||||
| 5. **Flexibility**: Apps can implement any authentication UX they need | ||||
|  | ||||
| ## 🔒 Security Considerations | ||||
|  | ||||
| ### Client Library (Generic) | ||||
| - ✅ **Secure crypto operations** using secp256k1 | ||||
| - ✅ **Proper nonce handling** with expiration | ||||
| - ✅ **Ethereum-compatible signing** (eth_sign style) | ||||
| - ✅ **Cross-platform security** (WASM + Native) | ||||
|  | ||||
| ### Demo App (App-Specific) | ||||
| - ✅ **Hardcoded keys for app** (easy to rotate) | ||||
| - ✅ **No sensitive server storage** needed | ||||
| - ✅ **Local storage** (non-sensitive state only) | ||||
| - ✅ **Clear separation** of concerns | ||||
|  | ||||
| ## 🚀 Migration Benefits | ||||
|  | ||||
| ### Before (Mixed Concerns) | ||||
| - Duplicated crypto code in both client_ws and app | ||||
| - Email authentication mixed into generic library | ||||
| - Hard to reuse client_ws in other projects | ||||
| - Unclear separation of responsibilities | ||||
|  | ||||
| ### After (Clean Separation) | ||||
| - Single source of truth for crypto operations (client_ws) | ||||
| - App-specific logic clearly separated (app/auth/) | ||||
| - client_ws is reusable by any application | ||||
| - Clear integration patterns and documentation | ||||
|  | ||||
| This clean architecture ensures that the `client_ws` library remains focused on its core responsibility (secure WebSocket client with private key authentication) while the app app handles all user-facing and business logic appropriately. | ||||
							
								
								
									
										91
									
								
								src/app/csslint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/app/csslint.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # Parse arguments | ||||
| CLEAN=false | ||||
| ARGS=() | ||||
|  | ||||
| for arg in "$@"; do | ||||
|   if [[ "$arg" == "--clean" ]]; then | ||||
|     CLEAN=true | ||||
|   else | ||||
|     ARGS+=("$arg") | ||||
|   fi | ||||
| done | ||||
|  | ||||
| CSS_DIR="${ARGS[0]:-static}" | ||||
| PROJECT_DIR="${ARGS[1]:-.}" | ||||
|  | ||||
| echo "🔍 Scanning CSS directory: $CSS_DIR" | ||||
| echo "📁 Project source directory: $PROJECT_DIR" | ||||
| echo "🧹 Clean mode: $CLEAN" | ||||
|  | ||||
| USED_CLASSES=$(mktemp) | ||||
| CLASS_NAMES=$(mktemp) | ||||
|  | ||||
| # Step 1: collect all class names used in Rust/Yew | ||||
| grep -rho --include="*.rs" 'class\s*=\s*["'"'"'][^"'"'"']*["'"'"']' "$PROJECT_DIR" \ | ||||
|   | grep -o '[a-zA-Z0-9_-]\+' \ | ||||
|   | sort -u > "$USED_CLASSES" | ||||
|  | ||||
| # Step 2: extract class selectors from CSS | ||||
| grep -rho '^\s*\.[a-zA-Z_-][a-zA-Z0-9_-]*\s*{' "$CSS_DIR" \ | ||||
|   | sed -E 's/^\s*//; s/\s*\{.*$//' \ | ||||
|   | sort -u > "$CLASS_NAMES" | ||||
|  | ||||
| # Step 3: clean or list unused classes | ||||
| find "$CSS_DIR" -type f -name "*.css" | while read -r css_file; do | ||||
|   if $CLEAN; then | ||||
|     TMP_CLEANED=$(mktemp) | ||||
|     awk -v used_classes="$USED_CLASSES" ' | ||||
|       BEGIN { | ||||
|         while ((getline line < used_classes) > 0) { | ||||
|           used[line] = 1 | ||||
|         } | ||||
|         in_block = 0 | ||||
|         brace_depth = 0 | ||||
|         current_class = "" | ||||
|       } | ||||
|       { | ||||
|         # Start of a class rule | ||||
|         if (!in_block && $0 ~ /^[ \t]*\.[a-zA-Z_-][a-zA-Z0-9_-]*[ \t]*\{/) { | ||||
|           line = $0 | ||||
|           gsub(/^[ \t]*\./, "", line) | ||||
|           sub(/[ \t]*\{.*/, "", line) | ||||
|           current_class = line | ||||
|  | ||||
|           if (!(current_class in used)) { | ||||
|             in_block = 1 | ||||
|             brace_depth = gsub(/\{/, "{") - gsub(/\}/, "}") | ||||
|             next | ||||
|           } | ||||
|         } else if (in_block) { | ||||
|           brace_depth += gsub(/\{/, "{") | ||||
|           brace_depth -= gsub(/\}/, "}") | ||||
|           if (brace_depth <= 0) { | ||||
|             in_block = 0 | ||||
|           } | ||||
|           next | ||||
|         } | ||||
|  | ||||
|         print $0 | ||||
|       } | ||||
|     ' "$css_file" > "$TMP_CLEANED" | ||||
|  | ||||
|     mv "$TMP_CLEANED" "$css_file" | ||||
|     echo "✅ Cleaned: $css_file" | ||||
|   else | ||||
|     while read -r class_selector; do | ||||
|       class_name=$(echo "$class_selector" | sed 's/^\.//') | ||||
|       if ! grep -qx "$class_name" "$USED_CLASSES"; then | ||||
|         echo "⚠️  Unused CSS class: $class_selector" | ||||
|       fi | ||||
|     done < "$CLASS_NAMES" | ||||
|     break | ||||
|   fi | ||||
| done | ||||
|  | ||||
| rm "$USED_CLASSES" "$CLASS_NAMES" | ||||
|  | ||||
| if $CLEAN; then | ||||
|   echo "🧹 Done: multi-line unused class blocks removed safely." | ||||
| fi | ||||
							
								
								
									
										31
									
								
								src/app/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/app/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| <!DOCTYPE html> | ||||
| <html> | ||||
| <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <title>Circles</title> | ||||
| <link data-trunk rel="css" href="static/css/common.css" /> | ||||
|     <link data-trunk rel="css" href="static/styles.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/auth.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/nav_island.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/library_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/library_viewer.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/members_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/intelligence_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/timeline_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/projects_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/governance_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/calendar_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/treasury_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/publishing_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/customize_view.css" /> | ||||
| <link data-trunk rel="css" href="static/css/circles_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/dashboard_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/inspector_view.css" /> | ||||
|     <link data-trunk rel="css" href="static/css/network_animation.css" /> | ||||
|     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" /> | ||||
|     <!-- Trunk will inject a script tag here for the WASM loader --> | ||||
| </head> | ||||
| <body> | ||||
|     <!-- The Yew app will be rendered here --> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										35
									
								
								src/app/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/app/index.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| html, | ||||
| body { | ||||
|   height: 100%; | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   align-items: center; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|  | ||||
|   background: linear-gradient(to bottom right, #444444, #009a5b); | ||||
|   font-size: 1.5rem; | ||||
| } | ||||
|  | ||||
| main { | ||||
|   color: #fff6d5; | ||||
|   font-family: sans-serif; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .logo { | ||||
|   height: 20em; | ||||
| } | ||||
|  | ||||
| .heart:after { | ||||
|   content: "❤️"; | ||||
|  | ||||
|   font-size: 1.75em; | ||||
| } | ||||
|  | ||||
| h1 + .subtitle { | ||||
|   display: block; | ||||
|   margin-top: -1em; | ||||
| } | ||||
							
								
								
									
										250
									
								
								src/app/src/app.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								src/app/src/app.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use crate::components::circles_view::CirclesView; | ||||
| use crate::components::nav_island::NavIsland; | ||||
| use crate::components::library_view::LibraryView; | ||||
| use crate::components::intelligence_view::IntelligenceView; | ||||
| use crate::components::inspector_view::InspectorView; | ||||
| use crate::components::publishing_view::PublishingView; | ||||
| use crate::components::customize_view::CustomizeViewComponent; | ||||
| use crate::components::login_component::LoginComponent; | ||||
| use crate::auth::{AuthManager, AuthState}; | ||||
| use crate::components::auth_view::AuthView; | ||||
|  | ||||
| // Props for the App component | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct AppProps { | ||||
|     pub start_circle_ws_url: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Copy, PartialEq)] | ||||
| pub enum AppView { | ||||
|     Login, | ||||
|     Circles, | ||||
|     Library, | ||||
|     Intelligence, | ||||
|     Publishing, | ||||
|     Customize, | ||||
|     Inspector, // Added Inspector | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub enum Msg { | ||||
|     SwitchView(AppView), | ||||
|     UpdateCirclesContext(Vec<String>), // Context URLs from CirclesView | ||||
|     AuthStateChanged(AuthState), | ||||
|     AuthenticationSuccessful, | ||||
|     AuthenticationFailed(String), | ||||
|     Logout, | ||||
| } | ||||
|  | ||||
| pub struct App { | ||||
|     current_view: AppView, | ||||
|     active_context_urls: Vec<String>, // Only context URLs from CirclesView | ||||
|     start_circle_ws_url: String, // Initial WebSocket URL for CirclesView | ||||
|     auth_manager: AuthManager, | ||||
|     auth_state: AuthState, | ||||
| } | ||||
|  | ||||
| impl Component for App { | ||||
|     type Message = Msg; | ||||
|     type Properties = AppProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         wasm_logger::init(wasm_logger::Config::default()); | ||||
|         log::info!("App created with authentication support."); | ||||
|          | ||||
|         let start_circle_ws_url = ctx.props().start_circle_ws_url.clone(); | ||||
|         let auth_manager = AuthManager::new(); | ||||
|         let auth_state = auth_manager.get_state(); | ||||
|          | ||||
|         // Set up auth state change callback | ||||
|         let link = ctx.link().clone(); | ||||
|         auth_manager.set_on_state_change(link.callback(Msg::AuthStateChanged)); | ||||
|          | ||||
|         // Determine initial view based on authentication state | ||||
|         let initial_view = match auth_state { | ||||
|             AuthState::Authenticated { .. } => AppView::Circles, | ||||
|             _ => AppView::Login, | ||||
|         }; | ||||
|  | ||||
|         Self { | ||||
|             current_view: initial_view, | ||||
|             active_context_urls: Vec::new(), | ||||
|             start_circle_ws_url, | ||||
|             auth_manager, | ||||
|             auth_state, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::UpdateCirclesContext(context_urls) => { | ||||
|                 log::info!("App: Received context update from CirclesView: {:?}", context_urls); | ||||
|                 self.active_context_urls = context_urls; | ||||
|                 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 | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             Msg::AuthStateChanged(state) => { | ||||
|                 log::info!("App: Auth state changed: {:?}", state); | ||||
|                 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; | ||||
|                     } | ||||
|                     _ => {} | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             Msg::AuthenticationSuccessful => { | ||||
|                 log::info!("App: Authentication successful"); | ||||
|                 self.current_view = AppView::Circles; | ||||
|                 true | ||||
|             } | ||||
|             Msg::AuthenticationFailed(error) => { | ||||
|                 log::error!("App: Authentication failed: {}", error); | ||||
|                 self.current_view = AppView::Login; | ||||
|                 true | ||||
|             } | ||||
|             Msg::Logout => { | ||||
|                 log::info!("App: User logout"); | ||||
|                 self.auth_manager.logout(); | ||||
|                 self.current_view = AppView::Login; | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|  | ||||
|         // If not authenticated and not on login view, show login | ||||
|         if !self.auth_manager.is_authenticated() && self.current_view != AppView::Login { | ||||
|             return html! { | ||||
|                 <LoginComponent | ||||
|                     auth_manager={self.auth_manager.clone()} | ||||
|                     on_authenticated={link.callback(|_| Msg::AuthenticationSuccessful)} | ||||
|                     on_error={link.callback(Msg::AuthenticationFailed)} | ||||
|                 /> | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         html! { | ||||
|             <div class="yew-app-container"> | ||||
|                 { self.render_header(link) } | ||||
|  | ||||
|                 { match self.current_view { | ||||
|                     AppView::Login => { | ||||
|                         html! { | ||||
|                             <LoginComponent | ||||
|                                 auth_manager={self.auth_manager.clone()} | ||||
|                                 on_authenticated={link.callback(|_| Msg::AuthenticationSuccessful)} | ||||
|                                 on_error={link.callback(Msg::AuthenticationFailed)} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     AppView::Circles => { | ||||
|                         html!{ | ||||
|                             <CirclesView | ||||
|                                 default_center_ws_url={self.start_circle_ws_url.clone()} | ||||
|                                 on_context_update={link.callback(Msg::UpdateCirclesContext)} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     AppView::Library => { | ||||
|                         html! { | ||||
|                             <LibraryView ws_addresses={self.active_context_urls.clone()} /> | ||||
|                         } | ||||
|                     }, | ||||
|                     AppView::Intelligence => html! { | ||||
|                         <IntelligenceView | ||||
|                             all_circles={Rc::new(HashMap::new())} | ||||
|                             context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))} | ||||
|                         /> | ||||
|                     }, | ||||
|                     AppView::Publishing => html! { | ||||
|                         <PublishingView | ||||
|                             all_circles={Rc::new(HashMap::new())} | ||||
|                             context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))} | ||||
|                         /> | ||||
|                     }, | ||||
|                     AppView::Inspector => { | ||||
|                         html! { | ||||
|                             <InspectorView | ||||
|                                 circle_ws_addresses={Rc::new(self.active_context_urls.clone())} | ||||
|                                 auth_manager={self.auth_manager.clone()} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     AppView::Customize => html! { | ||||
|                         <CustomizeViewComponent | ||||
|                             all_circles={Rc::new(HashMap::new())} | ||||
|                             context_circle_ws_urls={Some(Rc::new(self.active_context_urls.clone()))} | ||||
|                             app_callback={link.callback(|msg: Msg| msg)} | ||||
|                         /> | ||||
|                     }, | ||||
|                 }} | ||||
|                  | ||||
|                 { if self.current_view != AppView::Login { | ||||
|                     html! { | ||||
|                         <NavIsland | ||||
|                             current_view={self.current_view.clone()} | ||||
|                             on_switch_view={link.callback(Msg::SwitchView)} | ||||
|                         /> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! {} | ||||
|                 }} | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl App { | ||||
|     fn render_header(&self, link: &html::Scope<Self>) -> Html { | ||||
|         if self.current_view == AppView::Login { | ||||
|             return html! {}; | ||||
|         } | ||||
|  | ||||
|         html! { | ||||
|             <header> | ||||
|                 <div class="app-title-button"> | ||||
|                     <span class="app-title-name">{ "Circles" }</span> | ||||
|                 </div> | ||||
|                 <AuthView  | ||||
|                     auth_state={self.auth_state.clone()}  | ||||
|                     on_logout={link.callback(|_| Msg::Logout)}  | ||||
|                     on_login={link.callback(|_| Msg::SwitchView(AppView::Login))}  | ||||
|                 /> | ||||
|             </header> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										348
									
								
								src/app/src/auth/auth_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								src/app/src/auth/auth_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,348 @@ | ||||
| //! Authentication manager for coordinating authentication flows | ||||
| //!  | ||||
| //! This module provides the main AuthManager struct that coordinates | ||||
| //! the entire authentication process, including email lookup and | ||||
| //! integration with the client_ws library for WebSocket connections. | ||||
|  | ||||
| use std::rc::Rc; | ||||
| use std::cell::RefCell; | ||||
| use yew::Callback; | ||||
| use gloo_storage::{LocalStorage, SessionStorage, Storage}; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientError, CircleWsClientBuilder}; | ||||
| use circle_client_ws::auth::{validate_private_key, derive_public_key}; | ||||
| use crate::auth::types::{AuthResult, AuthError, AuthState, AuthMethod}; | ||||
| use crate::auth::email_store::{get_key_pair_for_email, is_email_available}; | ||||
|  | ||||
| /// Key for storing authentication state in local storage | ||||
| const AUTH_STATE_STORAGE_KEY: &str = "circles_auth_state_marker"; | ||||
| const PRIVATE_KEY_SESSION_STORAGE_KEY: &str = "circles_private_key"; | ||||
|  | ||||
| /// Authentication manager that coordinates the auth flow | ||||
| #[derive(Clone)] | ||||
| pub struct AuthManager { | ||||
|     state: Rc<RefCell<AuthState>>, | ||||
|     on_state_change: Rc<RefCell<Option<Callback<AuthState>>>>, | ||||
| } | ||||
|  | ||||
| impl PartialEq for AuthManager { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         // Compare based on the current auth state | ||||
|         self.get_state() == other.get_state() | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AuthManager { | ||||
|     /// Create a new authentication manager | ||||
|     pub fn new() -> Self { | ||||
|         let initial_state = Self::load_auth_state().unwrap_or(AuthState::NotAuthenticated); | ||||
|          | ||||
|         Self { | ||||
|             state: Rc::new(RefCell::new(initial_state)), | ||||
|             on_state_change: Rc::new(RefCell::new(None)), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Set callback for authentication state changes | ||||
|     pub fn set_on_state_change(&self, callback: Callback<AuthState>) { | ||||
|         *self.on_state_change.borrow_mut() = Some(callback); | ||||
|     } | ||||
|  | ||||
|     /// Get current authentication state | ||||
|     pub fn get_state(&self) -> AuthState { | ||||
|         self.state.borrow().clone() | ||||
|     } | ||||
|  | ||||
|     /// Check if currently authenticated | ||||
|     pub fn is_authenticated(&self) -> bool { | ||||
|         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<()> { | ||||
|         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))?; | ||||
|  | ||||
|         // Set authenticated state | ||||
|         let auth_state = AuthState::Authenticated { | ||||
|             public_key, | ||||
|             private_key: private_key.clone(), | ||||
|             method: AuthMethod::PrivateKey, | ||||
|         }; | ||||
|  | ||||
|         self.set_state(auth_state); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Create an authenticated WebSocket client using message-based authentication | ||||
|     pub async fn create_authenticated_client(&self, ws_url: &str) -> Result<CircleWsClient, CircleWsClientError> { | ||||
|         let auth_state = self.state.borrow().clone(); | ||||
|  | ||||
|         let private_key = match auth_state { | ||||
|             AuthState::Authenticated { private_key, .. } => private_key, | ||||
|             _ => return Err(CircleWsClientError::AuthNoKeyPair), | ||||
|         }; | ||||
|  | ||||
|         let mut client = CircleWsClientBuilder::new(ws_url.to_string()) | ||||
|             .with_keypair(private_key) | ||||
|             .build(); | ||||
|  | ||||
|         client.connect().await?; | ||||
|         client.authenticate().await?; | ||||
|  | ||||
|         Ok(client) | ||||
|     } | ||||
|  | ||||
|     /// Check if an email is available for authentication | ||||
|     pub fn is_email_available(&self, email: &str) -> bool { | ||||
|         is_email_available(email) | ||||
|     } | ||||
|  | ||||
|     /// Get list of available emails for app purposes | ||||
|     pub fn get_available_emails(&self) -> Vec<String> { | ||||
|         crate::auth::email_store::get_available_emails() | ||||
|     } | ||||
|  | ||||
|     /// Logout and clear authentication state | ||||
|     pub fn logout(&self) { | ||||
|         self.set_state(AuthState::NotAuthenticated); | ||||
|         self.clear_stored_auth_state(); | ||||
|     } | ||||
|  | ||||
|     /// Set authentication state and notify listeners | ||||
|     fn set_state(&self, new_state: AuthState) { | ||||
|         *self.state.borrow_mut() = new_state.clone(); | ||||
|          | ||||
|         // Save to local storage (excluding sensitive data) | ||||
|         self.save_auth_state(&new_state); | ||||
|          | ||||
|         // Notify listeners | ||||
|         if let Some(callback) = &*self.on_state_change.borrow() { | ||||
|             callback.emit(new_state); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Save authentication state to storage. | ||||
|     /// Private keys are stored in sessionStorage, method hints in localStorage. | ||||
|     fn save_auth_state(&self, state: &AuthState) { | ||||
|         match state { | ||||
|             AuthState::Authenticated { 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()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             AuthState::NotAuthenticated => { | ||||
|                 let _ = LocalStorage::set(AUTH_STATE_STORAGE_KEY, "not_authenticated".to_string()); | ||||
|                 let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY); | ||||
|             } | ||||
|             AuthState::Authenticating | AuthState::Failed(_) => { | ||||
|                 // Transient states, typically don't need to be persisted or can clear storage. | ||||
|                 // For now, let's clear localStorage for these, session might still be loading. | ||||
|                 let _ = LocalStorage::delete(AUTH_STATE_STORAGE_KEY); | ||||
|                 // Optionally, keep session storage if an auth attempt fails but might be retried. | ||||
|                 // However, a full logout or switch to NotAuthenticated should clear it. | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Load authentication state from storage. | ||||
|     fn load_auth_state() -> Option<AuthState> { | ||||
|         if let Ok(marker) = LocalStorage::get::<String>(AUTH_STATE_STORAGE_KEY) { | ||||
|             if marker == "private_key_auth_marker" { | ||||
|                 if let Ok(private_key) = SessionStorage::get::<String>(PRIVATE_KEY_SESSION_STORAGE_KEY) { | ||||
|                     if validate_private_key(&private_key).is_ok() { | ||||
|                         if let Ok(public_key) = derive_public_key(&private_key) { | ||||
|                             return Some(AuthState::Authenticated { | ||||
|                                 public_key, | ||||
|                                 private_key, | ||||
|                                 method: AuthMethod::PrivateKey, | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|                     // 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" { | ||||
|                 return Some(AuthState::NotAuthenticated); | ||||
|             } | ||||
|         } | ||||
|         // No valid marker or key found | ||||
|         None // Defaults to NotAuthenticated in AuthManager::new() | ||||
|     } | ||||
|  | ||||
|     /// Clear stored authentication state from both localStorage and sessionStorage | ||||
|     fn clear_stored_auth_state(&self) { | ||||
|         let _ = LocalStorage::delete(AUTH_STATE_STORAGE_KEY); | ||||
|         let _ = SessionStorage::delete(PRIVATE_KEY_SESSION_STORAGE_KEY); | ||||
|     } | ||||
|  | ||||
|     /// Get current authentication method if authenticated | ||||
|     pub fn get_auth_method(&self) -> Option<AuthMethod> { | ||||
|         match &*self.state.borrow() { | ||||
|             AuthState::Authenticated { method, .. } => Some(method.clone()), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get current public key if authenticated | ||||
|     pub fn get_public_key(&self) -> Option<String> { | ||||
|         match &*self.state.borrow() { | ||||
|             AuthState::Authenticated { public_key, .. } => Some(public_key.clone()), | ||||
|             _ => None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Validate current authentication state | ||||
|     pub fn validate_current_auth(&self) -> AuthResult<()> { | ||||
|         match &*self.state.borrow() { | ||||
|             AuthState::Authenticated { private_key, .. } => { | ||||
|                 validate_private_key(private_key) | ||||
|                     .map_err(|e| AuthError::from(e)) | ||||
|             } | ||||
|             _ => Err(AuthError::AuthFailed("Not authenticated".to_string())), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for AuthManager { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use wasm_bindgen_test::*; | ||||
|  | ||||
|     wasm_bindgen_test_configure!(run_in_browser); | ||||
|  | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_email_authentication() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         // Test with valid email | ||||
|         let result = auth_manager.authenticate_with_email("alice@example.com".to_string()).await; | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|         // Check that we can get the public key | ||||
|         assert!(auth_manager.get_public_key().is_some()); | ||||
|          | ||||
|         // Check auth method | ||||
|         match auth_manager.get_auth_method() { | ||||
|             Some(AuthMethod::Email(email)) => assert_eq!(email, "alice@example.com"), | ||||
|             _ => panic!("Expected email auth method"), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_private_key_authentication() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         // Test with valid private key | ||||
|         let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; | ||||
|         let result = auth_manager.authenticate_with_private_key(private_key.to_string()).await; | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|         // 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()); | ||||
|     } | ||||
|  | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_invalid_private_key() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         let result = auth_manager.authenticate_with_private_key("invalid_key".to_string()).await; | ||||
|         assert!(result.is_err()); | ||||
|         assert!(!auth_manager.is_authenticated()); | ||||
|     } | ||||
|  | ||||
|     #[wasm_bindgen_test] | ||||
|     async fn test_logout() { | ||||
|         let auth_manager = AuthManager::new(); | ||||
|          | ||||
|         // Authenticate first | ||||
|         let _ = auth_manager.authenticate_with_email("alice@example.com".to_string()).await; | ||||
|         assert!(auth_manager.is_authenticated()); | ||||
|          | ||||
|         // Logout | ||||
|         auth_manager.logout(); | ||||
|         assert!(!auth_manager.is_authenticated()); | ||||
|         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")); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										180
									
								
								src/app/src/auth/email_store.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								src/app/src/auth/email_store.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,180 @@ | ||||
| //! 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 std::collections::HashMap; | ||||
| use crate::auth::types::{AuthResult, AuthError}; | ||||
| use circle_client_ws::auth::derive_public_key; | ||||
|  | ||||
| /// A key pair consisting of private and public keys | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct KeyPair { | ||||
|     pub private_key: String, | ||||
|     pub public_key: String, | ||||
| } | ||||
|  | ||||
| /// Get the hardcoded email-to-key mappings | ||||
| ///  | ||||
| /// Returns a HashMap where: | ||||
| /// - Key: email address (String) | ||||
| /// - Value: KeyPair with private and public keys | ||||
| pub fn get_email_key_mappings() -> HashMap<String, KeyPair> { | ||||
|     let mut mappings = HashMap::new(); | ||||
|      | ||||
|     // Demo users with their private keys | ||||
|     // Note: These are for demonstration purposes only | ||||
|     let demo_keys = vec![ | ||||
|         ( | ||||
|             "alice@example.com", | ||||
|             "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" | ||||
|         ), | ||||
|         ( | ||||
|             "bob@example.com",  | ||||
|             "0xfedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" | ||||
|         ), | ||||
|         ( | ||||
|             "charlie@example.com", | ||||
|             "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" | ||||
|         ), | ||||
|         ( | ||||
|             "diana@example.com", | ||||
|             "0x9876543210fedcba9876543210fedcba9876543210fedcba9876543210fedcba" | ||||
|         ), | ||||
|         ( | ||||
|             "eve@example.com", | ||||
|             "0x1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff" | ||||
|         ), | ||||
|         ( | ||||
|             "admin@circles.com", | ||||
|             "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | ||||
|         ), | ||||
|         ( | ||||
|             "app@circles.com", | ||||
|             "0xdeadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcdef" | ||||
|         ), | ||||
|         ( | ||||
|             "test@circles.com", | ||||
|             "0xbaadf00dcafebabe9876543210fedcba9876543210fedcba9876543210fedcba" | ||||
|         ), | ||||
|     ]; | ||||
|      | ||||
|     // Generate key pairs for each app user | ||||
|     for (email, private_key) in demo_keys { | ||||
|         if let Ok(public_key) = derive_public_key(private_key) { | ||||
|             mappings.insert( | ||||
|                 email.to_string(), | ||||
|                 KeyPair { | ||||
|                     private_key: private_key.to_string(), | ||||
|                     public_key, | ||||
|                 } | ||||
|             ); | ||||
|         } else { | ||||
|             log::error!("Failed to derive public key for email: {}", email); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     mappings | ||||
| } | ||||
|  | ||||
| /// Look up a key pair by email address | ||||
| pub fn get_key_pair_for_email(email: &str) -> AuthResult<KeyPair> { | ||||
|     let mappings = get_email_key_mappings(); | ||||
|      | ||||
|     mappings.get(email) | ||||
|         .cloned() | ||||
|         .ok_or_else(|| AuthError::EmailNotFound(email.to_string())) | ||||
| } | ||||
|  | ||||
| /// Get all available email addresses | ||||
| pub fn get_available_emails() -> Vec<String> { | ||||
|     get_email_key_mappings().keys().cloned().collect() | ||||
| } | ||||
|  | ||||
| /// Check if an email address is available in the store | ||||
| 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 | ||||
| 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::{validate_private_key, verify_signature, sign_message}; | ||||
|  | ||||
|     #[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")); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								src/app/src/auth/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/src/auth/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| //! 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 | ||||
| //!  | ||||
| //! 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; | ||||
| pub use types::*; | ||||
|  | ||||
| // Re-export commonly used items from client_ws for convenience | ||||
							
								
								
									
										72
									
								
								src/app/src/auth/types.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/app/src/auth/types.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| //! Application-specific authentication types | ||||
| //!  | ||||
| //! This module defines app-specific authentication types that extend | ||||
| //! the core types from the client_ws library. | ||||
|  | ||||
| // Re-export core types from client_ws | ||||
|  | ||||
| // Define app-specific AuthResult that uses our local AuthError | ||||
| pub type AuthResult<T> = Result<T, AuthError>; | ||||
|  | ||||
| // Extend AuthError with app-specific variants | ||||
| use thiserror::Error; | ||||
|  | ||||
| #[derive(Debug, Clone, PartialEq, Error)] | ||||
| pub enum AuthError { | ||||
|     #[error("Client error: {0}")] | ||||
|     ClientError(String), | ||||
|  | ||||
|     #[error("Authentication failed: {0}")] | ||||
|     AuthFailed(String), | ||||
|  | ||||
|     // App-specific errors | ||||
|     #[error("Email not found: {0}")] | ||||
|     EmailNotFound(String), | ||||
|  | ||||
|     #[error("Generic error: {0}")] | ||||
|     Generic(String), | ||||
|  | ||||
|     #[error("Not authenticated")] | ||||
|     NotAuthenticated, | ||||
| } | ||||
|  | ||||
| impl From<circle_client_ws::CircleWsClientError> for AuthError { | ||||
|     fn from(err: circle_client_ws::CircleWsClientError) -> Self { | ||||
|         AuthError::ClientError(err.to_string()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<circle_client_ws::auth::AuthError> for AuthError { | ||||
|     fn from(err: circle_client_ws::auth::AuthError) -> Self { | ||||
|         AuthError::AuthFailed(err.to_string()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// 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) | ||||
| } | ||||
|  | ||||
| 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), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Application-specific authentication state | ||||
| #[derive(Debug, Clone, PartialEq)] | ||||
| pub enum AuthState { | ||||
|     NotAuthenticated, | ||||
|     Authenticating, | ||||
|     Authenticated { | ||||
|         public_key: String, | ||||
|         private_key: String, | ||||
|         method: AuthMethod, | ||||
|     }, | ||||
|     Failed(String), // Error message | ||||
| } | ||||
							
								
								
									
										175
									
								
								src/app/src/components/asset_details_card.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								src/app/src/components/asset_details_card.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::TocEntry; | ||||
| use crate::components::library_view::DisplayLibraryItem; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct AssetDetailsCardProps { | ||||
|     pub item: DisplayLibraryItem, | ||||
|     pub on_back: Callback<()>, | ||||
|     pub on_toc_click: Callback<usize>, | ||||
|     pub current_slide_index: Option<usize>, | ||||
| } | ||||
|  | ||||
| pub struct AssetDetailsCard; | ||||
|  | ||||
| impl Component for AssetDetailsCard { | ||||
|     type Message = (); | ||||
|     type Properties = AssetDetailsCardProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         match &props.item { | ||||
|             DisplayLibraryItem::Image(img) => html! { | ||||
|                 <div class="card asset-details-card"> | ||||
|                     <button class="back-button" onclick={back_handler}> | ||||
|                         <i class="fas fa-arrow-left"></i> {"Back to Library"} | ||||
|                     </button> | ||||
|                     <div class="asset-preview"> | ||||
|                         <img src={img.url.clone()} alt={img.title.clone()} class="asset-preview-image" /> | ||||
|                     </div> | ||||
|                     <div class="asset-info"> | ||||
|                         <h3 class="asset-title">{ &img.title }</h3> | ||||
|                         { if let Some(desc) = &img.description { | ||||
|                             html! { <p class="asset-description">{ desc }</p> } | ||||
|                         } else { html! {} }} | ||||
|                         <div class="asset-metadata"> | ||||
|                             <p><strong>{"Type:"}</strong> {"Image"}</p> | ||||
|                             <p><strong>{"Dimensions:"}</strong> { format!("{}×{}", img.width, img.height) }</p> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             DisplayLibraryItem::Pdf(pdf) => html! { | ||||
|                 <div class="card asset-details-card"> | ||||
|                     <button class="back-button" onclick={back_handler}> | ||||
|                         <i class="fas fa-arrow-left"></i> {"Back to Library"} | ||||
|                     </button> | ||||
|                     <div class="asset-preview"> | ||||
|                         <i class="fas fa-file-pdf asset-preview-icon"></i> | ||||
|                     </div> | ||||
|                     <div class="asset-info"> | ||||
|                         <h3 class="asset-title">{ &pdf.title }</h3> | ||||
|                         { if let Some(desc) = &pdf.description { | ||||
|                             html! { <p class="asset-description">{ desc }</p> } | ||||
|                         } else { html! {} }} | ||||
|                         <div class="asset-metadata"> | ||||
|                             <p><strong>{"Type:"}</strong> {"PDF Document"}</p> | ||||
|                             <p><strong>{"Pages:"}</strong> { pdf.page_count }</p> | ||||
|                         </div> | ||||
|                         <a href={pdf.url.clone()} target="_blank" class="external-link"> | ||||
|                             {"Open in new tab ↗"} | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             DisplayLibraryItem::Markdown(md) => html! { | ||||
|                 <div class="card asset-details-card"> | ||||
|                     <button class="back-button" onclick={back_handler}> | ||||
|                         <i class="fas fa-arrow-left"></i> {"Back to Library"} | ||||
|                     </button> | ||||
|                     <div class="asset-preview"> | ||||
|                         <i class="fab fa-markdown asset-preview-icon"></i> | ||||
|                     </div> | ||||
|                     <div class="asset-info"> | ||||
|                         <h3 class="asset-title">{ &md.title }</h3> | ||||
|                         { if let Some(desc) = &md.description { | ||||
|                             html! { <p class="asset-description">{ desc }</p> } | ||||
|                         } else { html! {} }} | ||||
|                         <div class="asset-metadata"> | ||||
|                             <p><strong>{"Type:"}</strong> {"Markdown Document"}</p> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             DisplayLibraryItem::Book(book) => html! { | ||||
|                 <div class="card asset-details-card"> | ||||
|                     <button class="back-button" onclick={back_handler}> | ||||
|                         <i class="fas fa-arrow-left"></i> {"Back to Library"} | ||||
|                     </button> | ||||
|                     <div class="asset-preview"> | ||||
|                         <i class="fas fa-book asset-preview-icon"></i> | ||||
|                     </div> | ||||
|                     <div class="asset-info"> | ||||
|                         <h3 class="asset-title">{ &book.title }</h3> | ||||
|                         { if let Some(desc) = &book.description { | ||||
|                             html! { <p class="asset-description">{ desc }</p> } | ||||
|                         } else { html! {} }} | ||||
|                         <div class="asset-metadata"> | ||||
|                             <p><strong>{"Pages:"}</strong> { book.pages.len() }</p> | ||||
|                         </div> | ||||
|                         { if !book.table_of_contents.is_empty() { | ||||
|                             html! { | ||||
|                                 <div class="table-of-contents"> | ||||
|                                     <h4>{"Table of Contents"}</h4> | ||||
|                                     { self.render_toc(ctx, &book.table_of_contents) } | ||||
|                                 </div> | ||||
|                             } | ||||
|                         } else { html! {} }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             DisplayLibraryItem::Slides(slides) => html! { | ||||
|                 <div class="card asset-details-card"> | ||||
|                     <button class="back-button" onclick={back_handler}> | ||||
|                         <i class="fas fa-arrow-left"></i> {"Back to Library"} | ||||
|                     </button> | ||||
|                     <div class="asset-preview"> | ||||
|                         <i class="fas fa-images asset-preview-icon"></i> | ||||
|                     </div> | ||||
|                     <div class="asset-info"> | ||||
|                         <h3 class="asset-title">{ &slides.title }</h3> | ||||
|                         { if let Some(desc) = &slides.description { | ||||
|                             html! { <p class="asset-description">{ desc }</p> } | ||||
|                         } else { html! {} }} | ||||
|                         <div class="asset-metadata"> | ||||
|                             <p><strong>{"Type:"}</strong> {"Slideshow"}</p> | ||||
|                             <p><strong>{"Slides:"}</strong> { slides.slide_urls.len() }</p> | ||||
|                             { if let Some(current_slide) = props.current_slide_index { | ||||
|                                 html! { <p><strong>{"Current Slide:"}</strong> { format!("{} / {}", current_slide + 1, slides.slide_urls.len()) }</p> } | ||||
|                             } else { html! {} }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl AssetDetailsCard { | ||||
|     fn render_toc(&self, ctx: &Context<Self>, toc: &[TocEntry]) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         html! { | ||||
|             <ul class="toc-list"> | ||||
|                 { toc.iter().map(|entry| { | ||||
|                     let page = entry.page as usize; | ||||
|                     let on_toc_click = props.on_toc_click.clone(); | ||||
|                     let onclick = Callback::from(move |_: MouseEvent| { | ||||
|                         on_toc_click.emit(page); | ||||
|                     }); | ||||
|                     html! { | ||||
|                         <li class="toc-item"> | ||||
|                             <button class="toc-link" onclick={onclick}> | ||||
|                                 { &entry.title } | ||||
|                             </button> | ||||
|                             { if !entry.subsections.is_empty() { | ||||
|                                 self.render_toc(ctx, &entry.subsections) | ||||
|                             } else { html! {} }} | ||||
|                         </li> | ||||
|                     } | ||||
|                 }).collect::<Html>() } | ||||
|             </ul> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										67
									
								
								src/app/src/components/auth_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/app/src/components/auth_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| use crate::auth::types::AuthState; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct AuthViewProps { | ||||
|     pub auth_state: AuthState, | ||||
|     pub on_logout: Callback<()>,  | ||||
|     pub on_login: Callback<()>,  // New callback for login | ||||
| } | ||||
|  | ||||
| #[function_component(AuthView)] | ||||
| pub fn auth_view(props: &AuthViewProps) -> Html { | ||||
|     match &props.auth_state { | ||||
|         AuthState::Authenticated { public_key, .. } => { | ||||
|             let on_logout = props.on_logout.clone(); | ||||
|             let logout_onclick = Callback::from(move |_| { | ||||
|                 on_logout.emit(()); | ||||
|             }); | ||||
|  | ||||
|             // Truncate the public key for display | ||||
|             let pk_short = if public_key.len() > 10 { | ||||
|                 format!("{}...{}", &public_key[..4], &public_key[public_key.len()-4..]) | ||||
|             } else { | ||||
|                 public_key.clone() | ||||
|             }; | ||||
|  | ||||
|             html! { | ||||
|                 <div class="auth-view-container"> | ||||
|                     <span class="public-key" title={public_key.clone()}>{ format!("PK: {}", pk_short) }</span> | ||||
|                     <button | ||||
|                         class="logout-button" | ||||
|                         onclick={logout_onclick} | ||||
|                         title="Logout" | ||||
|                     > | ||||
|                         <i class="fas fa-sign-out-alt"></i> | ||||
|                     </button> | ||||
|                 </div> | ||||
|             } | ||||
|         } | ||||
|         AuthState::NotAuthenticated | AuthState::Failed(_) => { | ||||
|             let on_login = props.on_login.clone(); | ||||
|             let login_onclick = Callback::from(move |_| { | ||||
|                 on_login.emit(()); | ||||
|             }); | ||||
|  | ||||
|             html! { | ||||
|                 <div class="auth-info"> | ||||
|                     <span class="auth-status">{ "Not Authenticated" }</span> | ||||
|                     <button | ||||
|                         class="login-button" | ||||
|                         onclick={login_onclick} | ||||
|                         title="Login" | ||||
|                     > | ||||
|                         <i class="fas fa-sign-in-alt"></i> | ||||
|                     </button> | ||||
|                 </div> | ||||
|             } | ||||
|         } | ||||
|         AuthState::Authenticating => { | ||||
|              html! { | ||||
|                 <div class="auth-info"> | ||||
|                     <span class="auth-status">{ "Authenticating..." }</span> | ||||
|                 </div> | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										155
									
								
								src/app/src/components/book_viewer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								src/app/src/components/book_viewer.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,155 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::{Book, TocEntry}; | ||||
|  | ||||
| #[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, | ||||
| } | ||||
|  | ||||
| impl Component for BookViewer { | ||||
|     type Message = BookViewerMsg; | ||||
|     type Properties = BookViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_page: 0, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             BookViewerMsg::GoToPage(page) => { | ||||
|                 self.current_page = page; | ||||
|                 true | ||||
|             } | ||||
|             BookViewerMsg::NextPage => { | ||||
|                 let props = _ctx.props(); | ||||
|                 if self.current_page < props.book.pages.len().saturating_sub(1) { | ||||
|                     self.current_page += 1; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             BookViewerMsg::PrevPage => { | ||||
|                 if self.current_page > 0 { | ||||
|                     self.current_page -= 1; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let total_pages = props.book.pages.len(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|          | ||||
|         let prev_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::PrevPage); | ||||
|         let next_handler = ctx.link().callback(|_: MouseEvent| BookViewerMsg::NextPage); | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer book-viewer"> | ||||
|                 <button class="back-button" onclick={back_handler}> | ||||
|                     <i class="fas fa-arrow-left"></i> {"Back to Collection"} | ||||
|                 </button> | ||||
|                 <div class="viewer-header"> | ||||
|                     <h2 class="viewer-title">{ &props.book.title }</h2> | ||||
|                     <div class="book-navigation"> | ||||
|                         <button | ||||
|                             class="nav-button" | ||||
|                             onclick={prev_handler} | ||||
|                             disabled={self.current_page == 0} | ||||
|                         > | ||||
|                             <i class="fas fa-chevron-left"></i> {"Previous"} | ||||
|                         </button> | ||||
|                         <span class="page-indicator"> | ||||
|                             { format!("Page {} of {}", self.current_page + 1, total_pages) } | ||||
|                         </span> | ||||
|                         <button | ||||
|                             class="nav-button" | ||||
|                             onclick={next_handler} | ||||
|                             disabled={self.current_page >= total_pages.saturating_sub(1)} | ||||
|                         > | ||||
|                             {"Next"} <i class="fas fa-chevron-right"></i> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="viewer-content"> | ||||
|                     <div class="book-page"> | ||||
|                         { if let Some(page_content) = props.book.pages.get(self.current_page) { | ||||
|                             self.render_markdown(page_content) | ||||
|                         } else { | ||||
|                             html! { <p>{"Page not found"}</p> } | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl BookViewer { | ||||
|     fn render_markdown(&self, content: &str) -> Html { | ||||
|         // Simple markdown rendering - convert basic markdown to HTML | ||||
|         let lines: Vec<&str> = content.lines().collect(); | ||||
|         let mut html_content = Vec::new(); | ||||
|  | ||||
|         for line in lines { | ||||
|             if line.starts_with("# ") { | ||||
|                 html_content.push(html! { <h1>{ &line[2..] }</h1> }); | ||||
|             } else if line.starts_with("## ") { | ||||
|                 html_content.push(html! { <h2>{ &line[3..] }</h2> }); | ||||
|             } else if line.starts_with("### ") { | ||||
|                 html_content.push(html! { <h3>{ &line[4..] }</h3> }); | ||||
|             } else if line.starts_with("- ") { | ||||
|                 html_content.push(html! { <li>{ &line[2..] }</li> }); | ||||
|             } else if line.starts_with("**") && line.ends_with("**") { | ||||
|                 let text = &line[2..line.len()-2]; | ||||
|                 html_content.push(html! { <p><strong>{ text }</strong></p> }); | ||||
|             } else if !line.trim().is_empty() { | ||||
|                 html_content.push(html! { <p>{ line }</p> }); | ||||
|             } else { | ||||
|                 html_content.push(html! { <br/> }); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         html! { <div>{ for html_content }</div> } | ||||
|     } | ||||
|  | ||||
|     pub fn render_toc(&self, ctx: &Context<Self>, toc: &[TocEntry]) -> Html { | ||||
|         html! { | ||||
|             <ul class="toc-list"> | ||||
|                 { toc.iter().map(|entry| { | ||||
|                     let page = entry.page as usize; | ||||
|                     let onclick = ctx.link().callback(move |_: MouseEvent| BookViewerMsg::GoToPage(page)); | ||||
|                     html! { | ||||
|                         <li class="toc-item"> | ||||
|                             <button class="toc-link" onclick={onclick}> | ||||
|                                 { &entry.title } | ||||
|                             </button> | ||||
|                             { if !entry.subsections.is_empty() { | ||||
|                                 self.render_toc(ctx, &entry.subsections) | ||||
|                             } else { html! {} }} | ||||
|                         </li> | ||||
|                     } | ||||
|                 }).collect::<Html>() } | ||||
|             </ul> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										665
									
								
								src/app/src/components/chat.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										665
									
								
								src/app/src/components/chat.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,665 @@ | ||||
| use yew::prelude::*; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use wasm_bindgen::JsCast; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub struct ChatMessage { | ||||
|     pub id: usize, | ||||
|     pub content: String, | ||||
|     pub sender: ChatSender, | ||||
|     pub timestamp: String, | ||||
|     pub title: Option<String>, | ||||
|     pub description: Option<String>, | ||||
|     pub status: Option<String>, | ||||
|     pub format: String, | ||||
|     pub source: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum ChatSender { | ||||
|     User, | ||||
|     Assistant, | ||||
|     System, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum InputType { | ||||
|     Text, | ||||
|     Code, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub struct ChatResponse { | ||||
|     pub data: Vec<u8>, | ||||
|     pub format: String, | ||||
|     pub source: String, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub struct Conversation { | ||||
|     pub id: u32, | ||||
|     pub title: String, | ||||
|     pub messages: Vec<ChatMessage>, | ||||
|     pub created_at: String, | ||||
|     pub last_updated: String, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub struct ConversationSummary { | ||||
|     pub id: u32, | ||||
|     pub title: String, | ||||
|     pub last_message_preview: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ChatInterfaceProps { | ||||
|     pub on_process_message: Callback<(Vec<u8>, String, Callback<ChatResponse>)>, // (data, format, response_callback) | ||||
|     pub placeholder: String, | ||||
|     pub show_title_description: bool, | ||||
|     pub conversation_title: Option<String>, | ||||
|     pub input_type: Option<InputType>, | ||||
|     pub input_format: Option<String>, | ||||
|     #[prop_or_default] | ||||
|     pub on_conversations_updated: Option<Callback<Vec<ConversationSummary>>>, | ||||
|     #[prop_or_default] | ||||
|     pub active_conversation_id: Option<u32>, | ||||
|     #[prop_or_default] | ||||
|     pub on_conversation_selected: Option<Callback<u32>>, | ||||
|     #[prop_or_default] | ||||
|     pub external_conversation_selection: Option<u32>, | ||||
|     #[prop_or_default] | ||||
|     pub external_new_conversation_trigger: Option<bool>, | ||||
| } | ||||
|  | ||||
| pub struct ChatInterface { | ||||
|     conversations: HashMap<u32, Conversation>, | ||||
|     active_conversation_id: Option<u32>, | ||||
|     current_input: String, | ||||
|     current_title: Option<String>, | ||||
|     current_description: Option<String>, | ||||
|     next_message_id: usize, | ||||
|     next_conversation_id: u32, | ||||
| } | ||||
|  | ||||
| pub enum ChatMsg { | ||||
|     UpdateInput(String), | ||||
|     UpdateTitle(String), | ||||
|     UpdateDescription(String), | ||||
|     SendMessage, | ||||
|     AddResponse(ChatResponse), | ||||
|     NewConversation, | ||||
|     SelectConversation(u32), | ||||
|     LoadConversation(u32), | ||||
| } | ||||
|  | ||||
| impl Component for ChatInterface { | ||||
|     type Message = ChatMsg; | ||||
|     type Properties = ChatInterfaceProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let mut chat_interface = Self { | ||||
|             conversations: HashMap::new(), | ||||
|             active_conversation_id: ctx.props().active_conversation_id, | ||||
|             current_input: String::new(), | ||||
|             current_title: None, | ||||
|             current_description: None, | ||||
|             next_message_id: 0, | ||||
|             next_conversation_id: 1, | ||||
|         }; | ||||
|          | ||||
|         // Create initial conversation if none exists | ||||
|         if chat_interface.active_conversation_id.is_none() { | ||||
|             chat_interface.create_new_conversation(); | ||||
|             // Notify parent immediately of the new conversation | ||||
|             if let Some(callback) = &ctx.props().on_conversations_updated { | ||||
|                 let summaries = chat_interface.get_conversation_summaries(); | ||||
|                 callback.emit(summaries); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         chat_interface | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             ChatMsg::UpdateInput(input) => { | ||||
|                 self.current_input = input; | ||||
|                 false | ||||
|             } | ||||
|             ChatMsg::UpdateTitle(title) => { | ||||
|                 self.current_title = Some(title); | ||||
|                 false | ||||
|             } | ||||
|             ChatMsg::UpdateDescription(description) => { | ||||
|                 self.current_description = Some(description); | ||||
|                 false | ||||
|             } | ||||
|             ChatMsg::SendMessage => { | ||||
|                 if !self.current_input.trim().is_empty() { | ||||
|                     // Ensure we have an active conversation | ||||
|                     if self.active_conversation_id.is_none() { | ||||
|                         self.create_new_conversation(); | ||||
|                     } | ||||
|                      | ||||
|                     let conversation_id = self.active_conversation_id.unwrap(); | ||||
|                      | ||||
|                     // Add user message to active conversation | ||||
|                     let input_format = ctx.props().input_format.clone().unwrap_or_else(|| "text".to_string()); | ||||
|                     let user_message = ChatMessage { | ||||
|                         id: self.next_message_id, | ||||
|                         content: self.current_input.clone(), | ||||
|                         sender: ChatSender::User, | ||||
|                         timestamp: chrono::Utc::now().to_rfc3339(), | ||||
|                         title: self.current_title.clone(), | ||||
|                         description: self.current_description.clone(), | ||||
|                         status: None, | ||||
|                         format: input_format.clone(), | ||||
|                         source: None, | ||||
|                     }; | ||||
|                      | ||||
|                     if let Some(conversation) = self.conversations.get_mut(&conversation_id) { | ||||
|                         conversation.messages.push(user_message); | ||||
|                         conversation.last_updated = chrono::Utc::now().to_rfc3339(); | ||||
|                          | ||||
|                         // Update conversation title if it's the first message | ||||
|                         if conversation.messages.len() == 1 { | ||||
|                             let title = if self.current_input.len() > 50 { | ||||
|                                 format!("{}...", &self.current_input[..47]) | ||||
|                             } else { | ||||
|                                 self.current_input.clone() | ||||
|                             }; | ||||
|                             conversation.title = title; | ||||
|                         } | ||||
|                     } | ||||
|                      | ||||
|                     self.next_message_id += 1; | ||||
|  | ||||
|                     // Process message through callback with response handler | ||||
|                     let input_data = self.current_input.as_bytes().to_vec(); | ||||
|                      | ||||
|                     // Create response callback that adds responses to chat | ||||
|                     let link = ctx.link().clone(); | ||||
|                     let response_callback = Callback::from(move |response: ChatResponse| { | ||||
|                         link.send_message(ChatMsg::AddResponse(response)); | ||||
|                     }); | ||||
|                      | ||||
|                     // Trigger processing with response callback | ||||
|                     ctx.props().on_process_message.emit((input_data, input_format, response_callback)); | ||||
|  | ||||
|                     // Clear inputs | ||||
|                     self.current_input.clear(); | ||||
|                     self.current_title = None; | ||||
|                     self.current_description = None; | ||||
|                      | ||||
|                     // Notify parent of conversation updates | ||||
|                     self.notify_conversations_updated(ctx); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             ChatMsg::AddResponse(response) => { | ||||
|                 if let Some(conversation_id) = self.active_conversation_id { | ||||
|                     // Add response from async callback to active conversation | ||||
|                     let response_content = String::from_utf8_lossy(&response.data).to_string(); | ||||
|                      | ||||
|                     // Use the format provided by the response to determine status | ||||
|                     let status = match response.format.as_str() { | ||||
|                         "error" => "Error".to_string(), | ||||
|                         _ => "Ok".to_string(), | ||||
|                     }; | ||||
|                      | ||||
|                     let response_message = ChatMessage { | ||||
|                         id: self.next_message_id, | ||||
|                         content: response_content, | ||||
|                         sender: ChatSender::Assistant, | ||||
|                         timestamp: chrono::Utc::now().to_rfc3339(), | ||||
|                         title: None, | ||||
|                         description: None, | ||||
|                         status: Some(status), | ||||
|                         format: response.format.clone(), | ||||
|                         source: Some(response.source.clone()), | ||||
|                     }; | ||||
|                      | ||||
|                     if let Some(conversation) = self.conversations.get_mut(&conversation_id) { | ||||
|                         conversation.messages.push(response_message); | ||||
|                         conversation.last_updated = chrono::Utc::now().to_rfc3339(); | ||||
|                     } | ||||
|                      | ||||
|                     self.next_message_id += 1; | ||||
|                      | ||||
|                     // Notify parent of conversation updates | ||||
|                     self.notify_conversations_updated(ctx); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             ChatMsg::NewConversation => { | ||||
|                 self.create_new_conversation(); | ||||
|                 self.notify_conversations_updated(ctx); | ||||
|                 if let Some(callback) = &ctx.props().on_conversation_selected { | ||||
|                     if let Some(id) = self.active_conversation_id { | ||||
|                         callback.emit(id); | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             ChatMsg::SelectConversation(conversation_id) => { | ||||
|                 if self.conversations.contains_key(&conversation_id) { | ||||
|                     self.active_conversation_id = Some(conversation_id); | ||||
|                     true | ||||
|                 } else { | ||||
|                     false | ||||
|                 } | ||||
|             } | ||||
|             ChatMsg::LoadConversation(conversation_id) => { | ||||
|                 self.active_conversation_id = Some(conversation_id); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         let mut should_update = false; | ||||
|          | ||||
|         // Handle external conversation selection | ||||
|         if let Some(new_active_id) = ctx.props().external_conversation_selection { | ||||
|             if old_props.external_conversation_selection != Some(new_active_id) { | ||||
|                 if self.conversations.contains_key(&new_active_id) { | ||||
|                     self.active_conversation_id = Some(new_active_id); | ||||
|                     should_update = true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Handle external new conversation trigger | ||||
|         if let Some(trigger) = ctx.props().external_new_conversation_trigger { | ||||
|             if old_props.external_new_conversation_trigger != Some(trigger) && trigger { | ||||
|                 self.create_new_conversation(); | ||||
|                 self.notify_conversations_updated(ctx); | ||||
|                 should_update = true; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         should_update | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|         let on_input = { | ||||
|             let link = ctx.link().clone(); | ||||
|             Callback::from(move |e: InputEvent| { | ||||
|                 let target = e.target().unwrap(); | ||||
|                 let value = if let Ok(input) = target.clone().dyn_into::<web_sys::HtmlInputElement>() { | ||||
|                     input.value() | ||||
|                 } else if let Ok(textarea) = target.dyn_into::<web_sys::HtmlTextAreaElement>() { | ||||
|                     textarea.value() | ||||
|                 } else { | ||||
|                     String::new() | ||||
|                 }; | ||||
|                 link.send_message(ChatMsg::UpdateInput(value)); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let on_title = { | ||||
|             let link = ctx.link().clone(); | ||||
|             Callback::from(move |e: InputEvent| { | ||||
|                 let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|                 link.send_message(ChatMsg::UpdateTitle(input.value())); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let on_description = { | ||||
|             let link = ctx.link().clone(); | ||||
|             Callback::from(move |e: InputEvent| { | ||||
|                 let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|                 link.send_message(ChatMsg::UpdateDescription(input.value())); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let on_submit = { | ||||
|             let link = ctx.link().clone(); | ||||
|             Callback::from(move |e: SubmitEvent| { | ||||
|                 e.prevent_default(); | ||||
|                 link.send_message(ChatMsg::SendMessage); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         // Get current conversation messages | ||||
|         let empty_messages = Vec::new(); | ||||
|         let current_messages = if let Some(conversation_id) = self.active_conversation_id { | ||||
|             self.conversations.get(&conversation_id) | ||||
|                 .map(|conv| &conv.messages) | ||||
|                 .unwrap_or(&empty_messages) | ||||
|         } else { | ||||
|             &empty_messages | ||||
|         }; | ||||
|  | ||||
|         // Get conversation title | ||||
|         let conversation_title = if let Some(conversation_id) = self.active_conversation_id { | ||||
|             self.conversations.get(&conversation_id) | ||||
|                 .map(|conv| conv.title.clone()) | ||||
|                 .or_else(|| props.conversation_title.clone()) | ||||
|         } else { | ||||
|             props.conversation_title.clone() | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="chat-panel"> | ||||
|                 <div class="messages-display"> | ||||
|                     { | ||||
|                         if let Some(title) = &conversation_title { | ||||
|                             html! { <h4>{ title }</h4> } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         } | ||||
|                     } | ||||
|                     { | ||||
|                         if current_messages.is_empty() { | ||||
|                             html! { <p class="empty-message">{ "No messages yet. Start the conversation!" }</p> } | ||||
|                         } else { | ||||
|                             html! { | ||||
|                                 <> | ||||
|                                     { for current_messages.iter().map(|msg| view_chat_message(msg)) } | ||||
|                                 </> | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 </div> | ||||
|                 <form onsubmit={on_submit} class="input-area"> | ||||
|                     { | ||||
|                         if props.show_title_description { | ||||
|                             html! { | ||||
|                                 <> | ||||
|                                     <input | ||||
|                                         type="text" | ||||
|                                         class="input-base title-input" | ||||
|                                         placeholder="Title for this message..." | ||||
|                                         value={self.current_title.clone().unwrap_or_default()} | ||||
|                                         oninput={on_title} | ||||
|                                     /> | ||||
|                                     <input | ||||
|                                         type="text" | ||||
|                                         class="input-base description-input" | ||||
|                                         placeholder="Description..." | ||||
|                                         value={self.current_description.clone().unwrap_or_default()} | ||||
|                                         oninput={on_description} | ||||
|                                     /> | ||||
|                                 </> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         } | ||||
|                     } | ||||
|                     { | ||||
|                         match props.input_type.as_ref().unwrap_or(&InputType::Text) { | ||||
|                             InputType::Code => { | ||||
|                                 let mut class = "input-base chat-input code-input".to_string(); | ||||
|                                 if let Some(format) = &props.input_format { | ||||
|                                     class.push_str(&format!(" format-{}", format)); | ||||
|                                 } | ||||
|                                 html! { | ||||
|                                     <textarea | ||||
|                                         class={class} | ||||
|                                         placeholder={props.placeholder.clone()} | ||||
|                                         value={self.current_input.clone()} | ||||
|                                         oninput={on_input} | ||||
|                                         rows="8" | ||||
|                                     /> | ||||
|                                 } | ||||
|                             } | ||||
|                             InputType::Text => { | ||||
|                                 html! { | ||||
|                                     <input | ||||
|                                         type="text" | ||||
|                                         class="input-base chat-input" | ||||
|                                         placeholder={props.placeholder.clone()} | ||||
|                                         value={self.current_input.clone()} | ||||
|                                         oninput={on_input} | ||||
|                                     /> | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     <button type="submit" class="button-base button-primary send-button">{ "Send" }</button> | ||||
|                 </form> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl ChatInterface { | ||||
|     fn create_new_conversation(&mut self) { | ||||
|         let now = chrono::Utc::now().to_rfc3339(); | ||||
|         let conversation = Conversation { | ||||
|             id: self.next_conversation_id, | ||||
|             title: format!("New Conversation {}", self.next_conversation_id), | ||||
|             messages: Vec::new(), | ||||
|             created_at: now.clone(), | ||||
|             last_updated: now, | ||||
|         }; | ||||
|          | ||||
|         self.conversations.insert(self.next_conversation_id, conversation); | ||||
|         self.active_conversation_id = Some(self.next_conversation_id); | ||||
|         self.next_conversation_id += 1; | ||||
|     } | ||||
|      | ||||
|     fn notify_conversations_updated(&self, ctx: &Context<Self>) { | ||||
|         if let Some(callback) = &ctx.props().on_conversations_updated { | ||||
|             let summaries = self.get_conversation_summaries(); | ||||
|             callback.emit(summaries); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn get_conversation_summaries(&self) -> Vec<ConversationSummary> { | ||||
|         let mut summaries: Vec<_> = self.conversations.values() | ||||
|             .map(|conv| { | ||||
|                 let last_message_preview = conv.messages.last() | ||||
|                     .map(|msg| { | ||||
|                         if msg.content.len() > 50 { | ||||
|                             format!("{}...", &msg.content[..47]) | ||||
|                         } else { | ||||
|                             msg.content.clone() | ||||
|                         } | ||||
|                     }); | ||||
|                  | ||||
|                 ConversationSummary { | ||||
|                     id: conv.id, | ||||
|                     title: conv.title.clone(), | ||||
|                     last_message_preview, | ||||
|                 } | ||||
|             }) | ||||
|             .collect(); | ||||
|          | ||||
|         // Sort by last updated (most recent first) | ||||
|         summaries.sort_by(|a, b| { | ||||
|             let a_conv = self.conversations.get(&a.id).unwrap(); | ||||
|             let b_conv = self.conversations.get(&b.id).unwrap(); | ||||
|             b_conv.last_updated.cmp(&a_conv.last_updated) | ||||
|         }); | ||||
|          | ||||
|         summaries | ||||
|     } | ||||
|      | ||||
|     pub fn new_conversation(&mut self) -> u32 { | ||||
|         self.create_new_conversation(); | ||||
|         self.active_conversation_id.unwrap() | ||||
|     } | ||||
|      | ||||
|     pub fn select_conversation(&mut self, conversation_id: u32) -> bool { | ||||
|         if self.conversations.contains_key(&conversation_id) { | ||||
|             self.active_conversation_id = Some(conversation_id); | ||||
|             true | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     pub fn get_conversations(&self) -> Vec<ConversationSummary> { | ||||
|         self.get_conversation_summaries() | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn view_chat_message(msg: &ChatMessage) -> Html { | ||||
|     let timestamp = format_timestamp(&msg.timestamp); | ||||
|     let sender_class = match msg.sender { | ||||
|         ChatSender::User => "user-message", | ||||
|         ChatSender::Assistant => "ai-message", | ||||
|         ChatSender::System => "system-message", | ||||
|     }; | ||||
|      | ||||
|     // Use source name for responses, fallback to default names | ||||
|     let sender_name = match msg.sender { | ||||
|         ChatSender::User => "You".to_string(), | ||||
|         ChatSender::Assistant => { | ||||
|             msg.source.as_ref().unwrap_or(&"Assistant".to_string()).clone() | ||||
|         }, | ||||
|         ChatSender::System => "System".to_string(), | ||||
|     }; | ||||
|  | ||||
|     // Add format-specific classes | ||||
|     let mut message_classes = vec!["message".to_string(), sender_class.to_string()]; | ||||
|     message_classes.push(format!("format-{}", msg.format)); | ||||
|      | ||||
|     // Add error class if it's an error message | ||||
|     if msg.status.as_ref().map_or(false, |s| s == "Error") { | ||||
|         message_classes.push("message-error".to_string()); | ||||
|     } | ||||
|  | ||||
|     html! { | ||||
|         <div class={classes!(message_classes)} key={msg.id.to_string()}> | ||||
|             <div class="message-header"> | ||||
|                 <span class="sender">{ sender_name }</span> | ||||
|                 <span class="timestamp">{ timestamp }</span> | ||||
|                 { | ||||
|                     if let Some(status) = &msg.status { | ||||
|                         let status_class = match status.as_str() { | ||||
|                             "Ok" => "status-ok", | ||||
|                             "Error" => "status-error", | ||||
|                             _ => "status-pending", | ||||
|                         }; | ||||
|                         html! { | ||||
|                             <span class={classes!("status", status_class)}>{ status }</span> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|             <div class="message-content"> | ||||
|                 { | ||||
|                     if let Some(title) = &msg.title { | ||||
|                         html! { <div class="message-title">{ title }</div> } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|                 { | ||||
|                     if let Some(description) = &msg.description { | ||||
|                         html! { <div class="message-description">{ description }</div> } | ||||
|                     } else { | ||||
|                         html! {} | ||||
|                     } | ||||
|                 } | ||||
|                 { render_message_content(&msg.content, &msg.format) } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_message_content(content: &str, format: &str) -> Html { | ||||
|     match format { | ||||
|         "rhai" => render_code_with_line_numbers(content, "rhai"), | ||||
|         "error" => html! { | ||||
|             <div class="message-text error-content"> | ||||
|                 <div class="error-icon">{"⚠️"}</div> | ||||
|                 <div class="error-text">{ content }</div> | ||||
|             </div> | ||||
|         }, | ||||
|         _ => html! { | ||||
|             <div class="message-text">{ content }</div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_code_with_line_numbers(content: &str, language: &str) -> Html { | ||||
|     let lines: Vec<&str> = content.lines().collect(); | ||||
|      | ||||
|     html! { | ||||
|         <div class={format!("code-block language-{}", language)}> | ||||
|             <div class="code-header"> | ||||
|                 <span class="language-label">{ language.to_uppercase() }</span> | ||||
|             </div> | ||||
|             <div class="code-content"> | ||||
|                 <div class="line-numbers"> | ||||
|                     { for (1..=lines.len()).map(|i| html! { | ||||
|                         <div class="line-number">{ i }</div> | ||||
|                     }) } | ||||
|                 </div> | ||||
|                 <div class="code-lines"> | ||||
|                     { for lines.iter().map(|line| html! { | ||||
|                         <div class="code-line">{ line }</div> | ||||
|                     }) } | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_timestamp(timestamp_str: &str) -> String { | ||||
|     match DateTime::parse_from_rfc3339(timestamp_str) { | ||||
|         Ok(dt) => dt.with_timezone(&Utc).format("%H:%M").to_string(), | ||||
|         Err(_) => timestamp_str.to_string(), | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct ConversationListProps { | ||||
|     pub conversations: Vec<ConversationSummary>, | ||||
|     pub active_conversation_id: Option<u32>, | ||||
|     pub on_select_conversation: Callback<u32>, | ||||
|     pub on_new_conversation: Callback<()>, | ||||
|     pub title: String, | ||||
| } | ||||
|  | ||||
| #[function_component(ConversationList)] | ||||
| pub fn conversation_list(props: &ConversationListProps) -> Html { | ||||
|     let on_new = { | ||||
|         let on_new_conversation = props.on_new_conversation.clone(); | ||||
|         Callback::from(move |_| { | ||||
|             on_new_conversation.emit(()); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="card"> | ||||
|             <h3>{ &props.title }</h3> | ||||
|             <button onclick={on_new} class="new-conversation-btn">{ "+ New Chat" }</button> | ||||
|             <ul> | ||||
|                 { for props.conversations.iter().map(|conv| { | ||||
|                     let conv_id = conv.id; | ||||
|                     let is_active = props.active_conversation_id == Some(conv_id); | ||||
|                     let class_name = if is_active { "active-conversation-item" } else { "conversation-item" }; | ||||
|                     let on_select = { | ||||
|                         let on_select_conversation = props.on_select_conversation.clone(); | ||||
|                         Callback::from(move |_| { | ||||
|                             on_select_conversation.emit(conv_id); | ||||
|                         }) | ||||
|                     }; | ||||
|                      | ||||
|                     html! { | ||||
|                         <li class={class_name} onclick={on_select} key={conv_id.to_string()}> | ||||
|                             <div class="conversation-title">{ &conv.title }</div> | ||||
|                             { | ||||
|                                 if let Some(preview) = &conv.last_message_preview { | ||||
|                                     html! { <div class="conversation-preview">{ preview }</div> } | ||||
|                                 } else { | ||||
|                                     html! {} | ||||
|                                 } | ||||
|                             } | ||||
|                         </li> | ||||
|                     } | ||||
|                 }) } | ||||
|             </ul> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										456
									
								
								src/app/src/components/circles_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										456
									
								
								src/app/src/components/circles_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,456 @@ | ||||
| use heromodels::models::circle::Circle; | ||||
| use yew::prelude::*; | ||||
| use yew::functional::Reducible; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use web_sys::WheelEvent; | ||||
|  | ||||
| use crate::ws_manager::fetch_data_from_ws_url; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| struct RotationState { | ||||
|     value: i32, | ||||
| } | ||||
|  | ||||
| enum RotationAction { | ||||
|     Rotate(i32), | ||||
| } | ||||
|  | ||||
| impl Reducible for RotationState { | ||||
|     type Action = RotationAction; | ||||
|  | ||||
|     fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> { | ||||
|         let next_value = match action { | ||||
|             RotationAction::Rotate(change) => self.value + change, | ||||
|         }; | ||||
|         RotationState { value: next_value }.into() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct CirclesViewProps { | ||||
|     pub default_center_ws_url: String, // The starting center circle WebSocket URL | ||||
|     pub on_context_update: Callback<Vec<String>>, // Single callback for context updates | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub enum CirclesViewMsg { | ||||
|     CenterCircleFetched(Circle), | ||||
|     SurroundingCircleFetched(String, Result<Circle, String>), // ws_url, result | ||||
|     CircleClicked(String), | ||||
|     BackgroundClicked, | ||||
|     RotateCircles(i32), // rotation delta | ||||
| } | ||||
|  | ||||
| pub struct CirclesView { | ||||
|     // Two primary dynamic states | ||||
|     center_circle: String, | ||||
|     is_selected: bool, | ||||
|      | ||||
|     // Supporting state | ||||
|     circles: HashMap<String, Circle>, | ||||
|     navigation_stack: Vec<String>, | ||||
|     loading_states: HashMap<String, bool>, | ||||
|      | ||||
|     // Rotation state for surrounding circles | ||||
|     rotation_value: i32, | ||||
| } | ||||
|  | ||||
| impl Component for CirclesView { | ||||
|     type Message = CirclesViewMsg; | ||||
|     type Properties = CirclesViewProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let props = ctx.props(); | ||||
|         let center_ws_url = props.default_center_ws_url.clone(); | ||||
|          | ||||
|         log::info!("CirclesView: Creating component with center circle: {}", center_ws_url); | ||||
|          | ||||
|         let mut component = Self { | ||||
|             center_circle: center_ws_url.clone(), | ||||
|             is_selected: false, | ||||
|             circles: HashMap::new(), | ||||
|             navigation_stack: vec![center_ws_url.clone()], | ||||
|             loading_states: HashMap::new(), | ||||
|             rotation_value: 0, | ||||
|         }; | ||||
|  | ||||
|         // Fetch center circle immediately | ||||
|         component.fetch_center_circle(ctx, ¢er_ws_url); | ||||
|  | ||||
|         component | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             CirclesViewMsg::CenterCircleFetched(mut circle) => { | ||||
|                 log::info!("CirclesView: Center circle fetched: {}", circle.title); | ||||
|                  | ||||
|                 // Ensure circle has correct ws_url | ||||
|                 if circle.ws_url.is_empty() { | ||||
|                     circle.ws_url = self.center_circle.clone(); | ||||
|                 } | ||||
|                  | ||||
|                 // Store center circle | ||||
|                 self.circles.insert(circle.ws_url.clone(), circle.clone()); | ||||
|                  | ||||
|                 // Start fetching surrounding circles progressively | ||||
|                 self.start_surrounding_circles_fetch(ctx, &circle); | ||||
|                  | ||||
|                 // Update context immediately with center circle | ||||
|                 self.update_circles_context(ctx); | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|             CirclesViewMsg::SurroundingCircleFetched(ws_url, result) => { | ||||
|                 log::debug!("CirclesView: Surrounding circle fetch result for {}: {:?}", ws_url, result.is_ok()); | ||||
|                  | ||||
|                 // Remove from loading states | ||||
|                 self.loading_states.remove(&ws_url); | ||||
|                  | ||||
|                 match result { | ||||
|                     Ok(mut circle) => { | ||||
|                         // Ensure circle has correct ws_url | ||||
|                         if circle.ws_url.is_empty() { | ||||
|                             circle.ws_url = ws_url.clone(); | ||||
|                         } | ||||
|                          | ||||
|                         // Store the circle | ||||
|                         self.circles.insert(ws_url, circle); | ||||
|                          | ||||
|                         // Update context with new circle available | ||||
|                         self.update_circles_context(ctx); | ||||
|                     } | ||||
|                     Err(error) => { | ||||
|                         log::error!("CirclesView: Failed to fetch circle {}: {}", ws_url, error); | ||||
|                         // Continue without this circle - don't block the UI | ||||
|                     } | ||||
|                 } | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|             CirclesViewMsg::CircleClicked(ws_url) => { | ||||
|                 self.handle_circle_click(ctx, ws_url) | ||||
|             } | ||||
|             CirclesViewMsg::BackgroundClicked => { | ||||
|                 self.handle_background_click(ctx) | ||||
|             } | ||||
|             CirclesViewMsg::RotateCircles(delta) => { | ||||
|                 self.rotation_value += delta; | ||||
|                 log::debug!("CirclesView: Rotation updated to: {}", self.rotation_value); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         log::debug!("CirclesView: Rendering view. Center: {}, Circles loaded: {}, Selected: {}", | ||||
|                    self.center_circle, self.circles.len(), self.is_selected); | ||||
|          | ||||
|         let center_circle_data = self.circles.get(&self.center_circle); | ||||
|          | ||||
|         // Get surrounding circles only if center is not selected | ||||
|         let surrounding_circles_data: Vec<&Circle> = if self.is_selected { | ||||
|             Vec::new() | ||||
|         } else { | ||||
|             // Get surrounding circles from center circle's circles field | ||||
|             if let Some(center_data) = center_circle_data { | ||||
|                 center_data.circles.iter() | ||||
|                     .filter_map(|ws_url| self.circles.get(ws_url)) | ||||
|                     .collect() | ||||
|             } else { | ||||
|                 Vec::new() | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         let link = ctx.link(); | ||||
|         let on_background_click_handler = link.callback(|_: MouseEvent| CirclesViewMsg::BackgroundClicked); | ||||
|          | ||||
|         // Add wheel event handler for rotation | ||||
|         let on_wheel_handler = { | ||||
|             let link = link.clone(); | ||||
|             Callback::from(move |e: WheelEvent| { | ||||
|                 e.prevent_default(); | ||||
|                 let delta = if e.delta_y() > 0.0 { 10 } else { -10 }; | ||||
|                 link.send_message(CirclesViewMsg::RotateCircles(delta)); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let petals_html: Vec<Html> = surrounding_circles_data.iter().enumerate().map(|(original_idx, circle_data)| { | ||||
|             // Calculate rotated position index based on rotation value | ||||
|             let total_circles = surrounding_circles_data.len(); | ||||
|             let rotation_steps = (self.rotation_value / 60) % total_circles as i32; // 60 degrees per step | ||||
|             let rotated_idx = ((original_idx as i32 + rotation_steps) % total_circles as i32 + total_circles as i32) % total_circles as i32; | ||||
|              | ||||
|             self.render_circle_element( | ||||
|                 circle_data, | ||||
|                 false, // is_center | ||||
|                 Some(rotated_idx as usize), // rotated position_index | ||||
|                 link, | ||||
|             ) | ||||
|         }).collect(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="circles-view" | ||||
|                  onclick={on_background_click_handler} | ||||
|                  onwheel={on_wheel_handler}> | ||||
|                 <div class="flower-container"> | ||||
|                     {if let Some(center_data) = center_circle_data { | ||||
|                         self.render_circle_element( | ||||
|                             center_data, | ||||
|                             true, // is_center | ||||
|                             None, // position_index | ||||
|                             link, | ||||
|                         ) | ||||
|                     } else { | ||||
|                         html! { <p>{ "Loading center circle..." }</p> } | ||||
|                     }} | ||||
|  | ||||
|                     { for petals_html } | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl CirclesView { | ||||
|     /// Fetch center circle data | ||||
|     fn fetch_center_circle(&mut self, ctx: &Context<Self>, ws_url: &str) { | ||||
|         log::debug!("CirclesView: Fetching center circle from {}", ws_url); | ||||
|          | ||||
|         let link = ctx.link().clone(); | ||||
|         let ws_url_clone = ws_url.to_string(); | ||||
|          | ||||
|         spawn_local(async move { | ||||
|             match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await { | ||||
|                 Ok(circle) => { | ||||
|                     link.send_message(CirclesViewMsg::CenterCircleFetched(circle)); | ||||
|                 } | ||||
|                 Err(error) => { | ||||
|                     log::error!("CirclesView: Failed to fetch center circle from {}: {}", ws_url_clone, error); | ||||
|                     // Could emit an error message here if needed | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     /// Start progressive fetching of surrounding circles | ||||
|     fn start_surrounding_circles_fetch(&mut self, ctx: &Context<Self>, center_circle: &Circle) { | ||||
|         log::info!("CirclesView: Starting progressive fetch of {} surrounding circles", center_circle.circles.len()); | ||||
|          | ||||
|         for surrounding_ws_url in ¢er_circle.circles { | ||||
|             self.fetch_surrounding_circle(ctx, surrounding_ws_url); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Fetch individual surrounding circle | ||||
|     fn fetch_surrounding_circle(&mut self, ctx: &Context<Self>, ws_url: &str) { | ||||
|         log::debug!("CirclesView: Fetching surrounding circle from {}", ws_url); | ||||
|          | ||||
|         // Mark as loading | ||||
|         self.loading_states.insert(ws_url.to_string(), true); | ||||
|          | ||||
|         let link = ctx.link().clone(); | ||||
|         let ws_url_clone = ws_url.to_string(); | ||||
|          | ||||
|         spawn_local(async move { | ||||
|             let result = fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await; | ||||
|             link.send_message(CirclesViewMsg::SurroundingCircleFetched(ws_url_clone, result)); | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     /// Update circles context and notify parent | ||||
|     fn update_circles_context(&self, ctx: &Context<Self>) { | ||||
|         let context_urls = if self.is_selected { | ||||
|             // When selected, context is only the center circle | ||||
|             vec![self.center_circle.clone()] | ||||
|         } else { | ||||
|             // When unselected, context includes center + available surrounding circles | ||||
|             let mut urls = vec![self.center_circle.clone()]; | ||||
|              | ||||
|             if let Some(center_circle) = self.circles.get(&self.center_circle) { | ||||
|                 // Add surrounding circles that are already loaded | ||||
|                 for surrounding_url in ¢er_circle.circles { | ||||
|                     if self.circles.contains_key(surrounding_url) { | ||||
|                         urls.push(surrounding_url.clone()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             urls | ||||
|         }; | ||||
|          | ||||
|         log::debug!("CirclesView: Updating context with {} URLs", context_urls.len()); | ||||
|         ctx.props().on_context_update.emit(context_urls); | ||||
|     } | ||||
|      | ||||
|     /// Handle circle click logic | ||||
|     fn handle_circle_click(&mut self, ctx: &Context<Self>, ws_url: String) -> bool { | ||||
|         log::debug!("CirclesView: Circle clicked: {}", ws_url); | ||||
|          | ||||
|         if ws_url == self.center_circle { | ||||
|             // Center circle clicked - toggle selection | ||||
|             self.is_selected = !self.is_selected; | ||||
|             log::info!("CirclesView: Center circle toggled, selected: {}", self.is_selected); | ||||
|         } else { | ||||
|             // Surrounding circle clicked - make it the new center | ||||
|             log::info!("CirclesView: Setting new center circle: {}", ws_url); | ||||
|              | ||||
|             // Push current center to navigation stack BEFORE changing it | ||||
|             self.push_to_navigation_stack(self.center_circle.clone()); | ||||
|              | ||||
|             // Set new center and unselect | ||||
|             self.center_circle = ws_url.clone(); | ||||
|             self.is_selected = false; | ||||
|              | ||||
|             // Now push the new center to the stack as well | ||||
|             self.push_to_navigation_stack(self.center_circle.clone()); | ||||
|              | ||||
|             // Fetch new center circle if not already loaded | ||||
|             if !self.circles.contains_key(&ws_url) { | ||||
|                 self.fetch_center_circle(ctx, &ws_url); | ||||
|             } else { | ||||
|                 // If already loaded, start fetching its surrounding circles | ||||
|                 if let Some(circle) = self.circles.get(&ws_url).cloned() { | ||||
|                     self.start_surrounding_circles_fetch(ctx, &circle); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Update context | ||||
|         self.update_circles_context(ctx); | ||||
|          | ||||
|         true | ||||
|     } | ||||
|      | ||||
|     /// Handle background click logic | ||||
|     fn handle_background_click(&mut self, ctx: &Context<Self>) -> bool { | ||||
|         log::debug!("CirclesView: Background clicked, selected: {}, stack size: {}", | ||||
|                    self.is_selected, self.navigation_stack.len()); | ||||
|          | ||||
|         if self.is_selected { | ||||
|             // If selected, unselect | ||||
|             self.is_selected = false; | ||||
|             log::info!("CirclesView: Background click - unselecting center circle"); | ||||
|         } else { | ||||
|             // If unselected, navigate back in stack | ||||
|             if let Some(previous_center) = self.pop_from_navigation_stack() { | ||||
|                 log::info!("CirclesView: Background click - navigating back to: {}", previous_center); | ||||
|                  | ||||
|                 self.center_circle = previous_center.clone(); | ||||
|                 self.is_selected = false; | ||||
|                  | ||||
|                 // Fetch previous center if not loaded | ||||
|                 if !self.circles.contains_key(&previous_center) { | ||||
|                     self.fetch_center_circle(ctx, &previous_center); | ||||
|                 } else { | ||||
|                     // If already loaded, start fetching its surrounding circles | ||||
|                     if let Some(circle) = self.circles.get(&previous_center).cloned() { | ||||
|                         self.start_surrounding_circles_fetch(ctx, &circle); | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 log::debug!("CirclesView: Background click - no previous circle in stack"); | ||||
|                 return false; // No change | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Update context | ||||
|         self.update_circles_context(ctx); | ||||
|          | ||||
|         true | ||||
|     } | ||||
|      | ||||
|     /// Push circle to navigation stack | ||||
|     fn push_to_navigation_stack(&mut self, ws_url: String) { | ||||
|         // Only push if it's different from the current top | ||||
|         if self.navigation_stack.last() != Some(&ws_url) { | ||||
|             self.navigation_stack.push(ws_url.clone()); | ||||
|             log::debug!("CirclesView: Pushed {} to navigation stack: {:?}", ws_url, self.navigation_stack); | ||||
|         } else { | ||||
|             log::debug!("CirclesView: Not pushing {} - already at top of stack", ws_url); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /// Pop circle from navigation stack and return the previous one | ||||
|     fn pop_from_navigation_stack(&mut self) -> Option<String> { | ||||
|         if self.navigation_stack.len() > 1 { | ||||
|             // Remove current center from stack | ||||
|             let popped = self.navigation_stack.pop(); | ||||
|             log::debug!("CirclesView: Popped {:?} from navigation stack", popped); | ||||
|              | ||||
|             // Return the previous center (now at the top of stack) | ||||
|             let previous = self.navigation_stack.last().cloned(); | ||||
|             log::debug!("CirclesView: Navigation stack after pop: {:?}, returning: {:?}", self.navigation_stack, previous); | ||||
|             previous | ||||
|         } else { | ||||
|             log::debug!("CirclesView: Cannot navigate back - stack size: {}, stack: {:?}", self.navigation_stack.len(), self.navigation_stack); | ||||
|             None | ||||
|         } | ||||
|     } | ||||
|     fn render_circle_element( | ||||
|         &self, | ||||
|         circle: &Circle, | ||||
|         is_center: bool, | ||||
|         position_index: Option<usize>, | ||||
|         link: &yew::html::Scope<CirclesView>, | ||||
|     ) -> Html { | ||||
|         let ws_url = circle.ws_url.clone(); | ||||
|         let show_description = is_center && self.is_selected; | ||||
|          | ||||
|         let on_click_handler = { | ||||
|             let ws_url_clone = ws_url.clone(); | ||||
|             link.callback(move |e: MouseEvent| { | ||||
|                 e.stop_propagation(); | ||||
|                 CirclesViewMsg::CircleClicked(ws_url_clone.clone()) | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         let mut class_name_parts: Vec<String> = vec!["circle".to_string()]; | ||||
|         if is_center { | ||||
|             class_name_parts.push("center-circle".to_string()); | ||||
|             if show_description { | ||||
|                 class_name_parts.push("sole-selected".to_string()); | ||||
|             } | ||||
|         } else { | ||||
|             class_name_parts.push("outer-circle-layout".to_string()); | ||||
|             if let Some(idx) = position_index { | ||||
|                 if idx < 6 { | ||||
|                     class_name_parts.push(format!("circle-position-{}", idx + 1)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         let class_name = class_name_parts.join(" "); | ||||
|          | ||||
|         let size = if is_center { | ||||
|             if show_description { | ||||
|                 "400px" // Center circle, selected (description shown) | ||||
|             } else { | ||||
|                 "300px" // Center circle, unselected (name only) | ||||
|             } | ||||
|         } else { | ||||
|             "300px" // Surrounding (petal) circles | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class={class_name} | ||||
|                  style={format!("width: {}; height: {};", size, size)} | ||||
|                  onclick={on_click_handler} | ||||
|                  > | ||||
|                 { | ||||
|                     if show_description { | ||||
|                         html! { | ||||
|                             <div class="circle-text-container"> | ||||
|                                 <span class="circle-title">{ &circle.title }</span> | ||||
|                                 <span class="circle-description">{ &circle.description.as_deref().unwrap_or("") }</span> | ||||
|                             </div> | ||||
|                         } | ||||
|                     } else { | ||||
|                         html! { <span class="circle-text">{ &circle.title }</span> } | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										311
									
								
								src/app/src/components/customize_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								src/app/src/components/customize_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,311 @@ | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::circle::Circle; | ||||
| use web_sys::InputEvent; | ||||
|  | ||||
| // Import from common_models | ||||
| // Assuming AppMsg is used for updates. This might need to be specific to theme updates. | ||||
| use crate::app::Msg as AppMsg; | ||||
|  | ||||
|  | ||||
| // --- Enum for Setting Control Types (can be kept local or moved if shared) --- | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum ThemeSettingControlType { | ||||
|     ColorSelection(Vec<String>), // List of color hex values | ||||
|     PatternSelection(Vec<String>), // List of pattern names/classes | ||||
|     LogoSelection(Vec<String>), // List of predefined logo symbols or image URLs | ||||
|     Toggle, | ||||
|     TextInput, // For URL input or custom text | ||||
| } | ||||
|  | ||||
| // --- Data Structure for Defining a Theme Setting --- | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub struct ThemeSettingDefinition { | ||||
|     pub key: String, // Corresponds to the key in CircleData.theme HashMap | ||||
|     pub label: String, | ||||
|     pub description: String, | ||||
|     pub control_type: ThemeSettingControlType, | ||||
|     pub default_value: String, // Used if not present in circle's theme | ||||
| } | ||||
|  | ||||
| // --- Props for the Component --- | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct CustomizeViewProps { | ||||
|     pub all_circles: Rc<HashMap<String, Circle>>, | ||||
|     // Assuming context_circle_ws_urls provides the WebSocket URL of the circle being customized. | ||||
|     // For simplicity, we'll use the first URL if multiple are present. | ||||
|     // A more robust solution might involve a dedicated `active_customization_circle_ws_url: Option<String>` prop. | ||||
|     pub context_circle_ws_urls: Option<Rc<Vec<String>>>, | ||||
|     pub app_callback: Callback<AppMsg>, // For emitting update messages | ||||
| } | ||||
|  | ||||
| // --- Statically Defined Theme Settings --- | ||||
| fn get_theme_setting_definitions() -> Vec<ThemeSettingDefinition> { | ||||
|     vec![ | ||||
|         ThemeSettingDefinition { | ||||
|             key: "theme_primary_color".to_string(), | ||||
|             label: "Primary Color".to_string(), | ||||
|             description: "Main accent color for the interface.".to_string(), | ||||
|             control_type: ThemeSettingControlType::ColorSelection(vec![ | ||||
|                 "#3b82f6".to_string(), "#ef4444".to_string(), "#10b981".to_string(), | ||||
|                 "#f59e0b".to_string(), "#8b5cf6".to_string(), "#06b6d4".to_string(), | ||||
|                 "#ec4899".to_string(), "#84cc16".to_string(), "#f97316".to_string(), | ||||
|                 "#6366f1".to_string(), "#14b8a6".to_string(), "#f43f5e".to_string(), | ||||
|                 "#ffffff".to_string(), "#cbd5e1".to_string(), "#64748b".to_string(), | ||||
|             ]), | ||||
|             default_value: "#3b82f6".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "theme_background_color".to_string(), | ||||
|             label: "Background Color".to_string(), | ||||
|             description: "Overall background color.".to_string(), | ||||
|             control_type: ThemeSettingControlType::ColorSelection(vec![ | ||||
|                 "#000000".to_string(), "#0a0a0a".to_string(), "#121212".to_string(), "#18181b".to_string(),  | ||||
|                 "#1f2937".to_string(), "#374151".to_string(), "#4b5563".to_string(), | ||||
|                 "#f9fafb".to_string(), "#f3f4f6".to_string(), "#e5e7eb".to_string(), | ||||
|             ]), | ||||
|             default_value: "#0a0a0a".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "background_pattern".to_string(), | ||||
|             label: "Background Pattern".to_string(), | ||||
|             description: "Subtle pattern for the background.".to_string(), | ||||
|             control_type: ThemeSettingControlType::PatternSelection(vec![ | ||||
|                 "none".to_string(), "dots".to_string(), "grid".to_string(), | ||||
|                 "diagonal".to_string(), "waves".to_string(), "mesh".to_string(), | ||||
|             ]), | ||||
|             default_value: "none".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "circle_logo".to_string(), // Could be a symbol or a key for an image URL | ||||
|             label: "Circle Logo/Symbol".to_string(), | ||||
|             description: "Select a symbol or provide a URL below.".to_string(), | ||||
|             control_type: ThemeSettingControlType::LogoSelection(vec![ | ||||
|                 "◯".to_string(), "◆".to_string(), "★".to_string(), "▲".to_string(),  | ||||
|                 "●".to_string(), "■".to_string(), "🌍".to_string(), "🚀".to_string(),  | ||||
|                 "💎".to_string(), "🔥".to_string(), "⚡".to_string(), "🎯".to_string(), | ||||
|                 "custom_url".to_string(), // Represents using the URL input | ||||
|             ]), | ||||
|             default_value: "◯".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "circle_logo_url".to_string(), | ||||
|             label: "Custom Logo URL".to_string(), | ||||
|             description: "URL for a custom logo image (PNG, SVG recommended).".to_string(), | ||||
|             control_type: ThemeSettingControlType::TextInput, | ||||
|             default_value: "".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "nav_dashboard_visible".to_string(), | ||||
|             label: "Show Dashboard in Nav".to_string(), | ||||
|             description: "".to_string(), | ||||
|             control_type: ThemeSettingControlType::Toggle, | ||||
|             default_value: "true".to_string(), | ||||
|         }, | ||||
|         ThemeSettingDefinition { | ||||
|             key: "nav_timeline_visible".to_string(), | ||||
|             label: "Show Timeline in Nav".to_string(), | ||||
|             description: "".to_string(), | ||||
|             control_type: ThemeSettingControlType::Toggle, | ||||
|             default_value: "true".to_string(), | ||||
|         }, | ||||
|         // Add more settings as needed, e.g., font selection, border radius, etc. | ||||
|     ] | ||||
| } | ||||
|  | ||||
|  | ||||
| #[function_component(CustomizeViewComponent)] | ||||
| pub fn customize_view_component(props: &CustomizeViewProps) -> Html { | ||||
|     let theme_definitions = get_theme_setting_definitions(); | ||||
|  | ||||
|     // Determine the active circle for customization | ||||
|     let active_circle_ws_url: Option<String> = props.context_circle_ws_urls.as_ref() | ||||
|         .and_then(|ws_urls| ws_urls.first().cloned()); | ||||
|  | ||||
|     let active_circle_theme: Option<HashMap<String, String>> = active_circle_ws_url.as_ref() | ||||
|         .and_then(|ws_url| props.all_circles.get(ws_url)) | ||||
|         // TODO: Re-implement theme handling. The canonical Circle struct does not have a direct 'theme' field. | ||||
|         // .map(|circle_data| circle_data.theme.clone()); | ||||
|         .map(|_circle_data| HashMap::new()); // Placeholder, provides an empty theme | ||||
|  | ||||
|     let on_setting_update_emitter = props.app_callback.clone(); | ||||
|  | ||||
|     html! { | ||||
|         <div class="view-container customize-view"> | ||||
|             <div class="view-header"> | ||||
|                 <h1 class="view-title">{"Customize Appearance"}</h1> | ||||
|                 { if active_circle_ws_url.is_none() { | ||||
|                     html!{ <p class="customize-no-circle-msg">{"Select a circle context to customize its appearance."}</p> } | ||||
|                 } else { html!{} }} | ||||
|             </div> | ||||
|  | ||||
|             { if let Some(current_circle_ws_url) = active_circle_ws_url { | ||||
|                 html! { | ||||
|                     <div class="customize-content"> | ||||
|                         { for theme_definitions.iter().map(|setting_def| { | ||||
|                             let current_value = active_circle_theme.as_ref() | ||||
|                                 .and_then(|theme| theme.get(&setting_def.key).cloned()) | ||||
|                                 .unwrap_or_else(|| setting_def.default_value.clone()); | ||||
|                              | ||||
|                             render_setting_control( | ||||
|                                 setting_def.clone(), | ||||
|                                 current_value, | ||||
|                                 current_circle_ws_url.clone(), | ||||
|                                 on_setting_update_emitter.clone() | ||||
|                             ) | ||||
|                         })} | ||||
|                     </div> | ||||
|                 } | ||||
|             } else { | ||||
|                 html!{} // Or a message indicating no circle is selected for customization | ||||
|             }} | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_setting_control( | ||||
|     setting_def: ThemeSettingDefinition, | ||||
|     current_value: String, | ||||
|     circle_ws_url: String, | ||||
|     app_callback: Callback<AppMsg>, | ||||
| ) -> Html { | ||||
|     let setting_key = setting_def.key.clone(); | ||||
|      | ||||
|     let on_value_change = { | ||||
|         let circle_ws_url_clone = circle_ws_url.clone(); | ||||
|         let setting_key_clone = setting_key.clone(); | ||||
|         Callback::from(move |new_value: String| { | ||||
|             // Emit a message to app.rs to update the theme | ||||
|             // AppMsg should have a variant like UpdateCircleTheme(circle_id, theme_key, new_value) | ||||
|             // TODO: Update this to use WebSocket URL instead of u32 ID | ||||
|             // For now, we'll need to convert or update the message type | ||||
|             // app_callback.emit(AppMsg::UpdateCircleThemeValue( | ||||
|             //     circle_ws_url_clone.clone(), | ||||
|             //     setting_key_clone.clone(), | ||||
|             //     new_value, | ||||
|             // )); | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let control_html = match setting_def.control_type { | ||||
|         ThemeSettingControlType::ColorSelection(ref colors) => { | ||||
|             let on_select = on_value_change.clone(); | ||||
|             html! { | ||||
|                 <div class="color-grid"> | ||||
|                     { for colors.iter().map(|color_option| { | ||||
|                         let is_selected = *color_option == current_value; | ||||
|                         let option_value = color_option.clone(); | ||||
|                         let on_click_handler = { | ||||
|                             let on_select = on_select.clone(); | ||||
|                             Callback::from(move |_| on_select.emit(option_value.clone())) | ||||
|                         }; | ||||
|                         html! { | ||||
|                             <div | ||||
|                                 class={classes!("color-option", is_selected.then_some("selected"))} | ||||
|                                 style={format!("background-color: {};", color_option)} | ||||
|                                 onclick={on_click_handler} | ||||
|                                 title={color_option.clone()} | ||||
|                             /> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         ThemeSettingControlType::PatternSelection(ref patterns) => { | ||||
|             let on_select = on_value_change.clone(); | ||||
|             html! { | ||||
|                 <div class="pattern-grid"> | ||||
|                     { for patterns.iter().map(|pattern_option| { | ||||
|                         let is_selected = *pattern_option == current_value; | ||||
|                         let option_value = pattern_option.clone(); | ||||
|                         let pattern_class = format!("pattern-preview-{}", pattern_option.replace(" ", "-").to_lowercase()); | ||||
|                         let on_click_handler = { | ||||
|                             let on_select = on_select.clone(); | ||||
|                             Callback::from(move |_| on_select.emit(option_value.clone())) | ||||
|                         }; | ||||
|                         html! { | ||||
|                             <div | ||||
|                                 class={classes!("pattern-option", pattern_class, is_selected.then_some("selected"))} | ||||
|                                 onclick={on_click_handler} | ||||
|                                 title={pattern_option.clone()} | ||||
|                             /> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         ThemeSettingControlType::LogoSelection(ref logos) => { | ||||
|             let on_select = on_value_change.clone(); | ||||
|             html! { | ||||
|                 <div class="logo-grid"> | ||||
|                     { for logos.iter().map(|logo_option| { | ||||
|                         let is_selected = *logo_option == current_value; | ||||
|                         let option_value = logo_option.clone(); | ||||
|                         let on_click_handler = { | ||||
|                             let on_select = on_select.clone(); | ||||
|                             Callback::from(move |_| on_select.emit(option_value.clone())) | ||||
|                         }; | ||||
|                         html! { | ||||
|                             <div | ||||
|                                 class={classes!("logo-option", is_selected.then_some("selected"))} | ||||
|                                 onclick={on_click_handler} | ||||
|                                 title={logo_option.clone()} | ||||
|                             > | ||||
|                                 { if logo_option == "custom_url" { "URL" } else { logo_option } } | ||||
|                             </div> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             } | ||||
|         }, | ||||
|         ThemeSettingControlType::Toggle => { | ||||
|             let checked = current_value.to_lowercase() == "true"; | ||||
|             let on_toggle = { | ||||
|                 let on_value_change = on_value_change.clone(); | ||||
|                 Callback::from(move |e: Event| { | ||||
|                     let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|                     on_value_change.emit(if input.checked() { "true".to_string() } else { "false".to_string() }); | ||||
|                 }) | ||||
|             }; | ||||
|             html! { | ||||
|                 <label class="setting-toggle-switch"> | ||||
|                     <input type="checkbox" checked={checked} onchange={on_toggle} /> | ||||
|                     <span class="setting-toggle-slider"></span> | ||||
|                 </label> | ||||
|             } | ||||
|         }, | ||||
|         ThemeSettingControlType::TextInput => { | ||||
|             let on_input = { | ||||
|                 let on_value_change = on_value_change.clone(); | ||||
|                 Callback::from(move |e: InputEvent| { | ||||
|                     let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|                     on_value_change.emit(input.value()); | ||||
|                 }) | ||||
|             }; | ||||
|             html! { | ||||
|                 <input | ||||
|                     type="text" | ||||
|                     class="setting-text-input input-base" | ||||
|                     placeholder={setting_def.description.clone()} | ||||
|                     value={current_value.clone()} | ||||
|                     oninput={on_input} | ||||
|                 /> | ||||
|             } | ||||
|         }, | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <div class="setting-item card-base"> | ||||
|             <div class="setting-info"> | ||||
|                 <label class="setting-label">{ &setting_def.label }</label> | ||||
|                 { if !setting_def.description.is_empty() && setting_def.control_type != ThemeSettingControlType::TextInput { // Placeholder is used for TextInput desc | ||||
|                     html!{ <p class="setting-description">{ &setting_def.description }</p> } | ||||
|                 } else { html!{} }} | ||||
|             </div> | ||||
|             <div class="setting-control"> | ||||
|                 { control_html } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										48
									
								
								src/app/src/components/image_viewer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/app/src/components/image_viewer.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Image; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct ImageViewerProps { | ||||
|     pub image: Image, | ||||
|     pub on_back: Callback<()>, | ||||
| } | ||||
|  | ||||
| pub struct ImageViewer; | ||||
|  | ||||
| impl Component for ImageViewer { | ||||
|     type Message = (); | ||||
|     type Properties = ImageViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer image-viewer"> | ||||
|                 <button class="back-button" onclick={back_handler}> | ||||
|                     <i class="fas fa-arrow-left"></i> {"Back to Collection"} | ||||
|                 </button> | ||||
|                 <div class="viewer-header"> | ||||
|                     <h2 class="viewer-title">{ &props.image.title }</h2> | ||||
|                 </div> | ||||
|                 <div class="viewer-content"> | ||||
|                     <img | ||||
|                         src={props.image.url.clone()} | ||||
|                         alt={props.image.title.clone()} | ||||
|                         class="viewer-image" | ||||
|                     /> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										90
									
								
								src/app/src/components/inspector_auth_tab.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/app/src/components/inspector_auth_tab.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| use crate::auth::AuthManager; | ||||
|  | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorAuthTabProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
|     pub auth_manager: AuthManager, | ||||
| } | ||||
|  | ||||
| pub enum Msg { | ||||
|     RunAuth(String), | ||||
|     SetLog(String, String), | ||||
| } | ||||
|  | ||||
| pub struct InspectorAuthTab { | ||||
|     logs: HashMap<String, String>, | ||||
| } | ||||
|  | ||||
| impl Component for InspectorAuthTab { | ||||
|     type Message = Msg; | ||||
|     type Properties = InspectorAuthTabProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             logs: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::RunAuth(ws_url) => { | ||||
|                 let auth_manager = ctx.props().auth_manager.clone(); | ||||
|                 let link = ctx.link().clone(); | ||||
|                 let url = ws_url.clone(); | ||||
|  | ||||
|                 self.logs | ||||
|                     .insert(url.clone(), "Authenticating...".to_string()); | ||||
|  | ||||
|                 spawn_local(async move { | ||||
|                     let result_log = match auth_manager.create_authenticated_client(&url).await { | ||||
|                         Ok(_) => format!("Successfully authenticated with {}", url), | ||||
|                         Err(e) => format!("Failed to authenticate with {}: {}", url, e), | ||||
|                     }; | ||||
|                     link.send_message(Msg::SetLog(url, result_log)); | ||||
|                 }); | ||||
|                 true | ||||
|             } | ||||
|             Msg::SetLog(url, log_msg) => { | ||||
|                 self.logs.insert(url, log_msg); | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="inspector-auth-tab"> | ||||
|                 <div class="log-list"> | ||||
|                     { for ctx.props().circle_ws_addresses.iter().map(|ws_url| self.render_log_entry(ctx, ws_url)) } | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl InspectorAuthTab { | ||||
|     fn render_log_entry(&self, ctx: &Context<Self>, ws_url: &String) -> Html { | ||||
|         let url = ws_url.clone(); | ||||
|         let onclick = ctx.link().callback(move |_| Msg::RunAuth(url.clone())); | ||||
|         let log_output = self | ||||
|             .logs | ||||
|             .get(ws_url) | ||||
|             .cloned() | ||||
|             .unwrap_or_else(|| "Ready".to_string()); | ||||
|  | ||||
|         html! { | ||||
|             <div class="log-entry"> | ||||
|                 <div class="url-info"> | ||||
|                     <span class="url">{ ws_url.clone() }</span> | ||||
|                     <button class="button is-small" {onclick}>{"Authenticate"}</button> | ||||
|                 </div> | ||||
|                 <pre class="log-output">{ log_output }</pre> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										141
									
								
								src/app/src/components/inspector_interact_tab.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/app/src/components/inspector_interact_tab.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,141 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use crate::components::chat::{ChatInterface, ConversationList, ConversationSummary, InputType, ChatResponse}; | ||||
| use crate::rhai_executor::execute_rhai_script_remote; | ||||
| use crate::ws_manager::fetch_data_from_ws_url; | ||||
| use heromodels::models::circle::Circle; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorInteractTabProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
|     pub conversations: Vec<ConversationSummary>, | ||||
|     pub active_conversation_id: Option<u32>, | ||||
|     pub external_conversation_selection: Option<u32>, | ||||
|     pub external_new_conversation_trigger: bool, | ||||
|     pub on_conversations_updated: Callback<Vec<ConversationSummary>>, | ||||
|     pub on_conversation_selected: Callback<u32>, | ||||
|     pub on_new_conversation: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct CircleInfo { | ||||
|     pub name: String, | ||||
|     pub ws_url: String, | ||||
| } | ||||
|  | ||||
| #[function_component(InspectorInteractTab)] | ||||
| pub fn inspector_interact_tab(props: &InspectorInteractTabProps) -> Html { | ||||
|     let circle_names = use_state(|| HashMap::<String, String>::new()); | ||||
|      | ||||
|     // Fetch circle names when component mounts or addresses change | ||||
|     { | ||||
|         let circle_names = circle_names.clone(); | ||||
|         let ws_addresses = props.circle_ws_addresses.clone(); | ||||
|          | ||||
|         use_effect_with(ws_addresses.clone(), move |addresses| { | ||||
|             let circle_names = circle_names.clone(); | ||||
|              | ||||
|             for ws_url in addresses.iter() { | ||||
|                 let ws_url_clone = ws_url.clone(); | ||||
|                 let circle_names_clone = circle_names.clone(); | ||||
|                  | ||||
|                 spawn_local(async move { | ||||
|                     match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await { | ||||
|                         Ok(circle) => { | ||||
|                             let mut names = (*circle_names_clone).clone(); | ||||
|                             names.insert(ws_url_clone, circle.title); | ||||
|                             circle_names_clone.set(names); | ||||
|                         } | ||||
|                         Err(_) => { | ||||
|                             // If we can't fetch the circle name, use a fallback | ||||
|                             let mut names = (*circle_names_clone).clone(); | ||||
|                             names.insert(ws_url_clone.clone(), format!("Circle ({})", ws_url_clone)); | ||||
|                             circle_names_clone.set(names); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|              | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let on_process_message = { | ||||
|         let ws_urls = props.circle_ws_addresses.clone(); | ||||
|         let circle_names = circle_names.clone(); | ||||
|          | ||||
|         Callback::from(move |(data, format, response_callback): (Vec<u8>, String, Callback<ChatResponse>)| { | ||||
|             // Convert bytes to string for processing | ||||
|             let script_content = String::from_utf8_lossy(&data).to_string(); | ||||
|             let urls = ws_urls.clone(); | ||||
|             let names = (*circle_names).clone(); | ||||
|              | ||||
|             // Remote execution - async responses | ||||
|             for ws_url in urls.iter() { | ||||
|                 let script_clone = script_content.clone(); | ||||
|                 let url_clone = ws_url.clone(); | ||||
|                 let circle_name = names.get(ws_url).cloned().unwrap_or_else(|| format!("Circle ({})", ws_url)); | ||||
|                 let format_clone = format.clone(); | ||||
|                 let response_callback_clone = response_callback.clone(); | ||||
|                  | ||||
|                 spawn_local(async move { | ||||
|                     let response = execute_rhai_script_remote(&script_clone, &url_clone, &circle_name).await; | ||||
|                     let status = if response.success { "✅" } else { "❌" }; | ||||
|                      | ||||
|                     // Set format based on execution success | ||||
|                     let response_format = if response.success { | ||||
|                         format_clone | ||||
|                     } else { | ||||
|                         "error".to_string() | ||||
|                     }; | ||||
|                      | ||||
|                     let chat_response = ChatResponse { | ||||
|                         data: format!("{} {}", status, response.output).into_bytes(), | ||||
|                         format: response_format, | ||||
|                         source: response.source, | ||||
|                     }; | ||||
|                     response_callback_clone.emit(chat_response); | ||||
|                 }); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     html! { | ||||
|         <ChatInterface | ||||
|             on_process_message={on_process_message} | ||||
|             placeholder={"Enter your Rhai script here...".to_string()} | ||||
|             show_title_description={false} | ||||
|             conversation_title={Some("Script Interaction".to_string())} | ||||
|             input_type={Some(InputType::Code)} | ||||
|             input_format={Some("rhai".to_string())} | ||||
|             on_conversations_updated={Some(props.on_conversations_updated.clone())} | ||||
|             active_conversation_id={props.active_conversation_id} | ||||
|             on_conversation_selected={Some(props.on_conversation_selected.clone())} | ||||
|             external_conversation_selection={props.external_conversation_selection} | ||||
|             external_new_conversation_trigger={Some(props.external_new_conversation_trigger)} | ||||
|         /> | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorInteractSidebarProps { | ||||
|     pub conversations: Vec<ConversationSummary>, | ||||
|     pub active_conversation_id: Option<u32>, | ||||
|     pub on_select_conversation: Callback<u32>, | ||||
|     pub on_new_conversation: Callback<()>, | ||||
| } | ||||
|  | ||||
| #[function_component(InspectorInteractSidebar)] | ||||
| pub fn inspector_interact_sidebar(props: &InspectorInteractSidebarProps) -> Html { | ||||
|     html! { | ||||
|         <ConversationList | ||||
|             conversations={props.conversations.clone()} | ||||
|             active_conversation_id={props.active_conversation_id} | ||||
|             on_select_conversation={props.on_select_conversation.clone()} | ||||
|             on_new_conversation={props.on_new_conversation.clone()} | ||||
|             title={"Chat History".to_string()} | ||||
|         /> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										85
									
								
								src/app/src/components/inspector_logs_tab.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/app/src/components/inspector_logs_tab.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorLogsTabProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct LogEntry { | ||||
|     pub timestamp: String, | ||||
|     pub level: String, | ||||
|     pub source: String, | ||||
|     pub message: String, | ||||
| } | ||||
|  | ||||
| #[function_component(InspectorLogsTab)] | ||||
| pub fn inspector_logs_tab(props: &InspectorLogsTabProps) -> Html { | ||||
|     let logs = use_state(|| { | ||||
|         vec![ | ||||
|             LogEntry { | ||||
|                 timestamp: "17:05:24".to_string(), | ||||
|                 level: "INFO".to_string(), | ||||
|                 source: "inspector".to_string(), | ||||
|                 message: "Inspector initialized".to_string(), | ||||
|             }, | ||||
|             LogEntry { | ||||
|                 timestamp: "17:05:25".to_string(), | ||||
|                 level: "INFO".to_string(), | ||||
|                 source: "network".to_string(), | ||||
|                 message: format!("Monitoring {} circle connections", props.circle_ws_addresses.len()), | ||||
|             }, | ||||
|             LogEntry { | ||||
|                 timestamp: "17:05:26".to_string(), | ||||
|                 level: "DEBUG".to_string(), | ||||
|                 source: "websocket".to_string(), | ||||
|                 message: "Connection status checks initiated".to_string(), | ||||
|             }, | ||||
|         ] | ||||
|     }); | ||||
|  | ||||
|     let error_count = logs.iter().filter(|l| l.level == "ERROR").count(); | ||||
|     let warn_count = logs.iter().filter(|l| l.level == "WARN").count(); | ||||
|  | ||||
|     html! { | ||||
|         <div class="content-panel"> | ||||
|             <div class="logs-overview"> | ||||
|                 <div class="logs-stats"> | ||||
|                     <div class="logs-stat"> | ||||
|                         <span class="stat-label">{"Total"}</span> | ||||
|                         <span class="stat-value">{logs.len()}</span> | ||||
|                     </div> | ||||
|                     <div class="logs-stat"> | ||||
|                         <span class="stat-label">{"Errors"}</span> | ||||
|                         <span class={classes!("stat-value", if error_count > 0 { "stat-error" } else { "" })}>{error_count}</span> | ||||
|                     </div> | ||||
|                     <div class="logs-stat"> | ||||
|                         <span class="stat-label">{"Warnings"}</span> | ||||
|                         <span class={classes!("stat-value", if warn_count > 0 { "stat-warn" } else { "" })}>{warn_count}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="logs-container"> | ||||
|                     { for logs.iter().rev().map(|log| { | ||||
|                         let level_class = match log.level.as_str() { | ||||
|                             "ERROR" => "log-error", | ||||
|                             "WARN" => "log-warn", | ||||
|                             "INFO" => "log-info", | ||||
|                             _ => "log-debug", | ||||
|                         }; | ||||
|                          | ||||
|                         html! { | ||||
|                             <div class={classes!("log-entry", level_class)}> | ||||
|                                 <span class="log-time">{&log.timestamp}</span> | ||||
|                                 <span class={classes!("log-level", level_class)}>{&log.level}</span> | ||||
|                                 <span class="log-source">{&log.source}</span> | ||||
|                                 <span class="log-message">{&log.message}</span> | ||||
|                             </div> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										136
									
								
								src/app/src/components/inspector_network_tab.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/app/src/components/inspector_network_tab.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use crate::components::world_map_svg::render_world_map_svg; | ||||
| use crate::components::network_animation_view::NetworkAnimationView; | ||||
| use common_models::CircleData; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorNetworkTabProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct TrafficEntry { | ||||
|     pub timestamp: String, | ||||
|     pub direction: String, // "Sent" or "Received" | ||||
|     pub ws_url: String, | ||||
|     pub message_type: String, | ||||
|     pub size: String, | ||||
|     pub status: String, | ||||
| } | ||||
|  | ||||
| #[function_component(InspectorNetworkTab)] | ||||
| pub fn inspector_network_tab(props: &InspectorNetworkTabProps) -> Html { | ||||
|     // Create circle data for the map animation | ||||
|     let circles_data = use_memo(props.circle_ws_addresses.clone(), |addresses| { | ||||
|         let mut circles = HashMap::new(); | ||||
|          | ||||
|         for (index, ws_url) in addresses.iter().enumerate() { | ||||
|             circles.insert(index as u32 + 1, CircleData { | ||||
|                 id: index as u32 + 1, | ||||
|                 name: format!("Circle {}", index + 1), | ||||
|                 description: format!("Circle at {}", ws_url), | ||||
|                 ws_url: ws_url.clone(), | ||||
|                 ws_urls: vec![], | ||||
|                 theme: HashMap::new(), | ||||
|                 tasks: None, | ||||
|                 epics: None, | ||||
|                 sprints: None, | ||||
|                 proposals: None, | ||||
|                 members: None, | ||||
|                 library: None, | ||||
|                 intelligence: None, | ||||
|                 timeline: None, | ||||
|                 calendar_events: None, | ||||
|                 treasury: None, | ||||
|                 publications: None, | ||||
|                 deployments: None, | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         Rc::new(circles) | ||||
|     }); | ||||
|  | ||||
|     // Mock traffic data | ||||
|     let traffic_entries = use_state(|| { | ||||
|         vec![ | ||||
|             TrafficEntry { | ||||
|                 timestamp: "22:28:15".to_string(), | ||||
|                 direction: "Sent".to_string(), | ||||
|                 ws_url: "ws://localhost:9000".to_string(), | ||||
|                 message_type: "get_circle()".to_string(), | ||||
|                 size: "245 B".to_string(), | ||||
|                 status: "Success".to_string(), | ||||
|             }, | ||||
|             TrafficEntry { | ||||
|                 timestamp: "22:28:14".to_string(), | ||||
|                 direction: "Received".to_string(), | ||||
|                 ws_url: "ws://localhost:9001".to_string(), | ||||
|                 message_type: "circle_data".to_string(), | ||||
|                 size: "1.2 KB".to_string(), | ||||
|                 status: "Success".to_string(), | ||||
|             }, | ||||
|             TrafficEntry { | ||||
|                 timestamp: "22:28:13".to_string(), | ||||
|                 direction: "Sent".to_string(), | ||||
|                 ws_url: "ws://localhost:9002".to_string(), | ||||
|                 message_type: "ping".to_string(), | ||||
|                 size: "64 B".to_string(), | ||||
|                 status: "Success".to_string(), | ||||
|             }, | ||||
|             TrafficEntry { | ||||
|                 timestamp: "22:28:12".to_string(), | ||||
|                 direction: "Received".to_string(), | ||||
|                 ws_url: "ws://localhost:9003".to_string(), | ||||
|                 message_type: "pong".to_string(), | ||||
|                 size: "64 B".to_string(), | ||||
|                 status: "Success".to_string(), | ||||
|             }, | ||||
|             TrafficEntry { | ||||
|                 timestamp: "22:28:11".to_string(), | ||||
|                 direction: "Sent".to_string(), | ||||
|                 ws_url: "ws://localhost:9004".to_string(), | ||||
|                 message_type: "execute_script".to_string(), | ||||
|                 size: "512 B".to_string(), | ||||
|                 status: "Success".to_string(), | ||||
|             }, | ||||
|         ] | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <div class="column"> | ||||
|             <div class="network-map-container"> | ||||
|                 { render_world_map_svg() } | ||||
|                 <NetworkAnimationView all_circles={(*circles_data).clone()} /> | ||||
|             </div> | ||||
|             <div class="network-traffic"> | ||||
|                 <div class="traffic-table"> | ||||
|                     <div class="traffic-header"> | ||||
|                         <div class="traffic-col">{"Time"}</div> | ||||
|                         <div class="traffic-col">{"Direction"}</div> | ||||
|                         <div class="traffic-col">{"WebSocket"}</div> | ||||
|                         <div class="traffic-col">{"Message"}</div> | ||||
|                         <div class="traffic-col">{"Size"}</div> | ||||
|                         <div class="traffic-col">{"Status"}</div> | ||||
|                     </div> | ||||
|                     { for traffic_entries.iter().map(|entry| { | ||||
|                         let direction_class = if entry.direction == "Sent" { "traffic-sent" } else { "traffic-received" }; | ||||
|                         let status_class = if entry.status == "Success" { "traffic-success" } else { "traffic-error" }; | ||||
|                          | ||||
|                         html! { | ||||
|                             <div class="traffic-row"> | ||||
|                                 <div class="traffic-col traffic-time">{&entry.timestamp}</div> | ||||
|                                 <div class={classes!("traffic-col", "traffic-direction", direction_class)}>{&entry.direction}</div> | ||||
|                                 <div class="traffic-col traffic-url">{&entry.ws_url}</div> | ||||
|                                 <div class="traffic-col traffic-message">{&entry.message_type}</div> | ||||
|                                 <div class="traffic-col traffic-size">{&entry.size}</div> | ||||
|                                 <div class={classes!("traffic-col", "traffic-status", status_class)}>{&entry.status}</div> | ||||
|                             </div> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										338
									
								
								src/app/src/components/inspector_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										338
									
								
								src/app/src/components/inspector_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,338 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use crate::components::chat::{ConversationSummary}; | ||||
| use crate::components::sidebar_layout::SidebarLayout; | ||||
| use crate::components::inspector_network_tab::InspectorNetworkTab; | ||||
| use crate::components::inspector_logs_tab::InspectorLogsTab; | ||||
| use crate::auth::AuthManager; | ||||
| use crate::components::inspector_auth_tab::InspectorAuthTab; | ||||
| use crate::components::inspector_interact_tab::{InspectorInteractTab, InspectorInteractSidebar}; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorViewProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
|     pub auth_manager: AuthManager, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum InspectorTab { | ||||
|     Network, | ||||
|     Logs, | ||||
|     Interact, | ||||
|     Auth, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum InspectorViewState { | ||||
|     Overview, | ||||
|     Tab(InspectorTab), | ||||
| } | ||||
|  | ||||
| impl InspectorTab { | ||||
|     fn icon(&self) -> &'static str { | ||||
|         match self { | ||||
|             InspectorTab::Network => "fa-network-wired", | ||||
|             InspectorTab::Logs => "fa-list-alt", | ||||
|             InspectorTab::Interact => "fa-terminal", | ||||
|             InspectorTab::Auth => "fa-key", | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn title(&self) -> &'static str { | ||||
|         match self { | ||||
|             InspectorTab::Network => "Network", | ||||
|             InspectorTab::Logs => "Logs", | ||||
|             InspectorTab::Interact => "Interact", | ||||
|             InspectorTab::Auth => "Auth", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub struct InspectorView { | ||||
|     current_view: InspectorViewState, | ||||
|     // Chat-related state for interact tab | ||||
|     conversations: Vec<ConversationSummary>, | ||||
|     active_conversation_id: Option<u32>, | ||||
|     external_conversation_selection: Option<u32>, | ||||
|     external_new_conversation_trigger: bool, | ||||
| } | ||||
|  | ||||
| pub enum Msg { | ||||
|     SelectTab(InspectorTab), | ||||
|     BackToOverview, | ||||
|     // Conversation management messages | ||||
|     SelectConversation(u32), | ||||
|     NewConversation, | ||||
|     ConversationsUpdated(Vec<ConversationSummary>), | ||||
| } | ||||
|  | ||||
| impl Component for InspectorView { | ||||
|     type Message = Msg; | ||||
|     type Properties = InspectorViewProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_view: InspectorViewState::Overview, | ||||
|             conversations: Vec::new(), | ||||
|             active_conversation_id: None, | ||||
|             external_conversation_selection: None, | ||||
|             external_new_conversation_trigger: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::SelectTab(tab) => { | ||||
|                 self.current_view = InspectorViewState::Tab(tab); | ||||
|                 true | ||||
|             } | ||||
|             Msg::BackToOverview => { | ||||
|                 self.current_view = InspectorViewState::Overview; | ||||
|                 true | ||||
|             } | ||||
|             Msg::SelectConversation(conv_id) => { | ||||
|                 self.active_conversation_id = Some(conv_id); | ||||
|                 self.external_conversation_selection = Some(conv_id); | ||||
|                 true | ||||
|             } | ||||
|             Msg::NewConversation => { | ||||
|                 self.active_conversation_id = None; | ||||
|                 self.external_new_conversation_trigger = !self.external_new_conversation_trigger; | ||||
|                 true | ||||
|             } | ||||
|             Msg::ConversationsUpdated(conversations) => { | ||||
|                 self.conversations = conversations; | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         match &self.current_view { | ||||
|             InspectorViewState::Overview => { | ||||
|                 html! { | ||||
|                     <SidebarLayout | ||||
|                         sidebar_content={self.render_overview_sidebar(ctx)} | ||||
|                         main_content={self.render_overview_content(ctx)} | ||||
|                     /> | ||||
|                 } | ||||
|             } | ||||
|             InspectorViewState::Tab(tab) => { | ||||
|                 let on_background_click = ctx.link().callback(|_| Msg::BackToOverview); | ||||
|                  | ||||
|                 let main_content = match tab { | ||||
|                     InspectorTab::Network => html! { | ||||
|                         <InspectorNetworkTab circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} /> | ||||
|                     }, | ||||
|                     InspectorTab::Logs => html! { | ||||
|                         <InspectorLogsTab circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} /> | ||||
|                     }, | ||||
|                     InspectorTab::Interact => { | ||||
|                         let on_conv_select = ctx.link().callback(Msg::SelectConversation); | ||||
|                         let on_new_conv = ctx.link().callback(|_| Msg::NewConversation); | ||||
|                         let on_conv_update = ctx.link().callback(Msg::ConversationsUpdated); | ||||
|                         html! { | ||||
|                             <InspectorInteractTab | ||||
|                                 circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} | ||||
|                                 conversations={self.conversations.clone()} | ||||
|                                 active_conversation_id={self.active_conversation_id} | ||||
|                                 external_conversation_selection={self.external_conversation_selection} | ||||
|                                 external_new_conversation_trigger={self.external_new_conversation_trigger} | ||||
|                                 on_conversations_updated={on_conv_update} | ||||
|                                 on_conversation_selected={on_conv_select} | ||||
|                                 on_new_conversation={on_new_conv} | ||||
|                             /> | ||||
|                         } | ||||
|                     }, | ||||
|                     InspectorTab::Auth => html! { | ||||
|                         <InspectorAuthTab  | ||||
|                             circle_ws_addresses={ctx.props().circle_ws_addresses.clone()} | ||||
|                             auth_manager={ctx.props().auth_manager.clone()} | ||||
|                         /> | ||||
|                     }, | ||||
|                 }; | ||||
|  | ||||
|                 html! { | ||||
|                     <SidebarLayout | ||||
|                         sidebar_content={self.render_tab_sidebar(ctx)} | ||||
|                         main_content={main_content} | ||||
|                         on_background_click={Some(on_background_click)} | ||||
|                     /> | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl InspectorView { | ||||
|     fn render_overview_sidebar(&self, ctx: &Context<Self>) -> Html { | ||||
|         let tabs = vec![ | ||||
|             InspectorTab::Network, | ||||
|             InspectorTab::Logs, | ||||
|             InspectorTab::Interact, | ||||
|             InspectorTab::Auth, | ||||
|         ]; | ||||
|  | ||||
|         html! { | ||||
|             <div class="cards-column"> | ||||
|                 { for tabs.iter().map(|tab| self.render_tab_card(ctx, tab)) } | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_tab_sidebar(&self, ctx: &Context<Self>) -> Html { | ||||
|         if let InspectorViewState::Tab(current_tab) = &self.current_view { | ||||
|             html! { | ||||
|                 <div class="sidebar"> | ||||
|                     <div class="cards-column"> | ||||
|                         { self.render_tab_card(ctx, current_tab) } | ||||
|                     </div> | ||||
|                     { match current_tab { | ||||
|                         InspectorTab::Network => { | ||||
|                             self.render_network_connections_sidebar(ctx) | ||||
|                         }, | ||||
|                         InspectorTab::Interact => { | ||||
|                             let on_select_conversation = ctx.link().callback(|conv_id: u32| { | ||||
|                                 Msg::SelectConversation(conv_id) | ||||
|                             }); | ||||
|  | ||||
|                             let on_new_conversation = ctx.link().callback(|_| { | ||||
|                                 Msg::NewConversation | ||||
|                             }); | ||||
|  | ||||
|                             html! { | ||||
|                                 <InspectorInteractSidebar | ||||
|                                     conversations={self.conversations.clone()} | ||||
|                                     active_conversation_id={self.active_conversation_id} | ||||
|                                     on_select_conversation={on_select_conversation} | ||||
|                                     on_new_conversation={on_new_conversation} | ||||
|                                 /> | ||||
|                             } | ||||
|                         }, | ||||
|                         _ => html! {} | ||||
|                     }} | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_tab_card(&self, ctx: &Context<Self>, tab: &InspectorTab) -> Html { | ||||
|         let is_selected = match &self.current_view { | ||||
|             InspectorViewState::Tab(current_tab) => current_tab == tab, | ||||
|             _ => false, | ||||
|         }; | ||||
|         let tab_clone = tab.clone(); | ||||
|         let onclick = ctx.link().callback(move |_| Msg::SelectTab(tab_clone.clone())); | ||||
|  | ||||
|         let card_class = if is_selected { | ||||
|             "card selected" | ||||
|         } else { | ||||
|             "card" | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class={card_class} onclick={onclick}> | ||||
|                 <header> | ||||
|                     <i class={classes!("fas", tab.icon())}></i> | ||||
|                     <span class="tab-title">{tab.title()}</span> | ||||
|                 </header> | ||||
|                 { if is_selected { self.render_tab_details(ctx, tab) } else { html! {} } } | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_tab_details(&self, ctx: &Context<Self>, tab: &InspectorTab) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         match tab { | ||||
|             InspectorTab::Network => html! { | ||||
|                 <div class="tab-details"> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Circles:"}</span> | ||||
|                         <span class="detail-value">{props.circle_ws_addresses.len()}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             InspectorTab::Logs => html! { | ||||
|                 <div class="tab-details"> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Monitoring:"}</span> | ||||
|                         <span class="detail-value">{props.circle_ws_addresses.len()}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             InspectorTab::Interact => html! { | ||||
|                 <div class="tab-details"> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Mode:"}</span> | ||||
|                         <span class="detail-value">{"Rhai Script"}</span> | ||||
|                     </div> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Targets:"}</span> | ||||
|                         <span class="detail-value">{props.circle_ws_addresses.len()}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|             InspectorTab::Auth => html! { | ||||
|                 <div class="tab-details"> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Action:"}</span> | ||||
|                         <span class="detail-value">{"Authenticate"}</span> | ||||
|                     </div> | ||||
|                     <div class="detail-item"> | ||||
|                         <span class="detail-label">{"Targets:"}</span> | ||||
|                         <span class="detail-value">{props.circle_ws_addresses.len()}</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_network_connections_sidebar(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let connected_count = props.circle_ws_addresses.len(); | ||||
|          | ||||
|         html! { | ||||
|             <div class="ws-status"> | ||||
|                 <div class="ws-status-header"> | ||||
|                     <span class="ws-status-title">{"Connections"}</span> | ||||
|                     <span class="ws-status-count">{format!("{}/{}", connected_count, connected_count)}</span> | ||||
|                 </div> | ||||
|                 <div class="ws-connections"> | ||||
|                     { for props.circle_ws_addresses.iter().enumerate().map(|(index, ws_url)| { | ||||
|                         html! { | ||||
|                             <div class="ws-connection"> | ||||
|                                 <div class="ws-status-dot ws-status-connected"></div> | ||||
|                                 <div class="ws-connection-info"> | ||||
|                                     <div class="ws-connection-name">{format!("Circle {}", index + 1)}</div> | ||||
|                                     <div class="ws-connection-url">{ws_url}</div> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         } | ||||
|                     })} | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_overview_content(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         html! { | ||||
|             <div class="content-panel"> | ||||
|                 <div class="overview-grid"> | ||||
|                     <div class="overview-card"> | ||||
|                         <h3>{"Circle Connections"}</h3> | ||||
|                         <div class="metric-value">{props.circle_ws_addresses.len()}</div> | ||||
|                         <div class="metric-label">{"Active Circles"}</div> | ||||
|                     </div> | ||||
|                     <div class="overview-card"> | ||||
|                         <h3>{"Inspector Tools"}</h3> | ||||
|                         <div class="metric-value">{"3"}</div> | ||||
|                         <div class="metric-label">{"Available tabs"}</div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										115
									
								
								src/app/src/components/inspector_websocket_status.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								src/app/src/components/inspector_websocket_status.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,115 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use crate::ws_manager::fetch_data_from_ws_url; | ||||
| use heromodels::models::circle::Circle; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct InspectorWebSocketStatusProps { | ||||
|     pub circle_ws_addresses: Rc<Vec<String>>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum ConnectionStatus { | ||||
|     Connecting, | ||||
|     Connected, | ||||
|     Error, | ||||
| } | ||||
|  | ||||
| impl ConnectionStatus { | ||||
|     fn to_class(&self) -> &'static str { | ||||
|         match self { | ||||
|             ConnectionStatus::Connecting => "status-connecting", | ||||
|             ConnectionStatus::Connected => "status-connected", | ||||
|             ConnectionStatus::Error => "status-error", | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct CircleConnectionInfo { | ||||
|     pub name: String, | ||||
|     pub ws_url: String, | ||||
|     pub status: ConnectionStatus, | ||||
| } | ||||
|  | ||||
| #[function_component(InspectorWebSocketStatus)] | ||||
| pub fn inspector_websocket_status(props: &InspectorWebSocketStatusProps) -> Html { | ||||
|     let connections = use_state(|| Vec::<CircleConnectionInfo>::new()); | ||||
|      | ||||
|     // Initialize and check connection status for each WebSocket | ||||
|     { | ||||
|         let connections = connections.clone(); | ||||
|         let ws_addresses = props.circle_ws_addresses.clone(); | ||||
|          | ||||
|         use_effect_with(ws_addresses.clone(), move |addresses| { | ||||
|             // Initialize all connections as connecting | ||||
|             let initial_connections: Vec<CircleConnectionInfo> = addresses.iter().map(|ws_url| { | ||||
|                 CircleConnectionInfo { | ||||
|                     name: format!("Circle ({})", ws_url.split('/').last().unwrap_or(ws_url)), | ||||
|                     ws_url: ws_url.clone(), | ||||
|                     status: ConnectionStatus::Connecting, | ||||
|                 } | ||||
|             }).collect(); | ||||
|              | ||||
|             connections.set(initial_connections); | ||||
|              | ||||
|             // Check each connection | ||||
|             for (index, ws_url) in addresses.iter().enumerate() { | ||||
|                 let ws_url_clone = ws_url.clone(); | ||||
|                 let connections_clone = connections.clone(); | ||||
|                  | ||||
|                 spawn_local(async move { | ||||
|                     match fetch_data_from_ws_url::<Circle>(&ws_url_clone, "get_circle().json()").await { | ||||
|                         Ok(circle) => { | ||||
|                             connections_clone.set({ | ||||
|                                 let mut conns = (*connections_clone).clone(); | ||||
|                                 if let Some(conn) = conns.get_mut(index) { | ||||
|                                     conn.name = circle.title; | ||||
|                                     conn.status = ConnectionStatus::Connected; | ||||
|                                 } | ||||
|                                 conns | ||||
|                             }); | ||||
|                         } | ||||
|                         Err(_) => { | ||||
|                             connections_clone.set({ | ||||
|                                 let mut conns = (*connections_clone).clone(); | ||||
|                                 if let Some(conn) = conns.get_mut(index) { | ||||
|                                     conn.status = ConnectionStatus::Error; | ||||
|                                 } | ||||
|                                 conns | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|              | ||||
|             || {} | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     let connected_count = connections.iter().filter(|c| c.status == ConnectionStatus::Connected).count(); | ||||
|  | ||||
|     html! { | ||||
|         <div class="ws-status"> | ||||
|             <div class="ws-status-header"> | ||||
|                 <span class="ws-status-title">{"Connections"}</span> | ||||
|                 <span class="ws-status-count">{format!("{}/{}", connected_count, connections.len())}</span> | ||||
|             </div> | ||||
|             <div class="ws-connections"> | ||||
|                 { for connections.iter().map(|conn| { | ||||
|                     html! { | ||||
|                         <div class="ws-connection"> | ||||
|                             <div class={classes!("ws-status-dot", conn.status.to_class())}></div> | ||||
|                             <div class="ws-connection-info"> | ||||
|                                 <div class="ws-connection-name">{&conn.name}</div> | ||||
|                                 <div class="ws-connection-url">{&conn.ws_url}</div> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 })} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										294
									
								
								src/app/src/components/intelligence_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										294
									
								
								src/app/src/components/intelligence_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,294 @@ | ||||
| use yew::prelude::*; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
|  | ||||
| // Imports from common_models | ||||
| use common_models::{AiMessageRole, AiConversation}; | ||||
| use heromodels::models::circle::Circle; | ||||
| use crate::ws_manager::CircleWsManager; | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct IntelligenceViewProps { | ||||
|     pub all_circles: Rc<HashMap<String, Circle>>, | ||||
|     pub context_circle_ws_urls: Option<Rc<Vec<String>>>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub enum IntelligenceMsg { | ||||
|     UpdateInput(String), | ||||
|     SubmitPrompt, | ||||
|     LoadConversation(u32), | ||||
|     StartNewConversation, | ||||
|     CircleDataUpdated(String, Circle), // ws_url, circle_data | ||||
|     CircleDataFetchFailed(String, String), // ws_url, error | ||||
|     ScriptExecuted(Result<String, String>), | ||||
| } | ||||
|  | ||||
| pub struct IntelligenceView { | ||||
|     current_input: String, | ||||
|     active_conversation_id: Option<u32>, | ||||
|     ws_manager: CircleWsManager, | ||||
|     loading_states: HashMap<String, bool>, | ||||
|     error_states: HashMap<String, Option<String>>, | ||||
| } | ||||
|  | ||||
| // A summary for listing conversations, as AiConversation can be large. | ||||
| #[derive(Properties, PartialEq, Clone, Debug)] | ||||
| pub struct AiConversationSummary { | ||||
|     pub id: u32, | ||||
|     pub title: Option<String>, | ||||
| } | ||||
|  | ||||
| impl From<&AiConversation> for AiConversationSummary { | ||||
|     fn from(conv: &AiConversation) -> Self { | ||||
|         AiConversationSummary { | ||||
|             id: conv.id, | ||||
|             title: conv.title.clone(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Component for IntelligenceView { | ||||
|     type Message = IntelligenceMsg; | ||||
|     type Properties = IntelligenceViewProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let ws_manager = CircleWsManager::new(); | ||||
|          | ||||
|         // Set up callback for circle data updates | ||||
|         let link = ctx.link().clone(); | ||||
|         ws_manager.set_on_data_fetched( | ||||
|             link.callback(|(ws_url, result): (String, Result<Circle, String>)| { | ||||
|                 match result { | ||||
|                     Ok(mut circle) => { | ||||
|                         if circle.ws_url.is_empty() { | ||||
|                             circle.ws_url = ws_url.clone(); | ||||
|                         } | ||||
|                         IntelligenceMsg::CircleDataUpdated(ws_url, circle) | ||||
|                     }, | ||||
|                     Err(e) => IntelligenceMsg::CircleDataFetchFailed(ws_url, e), | ||||
|                 } | ||||
|             }) | ||||
|         ); | ||||
|  | ||||
|         Self { | ||||
|             current_input: String::new(), | ||||
|             active_conversation_id: None, | ||||
|             ws_manager, | ||||
|             loading_states: HashMap::new(), | ||||
|             error_states: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             IntelligenceMsg::UpdateInput(input) => { | ||||
|                 self.current_input = input; | ||||
|                 false // No re-render needed for input updates | ||||
|             } | ||||
|             IntelligenceMsg::SubmitPrompt => { | ||||
|                 self.submit_intelligence_prompt(ctx); | ||||
|                 true | ||||
|             } | ||||
|             IntelligenceMsg::LoadConversation(conv_id) => { | ||||
|                 log::info!("Loading conversation with ID: {}", conv_id); | ||||
|                 self.active_conversation_id = Some(conv_id); | ||||
|                 true | ||||
|             } | ||||
|             IntelligenceMsg::StartNewConversation => { | ||||
|                 log::info!("Starting new conversation"); | ||||
|                 self.active_conversation_id = None; | ||||
|                 self.current_input.clear(); | ||||
|                 true | ||||
|             } | ||||
|             IntelligenceMsg::CircleDataUpdated(ws_url, _circle) => { | ||||
|                 log::info!("Circle data updated for: {}", ws_url); | ||||
|                 // Handle real-time updates to circle data | ||||
|                 true | ||||
|             } | ||||
|             IntelligenceMsg::CircleDataFetchFailed(ws_url, error) => { | ||||
|                 log::error!("Failed to fetch circle data for {}: {}", ws_url, error); | ||||
|                 true | ||||
|             } | ||||
|             IntelligenceMsg::ScriptExecuted(result) => { | ||||
|                 match result { | ||||
|                     Ok(output) => { | ||||
|                         log::info!("Script executed successfully: {}", output); | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         log::error!("Script execution failed: {}", e); | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|          | ||||
|         // Get aggregated conversations from context circles | ||||
|         let (active_conversation, conversation_history) = self.get_conversation_data(ctx); | ||||
|  | ||||
|         let on_input = link.callback(|e: InputEvent| { | ||||
|             let input: web_sys::HtmlInputElement = e.target_unchecked_into(); | ||||
|             IntelligenceMsg::UpdateInput(input.value()) | ||||
|         }); | ||||
|  | ||||
|         let on_submit = link.callback(|e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|             IntelligenceMsg::SubmitPrompt | ||||
|         }); | ||||
|  | ||||
|         let on_new_conversation = link.callback(|_| IntelligenceMsg::StartNewConversation); | ||||
|  | ||||
|         html! { | ||||
|             <div class="view-container sidebar-layout"> | ||||
|                 <div class="card"> | ||||
|                     <h3>{"Conversations"}</h3> | ||||
|                     <button onclick={on_new_conversation} class="new-conversation-btn">{ "+ New Chat" }</button> | ||||
|                     <ul> | ||||
|                         { for conversation_history.iter().map(|conv_summary| { | ||||
|                             let conv_id = conv_summary.id; | ||||
|                             let conv_title = conv_summary.title.as_deref().unwrap_or("New Conversation"); | ||||
|                             let is_active = self.active_conversation_id == Some(conv_summary.id); | ||||
|                             let class_name = if is_active { "active-conversation-item" } else { "conversation-item" }; | ||||
|                             let on_load = link.callback(move |_| IntelligenceMsg::LoadConversation(conv_id)); | ||||
|                             html!{ | ||||
|                                 <li class={class_name} onclick={on_load}> | ||||
|                                     { conv_title } | ||||
|                                 </li> | ||||
|                             } | ||||
|                         }) } | ||||
|                     </ul> | ||||
|                 </div> | ||||
|                 <div class="chat-panel"> | ||||
|                     <div class="messages-display"> | ||||
|                         { | ||||
|                             if let Some(active_conv) = &active_conversation { | ||||
|                                 html! { | ||||
|                                     <> | ||||
|                                         <h4>{ active_conv.title.as_deref().unwrap_or("Conversation") }</h4> | ||||
|                                         { for active_conv.messages.iter().map(|msg| { | ||||
|                                             let sender_class = match msg.role { | ||||
|                                                 AiMessageRole::User => "user-message", | ||||
|                                                 AiMessageRole::Assistant => "ai-message", | ||||
|                                                 AiMessageRole::System => "system-message", | ||||
|                                             }; | ||||
|                                             let sender_name = match msg.role { | ||||
|                                                 AiMessageRole::User => "User", | ||||
|                                                 AiMessageRole::Assistant => "Assistant", | ||||
|                                                 AiMessageRole::System => "System", | ||||
|                                             }; | ||||
|                                             html!{ | ||||
|                                                 <div class={classes!("message", sender_class)}> | ||||
|                                                     <span class="sender">{ sender_name }</span> | ||||
|                                                     <p>{ &msg.content }</p> | ||||
|                                                     <span class="timestamp">{ format_timestamp_string(&msg.timestamp) }</span> | ||||
|                                                 </div> | ||||
|                                             } | ||||
|                                         }) } | ||||
|                                     </> | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 html!{ <p>{ "Select a conversation or start a new one." }</p> } | ||||
|                             } | ||||
|                         } | ||||
|                     </div> | ||||
|                     <form onsubmit={on_submit} class="input-area"> | ||||
|                         <input | ||||
|                             type="text" | ||||
|                             class="input-base intelligence-input" | ||||
|                             placeholder="Ask anything..." | ||||
|                             value={self.current_input.clone()} | ||||
|                             oninput={on_input} | ||||
|                         /> | ||||
|                         <button type="submit" class="button-base button-primary send-button">{ "Send" }</button> | ||||
|                     </form> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl IntelligenceView { | ||||
|     fn get_conversation_data(&self, _ctx: &Context<Self>) -> (Option<Rc<AiConversation>>, Vec<AiConversationSummary>) { | ||||
|         // TODO: The Circle model does not currently have an `intelligence` field. | ||||
|         // This logic is temporarily disabled to allow compilation. | ||||
|         // We need to determine how to fetch and associate AI conversations with circles. | ||||
|         (None, Vec::new()) | ||||
|     } | ||||
|  | ||||
|     fn submit_intelligence_prompt(&mut self, ctx: &Context<Self>) { | ||||
|         let user_message_content = self.current_input.trim().to_string(); | ||||
|         if user_message_content.is_empty() { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         self.current_input.clear(); | ||||
|  | ||||
|         // Get target circle for the prompt | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             // Execute Rhai script to submit intelligence prompt | ||||
|             let script = format!( | ||||
|                 r#" | ||||
|                 let conversation_id = {}; | ||||
|                 let message = "{}"; | ||||
|                 submit_intelligence_prompt(conversation_id, message); | ||||
|                 "#, | ||||
|                 self.active_conversation_id.unwrap_or(0), | ||||
|                 user_message_content.replace('"', r#"\""#) | ||||
|             ); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
|                 spawn_local(async move { | ||||
|                     match script_future.await { | ||||
|                         Ok(result) => { | ||||
|                             link.send_message(IntelligenceMsg::ScriptExecuted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(IntelligenceMsg::ScriptExecuted(Err(format!("{:?}", e)))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn fetch_intelligence_data(&mut self, ws_url: &str) { | ||||
|         let script = r#" | ||||
|             let intelligence = get_intelligence(); | ||||
|             intelligence | ||||
|         "#.to_string(); | ||||
|  | ||||
|         if let Some(script_future) = self.ws_manager.execute_script(ws_url, script) { | ||||
|             spawn_local(async move { | ||||
|                 match script_future.await { | ||||
|                     Ok(result) => { | ||||
|                         log::info!("Intelligence data fetched: {}", result.output); | ||||
|                         // Parse and handle intelligence data | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         log::error!("Failed to fetch intelligence data: {:?}", e); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn format_timestamp_string(timestamp_str: &str) -> String { | ||||
|     match DateTime::parse_from_rfc3339(timestamp_str) { | ||||
|         Ok(dt) => dt.with_timezone(&Utc).format("%Y-%m-%d %H:%M").to_string(), | ||||
|         Err(_) => timestamp_str.to_string(), // Fallback to raw string if parsing fails | ||||
|     } | ||||
| } | ||||
							
								
								
									
										446
									
								
								src/app/src/components/library_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										446
									
								
								src/app/src/components/library_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,446 @@ | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use yew::prelude::*; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use heromodels::models::library::collection::Collection; | ||||
| use heromodels::models::library::items::{Image, Pdf, Markdown, Book, Slides}; | ||||
| use crate::ws_manager::{fetch_data_from_ws_urls, fetch_data_from_ws_url}; | ||||
| use crate::components::{ | ||||
|     book_viewer::BookViewer, | ||||
|     slides_viewer::SlidesViewer, | ||||
|     image_viewer::ImageViewer, | ||||
|     pdf_viewer::PdfViewer, | ||||
|     markdown_viewer::MarkdownViewer, | ||||
|     asset_details_card::AssetDetailsCard, | ||||
| }; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct LibraryViewProps { | ||||
|     pub ws_addresses: Vec<String>, | ||||
| } | ||||
|  | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum DisplayLibraryItem { | ||||
|     Image(Image), | ||||
|     Pdf(Pdf), | ||||
|     Markdown(Markdown), | ||||
|     Book(Book), | ||||
|     Slides(Slides), | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct DisplayLibraryCollection { | ||||
|     pub title: String, | ||||
|     pub description: Option<String>, | ||||
|     pub items: Vec<Rc<DisplayLibraryItem>>, | ||||
|     pub ws_url: String, | ||||
|     pub collection_key: String, | ||||
| } | ||||
|  | ||||
| pub enum Msg { | ||||
|     SelectCollection(usize), | ||||
|     CollectionsFetched(HashMap<String, Collection>), | ||||
|     ItemsFetched(String, Vec<DisplayLibraryItem>), // collection_key, items | ||||
|     ViewItem(DisplayLibraryItem), | ||||
|     BackToLibrary, | ||||
|     BackToCollections, | ||||
| } | ||||
|  | ||||
| pub struct LibraryView { | ||||
|     selected_collection_index: Option<usize>, | ||||
|     collections: HashMap<String, Collection>, | ||||
|     display_collections: Vec<DisplayLibraryCollection>, | ||||
|     loading: bool, | ||||
|     error: Option<String>, | ||||
|     viewing_item: Option<DisplayLibraryItem>, | ||||
|     view_state: ViewState, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| pub enum ViewState { | ||||
|     Collections, | ||||
|     CollectionItems, | ||||
|     ItemViewer, | ||||
| } | ||||
|  | ||||
| impl Component for LibraryView { | ||||
|     type Message = Msg; | ||||
|     type Properties = LibraryViewProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let props = ctx.props(); | ||||
|         let ws_addresses = props.ws_addresses.clone(); | ||||
|          | ||||
|         let link = ctx.link().clone(); | ||||
|         spawn_local(async move { | ||||
|             let collections = get_collections(&ws_addresses).await; | ||||
|             link.send_message(Msg::CollectionsFetched(collections)); | ||||
|         }); | ||||
|  | ||||
|         Self { | ||||
|             selected_collection_index: None, | ||||
|             collections: HashMap::new(), | ||||
|             display_collections: Vec::new(), | ||||
|             loading: true, | ||||
|             error: None, | ||||
|             viewing_item: None, | ||||
|             view_state: ViewState::Collections, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         if ctx.props().ws_addresses != old_props.ws_addresses { | ||||
|             let ws_addresses = ctx.props().ws_addresses.clone(); | ||||
|             let link = ctx.link().clone(); | ||||
|              | ||||
|             self.loading = true; | ||||
|             self.error = None; | ||||
|              | ||||
|             spawn_local(async move { | ||||
|                 let collections = get_collections(&ws_addresses).await; | ||||
|                 link.send_message(Msg::CollectionsFetched(collections)); | ||||
|             }); | ||||
|         } | ||||
|         true | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::CollectionsFetched(collections) => { | ||||
|                 log::info!("Collections fetched: {:?}", collections.keys().collect::<Vec<_>>()); | ||||
|                 self.collections = collections.clone(); | ||||
|                 self.loading = false; | ||||
|                  | ||||
|                 // Convert collections to display collections and start fetching items | ||||
|                 for (collection_key, collection) in collections { | ||||
|                     let ws_url = collection_key.split('_').next().unwrap_or("").to_string(); | ||||
|                     let display_collection = DisplayLibraryCollection { | ||||
|                         title: collection.title.clone(), | ||||
|                         description: collection.description.clone(), | ||||
|                         items: Vec::new(), | ||||
|                         ws_url: ws_url.clone(), | ||||
|                         collection_key: collection_key.clone(), | ||||
|                     }; | ||||
|                     self.display_collections.push(display_collection); | ||||
|                      | ||||
|                     // Fetch items for this collection | ||||
|                     let link = ctx.link().clone(); | ||||
|                     let collection_clone = collection.clone(); | ||||
|                     let collection_key_clone = collection_key.clone(); | ||||
|                      | ||||
|                     spawn_local(async move { | ||||
|                         let items = fetch_collection_items(&ws_url, &collection_clone).await; | ||||
|                         link.send_message(Msg::ItemsFetched(collection_key_clone, items)); | ||||
|                     }); | ||||
|                 } | ||||
|                  | ||||
|                 true | ||||
|             } | ||||
|             Msg::ItemsFetched(collection_key, items) => { | ||||
|                 // Find the display collection and update its items using exact key matching | ||||
|                 if let Some(display_collection) = self.display_collections.iter_mut() | ||||
|                     .find(|dc| dc.collection_key == collection_key) { | ||||
|                     display_collection.items = items.into_iter().map(Rc::new).collect(); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             Msg::ViewItem(item) => { | ||||
|                 self.viewing_item = Some(item); | ||||
|                 self.view_state = ViewState::ItemViewer; | ||||
|                 true | ||||
|             } | ||||
|             Msg::BackToLibrary => { | ||||
|                 self.viewing_item = None; | ||||
|                 self.view_state = ViewState::CollectionItems; | ||||
|                 true | ||||
|             } | ||||
|             Msg::BackToCollections => { | ||||
|                 self.viewing_item = None; | ||||
|                 self.selected_collection_index = None; | ||||
|                 self.view_state = ViewState::Collections; | ||||
|                 true | ||||
|             } | ||||
|             Msg::SelectCollection(idx) => { | ||||
|                 self.selected_collection_index = Some(idx); | ||||
|                 self.view_state = ViewState::CollectionItems; | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         match &self.view_state { | ||||
|             ViewState::ItemViewer => { | ||||
|                 if let Some(item) = &self.viewing_item { | ||||
|                     let back_callback = ctx.link().callback(|_| Msg::BackToLibrary); | ||||
|                     let toc_callback = Callback::from(|_page: usize| { | ||||
|                         // TOC navigation is now handled by the BookViewer component | ||||
|                     }); | ||||
|                      | ||||
|                     html! { | ||||
|                         <div class="view-container sidebar-layout"> | ||||
|                             <div class="sidebar"> | ||||
|                                 <AssetDetailsCard | ||||
|                                     item={item.clone()} | ||||
|                                     on_back={back_callback.clone()} | ||||
|                                     on_toc_click={toc_callback} | ||||
|                                     current_slide_index={None} | ||||
|                                 /> | ||||
|                             </div> | ||||
|                             <div class="library-content"> | ||||
|                                 { self.render_viewer_component(item, back_callback) } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { <p>{"No item selected"}</p> } | ||||
|                 } | ||||
|             } | ||||
|             ViewState::CollectionItems => { | ||||
|                 // Collection items view with click-outside to go back | ||||
|                 let back_handler = ctx.link().callback(|_: MouseEvent| Msg::BackToCollections); | ||||
|                 html! { | ||||
|                     <div class="view-container layout"> | ||||
|                         <div class="library-content" onclick={back_handler}> | ||||
|                             { self.render_collection_items_view(ctx) } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 } | ||||
|             } | ||||
|             ViewState::Collections => { | ||||
|                 // Collections view - no click-outside needed | ||||
|                 html! { | ||||
|                     <div class="view-container layout"> | ||||
|                         <div class="library-content"> | ||||
|                             { self.render_collections_view(ctx) } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl LibraryView { | ||||
|     fn render_viewer_component(&self, item: &DisplayLibraryItem, back_callback: Callback<()>) -> Html { | ||||
|         match item { | ||||
|             DisplayLibraryItem::Image(img) => html! { | ||||
|                 <ImageViewer image={img.clone()} on_back={back_callback} /> | ||||
|             }, | ||||
|             DisplayLibraryItem::Pdf(pdf) => html! { | ||||
|                 <PdfViewer pdf={pdf.clone()} on_back={back_callback} /> | ||||
|             }, | ||||
|             DisplayLibraryItem::Markdown(md) => html! { | ||||
|                 <MarkdownViewer markdown={md.clone()} on_back={back_callback} /> | ||||
|             }, | ||||
|             DisplayLibraryItem::Book(book) => html! { | ||||
|                 <BookViewer book={book.clone()} on_back={back_callback} /> | ||||
|             }, | ||||
|             DisplayLibraryItem::Slides(slides) => html! { | ||||
|                 <SlidesViewer slides={slides.clone()} on_back={back_callback} /> | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|  | ||||
| fn render_collections_view(&self, ctx: &Context<Self>) -> Html { | ||||
|     if self.loading { | ||||
|         html! { <p>{"Loading collections..."}</p> } | ||||
|     } else if let Some(err) = &self.error { | ||||
|         html! { <p class="error-message">{format!("Error: {}", err)}</p> } | ||||
|     } else if self.display_collections.is_empty() { | ||||
|         html! { <p class="no-collections-message">{"No collections available."}</p> } | ||||
|     } else { | ||||
|         html! { | ||||
|             <> | ||||
|                 <h1>{"Collections"}</h1> | ||||
|                 <div class="collections-grid"> | ||||
|                     { self.display_collections.iter().enumerate().map(|(idx, collection)| { | ||||
|                         let onclick = ctx.link().callback(move |e: MouseEvent| { | ||||
|                             e.stop_propagation(); | ||||
|                             Msg::SelectCollection(idx) | ||||
|                         }); | ||||
|                         let item_count = collection.items.len(); | ||||
|                         html! { | ||||
|                             <div class="card" onclick={onclick}> | ||||
|                                 <h3 class="collection-title">{ &collection.title }</h3> | ||||
|                                 { if let Some(desc) = &collection.description { | ||||
|                                     html! { <p class="collection-description">{ desc }</p> } | ||||
|                                 } else { | ||||
|                                     html! {} | ||||
|                                 }} | ||||
|                             </div> | ||||
|                         } | ||||
|                     }).collect::<Html>() } | ||||
|                 </div> | ||||
|             </> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_collection_items_view(&self, ctx: &Context<Self>) -> Html { | ||||
|     if let Some(selected_index) = self.selected_collection_index { | ||||
|         if let Some(collection) = self.display_collections.get(selected_index) { | ||||
|             html! { | ||||
|                 <> | ||||
|                     <header> | ||||
|                         <h2 onclick={|e: MouseEvent| e.stop_propagation()}>{ &collection.title }</h2> | ||||
|                         { if let Some(desc) = &collection.description { | ||||
|                             html! { <p onclick={|e: MouseEvent| e.stop_propagation()}>{ desc }</p> } | ||||
|                         } else { | ||||
|                             html! {} | ||||
|                         }} | ||||
|                     </header> | ||||
|                     <div class="library-items-grid"> | ||||
|                         { collection.items.iter().map(|item| { | ||||
|                             let item_clone = item.as_ref().clone(); | ||||
|                             let onclick = ctx.link().callback(move |e: MouseEvent| { | ||||
|                                 e.stop_propagation(); | ||||
|                                 Msg::ViewItem(item_clone.clone()) | ||||
|                             }); | ||||
|                              | ||||
|                             match item.as_ref() { | ||||
|                                 DisplayLibraryItem::Image(img) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <img src={img.url.clone()} class="item-thumbnail-img" alt={img.title.clone()} /> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &img.title }</p> | ||||
|                                             { if let Some(desc) = &img.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Pdf(pdf) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-file-pdf item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &pdf.title }</p> | ||||
|                                             { if let Some(desc) = &pdf.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} pages", pdf.page_count) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Markdown(md) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fab fa-markdown item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &md.title }</p> | ||||
|                                             { if let Some(desc) = &md.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Book(book) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-book item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &book.title }</p> | ||||
|                                             { if let Some(desc) = &book.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} pages", book.pages.len()) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                                 DisplayLibraryItem::Slides(slides) => html! { | ||||
|                                     <div class="library-item-card" onclick={onclick}> | ||||
|                                         <div class="item-preview"> | ||||
|                                             <i class="fas fa-images item-preview-fallback-icon"></i> | ||||
|                                         </div> | ||||
|                                         <div class="item-details"> | ||||
|                                             <p class="item-title">{ &slides.title }</p> | ||||
|                                             { if let Some(desc) = &slides.description { | ||||
|                                                 html! { <p class="item-description">{ desc }</p> } | ||||
|                                             } else { html! {} }} | ||||
|                                             <p class="item-meta">{ format!("{} slides", slides.slide_urls.len()) }</p> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 }, | ||||
|                             } | ||||
|                         }).collect::<Html>() } | ||||
|                     </div> | ||||
|                 </> | ||||
|             } | ||||
|         } else { | ||||
|             html! { <p>{"Collection not found."}</p> } | ||||
|         } | ||||
|     } else { | ||||
|         self.render_collections_view(ctx) | ||||
|     } | ||||
| } | ||||
|  | ||||
| } | ||||
|  | ||||
| /// Convenience function to fetch collections from WebSocket URLs | ||||
| async fn get_collections(ws_urls: &[String]) -> HashMap<String, Collection> { | ||||
|     let collections_arrays: HashMap<String, Vec<Collection>> = fetch_data_from_ws_urls(ws_urls, "list_collections().json()".to_string()).await; | ||||
|      | ||||
|     let mut result = HashMap::new(); | ||||
|     for (ws_url, collections_vec) in collections_arrays { | ||||
|         for (index, collection) in collections_vec.into_iter().enumerate() { | ||||
|             // Use a unique key combining ws_url and collection index/id | ||||
|             let key = format!("{}_{}", ws_url, collection.base_data.id); | ||||
|             result.insert(key, collection); | ||||
|         } | ||||
|     } | ||||
|     result | ||||
| } | ||||
|  | ||||
| /// Fetch all items for a collection from a WebSocket URL | ||||
| async fn fetch_collection_items(ws_url: &str, collection: &Collection) -> Vec<DisplayLibraryItem> { | ||||
|     let mut items = Vec::new(); | ||||
|      | ||||
|     // Fetch images | ||||
|     for image_id in &collection.images { | ||||
|         match fetch_data_from_ws_url::<Image>(ws_url, &format!("get_image({}).json()", image_id)).await { | ||||
|             Ok(image) => items.push(DisplayLibraryItem::Image(image)), | ||||
|             Err(e) => log::error!("Failed to fetch image {}: {}", image_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Fetch PDFs | ||||
|     for pdf_id in &collection.pdfs { | ||||
|         match fetch_data_from_ws_url::<Pdf>(ws_url, &format!("get_pdf({}).json()", pdf_id)).await { | ||||
|             Ok(pdf) => items.push(DisplayLibraryItem::Pdf(pdf)), | ||||
|             Err(e) => log::error!("Failed to fetch PDF {}: {}", pdf_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Fetch Markdowns | ||||
|     for markdown_id in &collection.markdowns { | ||||
|         match fetch_data_from_ws_url::<Markdown>(ws_url, &format!("get_markdown({}).json()", markdown_id)).await { | ||||
|             Ok(markdown) => items.push(DisplayLibraryItem::Markdown(markdown)), | ||||
|             Err(e) => log::error!("Failed to fetch markdown {}: {}", markdown_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Fetch Books | ||||
|     for book_id in &collection.books { | ||||
|         match fetch_data_from_ws_url::<Book>(ws_url, &format!("get_book({}).json()", book_id)).await { | ||||
|             Ok(book) => items.push(DisplayLibraryItem::Book(book)), | ||||
|             Err(e) => log::error!("Failed to fetch book {}: {}", book_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     // Fetch Slides | ||||
|     for slides_id in &collection.slides { | ||||
|         match fetch_data_from_ws_url::<Slides>(ws_url, &format!("get_slides({}).json()", slides_id)).await { | ||||
|             Ok(slides) => items.push(DisplayLibraryItem::Slides(slides)), | ||||
|             Err(e) => log::error!("Failed to fetch slides {}: {}", slides_id, e), | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     items | ||||
| } | ||||
							
								
								
									
										633
									
								
								src/app/src/components/login_component.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										633
									
								
								src/app/src/components/login_component.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,633 @@ | ||||
| //! Login component for authentication | ||||
| //!  | ||||
| //! This component provides a user interface for authentication using either | ||||
| //! email addresses (with hardcoded key lookup) or direct private key input. | ||||
|  | ||||
| use yew::prelude::*; | ||||
| use web_sys::HtmlInputElement; | ||||
| use crate::auth::{AuthManager, AuthState, AuthMethod}; | ||||
|  | ||||
| /// Props for the login component | ||||
| #[derive(Properties, PartialEq)] | ||||
| pub struct LoginProps { | ||||
|     /// Authentication manager instance | ||||
|     pub auth_manager: AuthManager, | ||||
|     /// Callback when authentication is successful | ||||
|     #[prop_or_default] | ||||
|     pub on_authenticated: Option<Callback<()>>, | ||||
|     /// Callback when authentication fails | ||||
|     #[prop_or_default] | ||||
|     pub on_error: Option<Callback<String>>, | ||||
| } | ||||
|  | ||||
| /// Login method selection | ||||
| #[derive(Debug, Clone, PartialEq)] | ||||
| enum LoginMethod { | ||||
|     Email, | ||||
|     PrivateKey, | ||||
|     CreateKey, | ||||
| } | ||||
|  | ||||
| /// Messages for the login component | ||||
| pub enum LoginMsg { | ||||
|     SetLoginMethod(LoginMethod), | ||||
|     SetEmail(String), | ||||
|     SetPrivateKey(String), | ||||
|     SubmitLogin, | ||||
|     AuthStateChanged(AuthState), | ||||
|     ShowAvailableEmails, | ||||
|     HideAvailableEmails, | ||||
|     SelectEmail(String), | ||||
|     GenerateNewKey, | ||||
|     CopyToClipboard(String), | ||||
|     UseGeneratedKey, | ||||
| } | ||||
|  | ||||
| /// Login component state | ||||
| pub struct LoginComponent { | ||||
|     login_method: LoginMethod, | ||||
|     email: String, | ||||
|     private_key: String, | ||||
|     is_loading: bool, | ||||
|     error_message: Option<String>, | ||||
|     show_available_emails: bool, | ||||
|     available_emails: Vec<String>, | ||||
|     auth_state: AuthState, | ||||
|     generated_private_key: Option<String>, | ||||
|     generated_public_key: Option<String>, | ||||
|     copy_feedback: Option<String>, | ||||
| } | ||||
|  | ||||
| impl Component for LoginComponent { | ||||
|     type Message = LoginMsg; | ||||
|     type Properties = LoginProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let auth_manager = ctx.props().auth_manager.clone(); | ||||
|         let auth_state = auth_manager.get_state(); | ||||
|          | ||||
|         // Set up auth state change callback | ||||
|         let link = ctx.link().clone(); | ||||
|         auth_manager.set_on_state_change(link.callback(LoginMsg::AuthStateChanged)); | ||||
|          | ||||
|         // Get available emails for app | ||||
|         let available_emails = auth_manager.get_available_emails(); | ||||
|  | ||||
|         Self { | ||||
|             login_method: LoginMethod::Email, | ||||
|             email: String::new(), | ||||
|             private_key: String::new(), | ||||
|             is_loading: false, | ||||
|             error_message: None, | ||||
|             show_available_emails: false, | ||||
|             available_emails, | ||||
|             auth_state, | ||||
|             generated_private_key: None, | ||||
|             generated_public_key: None, | ||||
|             copy_feedback: None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             LoginMsg::SetLoginMethod(method) => { | ||||
|                 self.login_method = method.clone(); | ||||
|                 self.error_message = None; | ||||
|                 self.copy_feedback = None; | ||||
|                 // Clear generated keys when switching away from CreateKey method | ||||
|                 if method != LoginMethod::CreateKey { | ||||
|                     self.generated_private_key = None; | ||||
|                     self.generated_public_key = None; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::SetEmail(email) => { | ||||
|                 self.email = email; | ||||
|                 self.error_message = None; | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::SetPrivateKey(private_key) => { | ||||
|                 self.private_key = private_key; | ||||
|                 self.error_message = None; | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::SubmitLogin => { | ||||
|                 if self.is_loading { | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 self.is_loading = true; | ||||
|                 self.error_message = None; | ||||
|  | ||||
|                 let auth_manager = ctx.props().auth_manager.clone(); | ||||
|                 let link = ctx.link().clone(); | ||||
|                 let on_authenticated = ctx.props().on_authenticated.clone(); | ||||
|                 let on_error = ctx.props().on_error.clone(); | ||||
|  | ||||
|                 match self.login_method { | ||||
|                     LoginMethod::Email => { | ||||
|                         let email = self.email.clone(); | ||||
|                         wasm_bindgen_futures::spawn_local(async move { | ||||
|                             match auth_manager.authenticate_with_email(email).await { | ||||
|                                 Ok(()) => { | ||||
|                                     if let Some(callback) = on_authenticated { | ||||
|                                         callback.emit(()); | ||||
|                                     } | ||||
|                                 } | ||||
|                                 Err(e) => { | ||||
|                                     if let Some(callback) = on_error { | ||||
|                                         callback.emit(e.to_string()); | ||||
|                                     } | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged(AuthState::Failed(e.to_string()))); | ||||
|                                 } | ||||
|                             } | ||||
|                         }); | ||||
|                     } | ||||
|                     LoginMethod::PrivateKey => { | ||||
|                         let private_key = self.private_key.clone(); | ||||
|                         wasm_bindgen_futures::spawn_local(async move { | ||||
|                             match auth_manager.authenticate_with_private_key(private_key).await { | ||||
|                                 Ok(()) => { | ||||
|                                     if let Some(callback) = on_authenticated { | ||||
|                                         callback.emit(()); | ||||
|                                     } | ||||
|                                 } | ||||
|                                 Err(e) => { | ||||
|                                     if let Some(callback) = on_error { | ||||
|                                         callback.emit(e.to_string()); | ||||
|                                     } | ||||
|                                     link.send_message(LoginMsg::AuthStateChanged(AuthState::Failed(e.to_string()))); | ||||
|                                 } | ||||
|                             } | ||||
|                         }); | ||||
|                     } | ||||
|                     LoginMethod::CreateKey => { | ||||
|                         // This shouldn't happen as CreateKey method doesn't have a submit button | ||||
|                         // But if it does, treat it as an error | ||||
|                         self.error_message = Some("Please generate a key first, then use it to login.".to_string()); | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::AuthStateChanged(state) => { | ||||
|                 self.auth_state = state.clone(); | ||||
|                 match state { | ||||
|                     AuthState::Authenticating => { | ||||
|                         self.is_loading = true; | ||||
|                         self.error_message = None; | ||||
|                     } | ||||
|                     AuthState::Authenticated { .. } => { | ||||
|                         self.is_loading = false; | ||||
|                         self.error_message = None; | ||||
|                     } | ||||
|                     AuthState::Failed(error) => { | ||||
|                         self.is_loading = false; | ||||
|                         self.error_message = Some(error); | ||||
|                     } | ||||
|                     AuthState::NotAuthenticated => { | ||||
|                         self.is_loading = false; | ||||
|                         self.error_message = None; | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::ShowAvailableEmails => { | ||||
|                 self.show_available_emails = true; | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::HideAvailableEmails => { | ||||
|                 self.show_available_emails = false; | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::SelectEmail(email) => { | ||||
|                 self.email = email; | ||||
|                 self.show_available_emails = false; | ||||
|                 self.error_message = None; | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::GenerateNewKey => { | ||||
|                 use circle_client_ws::auth as crypto_utils; | ||||
|                  | ||||
|                 match crypto_utils::generate_private_key() { | ||||
|                     Ok(private_key) => { | ||||
|                         match crypto_utils::derive_public_key(&private_key) { | ||||
|                             Ok(public_key) => { | ||||
|                                 self.generated_private_key = Some(private_key); | ||||
|                                 self.generated_public_key = Some(public_key); | ||||
|                                 self.error_message = None; | ||||
|                                 self.copy_feedback = None; | ||||
|                             } | ||||
|                             Err(e) => { | ||||
|                                 self.error_message = Some(format!("Failed to derive public key: {}", e)); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         self.error_message = Some(format!("Failed to generate private key: {}", e)); | ||||
|                     } | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::CopyToClipboard(text) => { | ||||
|                 // Simple fallback: show the text in an alert for now | ||||
|                 // TODO: Implement proper clipboard API when web_sys is properly configured | ||||
|                 if let Some(window) = web_sys::window() { | ||||
|                     window.alert_with_message(&format!("Copy this key:\n\n{}", text)).ok(); | ||||
|                     self.copy_feedback = Some("Key shown in alert - please copy manually".to_string()); | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             LoginMsg::UseGeneratedKey => { | ||||
|                 if let Some(private_key) = &self.generated_private_key { | ||||
|                     self.private_key = private_key.clone(); | ||||
|                     self.login_method = LoginMethod::PrivateKey; | ||||
|                     self.copy_feedback = None; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|  | ||||
|         // If already authenticated, show status | ||||
|         if let AuthState::Authenticated { method, public_key, .. } = &self.auth_state { | ||||
|             return self.render_authenticated_view(method, public_key, link); | ||||
|         } | ||||
|  | ||||
|         html! { | ||||
|             <div class="login-container"> | ||||
|                 <div class="login-card"> | ||||
|                     <h2 class="login-title">{ "Authenticate to Circles" }</h2> | ||||
|                      | ||||
|                     { self.render_method_selector(link) } | ||||
|                     { self.render_login_form(link) } | ||||
|                     { self.render_error_message() } | ||||
|                     { self.render_loading_indicator() } | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl LoginComponent { | ||||
|     fn render_method_selector(&self, link: &html::Scope<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="login-method-selector"> | ||||
|                 <div class="method-tabs"> | ||||
|                     <button | ||||
|                         class={classes!("method-tab", if self.login_method == LoginMethod::Email { "active" } else { "" })} | ||||
|                         onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::Email))} | ||||
|                         disabled={self.is_loading} | ||||
|                     > | ||||
|                         <i class="fas fa-envelope"></i> | ||||
|                         { " Email" } | ||||
|                     </button> | ||||
|                     <button | ||||
|                         class={classes!("method-tab", if self.login_method == LoginMethod::PrivateKey { "active" } else { "" })} | ||||
|                         onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::PrivateKey))} | ||||
|                         disabled={self.is_loading} | ||||
|                     > | ||||
|                         <i class="fas fa-key"></i> | ||||
|                         { " Private Key" } | ||||
|                     </button> | ||||
|                     <button | ||||
|                         class={classes!("method-tab", if self.login_method == LoginMethod::CreateKey { "active" } else { "" })} | ||||
|                         onclick={link.callback(|_| LoginMsg::SetLoginMethod(LoginMethod::CreateKey))} | ||||
|                         disabled={self.is_loading} | ||||
|                     > | ||||
|                         <i class="fas fa-plus-circle"></i> | ||||
|                         { " Create Key" } | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_login_form(&self, link: &html::Scope<Self>) -> Html { | ||||
|         match self.login_method { | ||||
|             LoginMethod::Email => self.render_email_form(link), | ||||
|             LoginMethod::PrivateKey => self.render_private_key_form(link), | ||||
|             LoginMethod::CreateKey => self.render_create_key_form(link), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_email_form(&self, link: &html::Scope<Self>) -> Html { | ||||
|         let on_email_input = link.batch_callback(|e: InputEvent| { | ||||
|             let input: HtmlInputElement = e.target_unchecked_into(); | ||||
|             Some(LoginMsg::SetEmail(input.value())) | ||||
|         }); | ||||
|  | ||||
|         let on_submit = link.callback(|e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|             LoginMsg::SubmitLogin | ||||
|         }); | ||||
|  | ||||
|         html! { | ||||
|             <form class="login-form" onsubmit={on_submit}> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="email-input">{ "Email Address" }</label> | ||||
|                     <div class="email-input-container"> | ||||
|                         <input | ||||
|                             id="email-input" | ||||
|                             type="email" | ||||
|                             class="form-input" | ||||
|                             placeholder="Enter your email address" | ||||
|                             value={self.email.clone()} | ||||
|                             oninput={on_email_input} | ||||
|                             disabled={self.is_loading} | ||||
|                             required=true | ||||
|                         /> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="email-dropdown-btn" | ||||
|                             onclick={link.callback(|_| LoginMsg::ShowAvailableEmails)} | ||||
|                             disabled={self.is_loading} | ||||
|                             title="Show available app emails" | ||||
|                         > | ||||
|                             <i class="fas fa-chevron-down"></i> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                     { self.render_email_dropdown(link) } | ||||
|                     <small class="form-help"> | ||||
|                         { "Use one of the app email addresses or click the dropdown to see available options." } | ||||
|                     </small> | ||||
|                 </div> | ||||
|                  | ||||
|                 <button | ||||
|                     type="submit" | ||||
|                     class="login-btn" | ||||
|                     disabled={self.is_loading || self.email.is_empty()} | ||||
|                 > | ||||
|                     { if self.is_loading { "Authenticating..." } else { "Login with Email" } } | ||||
|                 </button> | ||||
|             </form> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_private_key_form(&self, link: &html::Scope<Self>) -> Html { | ||||
|         let on_private_key_input = link.batch_callback(|e: InputEvent| { | ||||
|             let input: HtmlInputElement = e.target_unchecked_into(); | ||||
|             Some(LoginMsg::SetPrivateKey(input.value())) | ||||
|         }); | ||||
|  | ||||
|         let on_submit = link.callback(|e: SubmitEvent| { | ||||
|             e.prevent_default(); | ||||
|             LoginMsg::SubmitLogin | ||||
|         }); | ||||
|  | ||||
|         html! { | ||||
|             <form class="login-form" onsubmit={on_submit}> | ||||
|                 <div class="form-group"> | ||||
|                     <label for="private-key-input">{ "Private Key" }</label> | ||||
|                     <input | ||||
|                         id="private-key-input" | ||||
|                         type="password" | ||||
|                         class="form-input" | ||||
|                         placeholder="Enter your private key (hex format)" | ||||
|                         value={self.private_key.clone()} | ||||
|                         oninput={on_private_key_input} | ||||
|                         disabled={self.is_loading} | ||||
|                         required=true | ||||
|                     /> | ||||
|                     <small class="form-help"> | ||||
|                         { "Enter your secp256k1 private key in hexadecimal format (with or without 0x prefix)." } | ||||
|                     </small> | ||||
|                 </div> | ||||
|                  | ||||
|                 <button | ||||
|                     type="submit" | ||||
|                     class="login-btn" | ||||
|                     disabled={self.is_loading || self.private_key.is_empty()} | ||||
|                 > | ||||
|                     { if self.is_loading { "Authenticating..." } else { "Login with Private Key" } } | ||||
|                 </button> | ||||
|             </form> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_email_dropdown(&self, link: &html::Scope<Self>) -> Html { | ||||
|         if !self.show_available_emails { | ||||
|             return html! {}; | ||||
|         } | ||||
|  | ||||
|         html! { | ||||
|             <div class="email-dropdown"> | ||||
|                 <div class="dropdown-header"> | ||||
|                     <span>{ "Available Demo Emails" }</span> | ||||
|                     <button | ||||
|                         type="button" | ||||
|                         class="dropdown-close" | ||||
|                         onclick={link.callback(|_| LoginMsg::HideAvailableEmails)} | ||||
|                     > | ||||
|                         <i class="fas fa-times"></i> | ||||
|                     </button> | ||||
|                 </div> | ||||
|                 <div class="dropdown-list"> | ||||
|                     { for self.available_emails.iter().map(|email| { | ||||
|                         let email_clone = email.clone(); | ||||
|                         html! { | ||||
|                             <button | ||||
|                                 type="button" | ||||
|                                 class="dropdown-item" | ||||
|                                 onclick={link.callback(move |_| LoginMsg::SelectEmail(email_clone.clone()))} | ||||
|                             > | ||||
|                                 <i class="fas fa-user"></i> | ||||
|                                 { email } | ||||
|                             </button> | ||||
|                         } | ||||
|                     }) } | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_error_message(&self) -> Html { | ||||
|         if let Some(error) = &self.error_message { | ||||
|             html! { | ||||
|                 <div class="error-message"> | ||||
|                     <i class="fas fa-exclamation-triangle"></i> | ||||
|                     { error } | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_loading_indicator(&self) -> Html { | ||||
|         if self.is_loading { | ||||
|             html! { | ||||
|                 <div class="loading-indicator"> | ||||
|                     <div class="spinner"></div> | ||||
|                     <span>{ "Authenticating..." }</span> | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_authenticated_view(&self, method: &AuthMethod, public_key: &str, link: &html::Scope<Self>) -> Html { | ||||
|         let method_display = match method { | ||||
|             AuthMethod::Email(email) => format!("Email: {}", email), | ||||
|             AuthMethod::PrivateKey => "Private Key".to_string(), | ||||
|         }; | ||||
|  | ||||
|         let short_public_key = if public_key.len() > 20 { | ||||
|             format!("{}...{}", &public_key[..10], &public_key[public_key.len()-10..]) | ||||
|         } else { | ||||
|             public_key.to_string() | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="authenticated-container"> | ||||
|                 <div class="authenticated-card"> | ||||
|                     <div class="auth-success-icon"> | ||||
|                         <i class="fas fa-check-circle"></i> | ||||
|                     </div> | ||||
|                     <h3>{ "Authentication Successful" }</h3> | ||||
|                     <div class="auth-details"> | ||||
|                         <div class="auth-detail"> | ||||
|                             <label>{ "Method:" }</label> | ||||
|                             <span>{ method_display }</span> | ||||
|                         </div> | ||||
|                         <div class="auth-detail"> | ||||
|                             <label>{ "Public Key:" }</label> | ||||
|                             <span class="public-key" title={public_key.to_string()}>{ short_public_key }</span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <button | ||||
|                         class="logout-btn" | ||||
|                         onclick={link.callback(|_| { | ||||
|                             // This would need to be handled by the parent component | ||||
|                             LoginMsg::AuthStateChanged(AuthState::NotAuthenticated) | ||||
|                         })} | ||||
|                     > | ||||
|                         { "Logout" } | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_create_key_form(&self, link: &html::Scope<Self>) -> Html { | ||||
|         html! { | ||||
|             <div class="create-key-form"> | ||||
|                 <div class="form-group"> | ||||
|                     <h3>{ "Generate New secp256k1 Keypair" }</h3> | ||||
|                     <p class="form-help"> | ||||
|                         { "Create a new cryptographic keypair for authentication. " } | ||||
|                         { "Make sure to securely store your private key!" } | ||||
|                     </p> | ||||
|                      | ||||
|                     <button | ||||
|                         type="button" | ||||
|                         class="generate-key-btn" | ||||
|                         onclick={link.callback(|_| LoginMsg::GenerateNewKey)} | ||||
|                         disabled={self.is_loading} | ||||
|                     > | ||||
|                         <i class="fas fa-dice"></i> | ||||
|                         { " Generate New Keypair" } | ||||
|                     </button> | ||||
|                 </div> | ||||
|  | ||||
|                 { self.render_generated_keys(link) } | ||||
|                 { self.render_copy_feedback() } | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_generated_keys(&self, link: &html::Scope<Self>) -> Html { | ||||
|         if let (Some(private_key), Some(public_key)) = (&self.generated_private_key, &self.generated_public_key) { | ||||
|             let private_key_clone = private_key.clone(); | ||||
|             let public_key_clone = public_key.clone(); | ||||
|              | ||||
|             html! { | ||||
|                 <div class="generated-keys"> | ||||
|                     <div class="key-section"> | ||||
|                         <label>{ "Private Key (Keep Secret!)" }</label> | ||||
|                         <div class="key-display"> | ||||
|                             <input | ||||
|                                 type="text" | ||||
|                                 class="key-input" | ||||
|                                 value={private_key.clone()} | ||||
|                                 readonly=true | ||||
|                             /> | ||||
|                             <button | ||||
|                                 type="button" | ||||
|                                 class="copy-btn" | ||||
|                                 onclick={link.callback(move |_| LoginMsg::CopyToClipboard(private_key_clone.clone()))} | ||||
|                                 title="Copy private key to clipboard" | ||||
|                             > | ||||
|                                 <i class="fas fa-copy"></i> | ||||
|                             </button> | ||||
|                         </div> | ||||
|                         <small class="key-warning"> | ||||
|                             <i class="fas fa-exclamation-triangle"></i> | ||||
|                             { " Store this private key securely! Anyone with access to it can control your account." } | ||||
|                         </small> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="key-section"> | ||||
|                         <label>{ "Public Key (Safe to Share)" }</label> | ||||
|                         <div class="key-display"> | ||||
|                             <input | ||||
|                                 type="text" | ||||
|                                 class="key-input" | ||||
|                                 value={public_key.clone()} | ||||
|                                 readonly=true | ||||
|                             /> | ||||
|                             <button | ||||
|                                 type="button" | ||||
|                                 class="copy-btn" | ||||
|                                 onclick={link.callback(move |_| LoginMsg::CopyToClipboard(public_key_clone.clone()))} | ||||
|                                 title="Copy public key to clipboard" | ||||
|                             > | ||||
|                                 <i class="fas fa-copy"></i> | ||||
|                             </button> | ||||
|                         </div> | ||||
|                         <small class="key-info"> | ||||
|                             { "This is your public address that others can use to identify you." } | ||||
|                         </small> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="key-actions"> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="use-key-btn" | ||||
|                             onclick={link.callback(|_| LoginMsg::UseGeneratedKey)} | ||||
|                         > | ||||
|                             <i class="fas fa-arrow-right"></i> | ||||
|                             { " Use This Key to Login" } | ||||
|                         </button> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="generate-new-btn" | ||||
|                             onclick={link.callback(|_| LoginMsg::GenerateNewKey)} | ||||
|                         > | ||||
|                             <i class="fas fa-redo"></i> | ||||
|                             { " Generate New Keypair" } | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn render_copy_feedback(&self) -> Html { | ||||
|         if let Some(feedback) = &self.copy_feedback { | ||||
|             html! { | ||||
|                 <div class="copy-feedback"> | ||||
|                     <i class="fas fa-check"></i> | ||||
|                     { feedback } | ||||
|                 </div> | ||||
|             } | ||||
|         } else { | ||||
|             html! {} | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										75
									
								
								src/app/src/components/markdown_viewer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/app/src/components/markdown_viewer.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Markdown; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct MarkdownViewerProps { | ||||
|     pub markdown: Markdown, | ||||
|     pub on_back: Callback<()>, | ||||
| } | ||||
|  | ||||
| pub struct MarkdownViewer; | ||||
|  | ||||
| impl Component for MarkdownViewer { | ||||
|     type Message = (); | ||||
|     type Properties = MarkdownViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer markdown-viewer"> | ||||
|                 <button class="back-button" onclick={back_handler}> | ||||
|                     <i class="fas fa-arrow-left"></i> {"Back to Collection"} | ||||
|                 </button> | ||||
|                 <div class="viewer-header"> | ||||
|                     <h2 class="viewer-title">{ &props.markdown.title }</h2> | ||||
|                 </div> | ||||
|                 <div class="viewer-content"> | ||||
|                     <div class="markdown-content"> | ||||
|                         { self.render_markdown(&props.markdown.content) } | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl MarkdownViewer { | ||||
|     fn render_markdown(&self, content: &str) -> Html { | ||||
|         // Simple markdown rendering - convert basic markdown to HTML | ||||
|         let lines: Vec<&str> = content.lines().collect(); | ||||
|         let mut html_content = Vec::new(); | ||||
|  | ||||
|         for line in lines { | ||||
|             if line.starts_with("# ") { | ||||
|                 html_content.push(html! { <h1>{ &line[2..] }</h1> }); | ||||
|             } else if line.starts_with("## ") { | ||||
|                 html_content.push(html! { <h2>{ &line[3..] }</h2> }); | ||||
|             } else if line.starts_with("### ") { | ||||
|                 html_content.push(html! { <h3>{ &line[4..] }</h3> }); | ||||
|             } else if line.starts_with("- ") { | ||||
|                 html_content.push(html! { <li>{ &line[2..] }</li> }); | ||||
|             } else if line.starts_with("**") && line.ends_with("**") { | ||||
|                 let text = &line[2..line.len()-2]; | ||||
|                 html_content.push(html! { <p><strong>{ text }</strong></p> }); | ||||
|             } else if !line.trim().is_empty() { | ||||
|                 html_content.push(html! { <p>{ line }</p> }); | ||||
|             } else { | ||||
|                 html_content.push(html! { <br/> }); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         html! { <div>{ for html_content }</div> } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										31
									
								
								src/app/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/app/src/components/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| // This file declares the `components` module. | ||||
| pub mod circles_view; | ||||
| pub mod nav_island; | ||||
| pub mod library_view; | ||||
| // pub use library_view::{LibraryView, LibraryViewProps}; // Kept commented as it's unused or handled in app.rs | ||||
|  // Kept commented as it's unused or handled in app.rs | ||||
| // pub mod dashboard_view; // Commented out as dashboard_view.rs doesn't exist yet | ||||
| pub mod intelligence_view; | ||||
| pub mod network_animation_view; | ||||
| pub mod publishing_view; | ||||
| pub mod customize_view; | ||||
| pub mod inspector_view; | ||||
| pub mod inspector_network_tab; | ||||
| pub mod inspector_logs_tab; | ||||
| pub mod inspector_interact_tab; | ||||
| pub mod inspector_auth_tab; | ||||
| pub mod chat; | ||||
| pub mod sidebar_layout; | ||||
| pub mod world_map_svg; | ||||
|  | ||||
| // Authentication components | ||||
| pub mod login_component; | ||||
| pub mod auth_view; | ||||
|  | ||||
| // Library viewer components | ||||
| pub mod book_viewer; | ||||
| pub mod slides_viewer; | ||||
| pub mod image_viewer; | ||||
| pub mod pdf_viewer; | ||||
| pub mod markdown_viewer; | ||||
| pub mod asset_details_card; | ||||
							
								
								
									
										74
									
								
								src/app/src/components/nav_island.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								src/app/src/components/nav_island.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,74 @@ | ||||
| use yew::{function_component, Callback, Properties, classes, use_state, use_node_ref}; | ||||
| use web_sys::MouseEvent; | ||||
| use crate::app::AppView; // Assuming AppView is accessible | ||||
|  | ||||
| #[derive(Properties, PartialEq, Clone)] | ||||
| pub struct NavIslandProps { | ||||
|     pub current_view: AppView, | ||||
|     pub on_switch_view: Callback<AppView>, | ||||
| } | ||||
|  | ||||
| #[function_component(NavIsland)] | ||||
| pub fn nav_island(props: &NavIslandProps) -> yew::Html { | ||||
|     let is_clicked = use_state(|| false); | ||||
|     let nav_island_ref = use_node_ref(); | ||||
|     // Create all button data with their view/tab info | ||||
|     let mut all_buttons = vec![ | ||||
|         (AppView::Circles, None::<()>, "fas fa-circle-notch", "Circles"), | ||||
|         (AppView::Library, None::<()>, "fas fa-book", "Library"), | ||||
|         (AppView::Intelligence, None::<()>, "fas fa-brain", "Intelligence"), | ||||
|         (AppView::Publishing, None::<()>, "fas fa-rocket", "Publishing"),  | ||||
|         (AppView::Inspector, None::<()>, "fas fa-search-location", "Inspector"),  | ||||
|         (AppView::Customize, None::<()>, "fas fa-paint-brush", "Customize"),  | ||||
|     ]; | ||||
|  | ||||
|     // Find and move the active button to the front | ||||
|     let active_index = all_buttons.iter().position(|(view, tab, _, _)| { | ||||
|         *view == props.current_view && tab.is_none() // A button is active if its view matches and it has no specific tab | ||||
|     }); | ||||
|  | ||||
|     if let Some(index) = active_index { | ||||
|         let active_button = all_buttons.remove(index); | ||||
|         all_buttons.insert(0, active_button); | ||||
|     } | ||||
|  | ||||
|     let is_clicked_clone = is_clicked.clone(); | ||||
|     let onmouseenter = Callback::from(move |_: MouseEvent| { | ||||
|         // Only reset clicked state if user explicitly hovers after clicking | ||||
|         if *is_clicked_clone { | ||||
|             is_clicked_clone.set(false); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     yew::html! { | ||||
|         <div | ||||
|             class={classes!("nav-island", "collapsed", (*is_clicked).then_some("clicked"))} | ||||
|             ref={nav_island_ref.clone()} | ||||
|             onmouseenter={onmouseenter} | ||||
|         > | ||||
|             <div class="nav-island-buttons"> | ||||
|                 { for all_buttons.iter().map(|(button_app_view, _button_tab_opt, icon_class, text)| { | ||||
|                     let on_select_view_cb = props.on_switch_view.clone(); | ||||
|                     let view_to_emit = *button_app_view; | ||||
|  | ||||
|                     let is_clicked_setter = is_clicked.setter(); | ||||
|                     let button_click_handler = Callback::from(move |_| { | ||||
|                         is_clicked_setter.set(true); | ||||
|                         on_select_view_cb.emit(view_to_emit.clone()); | ||||
|                     }); | ||||
|  | ||||
|                     let is_active = *button_app_view == props.current_view && _button_tab_opt.is_none(); | ||||
|  | ||||
|                     yew::html! { | ||||
|                         <button | ||||
|                             class={classes!("nav-button", is_active.then_some("active"))} | ||||
|                             onclick={button_click_handler}> | ||||
|                             <i class={*icon_class}></i> | ||||
|                             <span>{ text }</span> | ||||
|                         </button> | ||||
|                     } | ||||
|                 }) } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										260
									
								
								src/app/src/components/network_animation_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								src/app/src/components/network_animation_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,260 @@ | ||||
| use yew::prelude::*; | ||||
| use std::collections::HashMap; | ||||
| use std::rc::Rc; | ||||
| use common_models::CircleData; | ||||
| use gloo_timers::callback::{Interval, Timeout}; | ||||
| use rand::seq::SliceRandom; | ||||
| use rand::Rng; | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| struct ServerNode { | ||||
|     x: f32, | ||||
|     y: f32, | ||||
|     name: String, | ||||
|     id: u32, | ||||
|     is_active: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| struct DataTransmission { | ||||
|     id: usize, | ||||
|     from_node: u32, | ||||
|     to_node: u32, | ||||
|     progress: f32, | ||||
|     transmission_type: TransmissionType, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug, PartialEq)] | ||||
| enum TransmissionType { | ||||
|     Data, | ||||
|     Sync, | ||||
|     Heartbeat, | ||||
| } | ||||
|  | ||||
| #[derive(Properties, Clone, PartialEq)] | ||||
| pub struct NetworkAnimationViewProps { | ||||
|     pub all_circles: Rc<HashMap<u32, CircleData>>, | ||||
| } | ||||
|  | ||||
| pub enum Msg { | ||||
|     StartTransmission, | ||||
|     UpdateTransmissions, | ||||
|     RemoveTransmission(usize), | ||||
|     PulseNode(u32), | ||||
| } | ||||
|  | ||||
| pub struct NetworkAnimationView { | ||||
|     server_nodes: Rc<HashMap<u32, ServerNode>>, | ||||
|     active_transmissions: Vec<DataTransmission>, | ||||
|     next_transmission_id: usize, | ||||
|     _transmission_interval: Option<Interval>, | ||||
|     _update_interval: Option<Interval>, | ||||
| } | ||||
|  | ||||
| impl NetworkAnimationView { | ||||
|     fn calculate_server_positions(all_circles: &Rc<HashMap<u32, CircleData>>) -> Rc<HashMap<u32, ServerNode>> { | ||||
|         let mut nodes = HashMap::new(); | ||||
|          | ||||
|         // Predefined realistic server locations on the world map (coordinates scaled to viewBox 783.086 x 400.649) | ||||
|         let server_positions = vec![ | ||||
|             (180.0, 150.0, "North America"),    // USA/Canada | ||||
|             (420.0, 130.0, "Europe"),           // Central Europe | ||||
|             (580.0, 160.0, "Asia"),             // East Asia | ||||
|             (220.0, 280.0, "South America"),    // Brazil/Argentina | ||||
|             (450.0, 220.0, "Africa"),           // Central Africa | ||||
|             (650.0, 320.0, "Oceania"),          // Australia | ||||
|             (400.0, 90.0, "Nordic"),            // Scandinavia | ||||
|             (520.0, 200.0, "Middle East"),      // Middle East | ||||
|         ]; | ||||
|  | ||||
|         for (i, (id, circle_data)) in all_circles.iter().enumerate() { | ||||
|             if let Some((x, y, region)) = server_positions.get(i % server_positions.len()) { | ||||
|                 nodes.insert(*id, ServerNode { | ||||
|                     x: *x, | ||||
|                     y: *y, | ||||
|                     name: format!("{}", circle_data.name), | ||||
|                     id: *id, | ||||
|                     is_active: true, | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         Rc::new(nodes) | ||||
|     } | ||||
|  | ||||
|     fn create_transmission(&mut self, from_id: u32, to_id: u32, transmission_type: TransmissionType) -> usize { | ||||
|         let id = self.next_transmission_id; | ||||
|         self.next_transmission_id += 1; | ||||
|          | ||||
|         self.active_transmissions.push(DataTransmission { | ||||
|             id, | ||||
|             from_node: from_id, | ||||
|             to_node: to_id, | ||||
|             progress: 0.0, | ||||
|             transmission_type, | ||||
|         }); | ||||
|          | ||||
|         id | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Component for NetworkAnimationView { | ||||
|     type Message = Msg; | ||||
|     type Properties = NetworkAnimationViewProps; | ||||
|  | ||||
|     fn create(ctx: &Context<Self>) -> Self { | ||||
|         let server_nodes = Self::calculate_server_positions(&ctx.props().all_circles); | ||||
|          | ||||
|         let link = ctx.link().clone(); | ||||
|         let transmission_interval = Interval::new(3000, move || { | ||||
|             link.send_message(Msg::StartTransmission); | ||||
|         }); | ||||
|  | ||||
|         let link2 = ctx.link().clone(); | ||||
|         let update_interval = Interval::new(50, move || { | ||||
|             link2.send_message(Msg::UpdateTransmissions); | ||||
|         }); | ||||
|  | ||||
|         Self { | ||||
|             server_nodes, | ||||
|             active_transmissions: Vec::new(), | ||||
|             next_transmission_id: 0, | ||||
|             _transmission_interval: Some(transmission_interval), | ||||
|             _update_interval: Some(update_interval), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             Msg::StartTransmission => { | ||||
|                 if self.server_nodes.len() < 2 { | ||||
|                     return false; | ||||
|                 } | ||||
|  | ||||
|                 let mut rng = rand::thread_rng(); | ||||
|                 let node_ids: Vec<u32> = self.server_nodes.keys().cloned().collect(); | ||||
|                  | ||||
|                 if let (Some(&from_id), Some(&to_id)) = ( | ||||
|                     node_ids.choose(&mut rng), | ||||
|                     node_ids.choose(&mut rng) | ||||
|                 ) { | ||||
|                     if from_id != to_id { | ||||
|                         let transmission_type = match rng.gen_range(0..3) { | ||||
|                             0 => TransmissionType::Data, | ||||
|                             1 => TransmissionType::Sync, | ||||
|                             _ => TransmissionType::Heartbeat, | ||||
|                         }; | ||||
|                          | ||||
|                         let transmission_id = self.create_transmission(from_id, to_id, transmission_type); | ||||
|                          | ||||
|                         // Pulse the source node | ||||
|                         ctx.link().send_message(Msg::PulseNode(from_id)); | ||||
|                          | ||||
|                         // Remove transmission after completion | ||||
|                         let link = ctx.link().clone(); | ||||
|                         let timeout = Timeout::new(2000, move || { | ||||
|                             link.send_message(Msg::RemoveTransmission(transmission_id)); | ||||
|                         }); | ||||
|                         timeout.forget(); | ||||
|                          | ||||
|                         return true; | ||||
|                     } | ||||
|                 } | ||||
|                 false | ||||
|             } | ||||
|             Msg::UpdateTransmissions => { | ||||
|                 let mut updated = false; | ||||
|                 for transmission in &mut self.active_transmissions { | ||||
|                     if transmission.progress < 1.0 { | ||||
|                         transmission.progress += 0.02; // 2% per update (50ms * 50 = 2.5s total) | ||||
|                         updated = true; | ||||
|                     } | ||||
|                 } | ||||
|                 updated | ||||
|             } | ||||
|             Msg::RemoveTransmission(id) => { | ||||
|                 let initial_len = self.active_transmissions.len(); | ||||
|                 self.active_transmissions.retain(|t| t.id != id); | ||||
|                 self.active_transmissions.len() != initial_len | ||||
|             } | ||||
|             Msg::PulseNode(_node_id) => { | ||||
|                 // This will trigger a re-render for node pulse animation | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, _ctx: &Context<Self>) -> Html { | ||||
|         let server_pins = self.server_nodes.iter().map(|(_id, node)| { | ||||
|             html! { | ||||
|                 <g class="server-node" transform={format!("translate({}, {})", node.x, node.y)}> | ||||
|                     // Subtle glow background | ||||
|                     <circle r="6" class="node-glow" /> | ||||
|                     // Main white pin | ||||
|                     <circle r="3" class="node-pin" /> | ||||
|                     // Ultra-subtle breathing effect | ||||
|                     <circle r="4" class="node-pulse" /> | ||||
|                     // Clean label | ||||
|                     <text x="0" y="16" class="node-label" text-anchor="middle">{&node.name}</text> | ||||
|                 </g> | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         let transmissions = self.active_transmissions.iter().map(|transmission| { | ||||
|             if let (Some(from_node), Some(to_node)) = ( | ||||
|                 self.server_nodes.get(&transmission.from_node), | ||||
|                 self.server_nodes.get(&transmission.to_node) | ||||
|             ) { | ||||
|                 html! { | ||||
|                     <g class="transmission-group"> | ||||
|                         // Simple connection line with subtle animation | ||||
|                         <line | ||||
|                             x1={from_node.x.to_string()} | ||||
|                             y1={from_node.y.to_string()} | ||||
|                             x2={to_node.x.to_string()} | ||||
|                             y2={to_node.y.to_string()} | ||||
|                             class="transmission-line" | ||||
|                         /> | ||||
|                     </g> | ||||
|                 } | ||||
|             } else { | ||||
|                 html! {} | ||||
|             } | ||||
|         }).collect::<Html>(); | ||||
|  | ||||
|         html! { | ||||
|             <div class="network-animation-overlay"> | ||||
|                 <svg  | ||||
|                     viewBox="0 0 783.086 400.649"  | ||||
|                     class="network-overlay-svg" | ||||
|                     style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;" | ||||
|                 > | ||||
|                     <defs> | ||||
|                         // Minimal gradient for node glow | ||||
|                         <@{"radialGradient"} id="nodeGlow" cx="50%" cy="50%" r="50%"> | ||||
|                             <stop offset="0%" style="stop-color: var(--primary-color, #007bff); stop-opacity: 0.3" /> | ||||
|                             <stop offset="100%" style="stop-color: var(--primary-color, #007bff); stop-opacity: 0" /> | ||||
|                         </@> | ||||
|                     </defs> | ||||
|                      | ||||
|                     <g class="server-nodes"> | ||||
|                         { for server_pins } | ||||
|                     </g> | ||||
|                      | ||||
|                     <g class="transmissions"> | ||||
|                         { transmissions } | ||||
|                     </g> | ||||
|                 </svg> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool { | ||||
|         if ctx.props().all_circles != old_props.all_circles { | ||||
|             self.server_nodes = Self::calculate_server_positions(&ctx.props().all_circles); | ||||
|             self.active_transmissions.clear(); | ||||
|             return true; | ||||
|         } | ||||
|         false | ||||
|     } | ||||
| } | ||||
							
								
								
									
										48
									
								
								src/app/src/components/pdf_viewer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/app/src/components/pdf_viewer.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Pdf; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct PdfViewerProps { | ||||
|     pub pdf: Pdf, | ||||
|     pub on_back: Callback<()>, | ||||
| } | ||||
|  | ||||
| pub struct PdfViewer; | ||||
|  | ||||
| impl Component for PdfViewer { | ||||
|     type Message = (); | ||||
|     type Properties = PdfViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer pdf-viewer"> | ||||
|                 <button class="back-button" onclick={back_handler}> | ||||
|                     <i class="fas fa-arrow-left"></i> {"Back to Collection"} | ||||
|                 </button> | ||||
|                 <div class="viewer-header"> | ||||
|                     <h2 class="viewer-title">{ &props.pdf.title }</h2> | ||||
|                 </div> | ||||
|                 <div class="viewer-content"> | ||||
|                     <iframe | ||||
|                         src={format!("{}#toolbar=1&navpanes=1&scrollbar=1", props.pdf.url)} | ||||
|                         class="pdf-frame" | ||||
|                         title={props.pdf.title.clone()} | ||||
|                     /> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										870
									
								
								src/app/src/components/publishing_view.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										870
									
								
								src/app/src/components/publishing_view.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,870 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::circle::Circle; | ||||
| use std::rc::Rc; | ||||
| use std::collections::HashMap; | ||||
| use chrono::{Utc, DateTime}; // Added TimeZone | ||||
| use web_sys::MouseEvent; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
|  | ||||
| // Import from common_models | ||||
| use common_models::{ | ||||
|     Publication, | ||||
|     Deployment, | ||||
|     PublicationType, | ||||
|     PublicationStatus, | ||||
|     PublicationSource, | ||||
|     PublicationSourceType, | ||||
| }; | ||||
|  | ||||
| // --- Component-Specific View State Enums --- | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum PublishingViewEnum { | ||||
|     PublicationsList, | ||||
|     PublicationDetail(u32), // publication_id | ||||
| } | ||||
|  | ||||
| #[derive(Clone, PartialEq, Debug)] | ||||
| pub enum PublishingPublicationTab { | ||||
|     Overview, | ||||
|     Analytics, | ||||
|     Deployments, | ||||
|     Settings, | ||||
| } | ||||
|  | ||||
| // --- Props for the Component --- | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct PublishingViewProps { | ||||
|     pub all_circles: Rc<HashMap<String, Circle>>, | ||||
|     pub context_circle_ws_urls: Option<Rc<Vec<String>>>, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub enum PublishingMsg { | ||||
|     SwitchView(PublishingViewEnum), | ||||
|     SwitchPublicationTab(PublishingPublicationTab), | ||||
|     CreateNewPublication, | ||||
|     TriggerDeployment(u32), // publication_id | ||||
|     DeletePublication(u32), // publication_id | ||||
|     SavePublicationSettings(u32), // publication_id | ||||
|     FetchPublications(String), // ws_url | ||||
|     PublicationsReceived(String, Vec<Publication>), // ws_url, publications | ||||
|     ActionCompleted(Result<String, String>), | ||||
| } | ||||
|  | ||||
| pub struct PublishingView { | ||||
|     current_view: PublishingViewEnum, | ||||
|     active_publication_tab: PublishingPublicationTab, | ||||
|     ws_manager: crate::ws_manager::CircleWsManager, | ||||
|     loading_states: HashMap<String, bool>, | ||||
|     error_states: HashMap<String, Option<String>>, | ||||
| } | ||||
|  | ||||
| impl Component for PublishingView { | ||||
|     type Message = PublishingMsg; | ||||
|     type Properties = PublishingViewProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_view: PublishingViewEnum::PublicationsList, | ||||
|             active_publication_tab: PublishingPublicationTab::Overview, | ||||
|             ws_manager: crate::ws_manager::CircleWsManager::new(), | ||||
|             loading_states: HashMap::new(), | ||||
|             error_states: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             PublishingMsg::SwitchView(view) => { | ||||
|                 self.current_view = view; | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::SwitchPublicationTab(tab) => { | ||||
|                 self.active_publication_tab = tab; | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::CreateNewPublication => { | ||||
|                 log::info!("Creating new publication"); | ||||
|                 self.create_publication_via_script(ctx); | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::TriggerDeployment(publication_id) => { | ||||
|                 log::info!("Triggering deployment for publication {}", publication_id); | ||||
|                 self.trigger_deployment_via_script(ctx, publication_id); | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::DeletePublication(publication_id) => { | ||||
|                 log::info!("Deleting publication {}", publication_id); | ||||
|                 self.delete_publication_via_script(ctx, publication_id); | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::SavePublicationSettings(publication_id) => { | ||||
|                 log::info!("Saving settings for publication {}", publication_id); | ||||
|                 self.save_publication_settings_via_script(ctx, publication_id); | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::FetchPublications(ws_url) => { | ||||
|                 log::info!("Fetching publications from: {}", ws_url); | ||||
|                 self.fetch_publications_from_circle(&ws_url); | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::PublicationsReceived(ws_url, publications) => { | ||||
|                 log::info!("Received {} publications from {}", publications.len(), ws_url); | ||||
|                 // Handle received publications - could update local cache if needed | ||||
|                 true | ||||
|             } | ||||
|             PublishingMsg::ActionCompleted(result) => { | ||||
|                 match result { | ||||
|                     Ok(output) => log::info!("Action completed successfully: {}", output), | ||||
|                     Err(e) => log::error!("Action failed: {}", e), | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let link = ctx.link(); | ||||
|         let props = ctx.props(); | ||||
|  | ||||
|         // Aggregate publications and deployments from all_circles based on context | ||||
|         let (filtered_publications, filtered_deployments) =  | ||||
|             get_filtered_publishing_data(&props.all_circles, &props.context_circle_ws_urls); | ||||
|  | ||||
|         match &self.current_view { | ||||
|             PublishingViewEnum::PublicationsList => { | ||||
|                 html! { | ||||
|                     <div class="view-container publishing-view-container"> | ||||
|                         <div class="view-header publishing-header"> | ||||
|                             <h1 class="view-title">{"Publications"}</h1> | ||||
|                             <div class="publishing-actions"> | ||||
|                                 <button class="action-btn primary" onclick={link.callback(|_| PublishingMsg::CreateNewPublication)}> | ||||
|                                     <i class="fas fa-plus"></i> | ||||
|                                     <span>{"New Publication"}</span> | ||||
|                                 </button> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
|                         <div class="publishing-content"> | ||||
|                             { render_publications_list(&filtered_publications, link) } | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 } | ||||
|             }, | ||||
|             PublishingViewEnum::PublicationDetail(publication_id) => { | ||||
|                 let publication = filtered_publications.iter() | ||||
|                     .find(|p| p.id == *publication_id) // Now u32 == u32 | ||||
|                     .cloned(); | ||||
|                  | ||||
|                 if let Some(pub_data) = publication { | ||||
|                     // Filter deployments specific to this publication | ||||
|                     let specific_deployments: Vec<Rc<Deployment>> = filtered_deployments.iter() | ||||
|                         .filter(|d| d.publication_id == pub_data.id) | ||||
|                         .cloned() | ||||
|                         .collect(); | ||||
|  | ||||
|                     html! { | ||||
|                         <div class="view-container publishing-view-container"> | ||||
|                             <div class="publishing-content"> | ||||
|                                 { render_expanded_publication_card( | ||||
|                                     &pub_data,  | ||||
|                                     link, | ||||
|                                     &self.active_publication_tab,  | ||||
|                                     &specific_deployments | ||||
|                                 )} | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     // Fallback to list if specific publication not found (e.g., after context change) | ||||
|                     html! { | ||||
|                         <div class="view-container publishing-view-container"> | ||||
|                              <div class="view-header publishing-header"> | ||||
|                                 <h1 class="view-title">{"Publications"}</h1> | ||||
|                              </div> | ||||
|                             <div class="publishing-content"> | ||||
|                                 <p>{"Publication not found. Showing list."}</p> | ||||
|                                 { render_publications_list(&filtered_publications, link) } | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl PublishingView { | ||||
|     fn create_publication_via_script(&mut self, ctx: &Context<Self>) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             let script = r#"create_publication("New Publication", "Website", "Draft");"#.to_string(); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
|                 spawn_local(async move { | ||||
|                     match script_future.await { | ||||
|                         Ok(result) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn trigger_deployment_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             let script = format!(r#"trigger_deployment({});"#, publication_id); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
|                 spawn_local(async move { | ||||
|                     match script_future.await { | ||||
|                         Ok(result) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn delete_publication_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             let script = format!(r#"delete_publication({});"#, publication_id); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
|                 spawn_local(async move { | ||||
|                     match script_future.await { | ||||
|                         Ok(result) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn save_publication_settings_via_script(&mut self, ctx: &Context<Self>, publication_id: u32) { | ||||
|         let props = ctx.props(); | ||||
|         let target_ws_url = props.context_circle_ws_urls | ||||
|             .as_ref() | ||||
|             .and_then(|urls| urls.first()) | ||||
|             .cloned(); | ||||
|  | ||||
|         if let Some(ws_url) = target_ws_url { | ||||
|             let script = format!(r#"save_publication_settings({});"#, publication_id); | ||||
|  | ||||
|             let link = ctx.link().clone(); | ||||
|             if let Some(script_future) = self.ws_manager.execute_script(&ws_url, script) { | ||||
|                 spawn_local(async move { | ||||
|                     match script_future.await { | ||||
|                         Ok(result) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Ok(result.output))); | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             link.send_message(PublishingMsg::ActionCompleted(Err(format!("{:?}", e)))); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn fetch_publications_from_circle(&mut self, ws_url: &str) { | ||||
|         let script = r#" | ||||
|             let publications = get_publications(); | ||||
|             publications | ||||
|         "#.to_string(); | ||||
|  | ||||
|         if let Some(script_future) = self.ws_manager.execute_script(ws_url, script) { | ||||
|             spawn_local(async move { | ||||
|                 match script_future.await { | ||||
|                     Ok(result) => { | ||||
|                         log::info!("Publications data fetched: {}", result.output); | ||||
|                         // Parse and handle publications data | ||||
|                     } | ||||
|                     Err(e) => { | ||||
|                         log::error!("Failed to fetch publications data: {:?}", e); | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn get_filtered_publishing_data( | ||||
|     _all_circles: &Rc<HashMap<String, Circle>>, | ||||
|     _context_circle_ws_urls: &Option<Rc<Vec<String>>>, | ||||
| ) -> (Vec<Rc<Publication>>, Vec<Rc<Deployment>>) { | ||||
|     // TODO: Implement actual data fetching based on context_circle_ws_urls | ||||
|     // For now, return mock data. | ||||
|     let filtered_publications = get_mock_publications(); | ||||
|     let filtered_deployments = get_mock_deployments(); | ||||
|  | ||||
|     (filtered_publications, filtered_deployments) | ||||
| } | ||||
|  | ||||
| fn render_publication_tab_button( | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
|     tab: PublishingPublicationTab, | ||||
|     active_tab: &PublishingPublicationTab, | ||||
|     icon: &str, | ||||
|     label: &str, | ||||
| ) -> Html { | ||||
|     let is_active = *active_tab == tab; | ||||
|     let tab_clone = tab.clone(); | ||||
|     let icon_owned = icon.to_string(); | ||||
|     let label_owned = label.to_string(); | ||||
|     let on_click_handler = link.callback(move |_| PublishingMsg::SwitchPublicationTab(tab_clone.clone())); | ||||
|  | ||||
|     html! { | ||||
|         <button | ||||
|             class={classes!("tab-btn", is_active.then_some("active"))} | ||||
|             onclick={on_click_handler} | ||||
|         > | ||||
|             <i class={icon_owned}></i> | ||||
|             <span>{label_owned}</span> | ||||
|         </button> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publications_list(publications: &[Rc<Publication>], link: &yew::html::Scope<PublishingView>) -> Html { | ||||
|     if publications.is_empty() { | ||||
|         return html! { | ||||
|             <div class="publications-view empty-state"> | ||||
|                 <i class="fas fa-cloud-upload-alt"></i> | ||||
|                 <h4>{"No publications yet"}</h4> | ||||
|                 <p>{"Create a new publication to deploy your projects."}</p> | ||||
|                  <button class="action-btn primary" onclick={link.callback(|_| PublishingMsg::CreateNewPublication)}> | ||||
|                     <i class="fas fa-plus"></i> | ||||
|                     <span>{"New Publication"}</span> | ||||
|                 </button> | ||||
|             </div> | ||||
|         }; | ||||
|     } | ||||
|     html! { | ||||
|         <div class="publications-view"> | ||||
|             <div class="publications-grid"> | ||||
|                 { for publications.iter().map(|publication| { | ||||
|                     render_publication_card(publication, link) | ||||
|                 }) } | ||||
|                 // "Add New" card can be a permanent fixture or conditional | ||||
|                 <div class="card-base add-publication-card" onclick={link.callback(|_| PublishingMsg::CreateNewPublication)}> | ||||
|                     <div class="add-publication-content"> | ||||
|                         <i class="fas fa-plus"></i> | ||||
|                         <h3 class="card-title">{"Create New Publication"}</h3> | ||||
|                         <div class="card-content"> | ||||
|                             <p>{"Deploy your website, app, or API"}</p> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_source(source: &Option<PublicationSource>) -> Html { | ||||
|     match source { | ||||
|         Some(s) => match s.source_type { | ||||
|             PublicationSourceType::GitRepository => html! { | ||||
|                 <div class="source-detail code-repo-source"> | ||||
|                     {match s.repo_type.as_deref() { | ||||
|                         Some("GitHub") => html! { <i class="fab fa-github"></i> }, | ||||
|                         Some("GitLab") => html! { <i class="fab fa-gitlab"></i> }, | ||||
|                         Some("Bitbucket") => html! { <i class="fab fa-bitbucket"></i> }, | ||||
|                         _ => html! { <i class="fas fa-code-branch"></i> }, | ||||
|                     }} | ||||
|                     <a href={s.url.clone().unwrap_or_default()} target="_blank" onclick={|e: MouseEvent| e.stop_propagation()}> | ||||
|                         {s.url.as_ref().and_then(|u| u.split('/').last()).unwrap_or("Repository")} | ||||
|                     </a> | ||||
|                     { if let Some(branch) = &s.repo_branch { | ||||
|                         html!{ <span class="repo-branch">{format!("({})", branch)}</span> } | ||||
|                     } else { html!{} }} | ||||
|                 </div> | ||||
|             }, | ||||
|             PublicationSourceType::StaticFolder => html! { | ||||
|                 <div class="source-detail static-folder-source"> | ||||
|                     <i class="fas fa-folder"></i> | ||||
|                     <span>{"Static Folder"}</span> | ||||
|                     { if let Some(p) = &s.path { html!{<span class="source-path">{p}</span>}} else {html!{}} } | ||||
|                 </div> | ||||
|             }, | ||||
|             PublicationSourceType::FileAsset => html! { | ||||
|                 <div class="source-detail static-asset-source"> | ||||
|                     <i class="fas fa-file-code"></i> | ||||
|                     <span>{s.path.as_deref().unwrap_or("File Asset")}</span> | ||||
|                 </div> | ||||
|             }, | ||||
|             PublicationSourceType::ExternalLink => html! { | ||||
|                 <div class="source-detail external-link-source"> | ||||
|                     <i class="fas fa-external-link-alt"></i> | ||||
|                     <a href={s.url.clone().unwrap_or_default()} target="_blank" onclick={|e: MouseEvent| e.stop_propagation()}> | ||||
|                         {s.url.as_ref().and_then(|u| u.split("//").nth(1)).unwrap_or("External Link")} | ||||
|                     </a> | ||||
|                 </div> | ||||
|             }, | ||||
|             PublicationSourceType::NotApplicable => html! { | ||||
|                 <div class="source-detail not-applicable-source"> | ||||
|                     <i class="fas fa-ban"></i> | ||||
|                     <span>{"N/A"}</span> | ||||
|                 </div> | ||||
|             } // End of PublicationSourceType::NotApplicable arm's html! | ||||
|         }, // End of Some(s) arm | ||||
|         None => html! { <div class="source-detail">{"Source not specified"}</div> } | ||||
|     } // End of match source for render_publication_source | ||||
| } // End of fn render_publication_source | ||||
|  | ||||
| fn render_publication_card(publication: &Rc<Publication>, link: &yew::html::Scope<PublishingView>) -> Html { | ||||
|     let status_class_name = format!("status-{}", format!("{:?}", publication.status).to_lowercase()); | ||||
|     let status_color = get_status_color(&publication.status); | ||||
|  | ||||
|     let type_icon = get_publication_type_icon(&publication.publication_type); | ||||
|  | ||||
|     let publication_id = publication.id; | ||||
|     let onclick_details = link.callback(move |_| PublishingMsg::SwitchView(PublishingViewEnum::PublicationDetail(publication_id))); | ||||
|  | ||||
|     html! { | ||||
|         <div class={classes!("publication-card", status_class_name)} key={publication.id} onclick={onclick_details}> | ||||
|             <div class="publication-cell-info"> | ||||
|                 <h3 class="publication-name">{&publication.name}</h3> | ||||
|                 <p class="publication-description">{publication.description.as_deref().unwrap_or("")}</p> | ||||
|             </div> | ||||
|  | ||||
|             <div class="publication-cell-category"> | ||||
|                 <div class="publication-type-display"> | ||||
|                     <i class={type_icon}></i> | ||||
|                     <span>{format!("{:?}", publication.publication_type)}</span> | ||||
|                 </div> | ||||
|                 <div class="publication-source-display"> | ||||
|                     { render_publication_source(&publication.source) } // This call should be fine now | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="publication-cell-asset-link"> | ||||
|                 { if let Some(url) = &publication.live_url { | ||||
|                     html! { | ||||
|                         <div class="publication-url-display"> | ||||
|                             <i class="fas fa-link"></i> | ||||
|                             <a href={url.clone()} target="_blank" onclick={|e: MouseEvent| e.stop_propagation()}> | ||||
|                                 {url.split("//").nth(1).unwrap_or_else(|| url.as_str())} | ||||
|                             </a> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else if let Some(domain) = &publication.custom_domain { | ||||
|                     html!{ | ||||
|                         <div class="publication-url-display"> | ||||
|                              <i class="fas fa-globe-americas"></i> | ||||
|                             <span>{domain}</span> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { <div class="publication-url-display">{"Not available"}</div> } | ||||
|                 }} | ||||
|             </div> | ||||
|  | ||||
|             <div class="publication-cell-stats"> | ||||
|                 // Simplified stats or remove if not readily available | ||||
|                 { if let Some(visitors) = publication.monthly_visitors { | ||||
|                     html!{ <div class="stat-item"><i class="fas fa-users"></i> {format!("{}k", visitors / 1000)}</div> } | ||||
|                 } else {html!{}}} | ||||
|                 { if let Some(uptime) = publication.uptime_percentage { | ||||
|                     html!{ <div class="stat-item"><i class="fas fa-heartbeat"></i> {format!("{:.1}%", uptime)}</div> } | ||||
|                 } else {html!{}}} | ||||
|             </div> | ||||
|  | ||||
|             <div class="publication-cell-deployment"> | ||||
|                 <div class="publication-status-display" style={format!("color: {}", status_color)}> | ||||
|                     <div class="status-indicator" style={format!("background-color: {}", status_color)}></div> | ||||
|                     <span>{format!("{:?}", publication.status)}</span> | ||||
|                 </div> | ||||
|                 <div class="deployment-detail-item"> | ||||
|                     <span class="detail-label">{"Last Deployed:"}</span> | ||||
|                     <span> | ||||
|                         { publication.last_deployed_at.as_ref().map_or_else( | ||||
|                             || "Never".to_string(), | ||||
|                             |ts| format_timestamp_string(ts) | ||||
|                         )} | ||||
|                     </span> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } // End of fn render_publication_card | ||||
|  | ||||
|  | ||||
| fn render_expanded_publication_card( | ||||
|     publication: &Publication, | ||||
|     link: &yew::html::Scope<PublishingView>, | ||||
|     active_tab: &PublishingPublicationTab, | ||||
|     deployments: &[Rc<Deployment>] // Pass only relevant deployments | ||||
| ) -> Html { | ||||
|     let status_color = get_status_color(&publication.status); | ||||
|     let type_icon = get_publication_type_icon(&publication.publication_type); | ||||
|  | ||||
|     html! { | ||||
|         <div class="card-base expanded-publication-card" key={publication.id.clone()}> | ||||
|             <div class="card-header expanded-card-header"> | ||||
|                 <div class="expanded-header-top"> | ||||
|                     <button | ||||
|                         class="back-btn" | ||||
|                         onclick={link.callback(|_| PublishingMsg::SwitchView(PublishingViewEnum::PublicationsList))} | ||||
|                     > | ||||
|                         <i class="fas fa-arrow-left"></i> | ||||
|                         <span>{"Back to Publications"}</span> | ||||
|                     </button> | ||||
|                      | ||||
|                     <div class="publication-header"> | ||||
|                         <div class="publication-type"> | ||||
|                             <i class={type_icon}></i> | ||||
|                             <span>{format!("{:?}", publication.publication_type)}</span> | ||||
|                         </div> | ||||
|                         <div class="publication-status" style={format!("color: {}", status_color)}> | ||||
|                             <div class="status-indicator" style={format!("background-color: {}", status_color)}></div> | ||||
|                             <span>{format!("{:?}", publication.status)}</span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="expanded-card-title"> | ||||
|                     <h2 class="card-title">{&publication.name}</h2> | ||||
|                     <p class="expanded-description">{publication.description.as_deref().unwrap_or("")}</p> | ||||
|                      | ||||
|                     { if let Some(url) = &publication.live_url { | ||||
|                         html! { | ||||
|                             <div class="publication-url"> | ||||
|                                 <i class="fas fa-external-link-alt"></i> | ||||
|                                 <a href={url.clone()} target="_blank">{url}</a> | ||||
|                             </div> | ||||
|                         } | ||||
|                     } else if let Some(domain) = &publication.custom_domain { | ||||
|                          html! { <div class="publication-url"><i class="fas fa-globe-americas"></i> {domain}</div> } | ||||
|                     } else { html! {} }} | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="expanded-card-tabs"> | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Overview, active_tab, "fas fa-home", "Overview") } | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Analytics, active_tab, "fas fa-chart-line", "Analytics") } | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Deployments, active_tab, "fas fa-rocket", "Deployments") } | ||||
|                     { render_publication_tab_button(link, PublishingPublicationTab::Settings, active_tab, "fas fa-cog", "Settings") } | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <div class="expanded-card-content"> | ||||
|                 { | ||||
|                     match active_tab { | ||||
|                         PublishingPublicationTab::Overview => render_expanded_publication_overview(publication, deployments), | ||||
|                         PublishingPublicationTab::Analytics => render_publication_analytics(publication), | ||||
|                         PublishingPublicationTab::Deployments => render_publication_deployments_tab(publication, deployments, link), | ||||
|                         PublishingPublicationTab::Settings => render_publication_settings(publication, link), | ||||
|                     } | ||||
|                 } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_expanded_publication_overview(publication: &Publication, deployments: &[Rc<Deployment>]) -> Html { | ||||
|     let recent_deployments: Vec<&Rc<Deployment>> = deployments.iter().take(3).collect(); | ||||
|  | ||||
|     html! { | ||||
|         <div class="expanded-overview"> | ||||
|             <div class="overview-sections"> | ||||
|                 <div class="overview-section"> | ||||
|                     <h3>{"Live Metrics"}</h3> | ||||
|                     <div class="live-metrics"> | ||||
|                         // Simplified metrics - data comes from publication model | ||||
|                         <div class="card-base metric-card"> | ||||
|                             <div class="metric-icon visitors"><i class="fas fa-users"></i></div> | ||||
|                             <div class="metric-info"> | ||||
|                                 <span class="metric-value">{publication.monthly_visitors.map_or("N/A".to_string(), |v| format!("{}k", v/1000))}</span> | ||||
|                                 <span class="metric-label">{"Monthly Visitors"}</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="card-base metric-card"> | ||||
|                             <div class="metric-icon uptime"><i class="fas fa-heartbeat"></i></div> | ||||
|                             <div class="metric-info"> | ||||
|                                 <span class="metric-value">{publication.uptime_percentage.map_or("N/A".to_string(), |u| format!("{:.1}%", u))}</span> | ||||
|                                 <span class="metric-label">{"Uptime"}</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                          <div class="card-base metric-card"> | ||||
|                             <div class="metric-icon performance"><i class="fas fa-tachometer-alt"></i></div> | ||||
|                             <div class="metric-info"> | ||||
|                                 <span class="metric-value">{publication.average_build_time_seconds.map_or("N/A".to_string(), |s| format!("{}s", s))}</span> | ||||
|                                 <span class="metric-label">{"Avg Build"}</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                         <div class="card-base metric-card"> | ||||
|                             <div class="metric-icon size"><i class="fas fa-hdd"></i></div> | ||||
|                             <div class="metric-info"> | ||||
|                                 <span class="metric-value">{publication.average_size_mb.map_or("N/A".to_string(), |s| format!("{:.1}MB", s))}</span> | ||||
|                                 <span class="metric-label">{"Avg Size"}</span> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="overview-section"> | ||||
|                     <h3>{"Recent Activity"}</h3> | ||||
|                     <div class="recent-deployments"> | ||||
|                         { if !recent_deployments.is_empty() { | ||||
|                             html! { for recent_deployments.iter().map(|deployment| render_compact_deployment_item(deployment)) } | ||||
|                         } else { | ||||
|                             html! { <div class="no-deployments"><i class="fas fa-rocket"></i><span>{"No recent deployments"}</span></div> } | ||||
|                         }} | ||||
|                     </div> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div class="overview-section"> | ||||
|                     <h3>{"Configuration Summary"}</h3> | ||||
|                     <div class="config-summary"> | ||||
|                         <div class="config-item"><span class="config-label">{"Source:"}</span> <span class="config-value">{render_publication_source(&publication.source)}</span></div> | ||||
|                         <div class="config-item"><span class="config-label">{"Build Cmd:"}</span> <span class="config-value">{publication.build_command.as_deref().unwrap_or("N/A")}</span></div> | ||||
|                         <div class="config-item"><span class="config-label">{"Output Dir:"}</span> <span class="config-value">{publication.output_directory.as_deref().unwrap_or("N/A")}</span></div> | ||||
|                         <div class="config-item"><span class="config-label">{"SSL:"}</span> <span class="config-value">{publication.ssl_enabled.map_or("N/A", |s| if s {"Enabled"} else {"Disabled"}).to_string()}</span></div> | ||||
|                         <div class="config-item"><span class="config-label">{"Custom Domain:"}</span> <span class="config-value">{publication.custom_domain.as_deref().unwrap_or("None")}</span></div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_compact_deployment_item(deployment: &Deployment) -> Html { | ||||
|     let status_color = get_status_color(&deployment.status); | ||||
|     html! { | ||||
|         <div class="compact-deployment"> | ||||
|             <div class="deployment-status"> | ||||
|                 <div class="status-indicator" style={format!("background-color: {}", status_color)}></div> | ||||
|             </div> | ||||
|             <div class="deployment-info"> | ||||
|                 <div class="commit-message">{deployment.commit_message.as_deref().unwrap_or("Deployment")}</div> | ||||
|                 <div class="deployment-meta"> | ||||
|                     {if let Some(hash) = &deployment.commit_hash { | ||||
|                         html!{<span class="commit-hash">{"#"}{hash.chars().take(8).collect::<String>()}</span>} | ||||
|                     } else {html!{}}} | ||||
|                     <span class="deploy-time">{format_timestamp_string(&deployment.deployed_at)}</span> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_analytics(_publication: &Publication) -> Html { | ||||
|     html! { | ||||
|         <div class="publication-analytics"> | ||||
|             <div class="analytics-grid"> /* Placeholder for actual charts/data */ </div> | ||||
|             <div class="analytics-charts"> | ||||
|                 <div class="card-base chart-card"> | ||||
|                     <h3 class="card-title">{"Traffic Overview"}</h3> | ||||
|                     <div class="card-content"><p>{"Detailed analytics coming soon..."}</p></div> | ||||
|                 </div> | ||||
|                 <div class="card-base chart-card"> | ||||
|                     <h3 class="card-title">{"Performance Metrics"}</h3> | ||||
|                     <div class="card-content"><p>{"Performance monitoring coming soon..."}</p></div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_deployments_tab(_publication: &Publication, deployments: &[Rc<Deployment>], link: &yew::html::Scope<PublishingView>) -> Html { | ||||
|     let publication_id = _publication.id; | ||||
|     html! { | ||||
|         <div class="publication-deployments-tab"> | ||||
|             <div class="deployments-header"> | ||||
|                 <h3>{"Deployment History"}</h3> | ||||
|                 <button class="action-btn primary" onclick={link.callback(move |_| PublishingMsg::TriggerDeployment(publication_id))}> | ||||
|                     <i class="fas fa-rocket"></i> | ||||
|                     <span>{"Deploy Now"}</span> | ||||
|                 </button> | ||||
|             </div> | ||||
|             <div class="deployments-list"> | ||||
|                 { if deployments.is_empty() { | ||||
|                     html! { | ||||
|                         <div class="empty-state"> | ||||
|                             <i class="fas fa-rocket"></i> | ||||
|                             <h4>{"No deployments yet"}</h4> | ||||
|                             <p>{"Deploy your publication to see deployment history here."}</p> | ||||
|                         </div> | ||||
|                     } | ||||
|                 } else { | ||||
|                     html! { for deployments.iter().map(|deployment| render_full_deployment_item(deployment)) } | ||||
|                 }} | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_full_deployment_item(deployment: &Deployment) -> Html { | ||||
|     let status_color = get_status_color(&deployment.status); | ||||
|     html! { | ||||
|         <div class="deployment-item full-deployment-item" key={deployment.id.clone()}> | ||||
|             <div class="deployment-status"> | ||||
|                 <div class="status-indicator" style={format!("background-color: {}", status_color)}></div> | ||||
|                 <span>{format!("{:?}", deployment.status)}</span> | ||||
|             </div> | ||||
|             <div class="deployment-content"> | ||||
|                 <div class="deployment-header"> | ||||
|                     <h4>{deployment.commit_message.as_deref().unwrap_or("Deployment Event")}</h4> | ||||
|                     {if let Some(branch) = &deployment.branch { | ||||
|                         html!{<span class="deployment-branch">{branch}</span>} | ||||
|                     } else {html!{}}} | ||||
|                 </div> | ||||
|                 <div class="deployment-meta"> | ||||
|                     {if let Some(hash) = &deployment.commit_hash { | ||||
|                         html!{<span class="commit-hash">{"Commit: #"}{hash.chars().take(8).collect::<String>()}</span>} | ||||
|                     } else {html!{}}} | ||||
|                     <span class="deploy-time">{"Deployed: "}{format_timestamp_string(&deployment.deployed_at)}</span> | ||||
|                     {if let Some(duration) = deployment.build_duration_seconds { | ||||
|                         html!{<span class="build-duration">{"Build: "}{format!("{}s", duration)}</span>} | ||||
|                     } else {html!{}}} | ||||
|                     {if let Some(user_id) = &deployment.deployed_by_user_id { | ||||
|                         html!{<span class="deployed-by">{"By: "}{user_id}</span>} // Ideally resolve to user name | ||||
|                     } else {html!{}}} | ||||
|                 </div> | ||||
|             </div> | ||||
|             {if let Some(log_url) = &deployment.deploy_log_url { | ||||
|                 html! { | ||||
|                     <div class="deployment-actions"> | ||||
|                         <a href={log_url.clone()} target="_blank" class="deploy-link action-btn-secondary"> | ||||
|                             <i class="fas fa-file-alt"></i> {" View Logs"} | ||||
|                         </a> | ||||
|                     </div> | ||||
|                 } | ||||
|             } else {html!{}}} | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn render_publication_settings(publication: &Publication, link: &yew::html::Scope<PublishingView>) -> Html { | ||||
|     let publication_id = publication.id; | ||||
|     html! { | ||||
|         <div class="publication-settings"> | ||||
|             <div class="settings-section"> | ||||
|                 <h3>{"Build & Deploy Settings"}</h3> | ||||
|                 <div class="settings-grid"> | ||||
|                     <div class="setting-item"> | ||||
|                         <label for={format!("build-cmd-{}", publication.id)}>{"Build Command"}</label> | ||||
|                         <input id={format!("build-cmd-{}", publication.id)} type="text" class="input-base" value={publication.build_command.clone().unwrap_or_default()} placeholder="e.g., npm run build" /> | ||||
|                     </div> | ||||
|                     <div class="setting-item"> | ||||
|                         <label for={format!("output-dir-{}", publication.id)}>{"Output Directory"}</label> | ||||
|                         <input id={format!("output-dir-{}", publication.id)} type="text" class="input-base" value={publication.output_directory.clone().unwrap_or_default()} placeholder="e.g., dist, public" /> | ||||
|                     </div> | ||||
|                     {if let Some(source) = &publication.source { | ||||
|                         if source.source_type == PublicationSourceType::GitRepository { | ||||
|                              html!{ | ||||
|                                 <> | ||||
|                                     <div class="setting-item"> | ||||
|                                         <label for={format!("repo-url-{}", publication.id)}>{"Repository URL"}</label> | ||||
|                                         <input id={format!("repo-url-{}", publication.id)} type="text" class="input-base" value={source.url.clone().unwrap_or_default()} /> | ||||
|                                     </div> | ||||
|                                     <div class="setting-item"> | ||||
|                                         <label for={format!("repo-branch-{}", publication.id)}>{"Deploy Branch"}</label> | ||||
|                                         <input id={format!("repo-branch-{}", publication.id)} type="text" class="input-base" value={source.repo_branch.clone().unwrap_or_default()} placeholder="e.g., main, master" /> | ||||
|                                     </div> | ||||
|                                 </> | ||||
|                              } | ||||
|                         } else {html!{}} | ||||
|                     } else {html!{}}} | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <div class="settings-section"> | ||||
|                 <h3>{"Domain Management"}</h3> | ||||
|                 <div class="settings-grid"> | ||||
|                     <div class="setting-item"> | ||||
|                         <label for={format!("custom-domain-{}", publication.id)}>{"Custom Domain"}</label> | ||||
|                         <input id={format!("custom-domain-{}", publication.id)} type="text" class="input-base" value={publication.custom_domain.clone().unwrap_or_default()} placeholder="e.g., myapp.example.com" /> | ||||
|                     </div> | ||||
|                     <div class="setting-item toggle-item"> | ||||
|                         <label for={format!("ssl-enabled-{}", publication.id)}>{"SSL Enabled"}</label> | ||||
|                         <input id={format!("ssl-enabled-{}", publication.id)} type="checkbox" checked={publication.ssl_enabled.unwrap_or(false)} /> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <div class="settings-section"> | ||||
|                 <h3>{"Danger Zone"}</h3> | ||||
|                 <div class="danger-actions"> | ||||
|                     <button class="action-btn danger" onclick={link.callback(move |_| PublishingMsg::DeletePublication(publication_id))}> | ||||
|                         <i class="fas fa-trash"></i> | ||||
|                         <span>{"Delete Publication"}</span> | ||||
|                     </button> | ||||
|                 </div> | ||||
|             </div> | ||||
|              <button class="action-btn primary save-settings-btn" onclick={link.callback(move |_| PublishingMsg::SavePublicationSettings(publication_id))}>{"Save Settings"}</button> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn get_status_color(status: &PublicationStatus) -> &'static str { | ||||
|     match status { | ||||
|         PublicationStatus::Deployed => "var(--success-color, #28a745)", | ||||
|         PublicationStatus::Building => "var(--warning-color, #ffc107)", | ||||
|         PublicationStatus::Failed => "var(--danger-color, #dc3545)", | ||||
|         PublicationStatus::Draft => "var(--info-color, #6c757d)", | ||||
|         PublicationStatus::Maintenance => "var(--primary-color, #007bff)", | ||||
|         PublicationStatus::Archived => "var(--muted-color, #adb5bd)", | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn get_publication_type_icon(pub_type: &PublicationType) -> &'static str { | ||||
|     match pub_type { | ||||
|         PublicationType::Website => "fas fa-globe", | ||||
|         PublicationType::WebApp => "fas fa-desktop", | ||||
|         PublicationType::StaticAsset => "fas fa-file-archive", // Changed for better distinction | ||||
|         PublicationType::Server => "fas fa-server", | ||||
|         PublicationType::API => "fas fa-plug", | ||||
|         PublicationType::Database => "fas fa-database", | ||||
|         PublicationType::CDN => "fas fa-cloud-upload-alt", // Changed for better distinction | ||||
|         PublicationType::Other => "fas fa-cube", | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Mock data fetcher functions | ||||
| fn get_mock_publications() -> Vec<Rc<Publication>> { | ||||
|     // TODO: Replace with actual data fetching logic or more realistic mock data | ||||
|     vec![] | ||||
| } | ||||
|  | ||||
| fn get_mock_deployments() -> Vec<Rc<Deployment>> { | ||||
|     // TODO: Replace with actual data fetching logic or more realistic mock data | ||||
|     vec![] | ||||
| } | ||||
|  | ||||
| fn format_timestamp_string(timestamp_str: &str) -> String { | ||||
|     match DateTime::parse_from_rfc3339(timestamp_str) { | ||||
|         Ok(dt) => dt.with_timezone(&Utc).format("%b %d, %Y %H:%M UTC").to_string(), | ||||
|         Err(_) => timestamp_str.to_string(), // Fallback if parsing fails | ||||
|     } | ||||
| } | ||||
							
								
								
									
										40
									
								
								src/app/src/components/sidebar_layout.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/app/src/components/sidebar_layout.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| use yew::prelude::*; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct SidebarLayoutProps { | ||||
|     pub sidebar_content: Html, | ||||
|     pub main_content: Html, | ||||
|     #[prop_or_default] | ||||
|     pub on_background_click: Option<Callback<()>>, | ||||
| } | ||||
|  | ||||
| #[function_component(SidebarLayout)] | ||||
| pub fn sidebar_layout(props: &SidebarLayoutProps) -> Html { | ||||
|     let on_background_click_handler = { | ||||
|         let on_background_click = props.on_background_click.clone(); | ||||
|         Callback::from(move |e: MouseEvent| { | ||||
|             if let Some(callback) = &on_background_click { | ||||
|                 callback.emit(()); | ||||
|             } | ||||
|         }) | ||||
|     }; | ||||
|  | ||||
|     let on_sidebar_click = Callback::from(|e: MouseEvent| { | ||||
|         e.stop_propagation(); | ||||
|     }); | ||||
|  | ||||
|     let on_main_click = Callback::from(|e: MouseEvent| { | ||||
|         e.stop_propagation(); | ||||
|     }); | ||||
|  | ||||
|     html! { | ||||
|         <div class="sidebar-layout" onclick={on_background_click_handler}> | ||||
|             <div class="sidebar" onclick={on_sidebar_click}> | ||||
|                 { props.sidebar_content.clone() } | ||||
|             </div> | ||||
|             <div class="main" onclick={on_main_click}> | ||||
|                 { props.main_content.clone() } | ||||
|             </div> | ||||
|         </div> | ||||
|     } | ||||
| } | ||||
							
								
								
									
										136
									
								
								src/app/src/components/slides_viewer.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/app/src/components/slides_viewer.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| use yew::prelude::*; | ||||
| use heromodels::models::library::items::Slides; | ||||
|  | ||||
| #[derive(Clone, PartialEq, Properties)] | ||||
| pub struct SlidesViewerProps { | ||||
|     pub slides: Slides, | ||||
|     pub on_back: Callback<()>, | ||||
| } | ||||
|  | ||||
| pub enum SlidesViewerMsg { | ||||
|     GoToSlide(usize), | ||||
|     NextSlide, | ||||
|     PrevSlide, | ||||
| } | ||||
|  | ||||
| pub struct SlidesViewer { | ||||
|     current_slide_index: usize, | ||||
| } | ||||
|  | ||||
| impl Component for SlidesViewer { | ||||
|     type Message = SlidesViewerMsg; | ||||
|     type Properties = SlidesViewerProps; | ||||
|  | ||||
|     fn create(_ctx: &Context<Self>) -> Self { | ||||
|         Self { | ||||
|             current_slide_index: 0, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { | ||||
|         match msg { | ||||
|             SlidesViewerMsg::GoToSlide(slide) => { | ||||
|                 self.current_slide_index = slide; | ||||
|                 true | ||||
|             } | ||||
|             SlidesViewerMsg::NextSlide => { | ||||
|                 let props = _ctx.props(); | ||||
|                 if self.current_slide_index < props.slides.slide_urls.len().saturating_sub(1) { | ||||
|                     self.current_slide_index += 1; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|             SlidesViewerMsg::PrevSlide => { | ||||
|                 if self.current_slide_index > 0 { | ||||
|                     self.current_slide_index -= 1; | ||||
|                 } | ||||
|                 true | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fn view(&self, ctx: &Context<Self>) -> Html { | ||||
|         let props = ctx.props(); | ||||
|         let total_slides = props.slides.slide_urls.len(); | ||||
|          | ||||
|         let back_handler = { | ||||
|             let on_back = props.on_back.clone(); | ||||
|             Callback::from(move |_: MouseEvent| { | ||||
|                 on_back.emit(()); | ||||
|             }) | ||||
|         }; | ||||
|          | ||||
|         let prev_handler = ctx.link().callback(|_: MouseEvent| SlidesViewerMsg::PrevSlide); | ||||
|         let next_handler = ctx.link().callback(|_: MouseEvent| SlidesViewerMsg::NextSlide); | ||||
|  | ||||
|         html! { | ||||
|             <div class="asset-viewer slides-viewer"> | ||||
|                 <button class="back-button" onclick={back_handler}> | ||||
|                     <i class="fas fa-arrow-left"></i> {"Back to Collection"} | ||||
|                 </button> | ||||
|                 <div class="viewer-header"> | ||||
|                     <h2 class="viewer-title">{ &props.slides.title }</h2> | ||||
|                     <div class="slides-navigation"> | ||||
|                         <button | ||||
|                             class="nav-button" | ||||
|                             onclick={prev_handler} | ||||
|                             disabled={self.current_slide_index == 0} | ||||
|                         > | ||||
|                             <i class="fas fa-chevron-left"></i> {"Previous"} | ||||
|                         </button> | ||||
|                         <span class="slide-indicator"> | ||||
|                             { format!("Slide {} of {}", self.current_slide_index + 1, total_slides) } | ||||
|                         </span> | ||||
|                         <button | ||||
|                             class="nav-button" | ||||
|                             onclick={next_handler} | ||||
|                             disabled={self.current_slide_index >= total_slides.saturating_sub(1)} | ||||
|                         > | ||||
|                             {"Next"} <i class="fas fa-chevron-right"></i> | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="viewer-content"> | ||||
|                     <div class="slide-container"> | ||||
|                         { if let Some(slide_url) = props.slides.slide_urls.get(self.current_slide_index) { | ||||
|                             html! { | ||||
|                                 <div class="slide"> | ||||
|                                     <img | ||||
|                                         src={slide_url.clone()} | ||||
|                                         alt={ | ||||
|                                             props.slides.slide_titles.get(self.current_slide_index) | ||||
|                                                 .and_then(|t| t.as_ref()) | ||||
|                                                 .unwrap_or(&format!("Slide {}", self.current_slide_index + 1)) | ||||
|                                                 .clone() | ||||
|                                         } | ||||
|                                         class="slide-image" | ||||
|                                     /> | ||||
|                                     { if let Some(Some(title)) = props.slides.slide_titles.get(self.current_slide_index) { | ||||
|                                         html! { <div class="slide-title">{ title }</div> } | ||||
|                                     } else { html! {} }} | ||||
|                                 </div> | ||||
|                             } | ||||
|                         } else { | ||||
|                             html! { <p>{"Slide not found"}</p> } | ||||
|                         }} | ||||
|                     </div> | ||||
|                     <div class="slide-thumbnails"> | ||||
|                         { props.slides.slide_urls.iter().enumerate().map(|(index, url)| { | ||||
|                             let is_current = index == self.current_slide_index; | ||||
|                             let onclick = ctx.link().callback(move |_: MouseEvent| SlidesViewerMsg::GoToSlide(index)); | ||||
|                             html! { | ||||
|                                 <div | ||||
|                                     class={classes!("slide-thumbnail", if is_current { "active" } else { "" })} | ||||
|                                     onclick={onclick} | ||||
|                                 > | ||||
|                                     <img src={url.clone()} alt={format!("Slide {}", index + 1)} /> | ||||
|                                     <span class="thumbnail-number">{ index + 1 }</span> | ||||
|                                 </div> | ||||
|                             } | ||||
|                         }).collect::<Html>() } | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								src/app/src/components/world_map_svg.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/app/src/components/world_map_svg.rs
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										22
									
								
								src/app/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| use wasm_bindgen::prelude::*; // Import wasm_bindgen | ||||
|  | ||||
| mod app; | ||||
| mod auth; // Declares the authentication module | ||||
| mod components; // Declares the components module | ||||
| mod rhai_executor; // Declares the rhai_executor module | ||||
| mod ws_manager; // Declares the WebSocket manager module | ||||
|  | ||||
| // This function is called when the WASM module is loaded. | ||||
| #[wasm_bindgen(start)] | ||||
| pub fn run_app() { | ||||
|     // Initialize wasm_logger. This allows use of `log::info!`, `log::warn!`, etc. | ||||
|     wasm_logger::init(wasm_logger::Config::default()); | ||||
|     log::info!("App is starting"); // Example log | ||||
|  | ||||
|     // Mount the Yew app to the document body with WebSocket URL | ||||
|     let props = app::AppProps { | ||||
|         start_circle_ws_url: "ws://localhost:9000/ws".to_string(), // Default WebSocket URL | ||||
|     }; | ||||
|     yew::Renderer::<app::App>::with_props(props).render(); | ||||
| } | ||||
|  | ||||
							
								
								
									
										165
									
								
								src/app/src/rhai_executor.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								src/app/src/rhai_executor.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | ||||
| use rhai::Engine; | ||||
| use engine::{create_heromodels_engine, mock_db::{create_mock_db, seed_mock_db}, eval_script}; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder}; | ||||
|  | ||||
| // Since we're in a WASM environment, we need to handle the database differently | ||||
| // We'll create a mock database that works in WASM | ||||
|  | ||||
| pub struct RhaiExecutor { | ||||
|     engine: Engine, | ||||
| } | ||||
|  | ||||
| impl RhaiExecutor { | ||||
|     pub fn new() -> Self { | ||||
|         // Create a mock database for the engine | ||||
|         let db = create_mock_db(); | ||||
|         seed_mock_db(db.clone()); | ||||
|          | ||||
|         // Create the heromodels engine with all the registered functions | ||||
|         let engine = create_heromodels_engine(db); | ||||
|          | ||||
|         Self { engine } | ||||
|     } | ||||
|      | ||||
|     pub fn execute_script(&self, script: &str) -> Result<String, String> { | ||||
|         if script.trim().is_empty() { | ||||
|             return Err("Script cannot be empty".to_string()); | ||||
|         } | ||||
|          | ||||
|         match eval_script(&self.engine, script) { | ||||
|             Ok(result) => { | ||||
|                 let output = if result.is_unit() { | ||||
|                     "Script executed successfully (no return value)".to_string() | ||||
|                 } else { | ||||
|                     format!("Result: {}", result) | ||||
|                 }; | ||||
|                 Ok(output) | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 Err(format!("Rhai execution error: {}", err)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl Default for RhaiExecutor { | ||||
|     fn default() -> Self { | ||||
|         Self::new() | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Debug)] | ||||
| pub struct ScriptResponse { | ||||
|     pub output: String, | ||||
|     pub success: bool, | ||||
|     pub source: String, | ||||
| } | ||||
|  | ||||
| // For local execution (self circle) | ||||
| pub fn execute_rhai_script_local(script: &str) -> ScriptResponse { | ||||
|     let executor = RhaiExecutor::new(); | ||||
|      | ||||
|     match executor.execute_script(script) { | ||||
|         Ok(output) => ScriptResponse { | ||||
|             output, | ||||
|             success: true, | ||||
|             source: "Local (My Space)".to_string(), | ||||
|         }, | ||||
|         Err(error) => ScriptResponse { | ||||
|             output: error, | ||||
|             success: false, | ||||
|             source: "Local (My Space)".to_string(), | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | ||||
| // For remote execution (other circles via WebSocket) | ||||
| pub async fn execute_rhai_script_remote(script: &str, ws_url: &str, source_name: &str) -> ScriptResponse { | ||||
|     if script.trim().is_empty() { | ||||
|         return ScriptResponse { | ||||
|             output: "Error: Script cannot be empty".to_string(), | ||||
|             success: false, | ||||
|             source: source_name.to_string(), | ||||
|         }; | ||||
|     } | ||||
|      | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.to_string()).build(); | ||||
|      | ||||
|     // Connect to the WebSocket | ||||
|     match client.connect().await { | ||||
|         Ok(_) => { | ||||
|             // Send the script for execution | ||||
|             match client.play(script.to_string()).await { | ||||
|                 Ok(result) => { | ||||
|                     // Disconnect after execution | ||||
|                     client.disconnect().await; | ||||
|                     ScriptResponse { | ||||
|                         output: result.output, | ||||
|                         success: true, | ||||
|                         source: source_name.to_string(), | ||||
|                     } | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     client.disconnect().await; | ||||
|                     ScriptResponse { | ||||
|                         output: format!("Remote execution error: {}", e), | ||||
|                         success: false, | ||||
|                         source: source_name.to_string(), | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Err(e) => { | ||||
|             ScriptResponse { | ||||
|                 output: format!("Connection error: {}", e), | ||||
|                 success: false, | ||||
|                 source: source_name.to_string(), | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Broadcast script to all WebSocket URLs and return all responses | ||||
| pub async fn broadcast_rhai_script(script: &str, ws_urls: &[String]) -> Vec<ScriptResponse> { | ||||
|     let mut responses = Vec::new(); | ||||
|      | ||||
|     // Add local execution first | ||||
|     // responses.push(execute_rhai_script_local(script)); | ||||
|      | ||||
|     // Execute on all remote circles | ||||
|     for (index, ws_url) in ws_urls.iter().enumerate() { | ||||
|         let source_name = format!("Circle {}", index + 1); | ||||
|         let response = execute_rhai_script_remote(script, ws_url, &source_name).await; | ||||
|         responses.push(response); | ||||
|     } | ||||
|      | ||||
|     responses | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|      | ||||
|     #[test] | ||||
|     fn test_basic_script_execution() { | ||||
|         let executor = RhaiExecutor::new(); | ||||
|          | ||||
|         // Test simple arithmetic | ||||
|         let result = executor.execute_script("2 + 3"); | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(result.unwrap().contains("5")); | ||||
|          | ||||
|         // Test variable assignment | ||||
|         let result = executor.execute_script("let x = 10; x * 2"); | ||||
|         assert!(result.is_ok()); | ||||
|         assert!(result.unwrap().contains("20")); | ||||
|     } | ||||
|      | ||||
|     #[test] | ||||
|     fn test_empty_script() { | ||||
|         let executor = RhaiExecutor::new(); | ||||
|         let result = executor.execute_script(""); | ||||
|         assert!(result.is_err()); | ||||
|         assert!(result.unwrap_err().contains("empty")); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										354
									
								
								src/app/src/ws_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										354
									
								
								src/app/src/ws_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,354 @@ | ||||
| use std::collections::HashMap; | ||||
| use circle_client_ws::{CircleWsClient, CircleWsClientBuilder, CircleWsClientError, PlayResultClient}; | ||||
| use log::{info, error, warn}; | ||||
| use serde::de::DeserializeOwned; | ||||
| use std::rc::Rc; | ||||
| use std::cell::RefCell; | ||||
| use wasm_bindgen_futures::spawn_local; | ||||
| use yew::Callback; | ||||
| use heromodels::models::circle::Circle; | ||||
| use crate::auth::AuthManager; | ||||
|  | ||||
| /// Type alias for Circle-specific WebSocket manager | ||||
| pub type CircleWsManager = WsManager<Circle>; | ||||
|  | ||||
| /// Manages multiple WebSocket connections to servers | ||||
| /// Generic over the data type T that will be fetched and deserialized | ||||
| #[derive(Clone)] | ||||
| pub struct WsManager<T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone + 'static, | ||||
| { | ||||
|     /// Map of WebSocket URL to client | ||||
|     clients: Rc<RefCell<HashMap<String, CircleWsClient>>>, | ||||
|     /// Callback to notify when data is fetched | ||||
|     on_data_fetched: Rc<RefCell<Option<Callback<(String, Result<T, String>)>>>>, | ||||
|     /// Optional authentication manager | ||||
|     auth_manager: Option<AuthManager>, | ||||
| } | ||||
|  | ||||
| impl<T> WsManager<T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone + 'static, | ||||
| { | ||||
|     pub fn new() -> Self { | ||||
|         Self { | ||||
|             clients: Rc::new(RefCell::new(HashMap::new())), | ||||
|             on_data_fetched: Rc::new(RefCell::new(None)), | ||||
|             auth_manager: None, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Create a new WsManager with authentication support | ||||
|     pub fn new_with_auth(auth_manager: AuthManager) -> Self { | ||||
|         Self { | ||||
|             clients: Rc::new(RefCell::new(HashMap::new())), | ||||
|             on_data_fetched: Rc::new(RefCell::new(None)), | ||||
|             auth_manager: Some(auth_manager), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Set the authentication manager | ||||
|     pub fn set_auth_manager(&mut self, auth_manager: AuthManager) { | ||||
|         self.auth_manager = Some(auth_manager); | ||||
|     } | ||||
|  | ||||
|     /// Check if authentication is enabled | ||||
|     pub fn has_auth(&self) -> bool { | ||||
|         self.auth_manager.is_some() | ||||
|     } | ||||
|  | ||||
|     /// Check if currently authenticated | ||||
|     pub fn is_authenticated(&self) -> bool { | ||||
|         self.auth_manager.as_ref().map_or(false, |auth| auth.is_authenticated()) | ||||
|     } | ||||
|  | ||||
|     /// Set callback for when data is fetched | ||||
|     pub fn set_on_data_fetched(&self, callback: Callback<(String, Result<T, String>)>) { | ||||
|         *self.on_data_fetched.borrow_mut() = Some(callback); | ||||
|     } | ||||
|  | ||||
|     /// Connect to a WebSocket server | ||||
|     pub async fn connect(&self, ws_url: String) -> Result<(), CircleWsClientError> { | ||||
|         if self.clients.borrow().contains_key(&ws_url) { | ||||
|             info!("Already connected to {}", ws_url); | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         let client = if let Some(auth_manager) = &self.auth_manager { | ||||
|             if auth_manager.is_authenticated() { | ||||
|                 auth_manager.create_authenticated_client(&ws_url).await? | ||||
|             } else { | ||||
|                 let mut client = CircleWsClientBuilder::new(ws_url.clone()).build(); | ||||
|                 client.connect().await?; | ||||
|                 client | ||||
|             } | ||||
|         } else { | ||||
|             let mut client = CircleWsClientBuilder::new(ws_url.clone()).build(); | ||||
|             client.connect().await?; | ||||
|             client | ||||
|         }; | ||||
|  | ||||
|         info!("Connected to WebSocket: {}", ws_url); | ||||
|         self.clients.borrow_mut().insert(ws_url, client); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Connect to a WebSocket server with explicit authentication | ||||
|     pub async fn connect_with_auth(&self, ws_url: String, force_auth: bool) -> Result<(), CircleWsClientError> { | ||||
|         if self.clients.borrow().contains_key(&ws_url) { | ||||
|             info!("Already connected to {}", ws_url); | ||||
|             return Ok(()); | ||||
|         } | ||||
|  | ||||
|         let client = if force_auth { | ||||
|             if let Some(auth_manager) = &self.auth_manager { | ||||
|                 if auth_manager.is_authenticated() { | ||||
|                     auth_manager.create_authenticated_client(&ws_url).await? | ||||
|                 } else { | ||||
|                     return Err(CircleWsClientError::ConnectionError("Authentication required but not authenticated".to_string())); | ||||
|                 } | ||||
|             } else { | ||||
|                 return Err(CircleWsClientError::ConnectionError("Authentication required but no auth manager available".to_string())); | ||||
|             } | ||||
|         } else { | ||||
|             let mut client = CircleWsClientBuilder::new(ws_url.clone()).build(); | ||||
|             client.connect().await?; | ||||
|             client | ||||
|         }; | ||||
|          | ||||
|         info!("Connected to WebSocket with auth: {}", ws_url); | ||||
|         self.clients.borrow_mut().insert(ws_url, client); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Fetch data from a WebSocket server using the provided script | ||||
|     pub fn fetch_data(&self, ws_url: &str, script: String) { | ||||
|         // Check if client exists without cloning | ||||
|         let has_client = self.clients.borrow().contains_key(ws_url); | ||||
|         if has_client { | ||||
|             let ws_url_clone = ws_url.to_string(); | ||||
|             let callback = self.on_data_fetched.borrow().clone(); | ||||
|             let clients = self.clients.clone(); | ||||
|              | ||||
|             spawn_local(async move { | ||||
|                 // Get the client inside the async block | ||||
|                 let play_future = { | ||||
|                     let clients_borrow = clients.borrow(); | ||||
|                     if let Some(client) = clients_borrow.get(&ws_url_clone) { | ||||
|                         Some(client.play(script)) | ||||
|                     } else { | ||||
|                         None | ||||
|                     } | ||||
|                 }; | ||||
|                  | ||||
|                 if let Some(future) = play_future { | ||||
|                     match future.await { | ||||
|                         Ok(result) => { | ||||
|                             info!("Received data from {}: {}", ws_url_clone, result.output); | ||||
|                              | ||||
|                             // Parse the JSON response to extract data | ||||
|                             match serde_json::from_str::<T>(&result.output) { | ||||
|                                 Ok(data) => { | ||||
|                                     if let Some(cb) = callback { | ||||
|                                         cb.emit((ws_url_clone.clone(), Ok(data))); | ||||
|                                     } | ||||
|                                 } | ||||
|                                 Err(e) => { | ||||
|                                     error!("Failed to parse data from {}: {}", ws_url_clone, e); | ||||
|                                     if let Some(cb) = callback { | ||||
|                                         cb.emit((ws_url_clone.clone(), Err(format!("Failed to parse data: {}", e)))); | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         Err(e) => { | ||||
|                             error!("Failed to fetch data from {}: {:?}", ws_url_clone, e); | ||||
|                             if let Some(cb) = callback { | ||||
|                                 cb.emit((ws_url_clone.clone(), Err(format!("WebSocket error: {:?}", e)))); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
|         } else { | ||||
|             warn!("No client found for WebSocket URL: {}", ws_url); | ||||
|             if let Some(cb) = &*self.on_data_fetched.borrow() { | ||||
|                 cb.emit((ws_url.to_string(), Err(format!("No connection to {}", ws_url)))); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Execute a Rhai script on a specific server | ||||
|     pub fn execute_script(&self, ws_url: &str, script: String) -> Option<impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>>> { | ||||
|         let clients = self.clients.clone(); | ||||
|         let ws_url = ws_url.to_string(); | ||||
|          | ||||
|         if clients.borrow().contains_key(&ws_url) { | ||||
|             Some(async move { | ||||
|                 let clients_borrow = clients.borrow(); | ||||
|                 if let Some(client) = clients_borrow.get(&ws_url) { | ||||
|                     client.play(script).await | ||||
|                 } else { | ||||
|                     Err(CircleWsClientError::NotConnected) | ||||
|                 } | ||||
|             }) | ||||
|         } else { | ||||
|             None | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get all connected WebSocket URLs | ||||
|     pub fn get_connected_urls(&self) -> Vec<String> { | ||||
|         self.clients.borrow().keys().cloned().collect() | ||||
|     } | ||||
|  | ||||
|     /// Disconnect from a specific WebSocket | ||||
|     pub async fn disconnect(&self, ws_url: &str) { | ||||
|         if let Some(mut client) = self.clients.borrow_mut().remove(ws_url) { | ||||
|             client.disconnect().await; | ||||
|             info!("Disconnected from WebSocket: {}", ws_url); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Disconnect from all WebSockets | ||||
|     pub async fn disconnect_all(&self) { | ||||
|         let mut clients = self.clients.borrow_mut(); | ||||
|         for (ws_url, mut client) in clients.drain() { | ||||
|             client.disconnect().await; | ||||
|             info!("Disconnected from WebSocket: {}", ws_url); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl<T> Drop for WsManager<T>  | ||||
| where  | ||||
|     T: DeserializeOwned + Clone + 'static, | ||||
| { | ||||
|     fn drop(&mut self) { | ||||
|         // Note: We can't call async disconnect_all in drop, but the clients | ||||
|         // should handle cleanup in their own Drop implementations | ||||
|         info!("WsManager dropped"); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Fetch data from multiple WebSocket servers | ||||
| /// Generic function that can fetch any deserializable type T | ||||
| pub async fn fetch_data_from_ws_urls<T>(ws_urls: &[String], script: String) -> HashMap<String, T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone, | ||||
| { | ||||
|     let mut results = HashMap::new(); | ||||
|      | ||||
|     for ws_url in ws_urls { | ||||
|         match fetch_data_from_ws_url::<T>(ws_url, &script).await { | ||||
|             Ok(data) => { | ||||
|                 results.insert(ws_url.clone(), data); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 error!("Failed to fetch data from {}: {}", ws_url, e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     results | ||||
| } | ||||
|  | ||||
| /// Fetch data from a single WebSocket server | ||||
| pub async fn fetch_data_from_ws_url<T>(ws_url: &str, script: &str) -> Result<T, String> | ||||
| where | ||||
|     T: DeserializeOwned, | ||||
| { | ||||
|     let mut client = CircleWsClientBuilder::new(ws_url.to_string()).build(); | ||||
|      | ||||
|     // Connect to the WebSocket | ||||
|     client.connect().await | ||||
|         .map_err(|e| format!("Failed to connect to {}: {:?}", ws_url, e))?; | ||||
|      | ||||
|     info!("Connected to WebSocket: {}", ws_url); | ||||
|      | ||||
|     // Execute the script | ||||
|     let result = client.play(script.to_string()).await | ||||
|         .map_err(|e| format!("Failed to execute script on {}: {:?}", ws_url, e))?; | ||||
|      | ||||
|     info!("Received data from {}: {}", ws_url, result.output); | ||||
|      | ||||
|     // Parse the JSON response | ||||
|     let data: T = serde_json::from_str(&result.output) | ||||
|         .map_err(|e| format!("Failed to parse data from {}: {}", ws_url, e))?; | ||||
|      | ||||
|     // Disconnect | ||||
|     client.disconnect().await; | ||||
|     info!("Disconnected from WebSocket: {}", ws_url); | ||||
|      | ||||
|     Ok(data) | ||||
| } | ||||
|  | ||||
| /// Fetch data from a single WebSocket server with authentication | ||||
| pub async fn fetch_data_from_ws_url_with_auth<T>( | ||||
|     ws_url: &str, | ||||
|     script: &str, | ||||
|     auth_manager: &AuthManager, | ||||
| ) -> Result<T, String> | ||||
| where | ||||
|     T: DeserializeOwned, | ||||
| { | ||||
|     let mut client = if auth_manager.is_authenticated() { | ||||
|         auth_manager | ||||
|             .create_authenticated_client(ws_url) | ||||
|             .await | ||||
|             .map_err(|e| format!("Authentication failed: {}", e))? | ||||
|     } else { | ||||
|         let mut client = CircleWsClientBuilder::new(ws_url.to_string()).build(); | ||||
|         client | ||||
|             .connect() | ||||
|             .await | ||||
|             .map_err(|e| format!("Connection failed: {}", e))?; | ||||
|         client | ||||
|     }; | ||||
|  | ||||
|     info!("Connected to WebSocket: {}", ws_url); | ||||
|  | ||||
|     // Execute the script | ||||
|     let result = client | ||||
|         .play(script.to_string()) | ||||
|         .await | ||||
|         .map_err(|e| format!("Failed to execute script on {}: {:?}", ws_url, e))?; | ||||
|  | ||||
|     info!("Received data from {}: {}", ws_url, result.output); | ||||
|  | ||||
|     // Parse the JSON response | ||||
|     let data: T = serde_json::from_str(&result.output) | ||||
|         .map_err(|e| format!("Failed to parse data from {}: {}", ws_url, e))?; | ||||
|  | ||||
|     // Disconnect | ||||
|     client.disconnect().await; | ||||
|     info!("Disconnected from WebSocket: {}", ws_url); | ||||
|  | ||||
|     Ok(data) | ||||
| } | ||||
|  | ||||
| /// Fetch data from multiple WebSocket servers with authentication | ||||
| pub async fn fetch_data_from_ws_urls_with_auth<T>( | ||||
|     ws_urls: &[String], | ||||
|     script: String, | ||||
|     auth_manager: &AuthManager | ||||
| ) -> HashMap<String, T> | ||||
| where | ||||
|     T: DeserializeOwned + Clone, | ||||
| { | ||||
|     let mut results = HashMap::new(); | ||||
|      | ||||
|     for ws_url in ws_urls { | ||||
|         match fetch_data_from_ws_url_with_auth::<T>(ws_url, &script, auth_manager).await { | ||||
|             Ok(data) => { | ||||
|                 results.insert(ws_url.clone(), data); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 error!("Failed to fetch data from {}: {}", ws_url, e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     results | ||||
| } | ||||
							
								
								
									
										719
									
								
								src/app/static/css/auth.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										719
									
								
								src/app/static/css/auth.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,719 @@ | ||||
| /* Authentication Component Styles */ | ||||
|  | ||||
| .login-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     min-height: 100vh; | ||||
|     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .login-card { | ||||
|     background: white; | ||||
|     border-radius: 12px; | ||||
|     box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); | ||||
|     padding: 40px; | ||||
|     width: 100%; | ||||
|     max-width: 450px; | ||||
|     animation: slideIn 0.3s ease-out; | ||||
| } | ||||
|  | ||||
| @keyframes slideIn { | ||||
|     from { | ||||
|         opacity: 0; | ||||
|         transform: translateY(-20px); | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|     } | ||||
| } | ||||
|  | ||||
| .login-title { | ||||
|     text-align: center; | ||||
|     color: #333; | ||||
|     margin-bottom: 30px; | ||||
|     font-size: 24px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Method Selector */ | ||||
| .login-method-selector { | ||||
|     margin-bottom: 30px; | ||||
| } | ||||
|  | ||||
| .method-tabs { | ||||
|     display: flex; | ||||
|     border-radius: 8px; | ||||
|     overflow: hidden; | ||||
|     border: 1px solid #e1e5e9; | ||||
| } | ||||
|  | ||||
| .method-tab { | ||||
|     flex: 1; | ||||
|     padding: 12px 16px; | ||||
|     background: #f8f9fa; | ||||
|     border: none; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     color: #6c757d; | ||||
| } | ||||
|  | ||||
| .method-tab:hover { | ||||
|     background: #e9ecef; | ||||
| } | ||||
|  | ||||
| .method-tab.active { | ||||
|     background: #007bff; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .method-tab:disabled { | ||||
|     cursor: not-allowed; | ||||
|     opacity: 0.6; | ||||
| } | ||||
|  | ||||
| .method-tab i { | ||||
|     margin-right: 8px; | ||||
| } | ||||
|  | ||||
| /* Form Styles */ | ||||
| .login-form { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .form-group { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .form-group label { | ||||
|     display: block; | ||||
|     margin-bottom: 8px; | ||||
|     font-weight: 500; | ||||
|     color: #333; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .form-input { | ||||
|     width: 100%; | ||||
|     padding: 12px 16px; | ||||
|     border: 1px solid #ddd; | ||||
|     border-radius: 6px; | ||||
|     font-size: 14px; | ||||
|     transition: border-color 0.2s ease, box-shadow 0.2s ease; | ||||
|     box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| .form-input:focus { | ||||
|     outline: none; | ||||
|     border-color: #007bff; | ||||
|     box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); | ||||
| } | ||||
|  | ||||
| .form-input:disabled { | ||||
|     background-color: #f8f9fa; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .form-help { | ||||
|     display: block; | ||||
|     margin-top: 6px; | ||||
|     font-size: 12px; | ||||
|     color: #6c757d; | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| /* Email Input Container */ | ||||
| .email-input-container { | ||||
|     position: relative; | ||||
|     display: flex; | ||||
| } | ||||
|  | ||||
| .email-input-container .form-input { | ||||
|     border-top-right-radius: 0; | ||||
|     border-bottom-right-radius: 0; | ||||
|     border-right: none; | ||||
| } | ||||
|  | ||||
| .email-dropdown-btn { | ||||
|     padding: 12px; | ||||
|     background: #f8f9fa; | ||||
|     border: 1px solid #ddd; | ||||
|     border-left: none; | ||||
|     border-top-right-radius: 6px; | ||||
|     border-bottom-right-radius: 6px; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
|     color: #6c757d; | ||||
| } | ||||
|  | ||||
| .email-dropdown-btn:hover { | ||||
|     background: #e9ecef; | ||||
| } | ||||
|  | ||||
| .email-dropdown-btn:disabled { | ||||
|     cursor: not-allowed; | ||||
|     opacity: 0.6; | ||||
| } | ||||
|  | ||||
| /* Email Dropdown */ | ||||
| .email-dropdown { | ||||
|     position: absolute; | ||||
|     top: 100%; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     background: white; | ||||
|     border: 1px solid #ddd; | ||||
|     border-radius: 6px; | ||||
|     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | ||||
|     z-index: 1000; | ||||
|     margin-top: 4px; | ||||
|     animation: dropdownSlide 0.2s ease-out; | ||||
| } | ||||
|  | ||||
| @keyframes dropdownSlide { | ||||
|     from { | ||||
|         opacity: 0; | ||||
|         transform: translateY(-10px); | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|     } | ||||
| } | ||||
|  | ||||
| .dropdown-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: 12px 16px; | ||||
|     border-bottom: 1px solid #e9ecef; | ||||
|     background: #f8f9fa; | ||||
|     border-top-left-radius: 6px; | ||||
|     border-top-right-radius: 6px; | ||||
| } | ||||
|  | ||||
| .dropdown-header span { | ||||
|     font-weight: 500; | ||||
|     color: #333; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .dropdown-close { | ||||
|     background: none; | ||||
|     border: none; | ||||
|     cursor: pointer; | ||||
|     color: #6c757d; | ||||
|     padding: 4px; | ||||
|     border-radius: 4px; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .dropdown-close:hover { | ||||
|     background: #e9ecef; | ||||
| } | ||||
|  | ||||
| .dropdown-list { | ||||
|     max-height: 200px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .dropdown-item { | ||||
|     width: 100%; | ||||
|     padding: 12px 16px; | ||||
|     background: none; | ||||
|     border: none; | ||||
|     text-align: left; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-size: 14px; | ||||
|     color: #333; | ||||
| } | ||||
|  | ||||
| .dropdown-item:hover { | ||||
|     background: #f8f9fa; | ||||
| } | ||||
|  | ||||
| .dropdown-item i { | ||||
|     margin-right: 12px; | ||||
|     color: #6c757d; | ||||
| } | ||||
|  | ||||
| /* Login Button */ | ||||
| .login-btn { | ||||
|     width: 100%; | ||||
|     padding: 14px; | ||||
|     background: #007bff; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     font-size: 16px; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease, transform 0.1s ease; | ||||
| } | ||||
|  | ||||
| .login-btn:hover:not(:disabled) { | ||||
|     background: #0056b3; | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .login-btn:disabled { | ||||
|     background: #6c757d; | ||||
|     cursor: not-allowed; | ||||
|     transform: none; | ||||
| } | ||||
|  | ||||
| /* Error Message */ | ||||
| .error-message { | ||||
|     background: #f8d7da; | ||||
|     color: #721c24; | ||||
|     padding: 12px 16px; | ||||
|     border-radius: 6px; | ||||
|     border: 1px solid #f5c6cb; | ||||
|     margin-bottom: 20px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .error-message i { | ||||
|     margin-right: 8px; | ||||
|     color: #dc3545; | ||||
| } | ||||
|  | ||||
| /* Loading Indicator */ | ||||
| .loading-indicator { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     padding: 20px; | ||||
|     color: #6c757d; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .spinner { | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
|     border: 2px solid #e9ecef; | ||||
|     border-top: 2px solid #007bff; | ||||
|     border-radius: 50%; | ||||
|     animation: spin 1s linear infinite; | ||||
|     margin-right: 12px; | ||||
| } | ||||
|  | ||||
| @keyframes spin { | ||||
|     0% { transform: rotate(0deg); } | ||||
|     100% { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| /* Authenticated View */ | ||||
| .authenticated-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     min-height: 100vh; | ||||
|     background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .authenticated-card { | ||||
|     background: white; | ||||
|     border-radius: 12px; | ||||
|     box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); | ||||
|     padding: 40px; | ||||
|     width: 100%; | ||||
|     max-width: 450px; | ||||
|     text-align: center; | ||||
|     animation: slideIn 0.3s ease-out; | ||||
| } | ||||
|  | ||||
| .auth-success-icon { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .auth-success-icon i { | ||||
|     font-size: 48px; | ||||
|     color: #28a745; | ||||
| } | ||||
|  | ||||
| .authenticated-card h3 { | ||||
|     color: #333; | ||||
|     margin-bottom: 30px; | ||||
|     font-size: 24px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .auth-details { | ||||
|     margin-bottom: 30px; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| .auth-detail { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: 12px 0; | ||||
|     border-bottom: 1px solid #e9ecef; | ||||
| } | ||||
|  | ||||
| .auth-detail:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .auth-detail label { | ||||
|     font-weight: 500; | ||||
|     color: #333; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .auth-detail span { | ||||
|     color: #6c757d; | ||||
|     font-size: 14px; | ||||
|     font-family: monospace; | ||||
| } | ||||
|  | ||||
| .public-key { | ||||
|     max-width: 200px; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .logout-btn { | ||||
|     width: 100%; | ||||
|     padding: 12px; | ||||
|     background: #dc3545; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .logout-btn:hover { | ||||
|     background: #c82333; | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (max-width: 480px) { | ||||
|     .login-container, | ||||
|     .authenticated-container { | ||||
|         padding: 10px; | ||||
|     } | ||||
|      | ||||
|     .login-card, | ||||
|     .authenticated-card { | ||||
|         padding: 30px 20px; | ||||
|     } | ||||
|      | ||||
|     .login-title, | ||||
|     .authenticated-card h3 { | ||||
|         font-size: 20px; | ||||
|     } | ||||
|      | ||||
|     .method-tab { | ||||
|         padding: 10px 12px; | ||||
|         font-size: 13px; | ||||
|     } | ||||
|      | ||||
|     .auth-detail { | ||||
|         flex-direction: column; | ||||
|         align-items: flex-start; | ||||
|         gap: 4px; | ||||
|     } | ||||
|      | ||||
|     .public-key { | ||||
|         max-width: 100%; | ||||
|     } | ||||
| } | ||||
|  | ||||
| .auth-info { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .auth-method, | ||||
| .auth-status { | ||||
|     font-size: 14px; | ||||
|     color: #333; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .auth-method { | ||||
|     color: #28a745; | ||||
| } | ||||
|  | ||||
| .auth-status { | ||||
|     color: #6c757d; | ||||
| } | ||||
|  | ||||
| .logout-button, | ||||
| .login-button { | ||||
|     background: none; | ||||
|     border: 1px solid #dc3545; | ||||
|     color: #dc3545; | ||||
|     padding: 6px 12px; | ||||
|     border-radius: 4px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     font-size: 12px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
| } | ||||
|  | ||||
| .login-button { | ||||
|     border-color: #007bff; | ||||
|     color: #007bff; | ||||
| } | ||||
|  | ||||
| .logout-button:hover { | ||||
|     background: #dc3545; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .login-button:hover { | ||||
|     background: #007bff; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .logout-button i, | ||||
| .login-button i { | ||||
|     font-size: 12px; | ||||
| } | ||||
|  | ||||
| /* Create Key Form Styles */ | ||||
| .create-key-form { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .create-key-form h3 { | ||||
|     color: #333; | ||||
|     margin-bottom: 12px; | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .generate-key-btn { | ||||
|     width: 100%; | ||||
|     padding: 14px; | ||||
|     background: #28a745; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     font-size: 16px; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease, transform 0.1s ease; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .generate-key-btn:hover:not(:disabled) { | ||||
|     background: #218838; | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .generate-key-btn:disabled { | ||||
|     background: #6c757d; | ||||
|     cursor: not-allowed; | ||||
|     transform: none; | ||||
| } | ||||
|  | ||||
| /* Generated Keys Display */ | ||||
| .generated-keys { | ||||
|     margin-top: 24px; | ||||
|     padding: 20px; | ||||
|     background: #f8f9fa; | ||||
|     border-radius: 8px; | ||||
|     border: 1px solid #e9ecef; | ||||
| } | ||||
|  | ||||
| .key-section { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .key-section:last-of-type { | ||||
|     margin-bottom: 24px; | ||||
| } | ||||
|  | ||||
| .key-section label { | ||||
|     display: block; | ||||
|     margin-bottom: 8px; | ||||
|     font-weight: 600; | ||||
|     color: #333; | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .key-display { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .key-input { | ||||
|     flex: 1; | ||||
|     padding: 10px 12px; | ||||
|     border: 1px solid #ddd; | ||||
|     border-radius: 6px; | ||||
|     font-size: 12px; | ||||
|     font-family: 'Courier New', monospace; | ||||
|     background: white; | ||||
|     color: #333; | ||||
|     word-break: break-all; | ||||
| } | ||||
|  | ||||
| .copy-btn { | ||||
|     padding: 10px 12px; | ||||
|     background: #007bff; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
|     min-width: 44px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| } | ||||
|  | ||||
| .copy-btn:hover { | ||||
|     background: #0056b3; | ||||
| } | ||||
|  | ||||
| .key-warning { | ||||
|     display: block; | ||||
|     margin-top: 8px; | ||||
|     font-size: 12px; | ||||
|     color: #dc3545; | ||||
|     line-height: 1.4; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .key-warning i { | ||||
|     margin-right: 4px; | ||||
| } | ||||
|  | ||||
| .key-info { | ||||
|     display: block; | ||||
|     margin-top: 8px; | ||||
|     font-size: 12px; | ||||
|     color: #6c757d; | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| /* Key Actions */ | ||||
| .key-actions { | ||||
|     display: flex; | ||||
|     gap: 12px; | ||||
|     flex-wrap: wrap; | ||||
| } | ||||
|  | ||||
| .use-key-btn { | ||||
|     flex: 1; | ||||
|     min-width: 140px; | ||||
|     padding: 12px 16px; | ||||
|     background: #007bff; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .use-key-btn:hover { | ||||
|     background: #0056b3; | ||||
| } | ||||
|  | ||||
| .generate-new-btn { | ||||
|     flex: 1; | ||||
|     min-width: 140px; | ||||
|     padding: 12px 16px; | ||||
|     background: #6c757d; | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .generate-new-btn:hover { | ||||
|     background: #5a6268; | ||||
| } | ||||
|  | ||||
| /* Copy Feedback */ | ||||
| .copy-feedback { | ||||
|     margin-top: 12px; | ||||
|     padding: 8px 12px; | ||||
|     background: #d4edda; | ||||
|     color: #155724; | ||||
|     border: 1px solid #c3e6cb; | ||||
|     border-radius: 6px; | ||||
|     font-size: 12px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
|     animation: fadeIn 0.3s ease-out; | ||||
| } | ||||
|  | ||||
| @keyframes fadeIn { | ||||
|     from { | ||||
|         opacity: 0; | ||||
|         transform: translateY(-10px); | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Responsive adjustments for create key form */ | ||||
| @media (max-width: 480px) { | ||||
|     .key-actions { | ||||
|         flex-direction: column; | ||||
|     } | ||||
|      | ||||
|     .use-key-btn, | ||||
|     .generate-new-btn { | ||||
|         min-width: 100%; | ||||
|     } | ||||
|      | ||||
|     .key-display { | ||||
|         flex-direction: column; | ||||
|         gap: 8px; | ||||
|     } | ||||
|      | ||||
|     .copy-btn { | ||||
|         min-width: 100%; | ||||
|     } | ||||
|      | ||||
|     .generated-keys { | ||||
|         padding: 16px; | ||||
|     } | ||||
|      | ||||
|     .key-input { | ||||
|         font-size: 11px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										676
									
								
								src/app/static/css/calendar_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										676
									
								
								src/app/static/css/calendar_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,676 @@ | ||||
| /* Calendar View - Game-like Minimalistic Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .calendar-view-container { | ||||
|     /* Extends .view-container from common.css */ | ||||
|     /* Specific height and margins for calendar view */ | ||||
|     height: calc(100vh - 120px); /* Overrides common.css if different */ | ||||
|     margin: 100px 40px 60px 40px; /* Specific margins */ | ||||
|     /* background: transparent; */ /* Should inherit from body or be set if needed */ | ||||
|     /* color: var(--text-primary); */ /* Inherits from common.css */ | ||||
|     /* font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; */ /* Uses font from common.css */ | ||||
|     /* display, flex-direction, overflow are covered by .view-container */ | ||||
| } | ||||
|  | ||||
| /* Header */ | ||||
| .calendar-header { | ||||
|     /* Extends .view-header from common.css */ | ||||
|     /* .view-header provides: display, justify-content, align-items, padding-bottom, border-bottom, margin-bottom */ | ||||
|     /* Original margin-bottom: 24px; padding: 0 8px; */ | ||||
|     /* common.css .view-header has margin-bottom: var(--spacing-md) (16px) and padding-bottom for border */ | ||||
|     /* If specific padding for calendar-header itself is needed beyond what .view-header provides, add here */ | ||||
| } | ||||
|  | ||||
| .calendar-navigation { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 16px; | ||||
| } | ||||
|  | ||||
| .nav-btn { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 12px; /* Specific padding */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable (8px) */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; /* Specific font size */ | ||||
|     font-weight: 500; | ||||
|     transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease, transform var(--transition-speed) ease; /* Align transition properties */ | ||||
|     min-width: 44px; /* Specific size */ | ||||
|     height: 44px; /* Specific size */ | ||||
| } | ||||
|  | ||||
| .nav-btn:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable for primary interaction */ | ||||
|     transform: translateY(-1px); /* Specific transform */ | ||||
| } | ||||
|  | ||||
| .today-btn { | ||||
|     padding: 12px 20px; | ||||
|     min-width: auto; | ||||
| } | ||||
|  | ||||
| .calendar-title { | ||||
|     /* Extends .view-title from common.css */ | ||||
|     /* .view-title provides: font-size, font-weight, color */ | ||||
|     /* Original font-size: 24px; font-weight: 600; color: var(--text-primary); */ | ||||
|     /* common.css .view-title has font-size: 1.8em; color: var(--primary-accent) */ | ||||
|     /* If var(--text-primary) is desired over var(--primary-accent) for this title: */ | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .calendar-view-switcher { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: 12px; | ||||
|     padding: 6px; | ||||
| } | ||||
|  | ||||
| .view-btn { | ||||
|     background: transparent; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: none; | ||||
|     padding: 12px 16px; /* Specific padding */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable (8px) */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; /* Specific font size */ | ||||
|     font-weight: 500; | ||||
|     transition: color var(--transition-speed) ease, background-color var(--transition-speed) ease, transform var(--transition-speed) ease; /* Align transition properties */ | ||||
|     position: relative; | ||||
|     overflow: hidden; /* For shimmer effect */ | ||||
| } | ||||
|  | ||||
| .view-btn::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: -100%; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); | ||||
|     transition: left 0.5s ease; | ||||
| } | ||||
|  | ||||
| .view-btn:hover::before { | ||||
|     left: 100%; | ||||
| } | ||||
|  | ||||
| .view-btn:hover { | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     transform: translateY(-1px); /* Specific transform */ | ||||
| } | ||||
|  | ||||
| .view-btn.active { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color for active/primary state, common pattern */ | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Use common variable and alpha mix for glow */ | ||||
| } | ||||
|  | ||||
| .calendar-actions { | ||||
|     display: flex; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .action-btn { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); /* Use common spacing variable */ | ||||
|     background: var(--surface-dark); /* Use common.css variable */ | ||||
|     color: var(--text-primary); /* Consistent with common.css */ | ||||
|     border: 1px solid var(--border-color); /* Use common.css variable */ | ||||
|     padding: 12px 20px; /* Specific padding for this view's action buttons */ | ||||
|     border-radius: var(--border-radius-medium); /* Use common.css variable (8px) */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; /* Specific font size */ | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; /* Consider aligning with var(--transition-speed) if appropriate */ | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .action-btn.primary { | ||||
|     background: var(--primary-accent); /* Use common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Use common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color for primary button, from common.css .button-primary */ | ||||
|     box-shadow: 0 0 15px color-mix(in srgb, var(--primary-accent) 20%, transparent); /* Use common.css variable in shadow */ | ||||
| } | ||||
|  | ||||
| .action-btn:hover { | ||||
|     /* Specific hover effect for this view's action buttons */ | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); /* This shadow is specific */ | ||||
|     /* Consider standardizing hover background if possible, e.g., var(--surface-medium) */ | ||||
|     background: var(--surface-medium); /* Example: align hover bg with common */ | ||||
|     border-color: var(--primary-accent); /* Example: align hover border with common */ | ||||
| } | ||||
|  | ||||
| .action-btn.primary:hover { | ||||
|     /* Specific hover effect for primary action buttons */ | ||||
|     box-shadow: 0 4px 25px color-mix(in srgb, var(--primary-accent) 40%, transparent); /* Use common.css variable in shadow */ | ||||
|     /* background for .primary.hover from common.css .button-primary:hover is color-mix(in srgb, var(--primary-accent) 85%, white) */ | ||||
|     background: color-mix(in srgb, var(--primary-accent) 85%, white); | ||||
| } | ||||
|  | ||||
| /* Content Area */ | ||||
| .calendar-content { | ||||
|     flex: 1; | ||||
|     overflow: hidden; | ||||
|     border-radius: 12px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| /* Month View */ | ||||
| .month-view { | ||||
|     height: 100%; | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .month-grid { | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .weekday-headers { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(7, 1fr); | ||||
|     gap: 1px; | ||||
|     margin-bottom: 1px; | ||||
| } | ||||
|  | ||||
| .weekday-header { | ||||
|     padding: 16px 8px; | ||||
|     text-align: center; | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .calendar-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(7, 1fr); | ||||
|     grid-template-rows: repeat(6, 1fr); | ||||
|     gap: 1px; | ||||
|     flex: 1; | ||||
|     background: var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .calendar-day { | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     padding: 12px 8px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .calendar-day:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     transform: scale(1.02); | ||||
| } | ||||
|  | ||||
| .calendar-day.other-month { | ||||
|     opacity: 0.4; | ||||
| } | ||||
|  | ||||
| .calendar-day.today { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .calendar-day.today .day-number { | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     font-weight: 700; | ||||
| } | ||||
|  | ||||
| .day-number { | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .day-events { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 2px; | ||||
| } | ||||
|  | ||||
| .event-dot { | ||||
|     padding: 2px 6px; | ||||
|     border-radius: 4px; | ||||
|     font-size: 10px; | ||||
|     font-weight: 500; | ||||
|     color: white; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .event-dot:hover { | ||||
|     transform: scale(1.05); | ||||
|     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .event-title { | ||||
|     display: block; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .more-events { | ||||
|     font-size: 10px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
|     padding: 2px 4px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| /* Week View */ | ||||
| .week-view { | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .week-header { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(7, 1fr); | ||||
|     gap: 1px; | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .week-day-header { | ||||
|     padding: 16px 8px; | ||||
|     text-align: center; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: 8px; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .week-day-header.today { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     box-shadow: 0 0 15px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .day-name { | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .day-number { | ||||
|     font-size: 18px; | ||||
|     font-weight: 700; | ||||
| } | ||||
|  | ||||
| .week-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(7, 1fr); | ||||
|     gap: 12px; | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .week-day-column { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 8px; | ||||
|     padding: 8px; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: 8px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| /* Day View */ | ||||
| .day-view { | ||||
|     height: 100%; | ||||
|     padding: 20px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .day-schedule { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .time-slots { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .time-slot { | ||||
|     display: flex; | ||||
|     min-height: 60px; | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .time-label { | ||||
|     width: 80px; | ||||
|     padding: 8px 16px; | ||||
|     font-size: 12px; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-secondary); | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     display: flex; | ||||
|     align-items: flex-start; | ||||
|     justify-content: flex-end; | ||||
| } | ||||
|  | ||||
| .time-content { | ||||
|     flex: 1; | ||||
|     padding: 8px 16px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 4px; | ||||
| } | ||||
|  | ||||
| /* Agenda View */ | ||||
| .agenda-view { | ||||
|     height: 100%; | ||||
|     padding: 20px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .agenda-list { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .agenda-item { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 16px; | ||||
|     padding: 16px; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: 12px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     border-left: 4px solid transparent; | ||||
| } | ||||
|  | ||||
| .agenda-item:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     transform: translateX(4px); | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .agenda-date { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     min-width: 60px; | ||||
|     padding: 8px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border-radius: 8px; | ||||
| } | ||||
|  | ||||
| .date-day { | ||||
|     font-size: 20px; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1; | ||||
| } | ||||
|  | ||||
| .date-month { | ||||
|     font-size: 12px; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .agenda-event-indicator { | ||||
|     width: 4px; | ||||
|     height: 40px; | ||||
|     border-radius: 2px; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .agenda-event-details { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 4px; | ||||
| } | ||||
|  | ||||
| .agenda-event-title { | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .agenda-event-time { | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .agenda-event-description { | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .agenda-event-location { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
| } | ||||
|  | ||||
| .agenda-event-location i { | ||||
|     font-size: 10px; | ||||
| } | ||||
|  | ||||
| .agenda-event-type { | ||||
|     padding: 6px 12px; | ||||
|     border-radius: 16px; | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| /* Event Blocks */ | ||||
| .event-block { | ||||
|     padding: 8px 12px; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: 6px; | ||||
|     border-left: 4px solid var(--primary-accent); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .event-block:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     transform: translateX(2px); | ||||
|     box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .event-block .event-title { | ||||
|     font-size: 13px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin-bottom: 2px; | ||||
| } | ||||
|  | ||||
| .event-block .event-location { | ||||
|     font-size: 11px; | ||||
|     color: var(--text-muted); | ||||
| } | ||||
|  | ||||
| /* Event Type Styles */ | ||||
| .event-meeting { | ||||
|     border-left-color: var(--primary-accent) !important; /* Mapped to common primary */ | ||||
| } | ||||
|  | ||||
| .event-deadline { | ||||
|     border-left-color: #ef4444 !important; /* Literal color (original var(--error)) */ | ||||
| } | ||||
|  | ||||
| .event-milestone { | ||||
|     border-left-color: #10b981 !important; /* Literal color (original var(--success)) */ | ||||
| } | ||||
|  | ||||
| .event-social { | ||||
|     border-left-color: #8b5cf6 !important; /* Literal color (original var(--accent)) */ | ||||
| } | ||||
|  | ||||
| .event-workshop { | ||||
|     border-left-color: #f59e0b !important; /* Literal color (original var(--warning)) */ | ||||
| } | ||||
|  | ||||
| .event-conference { | ||||
|     border-left-color: #06b6d4 !important; /* Literal color (original var(--info)) */ | ||||
| } | ||||
|  | ||||
| .event-personal { | ||||
|     border-left-color: #6b7280 !important; /* Literal color (original var(--secondary)) */ | ||||
| } | ||||
|  | ||||
| /* Agenda Event Type Colors */ | ||||
| .agenda-event-type.event-meeting { | ||||
|     background: var(--primary-accent); /* Mapped to common primary */ | ||||
|     color: var(--bg-dark); /* Text on primary accent */ | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-deadline { | ||||
|     background: #ef4444; /* Literal color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-milestone { | ||||
|     background: #10b981; /* Literal color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-social { | ||||
|     background: #8b5cf6; /* Literal color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-workshop { | ||||
|     background: #f59e0b; /* Literal color */ | ||||
|     color: var(--bg-dark); /* Text on amber/orange */ | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-conference { | ||||
|     background: #06b6d4; /* Literal color */ | ||||
|     color: var(--bg-dark); /* Text on cyan */ | ||||
| } | ||||
|  | ||||
| .agenda-event-type.event-personal { | ||||
|     background: #6b7280; /* Literal color */ | ||||
|     color: white; /* Text on gray */ | ||||
| } | ||||
|  | ||||
| /* Scrollbar styling is now handled globally by common.css */ | ||||
|  | ||||
| /* Responsive design */ | ||||
| @media (max-width: 768px) { | ||||
|     .calendar-view-container { | ||||
|         margin: 20px; | ||||
|         height: calc(100vh - 80px); | ||||
|     } | ||||
|      | ||||
|     .calendar-header { | ||||
|         flex-direction: column; | ||||
|         gap: 16px; | ||||
|         align-items: stretch; | ||||
|     } | ||||
|      | ||||
|     .calendar-navigation { | ||||
|         justify-content: center; | ||||
|     } | ||||
|      | ||||
|     .calendar-title { | ||||
|         text-align: center; | ||||
|         font-size: 20px; | ||||
|     } | ||||
|      | ||||
|     .calendar-view-switcher { | ||||
|         justify-content: center; | ||||
|     } | ||||
|      | ||||
|     .weekday-header, | ||||
|     .week-day-header { | ||||
|         padding: 12px 4px; | ||||
|         font-size: 11px; | ||||
|     } | ||||
|      | ||||
|     .calendar-day { | ||||
|         padding: 8px 4px; | ||||
|     } | ||||
|      | ||||
|     .day-number { | ||||
|         font-size: 12px; | ||||
|     } | ||||
|      | ||||
|     .event-dot { | ||||
|         font-size: 9px; | ||||
|         padding: 1px 4px; | ||||
|     } | ||||
|      | ||||
|     .week-grid { | ||||
|         grid-template-columns: 1fr; | ||||
|         gap: 8px; | ||||
|     } | ||||
|      | ||||
|     .agenda-item { | ||||
|         flex-direction: column; | ||||
|         align-items: flex-start; | ||||
|         gap: 12px; | ||||
|     } | ||||
|      | ||||
|     .agenda-date { | ||||
|         align-self: flex-start; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 480px) { | ||||
|     .calendar-view-switcher { | ||||
|         flex-wrap: wrap; | ||||
|     } | ||||
|      | ||||
|     .view-btn { | ||||
|         flex: 1; | ||||
|         min-width: 0; | ||||
|         justify-content: center; | ||||
|     } | ||||
|      | ||||
|     .calendar-grid { | ||||
|         grid-template-rows: repeat(6, minmax(80px, 1fr)); | ||||
|     } | ||||
|      | ||||
|     .time-label { | ||||
|         width: 60px; | ||||
|         font-size: 10px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										302
									
								
								src/app/static/css/circles_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								src/app/static/css/circles_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,302 @@ | ||||
| /* app/static/css/circles_view.css */ | ||||
| /* Styles for the interactive circles background view */ | ||||
|  | ||||
| :root { | ||||
|     /* --glow was in styles.css :root, used by .circle:hover and .center-circle */ | ||||
|     --glow: 0 0 15px var(--primary-accent); /* Using var(--primary-accent) from common.css */ | ||||
| } | ||||
|  | ||||
| .circles-view { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100vw; | ||||
|     height: 100vh; | ||||
|     z-index: 0; /* Base layer, ensure content is above this */ | ||||
|     /* background-color: rgba(0, 255, 0, 0.1); */ /* Debug background removed */ | ||||
| } | ||||
|  | ||||
| .app-title { /* This was in styles.css, seems related to the circles view context */ | ||||
|     position: absolute; | ||||
|     top: 20px; | ||||
|     left: 20px; | ||||
|     font-size: 28px; | ||||
|     font-weight: 600; | ||||
|     color: var(--primary-accent); /* Using var(--primary-accent) from common.css */ | ||||
|     z-index: 1001; /* Keep Yew's higher z-index over circles */ | ||||
| } | ||||
|  | ||||
| .flower-of-life-outer-container { | ||||
|     width: 100vw; | ||||
|     height: 100vh; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     overflow: hidden; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .flower-of-life-container { /* This is the Yew component's .flower-container */ | ||||
|     position: relative; | ||||
|     z-index: 1; /* Ensure circles are above the .circles-view green background */ | ||||
|     width: 1px; /* Circles are positioned relative to this central point */ | ||||
|     height: 1px; | ||||
| } | ||||
|  | ||||
| .center-circle-area, .surrounding-circles-area { | ||||
|     position: absolute; | ||||
|     top: 0; left: 0; width: 100%; height: 100%; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| /* Base .circle style from reference, adapted for Yew */ | ||||
| .circle { | ||||
|     position: absolute; | ||||
|     /* width & height will be set by inline styles in Yew (e.g., 80px or 120px) */ | ||||
|     border-radius: 50%; | ||||
|     /* border: 2px solid rgba(187, 134, 252, 0.3); Using accent color from common.css */ | ||||
|     border: 2px solid color-mix(in srgb, var(--primary-accent) 30%, transparent); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     text-align: center; | ||||
|     transition: all 0.5s ease; | ||||
|     cursor: pointer; | ||||
|     /* background: radial-gradient(circle at center, rgba(187, 134, 252, 0.1) 0%, transparent 70%); */ | ||||
|     background: radial-gradient(circle at center, color-mix(in srgb, var(--primary-accent) 10%, transparent) 0%, transparent 70%); | ||||
|     z-index: 1; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     opacity: 0; /* Circles are invisible until positioned by specific class */ | ||||
|     /* transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); /* More specific transition on positional classes */ | ||||
| } | ||||
|  | ||||
| .circle .circle-text { | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|  | ||||
|     width: max-content; | ||||
|     max-width: 200px; /* Prevent excessively wide text */ | ||||
|     text-align: center; | ||||
|  | ||||
|     font-size: 0.9em; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-primary); | ||||
|     opacity: 1; /* Text is fully opaque */ | ||||
|      | ||||
|     background-color: #000000; /* Solid black background */ | ||||
|     padding: 5px 10px; | ||||
|     border-radius: var(--border-radius-medium, 6px); | ||||
|     box-shadow: 0 2px 5px rgba(0,0,0,0.3); | ||||
|  | ||||
|     z-index: 3; /* Above parent circle (z-index 1 or 2) */ | ||||
|     transition: all 0.3s ease; | ||||
| } | ||||
|  | ||||
|  | ||||
| .circle:hover { | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: var(--glow); | ||||
|     /* background: radial-gradient(circle at center, rgba(187, 134, 252, 0.2) 0%, transparent 70%); */ | ||||
|     background: radial-gradient(circle at center, color-mix(in srgb, var(--primary-accent) 20%, transparent) 0%, transparent 70%); | ||||
| } | ||||
|  | ||||
| .circle:hover .circle-text { | ||||
|     opacity: 1; | ||||
|     color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .circle.center-circle { | ||||
|     opacity: 1 !important; | ||||
|     z-index: 2; | ||||
| } | ||||
|  | ||||
| .circle.center-circle:hover { | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 0 25px var(--primary-accent); /* Enhanced glow */ | ||||
| } | ||||
|  | ||||
| /* Positional classes from reference, for Yew's .circle.circle-position-N */ | ||||
| .circle.circle-position-1, .circle.circle-position-2, .circle.circle-position-3, .circle.circle-position-4, .circle.circle-position-5, .circle.circle-position-6 { | ||||
|     opacity: 1; | ||||
|     transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.5s ease; | ||||
| } | ||||
|  | ||||
| .circle.circle-position-1 { transform: translate(-50%, -50%) translateY(-144px); } | ||||
| .circle.circle-position-2 { transform: translate(-50%, -50%) translate(124px, -72px); } | ||||
| .circle.circle-position-3 { transform: translate(-50%, -50%) translate(124px, 72px); } | ||||
| .circle.circle-position-4 { transform: translate(-50%, -50%) translateY(144px); } | ||||
| .circle.circle-position-5 { transform: translate(-50%, -50%) translate(-124px, 72px); } | ||||
| .circle.circle-position-6 { transform: translate(-50%, -50%) translate(-124px, -72px); } | ||||
| /* Styling for sole-selected circle text (title and description) */ | ||||
| .circle .circle-text-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; /* Vertically center the block */ | ||||
|     align-items: center; /* Horizontally center the text within the block */ | ||||
|     text-align: center; /* Ensure text itself is centered if it wraps */ | ||||
|     padding: 10px; /* Add some padding */ | ||||
|     box-sizing: border-box; /* Include padding in width/height */ | ||||
|     height: 100%; /* Make container take full height of circle */ | ||||
|     overflow-y: auto; /* Allow scrolling if content overflows */ | ||||
| } | ||||
|  | ||||
| .circle .circle-title { | ||||
|     font-size: 1.8em; /* Larger font for the title */ | ||||
|     font-weight: bold; | ||||
|     margin-bottom: 0.5em; /* Space between title and description */ | ||||
|     color: #e0e0e0; /* Light color for title */ | ||||
| } | ||||
|  | ||||
| .circle .circle-description { | ||||
|     font-size: 1em; /* Standard font size for description */ | ||||
|     color: #b0b0b0; /* Slightly dimmer color for description */ | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| /* Ensure existing .circle-text for single line names is still centered */ | ||||
| .circle .circle-text { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     text-align: center; | ||||
|     padding: 10px; | ||||
|     box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| /* Additional styling for the .sole-selected class if needed for other effects */ | ||||
| .circle.center-circle.sole-selected { | ||||
|     /* Example: slightly different border or shadow if desired */ | ||||
|     /* box-shadow: 0 0 20px 5px var(--primary, #3b82f6); */ | ||||
| } | ||||
|  | ||||
| header { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
| } | ||||
|  | ||||
| .app-title-button { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     background-color: var(--bg-medium, #2a2a2a); | ||||
|     color: var(--text-primary, #e0e0e0); | ||||
|     border: 1px solid var(--border-color, #444); | ||||
|     padding: 10px 15px; | ||||
|     border-radius: var(--border-radius-large, 8px); | ||||
|     font-size: 1.2em; | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s ease, border-color 0.2s ease; | ||||
|     min-height: 50px; /* Ensure consistent height */ | ||||
|     min-width: 150px; /* Minimum width */ | ||||
|     box-shadow: 0 2px 5px rgba(0,0,0,0.2); | ||||
| } | ||||
|  | ||||
| .app-title-button:hover { | ||||
|     background-color: var(--bg-light, #3a3a3a); | ||||
|     border-color: var(--primary-accent, #bb86fc); | ||||
| } | ||||
|  | ||||
| .app-title-logo-img { | ||||
|     height: 30px; /* Adjust as needed */ | ||||
|     width: auto; | ||||
|     max-width: 100px; /* Prevent overly wide logos */ | ||||
|     object-fit: contain; | ||||
|     margin-right: 10px; | ||||
|     border-radius: 4px; /* Slight rounding for image logos */ | ||||
| } | ||||
|  | ||||
| .app-title-logo-symbol { | ||||
|     font-size: 1.5em; /* Larger for symbol */ | ||||
|     margin-right: 10px; | ||||
|     line-height: 1; /* Ensure it aligns well */ | ||||
| } | ||||
|  | ||||
| .app-title-name { | ||||
|     margin-right: 10px; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     max-width: 200px; /* Prevent very long names from breaking layout */ | ||||
| } | ||||
|  | ||||
| .app-title-status-icon { | ||||
|     font-size: 1em; | ||||
|     margin-left: auto; /* Pushes icon to the right if space allows */ | ||||
|     color: var(--primary-accent, #bb86fc); | ||||
| } | ||||
|  | ||||
| .app-title-dropdown { | ||||
|     position: absolute; | ||||
|     top: 100%; /* Position below the button */ | ||||
|     left: 0; | ||||
|     background-color: var(--bg-dark, #1e1e1e); | ||||
|     border: 1px solid var(--border-color-light, #555); | ||||
|     border-top: none; /* Avoid double border with button */ | ||||
|     border-radius: 0 0 var(--border-radius-large, 8px) var(--border-radius-large, 8px); | ||||
|     padding: 10px; | ||||
|     min-width: 250px; /* Ensure dropdown is wide enough */ | ||||
|     box-shadow: 0 4px 8px rgba(0,0,0,0.3); | ||||
|     max-height: 300px; /* Limit height and allow scrolling */ | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .app-title-dropdown .dropdown-header { | ||||
|     font-size: 0.9em; | ||||
|     color: var(--text-secondary, #aaa); | ||||
|     margin-bottom: 8px; | ||||
|     padding-bottom: 5px; | ||||
|     border-bottom: 1px solid var(--border-color, #444); | ||||
| } | ||||
|  | ||||
| .app-title-dropdown-item { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 8px 5px; | ||||
|     cursor: pointer; | ||||
|     border-radius: var(--border-radius-medium, 6px); | ||||
|     transition: background-color 0.15s ease; | ||||
| } | ||||
|  | ||||
| .app-title-dropdown-item:hover { | ||||
|     background-color: var(--bg-medium, #2a2a2a); | ||||
| } | ||||
|  | ||||
| .app-title-dropdown-item input[type="checkbox"] { | ||||
|     margin-right: 10px; | ||||
|     cursor: pointer; | ||||
|     /* Consider custom checkbox styling for better theme integration if needed */ | ||||
| } | ||||
|  | ||||
| .app-title-dropdown-item label { | ||||
|     color: var(--text-primary, #e0e0e0); | ||||
|     font-size: 1em; | ||||
|     cursor: pointer; | ||||
|     flex-grow: 1; /* Allow label to take remaining space */ | ||||
| } | ||||
|  | ||||
| .app-title-dropdown .no-sub-circles-message { | ||||
|     color: var(--text-secondary, #aaa); | ||||
|     font-style: italic; | ||||
|     padding: 10px 5px; | ||||
| } | ||||
|  | ||||
| /* Remove default h1 styling for app-title if it's no longer an h1 or if it conflicts */ | ||||
| /* This was the old .app-title style, adjust or remove if .app-title-container replaces it */ | ||||
| /* | ||||
| .app-title { | ||||
|     position: absolute; | ||||
|     top: 20px; | ||||
|     left: 20px; | ||||
|     font-size: 28px; | ||||
|     font-weight: 600; | ||||
|     color: var(--primary-accent); | ||||
|     z-index: 1001; | ||||
| } | ||||
| */ | ||||
							
								
								
									
										593
									
								
								src/app/static/css/common.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										593
									
								
								src/app/static/css/common.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,593 @@ | ||||
| /* app/static/css/common.css */ | ||||
|  | ||||
| /* Global CSS Custom Properties */ | ||||
| :root { | ||||
|     --font-primary: 'Inter', sans-serif; | ||||
|     --font-secondary: 'Roboto Mono', monospace; | ||||
|  | ||||
|     --bg-dark: #121212; | ||||
|     --bg-medium: #1E1E1E; | ||||
|     --bg-light: #2C2C2C; | ||||
|     --surface-dark: #1E1E1E; | ||||
|     --surface-medium: #4A4A4A; | ||||
|     --surface-light: #5A5A5A; | ||||
|  | ||||
|     --primary-accent: #00AEEF; /* Bright Blue */ | ||||
|     --secondary-accent: #FF4081; /* Bright Pink */ | ||||
|     --tertiary-accent: #FFC107; /* Amber */ | ||||
|  | ||||
|     --text-primary: #E0E0E0; | ||||
|     --text-secondary: #B0B0B0; | ||||
|     --text-disabled: #757575; | ||||
|  | ||||
|     --border-color: #424242; | ||||
|     --border-radius-small: 4px; | ||||
|     --border-radius-medium: 8px; | ||||
|     --border-radius-large: 16px; | ||||
|  | ||||
|     --shadow-small: 0 2px 4px rgba(0,0,0,0.2); | ||||
|     --shadow-medium: 0 4px 8px rgba(0,0,0,0.3); | ||||
|     --shadow-large: 0 8px 16px rgba(0,0,0,0.4); | ||||
|  | ||||
|     --spacing-unit: 8px; | ||||
|     --spacing-xs: calc(var(--spacing-unit) * 0.5);  /* 4px */ | ||||
|     --spacing-sm: var(--spacing-unit);             /* 8px */ | ||||
|     --spacing-md: calc(var(--spacing-unit) * 2);   /* 16px */ | ||||
|     --spacing-lg: calc(var(--spacing-unit) * 3);   /* 24px */ | ||||
|     --spacing-xl: calc(var(--spacing-unit) * 4);   /* 32px */ | ||||
|  | ||||
|     --transition-speed: 0.3s; | ||||
|  | ||||
|     /* Common Component Variables (can be extended) */ | ||||
|     --button-padding: var(--spacing-sm) var(--spacing-md); | ||||
|     --input-padding: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| /* Basic Reset */ | ||||
| * { | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| html { | ||||
|     height: 100vh; | ||||
|     scroll-behavior: smooth; | ||||
| } | ||||
|  | ||||
| body { | ||||
|     font-family: var(--font-primary); | ||||
|     background-color: var(--bg-dark); | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1.6; | ||||
|     overflow-x: hidden; /* Prevent horizontal scroll */ | ||||
| } | ||||
|  | ||||
| /* Common Scrollbar Styles */ | ||||
| ::-webkit-scrollbar { | ||||
|     width: 10px; | ||||
|     height: 10px; | ||||
| } | ||||
|  | ||||
| ::-webkit-scrollbar-track { | ||||
|     background: var(--bg-medium); | ||||
|     border-radius: var(--border-radius-small); | ||||
| } | ||||
|  | ||||
| ::-webkit-scrollbar-thumb { | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     border: 2px solid var(--bg-medium); /* Creates padding around thumb */ | ||||
| } | ||||
|  | ||||
| ::-webkit-scrollbar-thumb:hover { | ||||
|     background: var(--surface-light); | ||||
| } | ||||
|  | ||||
| ::-webkit-scrollbar-corner { | ||||
|     background: transparent; | ||||
| } | ||||
|  | ||||
| /* Base Layout Classes */ | ||||
| .view-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: calc(100vh - 60px); /* Assuming nav-island is 60px, adjust as needed */ | ||||
|     overflow: hidden; | ||||
|     padding: var(--spacing-lg); | ||||
|     gap: var(--spacing-lg); | ||||
| } | ||||
|  | ||||
| .view-main-content { | ||||
|     flex-grow: 1; | ||||
|     overflow-y: auto; /* For scrollable content within views */ | ||||
|     padding-right: var(--spacing-sm); /* Space for scrollbar */ | ||||
| } | ||||
|  | ||||
| .view-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding-bottom: var(--spacing-md); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     margin-bottom: var(--spacing-md); /* Added margin for separation */ | ||||
| } | ||||
|  | ||||
| .view-title { | ||||
|     font-size: 1.8em; | ||||
|     font-weight: 600; | ||||
|     color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| /* Base Component Styles */ | ||||
| .button-base { | ||||
|     padding: var(--button-padding); | ||||
|     border: 1px solid transparent; | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     font-family: var(--font-primary); | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, border-color var(--transition-speed) ease, transform var(--transition-speed) ease; | ||||
|     text-decoration: none; | ||||
|     display: inline-flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: var(--spacing-sm); | ||||
|     line-height: 1; /* Ensure consistent height */ | ||||
| } | ||||
| .button-base:active { | ||||
|     transform: translateY(1px); /* Subtle press effect */ | ||||
| } | ||||
|  | ||||
| .button-primary { | ||||
|     background-color: var(--primary-accent); | ||||
|     color: var(--bg-dark); | ||||
| } | ||||
| .button-primary:hover { | ||||
|     background-color: color-mix(in srgb, var(--primary-accent) 85%, white); | ||||
| } | ||||
|  | ||||
| .button-secondary { | ||||
|     background-color: var(--surface-dark); | ||||
|     color: var(--text-primary); | ||||
|     border-color: var(--border-color); | ||||
| } | ||||
| .button-secondary:hover { | ||||
|     background-color: var(--surface-medium); | ||||
|     border-color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .button-danger { | ||||
|     background-color: var(--secondary-accent); | ||||
|     color: white; | ||||
| } | ||||
| .button-danger:hover { | ||||
|     background-color: color-mix(in srgb, var(--secondary-accent) 85%, black); | ||||
| } | ||||
|  | ||||
| .tab-button { | ||||
|     padding: var(--spacing-sm) var(--spacing-md); | ||||
|     background-color: transparent; | ||||
|     color: var(--text-secondary); | ||||
|     border: none; | ||||
|     border-bottom: 2px solid transparent; | ||||
|     border-radius: 0; /* Tabs often don't have rounded corners */ | ||||
|     font-weight: 500; | ||||
|     cursor: pointer; | ||||
|     transition: color var(--transition-speed) ease, border-color var(--transition-speed) ease; | ||||
| } | ||||
| .tab-button.active, | ||||
| .tab-button:hover { | ||||
|     color: var(--primary-accent); | ||||
|     border-bottom-color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .action-button { | ||||
|     padding: var(--spacing-xs) var(--spacing-sm); /* Smaller action buttons */ | ||||
|     font-size: 0.9em; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     /* Default to secondary style, can be combined with .button-primary etc. */ | ||||
|     background-color: var(--surface-medium); | ||||
|     color: var(--text-primary); | ||||
|     border: 1px solid var(--border-color); | ||||
|     font-weight: 500; /* Added for consistency */ | ||||
|     cursor: pointer; /* Added for consistency */ | ||||
|     transition: background-color var(--transition-speed) ease, color var(--transition-speed) ease, border-color var(--transition-speed) ease, transform var(--transition-speed) ease; /* Added for consistency */ | ||||
| } | ||||
| .action-button:hover { | ||||
|     border-color: var(--primary-accent); | ||||
|     background-color: var(--surface-light); | ||||
| } | ||||
| .action-button:active { | ||||
|     transform: translateY(1px); /* Subtle press effect */ | ||||
| } | ||||
| .action-button.primary { /* Modifier for primary action button */ | ||||
|     background-color: var(--primary-accent); | ||||
|     color: var(--bg-dark); | ||||
|     border-color: var(--primary-accent); | ||||
| } | ||||
| .action-button.primary:hover { | ||||
|     background-color: color-mix(in srgb, var(--primary-accent) 85%, white); | ||||
| } | ||||
|  | ||||
|  | ||||
| .card-base { | ||||
|     background-color: var(--surface-dark); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     box-shadow: var(--shadow-small); | ||||
|     transition: box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
| .card-base:hover { | ||||
|     box-shadow: var(--shadow-medium); | ||||
|     transform: translateY(-2px); | ||||
| } | ||||
|  | ||||
| .card-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: var(--spacing-sm); | ||||
|     padding-bottom: var(--spacing-sm); /* Added padding */ | ||||
|     border-bottom: 1px solid var(--border-color); /* Separator for header */ | ||||
| } | ||||
|  | ||||
| .card-title { | ||||
|     font-size: 1.2em; | ||||
|     font-weight: 600; | ||||
|     color: var(--tertiary-accent); | ||||
| } | ||||
|  | ||||
| .card-content { | ||||
|     flex-grow: 1; | ||||
|     font-size: 0.95em; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| .card-footer { | ||||
|     margin-top: var(--spacing-md); | ||||
|     padding-top: var(--spacing-md); /* Increased padding */ | ||||
|     border-top: 1px solid var(--border-color); | ||||
|     display: flex; | ||||
|     justify-content: flex-end; | ||||
|     align-items: center; /* Align items in footer */ | ||||
|     gap: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .input-base { | ||||
|     background-color: var(--bg-light); | ||||
|     color: var(--text-primary); | ||||
|     border: 0px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     padding: var(--input-padding); | ||||
|     font-family: var(--font-primary); | ||||
|     font-size: 1em; | ||||
|     width: 100%; | ||||
|     transition: border-color var(--transition-speed) ease, box-shadow var(--transition-speed) ease; | ||||
| } | ||||
| .input-base:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); | ||||
| } | ||||
| .input-base::placeholder { | ||||
|     color: var(--text-disabled); | ||||
| } | ||||
|  | ||||
| /* Utility Classes */ | ||||
| .flex-row { display: flex; flex-direction: row; } | ||||
| .flex-col { display: flex; flex-direction: column; } | ||||
| .items-center { align-items: center; } | ||||
| .justify-start { justify-content: flex-start; } | ||||
| .justify-end { justify-content: flex-end; } | ||||
| .justify-center { justify-content: center; } | ||||
| .justify-between { justify-content: space-between; } | ||||
| .gap-xs { gap: var(--spacing-xs); } | ||||
| .gap-sm { gap: var(--spacing-sm); } | ||||
| .gap-md { gap: var(--spacing-md); } | ||||
| .gap-lg { gap: var(--spacing-lg); } | ||||
| .gap-xl { gap: var(--spacing-xl); } | ||||
|  | ||||
| .p-xs { padding: var(--spacing-xs); } | ||||
| .p-sm { padding: var(--spacing-sm); } | ||||
| .p-md { padding: var(--spacing-md); } | ||||
| .p-lg { padding: var(--spacing-lg); } | ||||
| .p-xl { padding: var(--spacing-xl); } | ||||
|  | ||||
| .m-xs { margin: var(--spacing-xs); } | ||||
| .m-sm { margin: var(--spacing-sm); } | ||||
| .m-md { margin: var(--spacing-md); } | ||||
| .m-lg { margin: var(--spacing-lg); } | ||||
| .m-xl { margin: var(--spacing-xl); } | ||||
|  | ||||
|  | ||||
| .text-center { text-align: center; } | ||||
| .text-left { text-align: left; } | ||||
| .text-right { text-align: right; } | ||||
|  | ||||
| .text-primary-accent { color: var(--primary-accent); } | ||||
| .text-secondary-accent { color: var(--secondary-accent); } | ||||
| .text-tertiary-accent { color: var(--tertiary-accent); } | ||||
| .text-light { color: var(--text-primary); } | ||||
| .text-muted { color: var(--text-secondary); } | ||||
| .text-disabled { color: var(--text-disabled); } | ||||
|  | ||||
|  | ||||
| .font-bold { font-weight: bold; } | ||||
| .font-semibold { font-weight: 600; } | ||||
| .font-normal { font-weight: normal; } | ||||
|  | ||||
|  | ||||
| .hidden { display: none !important; } | ||||
| .visually-hidden { | ||||
|   position: absolute; | ||||
|   width: 1px; | ||||
|   height: 1px; | ||||
|   padding: 0; | ||||
|   margin: -1px; | ||||
|   overflow: hidden; | ||||
|   clip: rect(0, 0, 0, 0); | ||||
|   white-space: nowrap; | ||||
|   border-width: 0; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Responsive Design Placeholders (can be expanded) */ | ||||
| @media (max-width: 768px) { | ||||
|     :root { | ||||
|         /* Adjust base font size or spacing for smaller screens if needed */ | ||||
|         /* Example: --spacing-unit: 6px; */ | ||||
|     } | ||||
|     .view-container { | ||||
|         padding: var(--spacing-md); | ||||
|         gap: var(--spacing-md); | ||||
|         height: calc(100vh - 50px); /* Example: smaller nav island */ | ||||
|     } | ||||
|     .view-title { | ||||
|         font-size: 1.6em; | ||||
|     } | ||||
|     .button-base { | ||||
|         /* padding: calc(var(--button-padding) * 0.8); /* Slightly smaller buttons */ | ||||
|         /* Consider adjusting padding directly if calc() is problematic or for clarity */ | ||||
|         padding: calc(var(--spacing-sm) * 0.8) calc(var(--spacing-md) * 0.8); | ||||
|     } | ||||
|     .card-base { | ||||
|         padding: var(--spacing-sm); | ||||
|     } | ||||
|     /* Add more mobile-specific adjustments */ | ||||
| } | ||||
|  | ||||
| @media (max-width: 480px) { | ||||
|     .view-title { | ||||
|         font-size: 1.4em; | ||||
|     } | ||||
|     .view-header { | ||||
|         flex-direction: column; | ||||
|         align-items: flex-start; | ||||
|         gap: var(--spacing-sm); | ||||
|     } | ||||
|     /* Further adjustments for very small screens */ | ||||
| } | ||||
|  | ||||
| /* Chat Message Styling */ | ||||
| .message { | ||||
|     margin-bottom: var(--spacing-md); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .message-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
| } | ||||
|  | ||||
| .message-header .sender { | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .message-header .timestamp { | ||||
|     font-size: 0.85em; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .status { | ||||
|     padding: 2px 6px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .status-ok { | ||||
|     background-color: color-mix(in srgb, #4CAF50 20%, transparent); | ||||
|     color: #4CAF50; | ||||
| } | ||||
|  | ||||
| .status-error { | ||||
|     background-color: color-mix(in srgb, var(--secondary-accent) 20%, transparent); | ||||
|     color: var(--secondary-accent); | ||||
| } | ||||
|  | ||||
| .status-pending { | ||||
|     background-color: color-mix(in srgb, var(--tertiary-accent) 20%, transparent); | ||||
|     color: var(--tertiary-accent); | ||||
| } | ||||
|  | ||||
| .message-content { | ||||
|     padding: var(--spacing-md); | ||||
|     background-color: var(--surface-dark); | ||||
| } | ||||
|  | ||||
| .message-title { | ||||
|     font-weight: 600; | ||||
|     color: var(--primary-accent); | ||||
|     margin-bottom: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .message-description { | ||||
|     font-size: 0.9em; | ||||
|     color: var(--text-secondary); | ||||
|     margin-bottom: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .message-text { | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| /* Format-specific styling */ | ||||
| .format-rhai .message-content { | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| .message-error .message-content { | ||||
|     background-color: color-mix(in srgb, var(--secondary-accent) 10%, var(--surface-dark)); | ||||
| } | ||||
|  | ||||
| /* Code block styling */ | ||||
| .code-block { | ||||
|     background-color: var(--bg-dark); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     overflow: hidden; | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
|  | ||||
| .code-header { | ||||
|     background-color: var(--surface-medium); | ||||
|     padding: var(--spacing-sm) var(--spacing-md); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
| } | ||||
|  | ||||
| .language-label { | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 600; | ||||
|     color: var(--primary-accent); | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
| } | ||||
|  | ||||
| .code-content { | ||||
|     display: flex; | ||||
|     max-height: 400px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .line-numbers { | ||||
|     background-color: var(--bg-medium); | ||||
|     padding: var(--spacing-md) var(--spacing-sm); | ||||
|     border-right: 1px solid var(--border-color); | ||||
|     user-select: none; | ||||
|     min-width: 40px; | ||||
| } | ||||
|  | ||||
| .line-number { | ||||
|     font-size: 0.85em; | ||||
|     color: var(--text-disabled); | ||||
|     text-align: right; | ||||
|     line-height: 1.5; | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
|  | ||||
| .code-lines { | ||||
|     flex: 1; | ||||
|     padding: var(--spacing-md); | ||||
|     overflow-x: auto; | ||||
| } | ||||
|  | ||||
| .code-line { | ||||
|     font-size: 0.9em; | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1.5; | ||||
|     font-family: var(--font-secondary); | ||||
|     white-space: pre; | ||||
|     min-height: 1.5em; | ||||
| } | ||||
|  | ||||
| .code-line:empty::before { | ||||
|     content: " "; | ||||
| } | ||||
|  | ||||
| /* Error content styling */ | ||||
| .error-content { | ||||
|     display: flex; | ||||
|     align-items: flex-start; | ||||
|     gap: var(--spacing-sm); | ||||
|     padding: var(--spacing-md); | ||||
|     background-color: color-mix(in srgb, var(--secondary-accent) 10%, transparent); | ||||
|     border-left: 4px solid var(--secondary-accent); | ||||
|     border-radius: var(--border-radius-small); | ||||
| } | ||||
|  | ||||
| .error-icon { | ||||
|     font-size: 1.2em; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .error-text { | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| /* User vs Assistant message styling */ | ||||
| .user-message .message-header { | ||||
|     background-color: color-mix(in srgb, var(--primary-accent) 15%, var(--surface-medium)); | ||||
| } | ||||
|  | ||||
| .user-message .message-header .sender { | ||||
|     color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .ai-message .message-header .sender { | ||||
|     color: var(--tertiary-accent); | ||||
| } | ||||
|  | ||||
| /* Input styling for code format */ | ||||
| .format-rhai { | ||||
|     font-family: var(--font-secondary); | ||||
|     background-color: var(--bg-dark); | ||||
|     border: 1px solid var(--border-color); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| .format-rhai::placeholder { | ||||
|     color: var(--text-disabled); | ||||
|     font-style: italic; | ||||
| } | ||||
|  | ||||
| .column { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex: 1; | ||||
|     gap: var(--spacing-md) | ||||
| } | ||||
|  | ||||
| .card { | ||||
|     background-color: var(--surface-dark); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .card:hover { | ||||
|     transform: translateY(-2px); | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 4px 10px color-mix(in srgb, var(--primary-accent) 20%, transparent); | ||||
| } | ||||
|  | ||||
| .card .collection-title { | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0 0 8px 0; | ||||
| } | ||||
|  | ||||
| .card .collection-description { | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
|     margin: 0; | ||||
| } | ||||
							
								
								
									
										348
									
								
								src/app/static/css/customize_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										348
									
								
								src/app/static/css/customize_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,348 @@ | ||||
| /* Customize View - Ultra Minimalistic */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .customize-view { | ||||
|     /* Extends .view-container from common.css */ | ||||
|     align-items: center; /* Specific alignment for this view */ | ||||
|     height: calc(100vh - 120px); /* Specific height */ | ||||
|     margin: 100px 40px 60px 40px; /* Specific margins */ | ||||
|     /* font-family will be inherited from common.css body */ | ||||
|     /* Other .view-container properties like display, flex-direction, color, background are inherited or set by common.css */ | ||||
| } | ||||
|  | ||||
| .view-header { | ||||
|     /* Extends .view-header from common.css */ | ||||
|     margin-bottom: var(--spacing-xl); /* Was 40px, using common.css spacing */ | ||||
|     text-align: center; /* Specific alignment */ | ||||
| } | ||||
|  | ||||
| .view-title { | ||||
|     /* Extends .view-title from common.css */ | ||||
|     font-size: 1.2em; /* Was 18px, common.css .view-title is 1.8em. This is an override. */ | ||||
|     /* common.css .view-title color is var(--primary-accent). This view uses var(--text-primary). */ | ||||
|     color: var(--text-primary); /* Specific color */ | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| /* Content Area */ | ||||
| .customize-content { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable (16px, was 12px) */ | ||||
|     padding: 32px; | ||||
|     width: 100%; | ||||
|     max-width: 700px; | ||||
| } | ||||
|  | ||||
| .settings-section { | ||||
|     margin-bottom: 32px; | ||||
| } | ||||
|  | ||||
| .settings-section:last-child { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .section-title { | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0 0 16px 0; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
| } | ||||
|  | ||||
| .settings-list { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 16px; | ||||
| } | ||||
|  | ||||
| .setting-item { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .setting-item.row { | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     gap: 24px; | ||||
| } | ||||
|  | ||||
| .setting-item.row .setting-group { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .setting-label { | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0 0 6px 0; | ||||
| } | ||||
|  | ||||
| .setting-control { | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| /* Input Styles */ | ||||
| .setting-input, | ||||
| .setting-input-select { | ||||
|     padding: 8px 12px; /* Specific padding, common.css input-base is var(--spacing-sm) (8px) */ | ||||
|     background: var(--bg-light); /* Align with common.css .input-base */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     color: var(--text-primary); | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, common.css input-base is var(--border-radius-small) (4px). Using medium (8px) for a slightly larger feel. */ | ||||
|     font-size: 14px; | ||||
|     transition: border-color 0.2s ease; | ||||
|     width: 100%; | ||||
|     outline: none; | ||||
| } | ||||
|  | ||||
| .setting-input:focus, | ||||
| .setting-input-select:focus { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Focus shadow from common.css .input-base */ | ||||
| } | ||||
| /* Text Input */ | ||||
| .setting-text-input { | ||||
|     padding: 8px 12px; /* Specific padding */ | ||||
|     background: var(--bg-light); /* Align with common.css .input-base */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     color: var(--text-primary); | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, using medium (8px) */ | ||||
|     font-size: 14px; | ||||
|     transition: border-color 0.2s ease; | ||||
|     width: 100%; | ||||
|     outline: none; | ||||
| } | ||||
|  | ||||
| .setting-text-input:focus { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Focus shadow from common.css .input-base */ | ||||
| } | ||||
|  | ||||
| .setting-text-input::placeholder { | ||||
|     color: var(--text-disabled); /* Align with common.css .input-base */ | ||||
| } | ||||
|  | ||||
| /* Color Selection Grid */ | ||||
| .color-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(6, 1fr); | ||||
|     gap: 8px; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .color-option { | ||||
|     width: 32px; | ||||
|     height: 32px; | ||||
|     border-radius: 6px; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .color-option:hover { | ||||
|     border-color: var(--text-secondary); | ||||
|     transform: scale(1.05); | ||||
| } | ||||
|  | ||||
| .color-option.selected { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .color-option.selected::after { | ||||
|     content: '✓'; | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     color: white; | ||||
|     font-size: 12px; | ||||
|     font-weight: bold; | ||||
|     text-shadow: 0 0 2px rgba(0, 0, 0, 0.8); | ||||
| } | ||||
|  | ||||
| /* Pattern Selection Grid */ | ||||
| .pattern-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(4, 1fr); | ||||
|     gap: 12px; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .pattern-option { | ||||
|     width: 60px; | ||||
|     height: 40px; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, using medium (8px) */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
|     background: var(--surface-medium); /* Common.css variable, was var(--hover) */ | ||||
| } | ||||
|  | ||||
| .pattern-option:hover { | ||||
|     border-color: var(--text-secondary); | ||||
|     transform: scale(1.02); | ||||
| } | ||||
|  | ||||
| .pattern-option.selected { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .pattern-option.selected::after { | ||||
|     content: '✓'; | ||||
|     position: absolute; | ||||
|     top: 4px; | ||||
|     right: 4px; | ||||
|     color: var(--primary-accent); /* Common.css variable */ | ||||
|     font-size: 10px; | ||||
|     font-weight: bold; | ||||
| } | ||||
|  | ||||
| /* Pattern Previews */ | ||||
| .pattern-dots { | ||||
|     background-image: radial-gradient(circle, var(--text-muted) 1px, transparent 1px); | ||||
|     background-size: 8px 8px; | ||||
| } | ||||
|  | ||||
| .pattern-grid-lines { | ||||
|     background-image:  | ||||
|         linear-gradient(var(--text-muted) 1px, transparent 1px), | ||||
|         linear-gradient(90deg, var(--text-muted) 1px, transparent 1px); | ||||
|     background-size: 10px 10px; | ||||
| } | ||||
|  | ||||
| .pattern-diagonal { | ||||
|     background-image: repeating-linear-gradient( | ||||
|         45deg, | ||||
|         transparent, | ||||
|         transparent 4px, | ||||
|         var(--text-muted) 4px, | ||||
|         var(--text-muted) 5px | ||||
|     ); | ||||
| } | ||||
|  | ||||
| .pattern-waves { | ||||
|     background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10c2.5-2.5 7.5-2.5 10 0s7.5 2.5 10 0v10H0V10z' fill='%23555555' fill-opacity='0.4'/%3E%3C/svg%3E"); | ||||
| } | ||||
|  | ||||
| /* Toggle Switch */ | ||||
| .setting-toggle-switch { | ||||
|     position: relative; | ||||
|     display: inline-block; | ||||
|     width: 44px; | ||||
|     height: 24px; | ||||
| } | ||||
|  | ||||
| .setting-toggle-switch input { | ||||
|     opacity: 0; | ||||
|     width: 0; | ||||
|     height: 0; | ||||
| } | ||||
|  | ||||
| .setting-toggle-slider { | ||||
|     position: absolute; | ||||
|     cursor: pointer; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: var(--bg-light); /* Align with common.css .input-base */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     transition: 0.3s; | ||||
|     border-radius: 24px; /* Specific large radius for toggle */ | ||||
| } | ||||
|  | ||||
| .setting-toggle-slider:before { | ||||
|     position: absolute; | ||||
|     content: ""; | ||||
|     height: 16px; | ||||
|     width: 16px; | ||||
|     left: 3px; | ||||
|     bottom: 3px; | ||||
|     background: var(--text-secondary); | ||||
|     transition: 0.3s; | ||||
|     border-radius: 50%; | ||||
| } | ||||
|  | ||||
| input:checked + .setting-toggle-slider { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| input:checked + .setting-toggle-slider:before { | ||||
|     transform: translateX(20px); | ||||
|     background: white; | ||||
| } | ||||
|  | ||||
| /* Logo Selection */ | ||||
| .logo-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(3, 1fr); | ||||
|     gap: 12px; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .logo-option { | ||||
|     width: 60px; | ||||
|     height: 40px; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, using medium (8px) */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     background: var(--surface-medium); /* Common.css variable, was var(--hover) */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     font-size: 18px; | ||||
| } | ||||
|  | ||||
| .logo-option:hover { | ||||
|     border-color: var(--text-secondary); | ||||
|     transform: scale(1.02); | ||||
| } | ||||
|  | ||||
| .logo-option.selected { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (max-width: 768px) { | ||||
|     .customize-view { | ||||
|         margin: 20px; | ||||
|     } | ||||
|  | ||||
|     .customize-content { | ||||
|         padding: 24px; | ||||
|         max-width: none; | ||||
|     } | ||||
|  | ||||
|     .setting-item.row { | ||||
|         flex-direction: column; | ||||
|         align-items: stretch; | ||||
|         gap: 16px; | ||||
|     } | ||||
|  | ||||
|     .color-grid { | ||||
|         grid-template-columns: repeat(4, 1fr); | ||||
|     } | ||||
|  | ||||
|     .pattern-grid { | ||||
|         grid-template-columns: repeat(3, 1fr); | ||||
|     } | ||||
|  | ||||
|     .logo-grid { | ||||
|         grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										51
									
								
								src/app/static/css/dashboard_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/app/static/css/dashboard_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| /* app/static/css/dashboard_view.css */ | ||||
| /* Styles for the dashboard overlay view */ | ||||
|  | ||||
| .dashboard-view { | ||||
|     position: absolute; | ||||
|     top: 0; left: 0; width: 100%; height: 100%; | ||||
|     background-color: rgba(0,0,0,0.7); | ||||
|     backdrop-filter: blur(5px); | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     padding: var(--spacing-lg); /* Use common spacing */ | ||||
|     z-index: 2000; | ||||
| } | ||||
|  | ||||
| .dashboard-view h2 { | ||||
|     color: var(--primary-accent); /* Use common variable */ | ||||
|     margin-bottom: var(--spacing-xl); /* Use common spacing */ | ||||
| } | ||||
|  | ||||
| .dashboard-cards { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     gap: var(--spacing-lg); /* Use common spacing */ | ||||
|     justify-content: center; | ||||
|     max-width: 900px; | ||||
| } | ||||
|  | ||||
| /* Specific card style for dashboard, distinct from common.css .card-base */ | ||||
| .dashboard-view .card { | ||||
|     background-color: var(--surface-dark); | ||||
|     /* border: 1px solid #333; Using var(--border-color) */ | ||||
|     border: 1px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-large); /* Larger radius for dashboard cards */ | ||||
|     padding: var(--spacing-lg); /* Use common spacing */ | ||||
|     width: 280px; | ||||
|     box-shadow: var(--shadow-medium); /* Use common shadow */ | ||||
|     transition: transform 0.2s, box-shadow 0.2s; | ||||
| } | ||||
|  | ||||
| .dashboard-view .card:hover { | ||||
|     transform: translateY(-5px); | ||||
|     box-shadow: var(--shadow-large); /* Use common shadow */ | ||||
| } | ||||
|  | ||||
| .dashboard-view .card h3 { | ||||
|     color: var(--secondary-accent); /* Use common variable */ | ||||
|     margin-top: 0; | ||||
|     margin-bottom: var(--spacing-md); /* Add some space below h3 */ | ||||
| } | ||||
							
								
								
									
										581
									
								
								src/app/static/css/governance_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										581
									
								
								src/app/static/css/governance_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,581 @@ | ||||
| /* Governance View - Game-like Interactive Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary (using literal hex for some status colors) */ | ||||
|  | ||||
| .governance-view-container { | ||||
|     /* Extends .view-container from common.css */ | ||||
|     height: calc(100vh - 120px); /* Specific height */ | ||||
|     margin: 100px 40px 60px 40px; /* Specific margins */ | ||||
|     gap: var(--spacing-lg); /* Was 24px, using common.css spacing */ | ||||
|     /* font-family will be inherited from common.css body */ | ||||
|     /* Other .view-container properties like display, flex-direction, color, background are inherited or set by common.css */ | ||||
| } | ||||
|  | ||||
| /* Featured Proposal - Main Focus */ | ||||
| .featured-proposal-container { | ||||
|     flex: 1; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     overflow: hidden; | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .featured-proposal-container.urgent { | ||||
|     border-color: #ef4444; /* Literal error color */ | ||||
|     box-shadow: 0 0 30px color-mix(in srgb, #ef4444 40%, transparent); /* urgent-glow replacement */ | ||||
|     animation: urgentPulse 2s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| @keyframes urgentPulse { | ||||
|     0%, 100% { box-shadow: 0 0 30px color-mix(in srgb, #ef4444 40%, transparent); } | ||||
|     50% { box-shadow: 0 0 50px color-mix(in srgb, #ef4444 40%, transparent); } | ||||
| } | ||||
|  | ||||
| .featured-proposal-header { | ||||
|     padding: 24px 32px; | ||||
|     background: linear-gradient(135deg, var(--surface-light), var(--surface-dark)); /* Common.css variables */ | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .featured-proposal-header::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: linear-gradient(135deg, color-mix(in srgb, var(--primary-accent) 10%, transparent), transparent); /* Use common primary */ | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| .featured-proposal-meta { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: flex-start; | ||||
|     margin-bottom: 16px; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .featured-proposal-title { | ||||
|     font-size: 28px; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0; | ||||
|     flex: 1; | ||||
|     margin-right: 24px; | ||||
| } | ||||
|  | ||||
| .featured-proposal-title.urgent { | ||||
|     color: #ef4444; /* Literal error color */ | ||||
| } | ||||
|  | ||||
| .featured-proposal-status { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .status-badge { | ||||
|     padding: 8px 16px; | ||||
|     border-radius: 20px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
| } | ||||
|  | ||||
| .status-badge.urgent { | ||||
|     background: #ef4444; /* Literal error color */ | ||||
|     color: white; | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, #ef4444 40%, transparent); /* urgent-glow replacement */ | ||||
| } | ||||
|  | ||||
| .status-badge.popular { | ||||
|     background: #10b981; /* Literal success color */ | ||||
|     color: white; | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, #10b981 30%, transparent); /* success-glow replacement */ | ||||
| } | ||||
|  | ||||
| .status-badge.controversial { | ||||
|     background: #f59e0b; /* Literal warning color */ | ||||
|     color: var(--bg-dark); /* Common.css variable for text on amber/orange */ | ||||
| } | ||||
|  | ||||
| .time-remaining { | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: #f59e0b; /* Literal warning color */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .time-remaining.critical { | ||||
|     color: #ef4444; /* Literal error color */ | ||||
|     animation: criticalBlink 1s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| @keyframes criticalBlink { | ||||
|     0%, 100% { opacity: 1; } | ||||
|     50% { opacity: 0.6; } | ||||
| } | ||||
|  | ||||
| .featured-proposal-content { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     padding: 32px; | ||||
|     gap: 32px; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .proposal-main { | ||||
|     flex: 2; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 24px; | ||||
| } | ||||
|  | ||||
| .proposal-description { | ||||
|     font-size: 16px; | ||||
|     line-height: 1.6; | ||||
|     color: var(--text-primary); | ||||
|     max-height: 200px; | ||||
|     overflow-y: auto; | ||||
|     padding-right: 8px; | ||||
| } | ||||
|  | ||||
| .proposal-attachments { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .attachment-item { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     padding: 8px 12px; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .attachment-item:hover { | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     color: var(--text-primary); | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .proposal-sidebar { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 24px; | ||||
| } | ||||
|  | ||||
| /* Voting Section */ | ||||
| .voting-section { | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: 24px; | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .voting-title { | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 16px; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .vote-tally { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .vote-option { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
|  | ||||
| .vote-bar { | ||||
|     flex: 1; | ||||
|     height: 8px; | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-small); /* Common.css variable */ | ||||
|     margin: 0 12px; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .vote-fill { | ||||
|     height: 100%; | ||||
|     border-radius: 4px; | ||||
|     transition: width 0.3s ease; | ||||
| } | ||||
|  | ||||
| .vote-fill.for { background: #10b981; } /* Literal success color */ | ||||
| .vote-fill.against { background: #ef4444; } /* Literal error color */ | ||||
| .vote-fill.abstain { background: #6b7280; } /* Literal secondary color */ | ||||
|  | ||||
| .vote-buttons { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .vote-btn { | ||||
|     flex: 1; | ||||
|     padding: 12px; | ||||
|     border: none; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .vote-btn::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: -100%; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); | ||||
|     transition: left 0.5s ease; | ||||
| } | ||||
|  | ||||
| .vote-btn:hover::before { | ||||
|     left: 100%; | ||||
| } | ||||
|  | ||||
| .vote-btn.for { | ||||
|     background: #10b981; /* Literal success color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .vote-btn.against { | ||||
|     background: #ef4444; /* Literal error color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .vote-btn.abstain { | ||||
|     background: #6b7280; /* Literal secondary color */ | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .vote-btn:hover { | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| /* Comments Section */ | ||||
| .comments-section { | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: 24px; | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     min-height: 0; | ||||
| } | ||||
|  | ||||
| .comments-title { | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     margin-bottom: 16px; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .comments-list { | ||||
|     flex: 1; | ||||
|     overflow-y: auto; | ||||
|     margin-bottom: 16px; | ||||
|     padding-right: 8px; | ||||
| } | ||||
|  | ||||
| .comment-item { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     padding: 12px; | ||||
|     margin-bottom: 8px; | ||||
|     border-left: 3px solid var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .comment-author { | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     color: #06b6d4; /* Literal info color */ | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .comment-text { | ||||
|     font-size: 14px; | ||||
|     line-height: 1.4; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .comment-input { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .comment-input input { | ||||
|     flex: 1; | ||||
|     background: var(--bg-light); /* Align with common.css .input-base */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     color: var(--text-primary); | ||||
|     padding: 8px 12px; /* Specific padding, common.css input-base is var(--spacing-sm) (8px) */ | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, using medium (8px) for consistency */ | ||||
|     font-size: 14px; | ||||
| } | ||||
|  | ||||
| .comment-input button { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color for primary button */ | ||||
|     border: none; | ||||
|     padding: 8px 16px; | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px, using medium (8px) */ | ||||
|     cursor: pointer; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| /* Proposal Cards Row */ | ||||
| .proposals-row-container { | ||||
|     height: 200px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: 16px; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .proposals-row-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
|  | ||||
| .proposals-row-title { | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .proposals-row { | ||||
|     display: flex; | ||||
|     gap: 16px; | ||||
|     overflow-x: auto; | ||||
|     padding-bottom: 8px; | ||||
|     height: calc(100% - 40px); | ||||
| } | ||||
|  | ||||
| .proposal-card { | ||||
|     min-width: 280px; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     padding: 16px; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .proposal-card::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: linear-gradient(135deg, rgba(255,255,255,0.05), transparent); | ||||
|     opacity: 0; | ||||
|     transition: opacity 0.2s ease; | ||||
| } | ||||
|  | ||||
| .proposal-card:hover::before { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| .proposal-card:hover { | ||||
|     transform: translateY(-4px); | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .proposal-card.selected { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .proposal-card-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: flex-start; | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .proposal-card-title { | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0; | ||||
|     flex: 1; | ||||
|     margin-right: 8px; | ||||
|     line-height: 1.3; | ||||
| } | ||||
|  | ||||
| .proposal-card-status { | ||||
|     padding: 2px 6px; | ||||
|     border-radius: 10px; | ||||
|     font-size: 10px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .proposal-card-description { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
|     margin-bottom: 8px; | ||||
|     display: -webkit-box; | ||||
|     -webkit-line-clamp: 2; | ||||
|     -webkit-box-orient: vertical; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .proposal-card-footer { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     font-size: 11px; | ||||
|     color: var(--text-muted); | ||||
| } | ||||
|  | ||||
| .proposal-urgency { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 4px; | ||||
| } | ||||
|  | ||||
| .urgency-indicator { | ||||
|     width: 8px; | ||||
|     height: 8px; | ||||
|     border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .urgency-indicator.high { background: #ef4444; } /* Literal error color */ | ||||
| .urgency-indicator.medium { background: #f59e0b; } /* Literal warning color */ | ||||
| .urgency-indicator.low { background: #10b981; } /* Literal success color */ | ||||
|  | ||||
| /* Scrollbar Styling is now handled globally by common.css */ | ||||
|  | ||||
| /* Empty States */ | ||||
| .governance-empty-state { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     height: 100%; | ||||
|     color: var(--text-muted); | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .governance-empty-state i { | ||||
|     font-size: 48px; | ||||
|     margin-bottom: 16px; | ||||
|     opacity: 0.5; | ||||
| } | ||||
|  | ||||
| .governance-empty-state p { | ||||
|     font-size: 16px; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (max-width: 1200px) { | ||||
|     .featured-proposal-content { | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
|     } | ||||
|      | ||||
|     .proposal-main { | ||||
|         flex: none; | ||||
|     } | ||||
|      | ||||
|     .proposal-sidebar { | ||||
|         flex: none; | ||||
|         flex-direction: row; | ||||
|         gap: 16px; | ||||
|     } | ||||
|      | ||||
|     .voting-section, | ||||
|     .comments-section { | ||||
|         flex: 1; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 768px) { | ||||
|     .governance-view-container { | ||||
|         margin: 20px; | ||||
|         height: calc(100vh - 80px); | ||||
|     } | ||||
|      | ||||
|     .featured-proposal-content { | ||||
|         padding: 20px; | ||||
|     } | ||||
|      | ||||
|     .featured-proposal-header { | ||||
|         padding: 20px; | ||||
|     } | ||||
|      | ||||
|     .featured-proposal-title { | ||||
|         font-size: 22px; | ||||
|     } | ||||
|      | ||||
|     .proposal-sidebar { | ||||
|         flex-direction: column; | ||||
|     } | ||||
|      | ||||
|     .proposals-row-container { | ||||
|         height: 160px; | ||||
|     } | ||||
|      | ||||
|     .proposal-card { | ||||
|         min-width: 240px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 480px) { | ||||
|     .featured-proposal-meta { | ||||
|         flex-direction: column; | ||||
|         gap: 12px; | ||||
|         align-items: flex-start; | ||||
|     } | ||||
|      | ||||
|     .featured-proposal-status { | ||||
|         align-self: stretch; | ||||
|         justify-content: space-between; | ||||
|     } | ||||
|      | ||||
|     .vote-buttons { | ||||
|         flex-direction: column; | ||||
|     } | ||||
|      | ||||
|     .proposal-card { | ||||
|         min-width: 200px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										735
									
								
								src/app/static/css/inspector_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										735
									
								
								src/app/static/css/inspector_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,735 @@ | ||||
| /* Inspector View - New minimal design with sidebar cards */ | ||||
|  | ||||
| .inspector-container { | ||||
|     display: flex; | ||||
|     height: calc(100vh - var(--app-title-bar-height, 60px) - var(--nav-island-height, 70px) - (2 * var(--spacing-lg))); | ||||
|     margin: 0 var(--spacing-lg) var(--spacing-lg) var(--spacing-lg); | ||||
|     gap: var(--spacing-lg); | ||||
| } | ||||
|  | ||||
| /* Sidebar with tab cards */ | ||||
| .inspector-sidebar { | ||||
|     width: 300px; | ||||
|     background: var(--surface-dark); | ||||
|     border-radius: var(--border-radius-large); | ||||
|     padding: var(--spacing-lg); | ||||
|     overflow-y: auto; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .sidebar-content { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .sidebar-title { | ||||
|     margin: 0 0 var(--spacing-lg) 0; | ||||
|     font-size: 1.5em; | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 600; | ||||
|     text-align: center; | ||||
|     border-bottom: 2px solid var(--primary-accent); | ||||
|     padding-bottom: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .tab-cards { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| /* Tab card styling */ | ||||
| .tab-card { | ||||
|     background: var(--surface-medium); | ||||
|     border: 2px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     cursor: pointer; | ||||
|     transition: all 0.3s ease; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .tab-card:hover { | ||||
|     border-color: var(--primary-accent); | ||||
|     background: color-mix(in srgb, var(--surface-medium) 80%, var(--primary-accent) 20%); | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|  | ||||
| .tab-card.selected { | ||||
|     background: var(--primary-accent); | ||||
|     border-color: var(--primary-accent); | ||||
|     color: var(--bg-dark); | ||||
|     transform: translateY(-4px); | ||||
|     box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| .tab-card-header { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .tab-card-header i { | ||||
|     font-size: 1.2em; | ||||
|     width: 20px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .tab-title { | ||||
|     font-weight: 600; | ||||
|     font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .tab-card.selected .tab-title, | ||||
| .tab-card.selected i { | ||||
|     color: var(--bg-dark); | ||||
| } | ||||
|  | ||||
| /* Tab details that appear when selected */ | ||||
| .tab-details { | ||||
|     margin-top: var(--spacing-md); | ||||
|     padding-top: var(--spacing-md); | ||||
|     border-top: 1px solid rgba(255, 255, 255, 0.2); | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .detail-item { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .detail-label { | ||||
|     color: rgba(255, 255, 255, 0.8); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .detail-value { | ||||
|     color: var(--bg-dark); | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .detail-value.status-active { | ||||
|     color: var(--bg-dark); | ||||
|     background: rgba(255, 255, 255, 0.2); | ||||
|     padding: 2px 8px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.8em; | ||||
| } | ||||
|  | ||||
| .detail-value.error { | ||||
|     color: #ff6b6b; | ||||
|     font-weight: 700; | ||||
| } | ||||
|  | ||||
| .content-panel { | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .content-panel h2 { | ||||
|     margin: 0 0 var(--spacing-lg) 0; | ||||
|     color: var(--primary-accent); | ||||
|     font-size: 1.8em; | ||||
|     font-weight: 600; | ||||
|     border-bottom: 2px solid var(--primary-accent); | ||||
|     padding-bottom: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| /* Overview content */ | ||||
| .overview-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | ||||
|     gap: var(--spacing-lg); | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .overview-card { | ||||
|     background: var(--surface-medium); | ||||
|     border: 1px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-lg); | ||||
|     text-align: center; | ||||
|     transition: transform 0.2s ease, box-shadow 0.2s ease; | ||||
| } | ||||
|  | ||||
| .overview-card:hover { | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|  | ||||
| .overview-card h3 { | ||||
|     margin: 0 0 var(--spacing-md) 0; | ||||
|     color: var(--text-primary); | ||||
|     font-size: 1.1em; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .metric-value { | ||||
|     font-size: 2.5em; | ||||
|     font-weight: 700; | ||||
|     color: var(--primary-accent); | ||||
|     margin-bottom: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .metric-value.status-active { | ||||
|     color: #4ecdc4; | ||||
|     font-size: 1.5em; | ||||
| } | ||||
|  | ||||
| .metric-label { | ||||
|     color: var(--text-secondary); | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| /* Network content */ | ||||
| .network-nodes { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | ||||
|     gap: var(--spacing-md); | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .network-node { | ||||
|     background: var(--surface-medium); | ||||
|     border: 1px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     transition: transform 0.2s ease; | ||||
| } | ||||
|  | ||||
| .network-node:hover { | ||||
|     transform: translateY(-2px); | ||||
| } | ||||
|  | ||||
| .network-node.node-connected { | ||||
|     border-left: 4px solid #4ecdc4; | ||||
| } | ||||
|  | ||||
| .network-node.node-disconnected { | ||||
|     border-left: 4px solid #ff6b6b; | ||||
| } | ||||
|  | ||||
| .network-node.node-unknown { | ||||
|     border-left: 4px solid #ffa726; | ||||
| } | ||||
|  | ||||
| .node-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: var(--spacing-sm); | ||||
| } | ||||
|  | ||||
| .node-name { | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .node-status { | ||||
|     padding: 4px 8px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .node-status.node-connected { | ||||
|     background: rgba(78, 205, 196, 0.2); | ||||
|     color: #4ecdc4; | ||||
| } | ||||
|  | ||||
| .node-status.node-disconnected { | ||||
|     background: rgba(255, 107, 107, 0.2); | ||||
|     color: #ff6b6b; | ||||
| } | ||||
|  | ||||
| .node-details { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .node-id, | ||||
| .node-latency { | ||||
|     font-size: 0.9em; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| /* Logs content */ | ||||
| .logs-container { | ||||
|     flex: 1; | ||||
|     overflow-y: auto; | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .log-entry { | ||||
|     display: grid; | ||||
|     grid-template-columns: auto auto 1fr 3fr; | ||||
|     gap: var(--spacing-sm); | ||||
|     padding: var(--spacing-sm); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     font-size: 0.9em; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .log-entry:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .log-entry.log-error { | ||||
|     background: rgba(255, 107, 107, 0.1); | ||||
|     border-left: 3px solid #ff6b6b; | ||||
| } | ||||
|  | ||||
| .log-entry.log-warn { | ||||
|     background: rgba(255, 167, 38, 0.1); | ||||
|     border-left: 3px solid #ffa726; | ||||
| } | ||||
|  | ||||
| .log-entry.log-info { | ||||
|     background: rgba(78, 205, 196, 0.1); | ||||
|     border-left: 3px solid #4ecdc4; | ||||
| } | ||||
|  | ||||
| .log-timestamp { | ||||
|     color: var(--text-tertiary); | ||||
|     font-family: var(--font-secondary); | ||||
|     font-size: 0.8em; | ||||
| } | ||||
|  | ||||
| .log-level { | ||||
|     padding: 2px 6px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 600; | ||||
|     text-align: center; | ||||
|     min-width: 50px; | ||||
| } | ||||
|  | ||||
| .log-level.log-error { | ||||
|     background: #ff6b6b; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .log-level.log-warn { | ||||
|     background: #ffa726; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .log-level.log-info { | ||||
|     background: #4ecdc4; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .log-level.log-debug { | ||||
|     background: var(--text-tertiary); | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .log-source { | ||||
|     color: var(--text-secondary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .log-message { | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| /* Interact content */ | ||||
| .interact-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-lg); | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .script-input-section, | ||||
| .script-output-section { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .script-input-section label, | ||||
| .script-output-section label { | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 600; | ||||
|     margin-bottom: var(--spacing-sm); | ||||
|     font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .script-input { | ||||
|     flex: 1; | ||||
|     background: var(--bg-light); | ||||
|     border: 2px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     color: var(--text-primary); | ||||
|     font-family: var(--font-secondary); | ||||
|     font-size: 0.9em; | ||||
|     resize: none; | ||||
|     min-height: 150px; | ||||
| } | ||||
|  | ||||
| .script-input:focus { | ||||
|     outline: none; | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 0 0 3px rgba(var(--primary-accent-rgb), 0.2); | ||||
| } | ||||
|  | ||||
| .execute-button { | ||||
|     align-self: flex-start; | ||||
|     background: var(--primary-accent); | ||||
|     color: var(--bg-dark); | ||||
|     border: none; | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-sm) var(--spacing-lg); | ||||
|     font-weight: 600; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-top: var(--spacing-sm); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .execute-button:hover { | ||||
|     background: color-mix(in srgb, var(--primary-accent) 90%, white); | ||||
|     transform: translateY(-1px); | ||||
|     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .script-output { | ||||
|     flex: 1; | ||||
|     background: var(--bg-dark); | ||||
|     border: 2px solid var(--border-color); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     color: var(--text-secondary); | ||||
|     font-family: var(--font-secondary); | ||||
|     font-size: 0.9em; | ||||
|     white-space: pre-wrap; | ||||
|     overflow-y: auto; | ||||
|     min-height: 150px; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| /* Responsive design */ | ||||
| @media (max-width: 768px) { | ||||
|     .inspector-container { | ||||
|         flex-direction: column; | ||||
|         height: auto; | ||||
|     } | ||||
|      | ||||
|     .inspector-sidebar { | ||||
|         width: 100%; | ||||
|         order: 2; | ||||
|     } | ||||
|      | ||||
|     .inspector-main { | ||||
|         order: 1; | ||||
|         min-height: 400px; | ||||
|     } | ||||
|      | ||||
|     .overview-grid { | ||||
|         grid-template-columns: 1fr; | ||||
|     } | ||||
|      | ||||
|     .network-nodes { | ||||
|         grid-template-columns: 1fr; | ||||
|     } | ||||
|      | ||||
|     .log-entry { | ||||
|         grid-template-columns: 1fr; | ||||
|         gap: var(--spacing-xs); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* WebSocket Status Sidebar */ | ||||
| .ws-status { | ||||
|     background: var(--surface-dark); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     margin-bottom: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .ws-status-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: var(--spacing-sm); | ||||
|     padding-bottom: var(--spacing-xs); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
| } | ||||
|  | ||||
| .ws-status-title { | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .ws-status-count { | ||||
|     background: var(--primary-accent); | ||||
|     color: var(--bg-dark); | ||||
|     padding: 2px 8px; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .ws-connections { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .ws-connection { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); | ||||
|     padding: var(--spacing-xs); | ||||
|     border-radius: var(--border-radius-small); | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .ws-connection:hover { | ||||
|     background: rgba(255, 255, 255, 0.05); | ||||
| } | ||||
|  | ||||
| .ws-status-dot { | ||||
|     width: 8px; | ||||
|     height: 8px; | ||||
|     border-radius: 50%; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .ws-status-dot.ws-status-connected { | ||||
|     background: #4ecdc4; | ||||
|     box-shadow: 0 0 4px rgba(78, 205, 196, 0.5); | ||||
| } | ||||
|  | ||||
| .ws-status-dot.ws-status-connecting { | ||||
|     background: #ffa726; | ||||
|     animation: pulse 1.5s infinite; | ||||
| } | ||||
|  | ||||
| .ws-status-dot.ws-status-error { | ||||
|     background: #ff6b6b; | ||||
| } | ||||
|  | ||||
| .ws-connection-info { | ||||
|     flex: 1; | ||||
|     min-width: 0; | ||||
| } | ||||
|  | ||||
| .ws-connection-name { | ||||
|     font-size: 0.9em; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-primary); | ||||
|     margin-bottom: 2px; | ||||
| } | ||||
|  | ||||
| .ws-connection-url { | ||||
|     font-size: 0.8em; | ||||
|     color: var(--text-tertiary); | ||||
|     font-family: var(--font-secondary); | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| /* Network Tab Styles */ | ||||
| .network-overview { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-lg); | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .stat-value.status-connected { | ||||
|     color: #4ecdc4; | ||||
| } | ||||
|  | ||||
| /* Traffic Table Styles */ | ||||
| .network-traffic { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .network-traffic h3 { | ||||
|     margin: 0 0 var(--spacing-md) 0; | ||||
|     color: var(--text-primary); | ||||
|     font-size: 1.2em; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .traffic-table { | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     border: 1px solid var(--border-color); | ||||
|     overflow: hidden; | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .traffic-header { | ||||
|     display: grid; | ||||
|     grid-template-columns: 80px 100px 200px 150px 80px 100px; | ||||
|     background: var(--bg-dark); | ||||
|     border-bottom: 2px solid var(--border-color); | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .traffic-row { | ||||
|     display: grid; | ||||
|     grid-template-columns: 80px 100px 200px 150px 80px 100px; | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .traffic-row:hover { | ||||
|     background: rgba(255, 255, 255, 0.05); | ||||
| } | ||||
|  | ||||
| .traffic-row:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .traffic-col { | ||||
|     padding: var(--spacing-sm); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-size: 0.85em; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .traffic-time { | ||||
|     color: var(--text-tertiary); | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
|  | ||||
| .traffic-direction { | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .traffic-direction.traffic-sent { | ||||
|     color: #ffa726; | ||||
| } | ||||
|  | ||||
| .traffic-direction.traffic-received { | ||||
|     color: #4ecdc4; | ||||
| } | ||||
|  | ||||
| .traffic-url { | ||||
|     color: var(--text-secondary); | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
|  | ||||
| .traffic-message { | ||||
|     color: var(--text-primary); | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
|  | ||||
| .traffic-size { | ||||
|     color: var(--text-secondary); | ||||
|     text-align: right; | ||||
|     justify-content: flex-end; | ||||
| } | ||||
|  | ||||
| .traffic-status { | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .traffic-status.traffic-success { | ||||
|     color: #4ecdc4; | ||||
| } | ||||
|  | ||||
| .traffic-status.traffic-error { | ||||
|     color: #ff6b6b; | ||||
| } | ||||
|  | ||||
| /* Logs Tab Styles */ | ||||
| .logs-overview { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: var(--spacing-lg); | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .logs-stats { | ||||
|     display: flex; | ||||
|     gap: var(--spacing-lg); | ||||
|     margin-bottom: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .logs-stat { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     background: var(--surface-medium); | ||||
|     padding: var(--spacing-md); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     border: 1px solid var(--border-color); | ||||
|     min-width: 100px; | ||||
| } | ||||
|  | ||||
| .logs-stat .stat-value.stat-error { | ||||
|     color: #ff6b6b; | ||||
| } | ||||
|  | ||||
| .logs-stat .stat-value.stat-warn { | ||||
|     color: #ffa726; | ||||
| } | ||||
|  | ||||
| .log-time { | ||||
|     color: var(--text-tertiary); | ||||
|     font-family: var(--font-secondary); | ||||
|     font-size: 0.8em; | ||||
| } | ||||
|  | ||||
| /* Animations */ | ||||
| @keyframes pulse { | ||||
|     0%, 100% { | ||||
|         opacity: 1; | ||||
|     } | ||||
|     50% { | ||||
|         opacity: 0.5; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Connection status dots for network nodes */ | ||||
| .node-status-dot { | ||||
|     width: 8px; | ||||
|     height: 8px; | ||||
|     border-radius: 50%; | ||||
|     margin-right: var(--spacing-xs); | ||||
| } | ||||
|  | ||||
| .node-status-dot.node-connected { | ||||
|     background: #4ecdc4; | ||||
|     box-shadow: 0 0 4px rgba(78, 205, 196, 0.5); | ||||
| } | ||||
|  | ||||
| .node-status-dot.node-disconnected { | ||||
|     background: #ff6b6b; | ||||
| } | ||||
|  | ||||
| .node-latency { | ||||
|     color: var(--text-tertiary); | ||||
|     font-size: 0.8em; | ||||
|     font-family: var(--font-secondary); | ||||
| } | ||||
							
								
								
									
										185
									
								
								src/app/static/css/intelligence_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/app/static/css/intelligence_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | ||||
| /* Intelligence View - Ultra Minimalistic Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .intelligence-view-container { | ||||
|     /* Extends .view-container from common.css but with flex-direction: row */ | ||||
|     flex-direction: row; /* Specific direction for this view */ | ||||
|     height: calc(100vh - 120px); /* Specific height */ | ||||
|     margin: 100px 40px 60px 40px; /* Specific margins */ | ||||
|     gap: var(--spacing-lg); /* Was 24px, using common.css spacing */ | ||||
|     /* font-family will be inherited from common.css body */ | ||||
|     /* Other .view-container properties like display, color, background, overflow are inherited or set by common.css */ | ||||
| } | ||||
|  | ||||
| .new-conversation-btn { | ||||
|     width: 100%; | ||||
|     background: transparent; /* Specific style */ | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 12px 16px; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-bottom: 20px; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| .new-conversation-btn:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common interaction color */ | ||||
| } | ||||
|  | ||||
| .conversation-item, | ||||
| .active-conversation-item { | ||||
|     padding: 12px 16px; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     margin-bottom: 4px; | ||||
|     cursor: pointer; | ||||
|     color: var(--text-secondary); | ||||
|     transition: all 0.2s ease; | ||||
|     font-size: 14px; | ||||
|     border: 1px solid transparent; | ||||
| } | ||||
|  | ||||
| .conversation-item:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .active-conversation-item { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .chat-panel { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .messages-display { | ||||
|     flex: 1; | ||||
|     overflow-y: auto; | ||||
|     padding: 24px; | ||||
|     background: transparent; | ||||
| } | ||||
|  | ||||
| .messages-display h4 { | ||||
|     margin: 0 0 24px 0; | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 600; | ||||
|     font-size: 18px; | ||||
|     padding-bottom: 12px; | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .messages-display p { | ||||
|     color: var(--text-secondary); | ||||
|     font-size: 14px; | ||||
|     text-align: center; | ||||
|     margin-top: 40px; | ||||
| } | ||||
|  | ||||
| .message { | ||||
|     margin-bottom: 20px; | ||||
|     max-width: 80%; | ||||
| } | ||||
|  | ||||
| .user-message { | ||||
|     margin-left: auto; | ||||
|     text-align: right; | ||||
| } | ||||
|  | ||||
| .ai-message { | ||||
|     margin-right: auto; | ||||
| } | ||||
|  | ||||
| .message .sender { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
|     margin-bottom: 6px; | ||||
|     display: block; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
| } | ||||
|  | ||||
| .message p { | ||||
|     background: var(--surface-light); /* Common.css variable for AI message bubble */ | ||||
|     padding: 12px 16px; | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     margin: 0; | ||||
|     font-size: 14px; | ||||
|     line-height: 1.5; | ||||
|     color: var(--text-primary); | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| .user-message p { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     border-bottom-right-radius: var(--border-radius-small); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .ai-message p { | ||||
|     border-bottom-left-radius: 4px; | ||||
| } | ||||
|  | ||||
| .input-area { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 20px 24px; | ||||
|     border-top: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     background: transparent; | ||||
|     gap: var(--spacing-md); /* Was 12px */ | ||||
| } | ||||
|  | ||||
| .intelligence-input { | ||||
|     flex: 1; | ||||
|     background: var(--bg-light); /* Align with common.css .input-base */ | ||||
|     color: var(--text-primary); | ||||
|     padding: 12px 16px; /* Specific padding, common.css input-base is var(--spacing-sm) (8px) */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     font-size: 14px; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .intelligence-input:focus { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Common input focus shadow */ | ||||
| } | ||||
|  | ||||
| .intelligence-input::placeholder { | ||||
|     color: var(--text-disabled); /* Align with common.css .input-base */ | ||||
| } | ||||
|  | ||||
| .send-button { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     border: none; | ||||
|     padding: 12px 20px; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .send-button:hover { | ||||
|     background: color-mix(in srgb, var(--primary-accent) 85%, white); /* Common primary button hover */ | ||||
| } | ||||
|  | ||||
| .send-button:disabled { | ||||
|     background: var(--surface-dark); /* Common disabled background */ | ||||
|     color: var(--text-disabled); /* Common disabled text color */ | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| /* Scrollbar styling is now handled globally by common.css */ | ||||
							
								
								
									
										465
									
								
								src/app/static/css/library_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										465
									
								
								src/app/static/css/library_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,465 @@ | ||||
| /* Library View - Ultra Minimalistic Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .sidebar-layout { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     height: calc(100vh - 120px); | ||||
|     margin: 100px 40px; | ||||
|     gap: var(--spacing-lg); | ||||
| } | ||||
|  | ||||
| .layout { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: calc(100vh - 120px); | ||||
|     margin: 100px 40px; | ||||
|     max-width: 1200px; | ||||
|     margin-left: auto; | ||||
|     margin-right: auto; | ||||
| } | ||||
|  | ||||
| .layout .library-content { | ||||
|     flex: 1; | ||||
|     border-radius: var(--border-radius-large); | ||||
|     padding: var(--spacing-lg); | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .library-content > h1 { | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .library-sidebar { | ||||
|     width: 280px; | ||||
|     overflow-y: auto; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .main { | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .sidebar { | ||||
|     width: 280px; | ||||
|     overflow-y: auto; | ||||
|     height: fit-content; | ||||
|     display: flex; | ||||
|     gap: 10px; | ||||
|     flex-direction: column; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .sidebar .card { | ||||
|     background: var(--surface-dark); | ||||
|     color: var(--text-secondary); | ||||
|     padding: var(--spacing-sm) var(--spacing-md); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: background-color 0.2s ease, border-color 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .sidebar .cards-column { | ||||
|     overflow: auto; | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 10px; | ||||
| } | ||||
|  | ||||
| .sidebar-section { | ||||
|     margin-bottom: 32px; | ||||
| } | ||||
|  | ||||
| .sidebar-section:last-child { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .sidebar-section h4 { | ||||
|     margin: 0 0 16px 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 600; | ||||
|     letter-spacing: 0.5px; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .no-collections-message { | ||||
|     padding: 16px 12px; | ||||
|     text-align: center; | ||||
|     color: var(--text-muted); | ||||
|     font-size: 13px; | ||||
|     font-style: italic; | ||||
| } | ||||
|  | ||||
| .library-content { | ||||
|     flex: 1; | ||||
|     padding: var(--spacing-lg); | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| /* Collections Grid for main view */ | ||||
| .collections-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | ||||
|     gap: var(--spacing-lg); | ||||
|     margin-bottom: var(--spacing-lg); | ||||
| } | ||||
|  | ||||
| /* Click-outside areas for navigation */ | ||||
| .view-container.layout[onclick], | ||||
| .view-container.sidebar-layout[onclick] { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .library-content[onclick] { | ||||
|     cursor: default; | ||||
| } | ||||
|  | ||||
| .library-content > header { | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .library-items-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); | ||||
|     gap: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| /* Library Item Card in Main Content Grid */ | ||||
| .library-item-card { | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     overflow: hidden; | ||||
|     height: 180px; | ||||
|     box-shadow: 0 1px 3px rgba(0,0,0,0.1); | ||||
| } | ||||
|  | ||||
| .library-item-card:hover { | ||||
|     background: var(--surface-dark); | ||||
|     transform: translateY(-2px); | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 4px 10px color-mix(in srgb, var(--primary-accent) 20%, transparent); | ||||
| } | ||||
|  | ||||
| .library-item-card .item-preview { | ||||
|     height: 100px; | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background-color: var(--surface-dark); | ||||
|     border-bottom: 1px solid var(--border-color); | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .library-item-card .item-preview .item-thumbnail-img { | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     object-fit: cover;  | ||||
| } | ||||
|  | ||||
| .library-item-card .item-preview .item-preview-fallback-icon { | ||||
|     font-size: 36px; | ||||
|     color: var(--text-muted); | ||||
| } | ||||
|  | ||||
| .library-item-card .item-details { | ||||
|     padding: 12px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-grow: 1; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .library-item-card .item-title { | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0 0 4px 0; | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .library-item-card .item-description { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-secondary); | ||||
|     margin: 0 0 6px 0; | ||||
|     line-height: 1.3; | ||||
|     height: 2.6em; /* Approx 2 lines */ | ||||
|     overflow: hidden; | ||||
|     display: -webkit-box; | ||||
|     -webkit-line-clamp: 2; | ||||
|     -webkit-box-orient: vertical; | ||||
|     line-clamp: 2; /* Standard property */ | ||||
| } | ||||
|  | ||||
| .library-item-card .item-meta { | ||||
|     font-size: 11px; | ||||
|     color: var(--text-muted); | ||||
|     margin-top: auto; /* Pushes to the bottom */ | ||||
| } | ||||
|  | ||||
| /* Removed .shelf, .shelf-header, .shelf-label-icon, .shelf-label, .shelf-description, .shelf-items, .shelf-item as they are not used by the current LibraryView component structure */ | ||||
| /* Removed .filter-btn and related styles as .collection-list-item is used instead */ | ||||
|  | ||||
| .item-preview-fallback-icon { | ||||
|     font-size: 24px; | ||||
|     color: var(--text-muted); | ||||
| } | ||||
|  | ||||
| .csv-preview-table { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     border-collapse: collapse; | ||||
|     font-size: 8px; | ||||
|     table-layout: fixed; | ||||
| } | ||||
|  | ||||
| .csv-preview-table th, | ||||
| .csv-preview-table td { | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 2px 3px; | ||||
|     text-align: left; | ||||
|     color: var(--text-secondary); | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .csv-preview-table th { | ||||
|     background: var(--surface-light); /* Common.css variable for table header */ | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .item-name { | ||||
|     padding: 12px; | ||||
|     font-size: 12px; | ||||
|     line-height: 1.3; | ||||
|     color: var(--text-primary); | ||||
|     text-align: center; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     flex: 1; | ||||
|     word-break: break-word; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     font-weight: 500; | ||||
| } | ||||
| /* Asset Viewer Styles */ | ||||
| .asset-viewer { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| .viewer-header { | ||||
|     margin-bottom: var(--spacing-lg); | ||||
|     padding-bottom: var(--spacing-md); | ||||
|     border-bottom: 1px solid var(--border-color-subtle); | ||||
| } | ||||
|  | ||||
| .viewer-title { | ||||
|     font-size: 24px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .viewer-content { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .viewer-image { | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     object-fit: contain; | ||||
|     border-radius: var(--border-radius-medium); | ||||
| } | ||||
|  | ||||
| .pdf-frame { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     border: none; | ||||
|     border-radius: var(--border-radius-medium); | ||||
| } | ||||
|  | ||||
| .markdown-content { | ||||
|     overflow-y: auto; | ||||
|     padding: var(--spacing-md); | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-medium); | ||||
| } | ||||
|  | ||||
| .book-navigation, | ||||
| .slides-navigation { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-md); | ||||
|     margin-top: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .nav-button { | ||||
|     background: var(--surface-medium); | ||||
|     color: var(--text-secondary); | ||||
|     border: 1px solid var(--border-color); | ||||
|     padding: 8px 16px; | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .nav-button:hover:not(:disabled) { | ||||
|     background: var(--surface-light); | ||||
|     color: var(--text-primary); | ||||
|     border-color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .nav-button:disabled { | ||||
|     opacity: 0.5; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .page-indicator, | ||||
| .slide-indicator { | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .book-page { | ||||
|     overflow-y: auto; | ||||
|     padding: var(--spacing-md); | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-medium); | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .slide-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     flex: 1; | ||||
|     margin-bottom: var(--spacing-md); | ||||
| } | ||||
|  | ||||
| .slide { | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .slide-image { | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     object-fit: contain; | ||||
|     border-radius: var(--border-radius-medium); | ||||
| } | ||||
|  | ||||
| .slide-title { | ||||
|     margin-top: var(--spacing-sm); | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .slide-thumbnails { | ||||
|     display: flex; | ||||
|     gap: var(--spacing-sm); | ||||
|     overflow-x: auto; | ||||
|     padding: var(--spacing-sm); | ||||
|     background: var(--surface-medium); | ||||
|     border-radius: var(--border-radius-medium); | ||||
| } | ||||
|  | ||||
| .slide-thumbnail { | ||||
|     flex-shrink: 0; | ||||
|     width: 60px; | ||||
|     height: 40px; | ||||
|     position: relative; | ||||
|     cursor: pointer; | ||||
|     border: 2px solid transparent; | ||||
|     border-radius: var(--border-radius-small); | ||||
|     overflow: hidden; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .slide-thumbnail:hover { | ||||
|     border-color: var(--primary-accent); | ||||
| } | ||||
|  | ||||
| .slide-thumbnail.active { | ||||
|     border-color: var(--primary-accent); | ||||
|     box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary-accent) 30%, transparent); | ||||
| } | ||||
|  | ||||
| .slide-thumbnail img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: cover; | ||||
| } | ||||
|  | ||||
| .thumbnail-number { | ||||
|     position: absolute; | ||||
|     bottom: 2px; | ||||
|     right: 2px; | ||||
|     background: rgba(0, 0, 0, 0.7); | ||||
|     color: white; | ||||
|     font-size: 10px; | ||||
|     padding: 1px 3px; | ||||
|     border-radius: 2px; | ||||
| } | ||||
|  | ||||
| /* Scrollbar styling is now handled globally by common.css */ | ||||
|  | ||||
| /* Responsive adjustments */ | ||||
| @media (max-width: 768px) { | ||||
|     .sidebar-layout { | ||||
|         margin: 40px 20px; | ||||
|         flex-direction: column; | ||||
|     } | ||||
|      | ||||
|     .layout { | ||||
|         margin: 40px 20px; | ||||
|     } | ||||
|      | ||||
|     .library-sidebar { | ||||
|         width: 100%; | ||||
|         order: 2; | ||||
|     } | ||||
|      | ||||
|     .library-content { | ||||
|         order: 1; | ||||
|     } | ||||
|      | ||||
|     .collections-grid { | ||||
|         grid-template-columns: 1fr; | ||||
|         gap: var(--spacing-md); | ||||
|     } | ||||
|      | ||||
|     .library-items-grid { | ||||
|         grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); | ||||
|         gap: 12px; | ||||
|     } | ||||
|      | ||||
|     .library-item-card { | ||||
|         height: 140px; | ||||
|     } | ||||
|      | ||||
|     .library-item-card .item-preview { | ||||
|         height: 80px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										480
									
								
								src/app/static/css/library_viewer.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										480
									
								
								src/app/static/css/library_viewer.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,480 @@ | ||||
| /* Library Viewer Styles */ | ||||
|  | ||||
| /* Asset Details Card (Sidebar) */ | ||||
| .asset-details-card { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 16px; | ||||
|     padding: 20px; | ||||
|     background: #1a1a1a; | ||||
|     border: 1px solid #333; | ||||
|     border-radius: 12px; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .back-button { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
|     padding: 8px 12px; | ||||
|     background: #333; | ||||
|     color: #fff; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     cursor: pointer; | ||||
|     font-size: 0.9rem; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .back-button:hover { | ||||
|     background: #444; | ||||
| } | ||||
|  | ||||
| .asset-preview { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     min-height: 120px; | ||||
|     background: #222; | ||||
|     border-radius: 8px; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .asset-preview-image { | ||||
|     max-width: 100%; | ||||
|     max-height: 120px; | ||||
|     object-fit: cover; | ||||
|     border-radius: 4px; | ||||
| } | ||||
|  | ||||
| .asset-preview-icon { | ||||
|     font-size: 3rem; | ||||
|     color: #666; | ||||
| } | ||||
|  | ||||
| .asset-info { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .asset-title { | ||||
|     margin: 0; | ||||
|     font-size: 1.25rem; | ||||
|     font-weight: 600; | ||||
|     color: #fff; | ||||
|     line-height: 1.3; | ||||
| } | ||||
|  | ||||
| .asset-description { | ||||
|     margin: 0; | ||||
|     color: #aaa; | ||||
|     font-size: 0.9rem; | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .asset-metadata { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 4px; | ||||
| } | ||||
|  | ||||
| .asset-metadata p { | ||||
|     margin: 0; | ||||
|     font-size: 0.85rem; | ||||
|     color: #ccc; | ||||
| } | ||||
|  | ||||
| .asset-metadata strong { | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| /* Asset Viewer (Main Content) */ | ||||
| .asset-viewer { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
|     background: #1a1a1a; | ||||
|     border-radius: 12px; | ||||
|     overflow: hidden; | ||||
|     border: 1px solid #333; | ||||
| } | ||||
|  | ||||
| .viewer-header { | ||||
|     padding: 20px 24px 16px; | ||||
|     border-bottom: 1px solid #333; | ||||
|     background: #222; | ||||
|     border-radius: 12px 12px 0 0; | ||||
| } | ||||
|  | ||||
| .viewer-title { | ||||
|     margin: 0; | ||||
|     font-size: 1.5rem; | ||||
|     font-weight: 600; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .viewer-content { | ||||
|     flex: 1; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     overflow: hidden; | ||||
|     padding: 20px; | ||||
|     min-height: 400px; | ||||
| } | ||||
|  | ||||
| /* Image Viewer */ | ||||
| .viewer-image { | ||||
|     max-width: 100%; | ||||
|     max-height: calc(100vh - 300px); | ||||
|     object-fit: contain; | ||||
|     border-radius: 4px; | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| /* PDF Viewer */ | ||||
| .pdf-frame { | ||||
|     width: 100%; | ||||
|     height: calc(100vh - 200px); | ||||
|     min-height: 500px; | ||||
|     border: none; | ||||
|     background: #fff; | ||||
|     border-radius: 4px; | ||||
| } | ||||
|  | ||||
| .external-link { | ||||
|     color: #60a5fa; | ||||
|     text-decoration: none; | ||||
|     font-weight: 500; | ||||
|     transition: color 0.2s ease; | ||||
|     display: inline-block; | ||||
|     margin-top: 8px; | ||||
|     padding: 6px 12px; | ||||
|     background: #333; | ||||
|     border-radius: 4px; | ||||
|     font-size: 0.85rem; | ||||
| } | ||||
|  | ||||
| .external-link:hover { | ||||
|     color: #93c5fd; | ||||
|     background: #444; | ||||
| } | ||||
|  | ||||
| /* Markdown Content */ | ||||
| .markdown-content { | ||||
|     padding: 0; | ||||
|     overflow-y: auto; | ||||
|     line-height: 1.6; | ||||
|     color: #e5e5e5; | ||||
|     max-height: calc(100vh - 200px); | ||||
| } | ||||
|  | ||||
| /* Table of Contents */ | ||||
| .table-of-contents { | ||||
|     margin-top: 16px; | ||||
| } | ||||
|  | ||||
| .table-of-contents h4 { | ||||
|     margin: 0 0 8px 0; | ||||
|     color: #fff; | ||||
|     font-size: 0.9rem; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .toc-list { | ||||
|     list-style: none; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .toc-item { | ||||
|     margin: 4px 0; | ||||
| } | ||||
|  | ||||
| .toc-link { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 6px 8px; | ||||
|     background: #333; | ||||
|     color: #ccc; | ||||
|     border: none; | ||||
|     border-radius: 4px; | ||||
|     text-align: left; | ||||
|     cursor: pointer; | ||||
|     font-size: 0.8rem; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .toc-link:hover { | ||||
|     background: #444; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| /* Book Viewer */ | ||||
| .book-viewer .viewer-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     flex-wrap: wrap; | ||||
|     gap: 16px; | ||||
| } | ||||
|  | ||||
| .book-navigation { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .nav-button { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
|     padding: 8px 12px; | ||||
|     background: #333; | ||||
|     color: #fff; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     cursor: pointer; | ||||
|     font-size: 0.9rem; | ||||
|     transition: background-color 0.2s ease; | ||||
| } | ||||
|  | ||||
| .nav-button:hover:not(:disabled) { | ||||
|     background: #444; | ||||
| } | ||||
|  | ||||
| .nav-button:disabled { | ||||
|     background: #222; | ||||
|     color: #666; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .page-indicator, | ||||
| .slide-indicator { | ||||
|     font-size: 0.9rem; | ||||
|     color: #aaa; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .book-page { | ||||
|     padding: 20px; | ||||
|     background: #222; | ||||
|     border-radius: 8px; | ||||
|     max-height: calc(100vh - 250px); | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| /* Slides Viewer */ | ||||
| .slides-viewer .viewer-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     flex-wrap: wrap; | ||||
|     gap: 16px; | ||||
| } | ||||
|  | ||||
| .slides-navigation { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .slide-container { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     min-height: 400px; | ||||
|     background: #222; | ||||
|     border-radius: 8px; | ||||
|     margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| .slide { | ||||
|     text-align: center; | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
| } | ||||
|  | ||||
| .slide-image { | ||||
|     max-width: 100%; | ||||
|     max-height: calc(100vh - 350px); | ||||
|     object-fit: contain; | ||||
|     border-radius: 4px; | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .slide-title { | ||||
|     margin-top: 12px; | ||||
|     padding: 8px 16px; | ||||
|     background: rgba(0, 0, 0, 0.7); | ||||
|     color: #fff; | ||||
|     border-radius: 4px; | ||||
|     font-size: 0.9rem; | ||||
|     display: inline-block; | ||||
| } | ||||
|  | ||||
| .slide-thumbnails { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
|     overflow-x: auto; | ||||
|     padding: 8px 0; | ||||
|     background: #222; | ||||
|     border-radius: 8px; | ||||
|     padding: 12px; | ||||
| } | ||||
|  | ||||
| .slide-thumbnail { | ||||
|     position: relative; | ||||
|     flex-shrink: 0; | ||||
|     width: 80px; | ||||
|     height: 60px; | ||||
|     border-radius: 4px; | ||||
|     overflow: hidden; | ||||
|     cursor: pointer; | ||||
|     border: 2px solid transparent; | ||||
|     transition: border-color 0.2s ease, transform 0.2s ease; | ||||
| } | ||||
|  | ||||
| .slide-thumbnail:hover { | ||||
|     transform: scale(1.05); | ||||
|     border-color: #555; | ||||
| } | ||||
|  | ||||
| .slide-thumbnail.active { | ||||
|     border-color: #60a5fa; | ||||
| } | ||||
|  | ||||
| .slide-thumbnail img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: cover; | ||||
| } | ||||
|  | ||||
| .thumbnail-number { | ||||
|     position: absolute; | ||||
|     top: 2px; | ||||
|     right: 2px; | ||||
|     background: rgba(0, 0, 0, 0.8); | ||||
|     color: #fff; | ||||
|     font-size: 0.7rem; | ||||
|     padding: 2px 4px; | ||||
|     border-radius: 2px; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .markdown-content h1 { | ||||
|     font-size: 2rem; | ||||
|     font-weight: 700; | ||||
|     margin: 0 0 16px 0; | ||||
|     color: #fff; | ||||
|     border-bottom: 2px solid #333; | ||||
|     padding-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .markdown-content h2 { | ||||
|     font-size: 1.5rem; | ||||
|     font-weight: 600; | ||||
|     margin: 24px 0 12px 0; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .markdown-content h3 { | ||||
|     font-size: 1.25rem; | ||||
|     font-weight: 600; | ||||
|     margin: 20px 0 8px 0; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .markdown-content p { | ||||
|     margin: 0 0 12px 0; | ||||
|     color: #d1d5db; | ||||
| } | ||||
|  | ||||
| .markdown-content li { | ||||
|     margin: 4px 0; | ||||
|     color: #d1d5db; | ||||
|     list-style-type: disc; | ||||
|     margin-left: 20px; | ||||
| } | ||||
|  | ||||
| .markdown-content strong { | ||||
|     font-weight: 600; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .markdown-content br { | ||||
|     margin: 8px 0; | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (max-width: 768px) { | ||||
|     .asset-details-card { | ||||
|         padding: 16px; | ||||
|     } | ||||
|      | ||||
|     .asset-preview { | ||||
|         min-height: 80px; | ||||
|     } | ||||
|      | ||||
|     .asset-preview-image { | ||||
|         max-height: 80px; | ||||
|     } | ||||
|      | ||||
|     .asset-title { | ||||
|         font-size: 1.1rem; | ||||
|     } | ||||
|      | ||||
|     .viewer-header { | ||||
|         padding: 16px 20px 12px; | ||||
|     } | ||||
|      | ||||
|     .viewer-title { | ||||
|         font-size: 1.25rem; | ||||
|     } | ||||
|      | ||||
|     .viewer-content { | ||||
|         padding: 16px; | ||||
|         min-height: 300px; | ||||
|     } | ||||
|      | ||||
|     .pdf-frame { | ||||
|         height: calc(100vh - 250px); | ||||
|         min-height: 400px; | ||||
|     } | ||||
|      | ||||
|     .markdown-content { | ||||
|         max-height: calc(100vh - 250px); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Smooth animations */ | ||||
| .asset-viewer { | ||||
|     animation: slideIn 0.3s ease-out; | ||||
| } | ||||
|  | ||||
| .asset-details-card { | ||||
|     animation: slideIn 0.3s ease-out 0.1s both; | ||||
| } | ||||
|  | ||||
| @keyframes slideIn { | ||||
|     from { | ||||
|         opacity: 0; | ||||
|         transform: translateY(20px); | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|         transform: translateY(0); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Library item cards hover effect */ | ||||
| .library-item-card { | ||||
|     cursor: pointer; | ||||
|     transition: transform 0.2s ease, box-shadow 0.2s ease; | ||||
| } | ||||
|  | ||||
| .library-item-card:hover { | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
							
								
								
									
										125
									
								
								src/app/static/css/members_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/app/static/css/members_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| .members-view .view-title { /* Assuming h1 in component is styled this way */ | ||||
|     color: var(--primary-accent); /* Common.css variable */ | ||||
|     text-align: center; | ||||
|     margin-bottom: var(--spacing-xl); /* Was 30px */ | ||||
|     font-size: 2em; | ||||
| } | ||||
|  | ||||
| .member-ripples-container { /* Default flex layout */ | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     justify-content: center; | ||||
|     gap: 35px; /* Increased gap for circular elements */ | ||||
|     padding: 20px 0; /* Add some vertical padding to the container */ | ||||
| } | ||||
|  | ||||
| .member-ripples-container.geometric-layout { /* For 1-4 members */ | ||||
|     position: relative; /* Context for absolute positioning */ | ||||
|     min-height: 400px; /* Ensure container has space for positioned items */ | ||||
|     /* Override flex properties if they were set directly on the class before */ | ||||
|     display: block; /* Or whatever is appropriate to override flex if needed */ | ||||
|     flex-wrap: nowrap; /* Override */ | ||||
|     justify-content: initial; /* Override */ | ||||
|     gap: 0; /* Override */ | ||||
|     padding: 0; /* Override or adjust as needed */ | ||||
| } | ||||
|  | ||||
| /* Styles for .member-ripple when inside .geometric-layout */ | ||||
| .member-ripples-container.geometric-layout .member-ripple { | ||||
|     position: absolute; | ||||
|     /* left, top, and transform will be set by inline styles from Rust */ | ||||
|     /* Other .member-ripple styles (size, border-radius, etc.) still apply */ | ||||
| } | ||||
|  | ||||
| /* End of rules for .member-ripples-container and its variants */ | ||||
|  | ||||
| .member-ripple { /* Renamed from .member-card */ | ||||
|     background-color: var(--surface-dark); /* Common.css variable */ | ||||
|     width: 180px; | ||||
|     height: 180px; | ||||
|     border-radius: 50%; /* Circular shape */ | ||||
|     padding: var(--spacing-md); /* Was 15px */ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     text-align: center; | ||||
|     box-shadow: var(--shadow-medium); /* Common.css variable, was 0 5px 15px rgba(0,0,0,0.35) */ | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
|     transition: transform var(--transition-speed) ease, box-shadow var(--transition-speed) ease; /* Common.css variable */ | ||||
|     position: relative; /* For potential future ripple pseudo-elements */ | ||||
| } | ||||
|  | ||||
| .member-ripple:hover { | ||||
|     transform: scale(1.05); | ||||
|     box-shadow: 0 0 25px color-mix(in srgb, var(--primary-accent) 40%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .member-avatar { | ||||
|     position: relative; /* For status indicator positioning */ | ||||
|     width: 90px; /* Slightly smaller to give more space for text */ | ||||
|     height: 90px; /* Keep square */ | ||||
|     margin-bottom: 10px; /* Adjusted margin for flex layout */ | ||||
|     border-radius: 50%; | ||||
|     overflow: hidden; /* Ensures image stays within circle */ | ||||
|     border: 3px solid var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .member-avatar img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: cover; | ||||
| } | ||||
|  | ||||
| .status-indicator { /* No changes needed for status indicator position relative to avatar */ | ||||
|     position: absolute; | ||||
|     bottom: 5px; /* Relative to avatar */ | ||||
|     right: 5px;  /* Relative to avatar */ | ||||
|     width: 18px; /* Slightly smaller to fit smaller avatar */ | ||||
|     height: 18px; | ||||
|     border-radius: 50%; | ||||
|     border: 2px solid var(--surface-dark); /* Common.css variable, for contrast */ | ||||
| } | ||||
|  | ||||
| .status-online { | ||||
|     background-color: #4CAF50; /* Green */ | ||||
| } | ||||
|  | ||||
| .status-away { | ||||
|     background-color: #FFC107; /* Amber */ | ||||
| } | ||||
|  | ||||
| .status-offline { | ||||
|     background-color: #757575; /* Grey */ | ||||
| } | ||||
|  | ||||
| .member-info { /* Added to ensure text is constrained if needed */ | ||||
|     max-width: 90%; /* Prevent text overflow if names are too long */ | ||||
| } | ||||
|  | ||||
| .member-info h3 { | ||||
|     margin: 5px 0 2px 0; /* Adjusted margins */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     font-size: 1.0em; /* Slightly smaller */ | ||||
|     line-height: 1.2;  | ||||
|     word-break: break-word; /* Prevent long names from breaking layout */ | ||||
| } | ||||
|  | ||||
| .member-info .member-role { | ||||
|     font-size: 0.8em; /* Slightly smaller */ | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     font-style: italic; | ||||
|     line-height: 1.1; | ||||
|     word-break: break-word; | ||||
| } | ||||
|  | ||||
| .members-view .empty-state { | ||||
|     text-align: center; | ||||
|     padding: 50px; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .members-view .empty-state p { | ||||
|     margin-bottom: 10px; | ||||
|     font-size: 1.1em; | ||||
| } | ||||
							
								
								
									
										97
									
								
								src/app/static/css/nav_island.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/app/static/css/nav_island.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| /* Navigation Island - from original Yew project CSS */ | ||||
| /* Navigation Island - simplified transform-based collapse/expand */ | ||||
| .nav-island { | ||||
|     position: fixed; | ||||
|     right: 28px; | ||||
|     bottom: 16px; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: flex-end; | ||||
|     background: var(--bg-medium); /* Was rgba(30,32,40,0.98), using common.css variable, assuming full opacity is acceptable */ | ||||
|     border-radius: var(--border-radius-large); /* Was 14px, using common.css variable (16px) */ | ||||
|     box-shadow: 0 8px 32px 0 rgba(0,0,0,0.18), 0 1.5px 8px 0 rgba(0,0,0,0.10); /* Keeping specific shadow */ | ||||
|     padding: 4px 9px; | ||||
|     z-index: 9000; | ||||
|     overflow: hidden; | ||||
|     transition: width 0.42s ease; | ||||
|     will-change: width; | ||||
| } | ||||
|  | ||||
| .nav-island.collapsed { | ||||
|     width: 88px; | ||||
| } | ||||
|  | ||||
| .nav-island.collapsed:hover:not(.clicked), | ||||
| .nav-island.collapsed:focus-within:not(.clicked) { | ||||
|     width: 720px; /* 9 buttons × (82px + 6px margin) + padding = ~816px */ | ||||
| } | ||||
|  | ||||
| .nav-island.clicked { | ||||
|     width: 88px !important; | ||||
| } | ||||
|  | ||||
| .nav-island-buttons { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: flex-end; | ||||
|     gap: 0; | ||||
|     white-space: nowrap; | ||||
|     transition: transform 0.42s ease; | ||||
|     will-change: transform; | ||||
| } | ||||
|  | ||||
| .nav-island.collapsed .nav-island-buttons { | ||||
|     transform: translateX(-6px); /* Small offset to keep first button visible */ | ||||
| } | ||||
|  | ||||
| .nav-island.collapsed:hover:not(.clicked) .nav-island-buttons, | ||||
| .nav-island.collapsed:focus-within:not(.clicked) .nav-island-buttons { | ||||
|     transform: translateX(0); | ||||
| } | ||||
|  | ||||
| .nav-island.clicked .nav-island-buttons { | ||||
|     transform: translateX(-6px) !important; | ||||
| } | ||||
|  | ||||
| .nav-button { | ||||
|     width: 82px; | ||||
|     background-color: transparent; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: 1px solid transparent; | ||||
|     border-radius: var(--border-radius-medium); /* Was 10px, using common.css variable (8px) */ | ||||
|     font-size: 0.85em; | ||||
|     font-weight: 400; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: 4px; | ||||
|     cursor: pointer; | ||||
|     transition: background 0.2s, color 0.2s, border 0.2s; | ||||
|     margin-right: 6px; | ||||
|     outline: none; | ||||
|     padding: 10px 15px; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .nav-button i { | ||||
|     font-size: 1.2em; | ||||
| } | ||||
|  | ||||
| .nav-button:hover { | ||||
|     background-color: color-mix(in srgb, var(--primary-accent) 10%, transparent); /* Common.css primary with alpha */ | ||||
|     color: var(--primary-accent); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .nav-button.active { | ||||
|     background-color: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Common.css variable */ | ||||
|     font-weight: 600; | ||||
|     box-shadow: 0 0 10px var(--primary-accent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .nav-button.active:hover { | ||||
|     background-color: var(--primary-accent); /* Common.css variable, keep active color on hover */ | ||||
|     color: var(--bg-dark); /* Common.css variable */ | ||||
| } | ||||
							
								
								
									
										179
									
								
								src/app/static/css/network_animation.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/app/static/css/network_animation.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| /* Network Animation Styles - Ultra Minimal and Sleek */ | ||||
|  | ||||
| .network-animation-overlay { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     pointer-events: none; | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| .network-overlay-svg { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     pointer-events: none; | ||||
|     z-index: 10; | ||||
| } | ||||
|  | ||||
| /* Ensure the world map container has relative positioning and proper sizing */ | ||||
| .network-map-container { | ||||
|     position: relative !important; | ||||
|     width: 100% !important; | ||||
|     height: auto !important; | ||||
|     min-height: 400px; | ||||
|     aspect-ratio: 783.086 / 400.649; /* Maintain SVG aspect ratio */ | ||||
| } | ||||
|  | ||||
| .network-map-container svg { | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
|     width: 100%; | ||||
|     height: auto; | ||||
| } | ||||
|  | ||||
| /* Make the world map darker */ | ||||
| .network-map-container svg path { | ||||
|     fill: #6c757d !important; /* Darker gray for the map */ | ||||
|     opacity: 0.7; | ||||
| } | ||||
|  | ||||
| /* Server Node Styles - Clean white and bright */ | ||||
| .server-node { | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .node-glow { | ||||
|     fill: rgba(255, 255, 255, 0.1); | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .node-pin { | ||||
|     fill: #ffffff; | ||||
|     stroke: rgba(0, 123, 255, 0.3); | ||||
|     stroke-width: 1; | ||||
|     transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|     filter: drop-shadow(0 2px 8px rgba(0, 123, 255, 0.2)); | ||||
| } | ||||
|  | ||||
| .node-pin:hover { | ||||
|     fill: #ffffff; | ||||
|     stroke: rgba(0, 123, 255, 0.6); | ||||
|     stroke-width: 2; | ||||
|     filter: drop-shadow(0 4px 12px rgba(0, 123, 255, 0.3)); | ||||
|     transform: scale(1.1); | ||||
| } | ||||
|  | ||||
| .node-core { | ||||
|     fill: #007bff; | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| /* Remove the ugly pulse - replace with subtle breathing effect */ | ||||
| .node-pulse { | ||||
|     fill: none; | ||||
|     stroke: rgba(0, 123, 255, 0.2); | ||||
|     stroke-width: 1; | ||||
|     opacity: 0; | ||||
|     animation: gentle-breathe 6s ease-in-out infinite; | ||||
| } | ||||
|  | ||||
| @keyframes gentle-breathe { | ||||
|     0%, 100% {  | ||||
|         opacity: 0; | ||||
|         transform: scale(1); | ||||
|     } | ||||
|     50% {  | ||||
|         opacity: 0.3; | ||||
|         transform: scale(1.2); | ||||
|     } | ||||
| } | ||||
|  | ||||
| .node-label { | ||||
|     fill: #495057; | ||||
|     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | ||||
|     font-size: 11px; | ||||
|     font-weight: 500; | ||||
|     opacity: 0.9; | ||||
| } | ||||
|  | ||||
| /* Transmission Styles - More visible with shadow */ | ||||
| .transmission-group { | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .transmission-line { | ||||
|     fill: none; | ||||
|     stroke: rgba(0, 123, 255, 0.7); | ||||
|     stroke-width: 2; | ||||
|     stroke-linecap: round; | ||||
|     opacity: 0; | ||||
|     animation: fade-in-out 4s ease-in-out infinite; | ||||
|     filter: drop-shadow(0 0 4px rgba(0, 123, 255, 0.3)); | ||||
| } | ||||
|  | ||||
| @keyframes fade-in-out { | ||||
|     0%, 100% { opacity: 0; } | ||||
|     50% { opacity: 0.8; } | ||||
| } | ||||
|  | ||||
| /* Responsive Design */ | ||||
| @media (max-width: 768px) { | ||||
|     .node-label { | ||||
|         font-size: 9px; | ||||
|     } | ||||
|      | ||||
|     .network-map-container { | ||||
|         min-height: 300px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 480px) { | ||||
|     .node-label { | ||||
|         font-size: 8px; | ||||
|     } | ||||
|      | ||||
|     .network-map-container { | ||||
|         min-height: 250px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Dark theme compatibility */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
|     .network-map-container svg path { | ||||
|         fill: #404040 !important; | ||||
|     } | ||||
|      | ||||
|     .node-label { | ||||
|         fill: rgba(255, 255, 255, 0.8); | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Reduced motion preference */ | ||||
| @media (prefers-reduced-motion: reduce) { | ||||
|     .node-pulse, | ||||
|     .transmission-line { | ||||
|         animation: none; | ||||
|     } | ||||
|      | ||||
|     .transmission-line { | ||||
|         opacity: 0.4; | ||||
|     } | ||||
|      | ||||
|     .node-pin { | ||||
|         transition: none; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Performance optimizations */ | ||||
| .network-overlay-svg * { | ||||
|     will-change: transform, opacity; | ||||
| } | ||||
|  | ||||
| .server-node { | ||||
|     /* Removed translateZ(0) as it was causing positioning issues */ | ||||
| } | ||||
							
								
								
									
										977
									
								
								src/app/static/css/projects_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										977
									
								
								src/app/static/css/projects_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,977 @@ | ||||
| /* Projects View - Game-like Minimalistic Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .projects-view-container { | ||||
|     /* Extends .view-container from common.css */ | ||||
|     height: calc(100vh - 120px); /* Specific height */ | ||||
|     margin: 100px 40px 60px 40px; /* Specific margins */ | ||||
|     /* font-family will be inherited from common.css body */ | ||||
|     /* Other .view-container properties are inherited or set by common.css */ | ||||
| } | ||||
|  | ||||
| /* Header */ | ||||
| .projects-header { | ||||
|     /* Extends .view-header from common.css */ | ||||
|     /* .view-header provides: display, justify-content, align-items, padding-bottom, border-bottom, margin-bottom */ | ||||
|     /* Original margin-bottom: 24px (var(--spacing-lg)); padding: 0 8px (var(--spacing-sm) on sides) */ | ||||
|     /* common.css .view-header has margin-bottom: var(--spacing-md) (16px). Override if 24px is needed. */ | ||||
|     margin-bottom: var(--spacing-lg); /* Explicitly use 24px equivalent */ | ||||
|     padding: 0 var(--spacing-sm); /* Explicitly use 8px equivalent for side padding */ | ||||
| } | ||||
|  | ||||
| .projects-tabs { | ||||
|     display: flex; | ||||
|     gap: var(--spacing-sm); /* Was 8px */ | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Was 12px */ | ||||
|     padding: 6px; /* Specific padding */ | ||||
| } | ||||
|  | ||||
| .tab-btn { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); /* Was 8px */ | ||||
|     background: transparent; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: none; | ||||
|     padding: 12px 16px; /* Specific padding */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .tab-btn::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: -100%; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); | ||||
|     transition: left 0.5s ease; | ||||
| } | ||||
|  | ||||
| .tab-btn:hover::before { | ||||
|     left: 100%; | ||||
| } | ||||
|  | ||||
| .tab-btn:hover { | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     transform: translateY(-1px); | ||||
| } | ||||
|  | ||||
| .tab-btn.active { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     box-shadow: 0 0 20px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .tab-btn i { | ||||
|     font-size: 16px; | ||||
| } | ||||
|  | ||||
| .projects-actions { | ||||
|     display: flex; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .action-btn { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); /* Was 8px */ | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 12px 20px; /* Specific padding */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .action-btn.primary { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color for primary button */ | ||||
|     box-shadow: 0 0 15px color-mix(in srgb, var(--primary-accent) 20%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .action-btn:hover { | ||||
|     transform: translateY(-2px); | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); /* Specific shadow */ | ||||
|     background: var(--surface-medium); /* Example: align hover bg with common */ | ||||
|     border-color: var(--primary-accent); /* Example: align hover border with common */ | ||||
| } | ||||
|  | ||||
| .action-btn.primary:hover { | ||||
|     box-shadow: 0 4px 25px color-mix(in srgb, var(--primary-accent) 40%, transparent); /* Glow with common primary */ | ||||
|     background: color-mix(in srgb, var(--primary-accent) 85%, white); /* Common primary button hover */ | ||||
| } | ||||
|  | ||||
| /* Content Area */ | ||||
| .projects-content { | ||||
|     flex: 1; | ||||
|     overflow: hidden; | ||||
|     border-radius: 12px; | ||||
| } | ||||
|  | ||||
| /* Kanban Board */ | ||||
| .kanban-board { | ||||
|     display: flex; | ||||
|     gap: 20px; | ||||
|     height: 100%; | ||||
|     overflow-x: auto; | ||||
|     padding: 8px; | ||||
| } | ||||
|  | ||||
| .kanban-column { | ||||
|     min-width: 300px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .column-header { | ||||
|     padding: 16px 20px; | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .column-header h3 { | ||||
|     margin: 0; | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .task-count { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     padding: 4px 8px; | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .column-content { | ||||
|     flex: 1; | ||||
|     padding: 16px; | ||||
|     overflow-y: auto; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| /* Task Cards */ | ||||
| .task-card { | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     padding: var(--spacing-md); /* Was 16px */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .task-card::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: linear-gradient(135deg, rgba(255,255,255,0.05), transparent); | ||||
|     opacity: 0; | ||||
|     transition: opacity 0.2s ease; | ||||
| } | ||||
|  | ||||
| .task-card:hover::before { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| .task-card:hover { | ||||
|     transform: translateY(-2px); | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); /* Specific shadow */ | ||||
| } | ||||
|  | ||||
| .task-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .task-priority { | ||||
|     width: 8px; | ||||
|     height: 8px; | ||||
|     border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .task-id { | ||||
|     font-size: 11px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .task-title { | ||||
|     margin: 0 0 8px 0; | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     line-height: 1.3; | ||||
| } | ||||
|  | ||||
| .task-description { | ||||
|     margin: 0 0 12px 0; | ||||
|     font-size: 12px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
|     display: -webkit-box; | ||||
|     -webkit-line-clamp: 2; | ||||
|     -webkit-box-orient: vertical; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .task-footer { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .task-assignee { | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     border-radius: 50%; | ||||
|     overflow: hidden; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .task-assignee img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: cover; | ||||
| } | ||||
|  | ||||
| .task-assignee.unassigned { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     color: var(--text-muted); | ||||
|     font-size: 10px; | ||||
| } | ||||
|  | ||||
| .story-points { | ||||
|     background: #06b6d4; /* Literal info color */ | ||||
|     color: var(--bg-dark); /* Text on cyan */ | ||||
|     padding: 2px 6px; | ||||
|     border-radius: 4px; | ||||
|     font-size: 11px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .task-tags { | ||||
|     display: flex; | ||||
|     gap: 4px; | ||||
| } | ||||
|  | ||||
| .tag { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     color: var(--text-muted); /* Common.css variable */ | ||||
|     padding: 2px 6px; | ||||
|     border-radius: 4px; | ||||
|     font-size: 10px; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .add-task-placeholder { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     gap: var(--spacing-sm); /* Was 8px */ | ||||
|     padding: var(--spacing-md); /* Was 16px */ | ||||
|     border: 2px dashed var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     color: var(--text-muted); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease; | ||||
|     margin-top: auto; | ||||
| } | ||||
|  | ||||
| .add-task-placeholder:hover { | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--primary-accent); /* Common.css variable */ | ||||
|     background: color-mix(in srgb, var(--primary-accent) 5%, transparent); /* Use common primary with alpha */ | ||||
| } | ||||
|  | ||||
| /* Epics View */ | ||||
| .epics-view { | ||||
|     height: 100%; | ||||
|     overflow-y: auto; | ||||
|     padding: 8px; | ||||
| } | ||||
|  | ||||
| .epics-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | ||||
|     gap: 20px; | ||||
| } | ||||
|  | ||||
| .epic-card { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     overflow: hidden; | ||||
|     transition: all 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .epic-card:hover { | ||||
|     transform: translateY(-4px); | ||||
|     box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .epic-header { | ||||
|     padding: 20px; | ||||
|     color: white; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .epic-header::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: linear-gradient(135deg, rgba(255,255,255,0.1), transparent); | ||||
| } | ||||
|  | ||||
| .epic-header h3 { | ||||
|     margin: 0 0 8px 0; | ||||
|     font-size: 18px; | ||||
|     font-weight: 700; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .epic-status { | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
|     opacity: 0.9; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .epic-content { | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| .epic-description { | ||||
|     margin: 0 0 16px 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| .epic-progress { | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .progress-bar { | ||||
|     width: 100%; | ||||
|     height: 6px; | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-small); /* Common.css variable */ | ||||
|     overflow: hidden; | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .progress-fill { | ||||
|     height: 100%; | ||||
|     border-radius: 3px; | ||||
|     transition: width 0.3s ease; | ||||
| } | ||||
|  | ||||
| .progress-text { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .epic-footer { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
| } | ||||
|  | ||||
| .epic-owner { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .epic-owner img, | ||||
| .avatar-placeholder { | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     border-radius: 50%; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .avatar-placeholder { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     color: var(--text-muted); | ||||
|     font-size: 10px; | ||||
| } | ||||
|  | ||||
| .epic-owner span { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-secondary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .epic-date { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| /* Sprints View */ | ||||
| .sprints-view { | ||||
|     height: 100%; | ||||
|     overflow-y: auto; | ||||
|     padding: 8px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 16px; | ||||
| } | ||||
|  | ||||
| .sprint-card { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-lg); /* Was 20px */ | ||||
|     transition: all 0.2s ease; | ||||
|     cursor: pointer; | ||||
| } | ||||
|  | ||||
| .sprint-card:hover { | ||||
|     transform: translateY(-2px); | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); /* Specific shadow */ | ||||
| } | ||||
|  | ||||
| .sprint-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: flex-start; | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .sprint-info h3 { | ||||
|     margin: 0 0 4px 0; | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .sprint-goal { | ||||
|     margin: 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .sprint-badge { | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     padding: 6px 12px; | ||||
|     border-radius: 16px; | ||||
|     font-size: 12px; | ||||
|     font-weight: 600; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .sprint-badge.active { | ||||
|     background: #10b981; /* Literal success color */ | ||||
|     color: white; | ||||
|     box-shadow: 0 0 15px color-mix(in srgb, #10b981 30%, transparent); /* Glow with literal success */ | ||||
| } | ||||
|  | ||||
| .sprint-metrics { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(4, 1fr); | ||||
|     gap: 16px; | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .metric { | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .metric-value { | ||||
|     display: block; | ||||
|     font-size: 24px; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
|     margin-bottom: 4px; | ||||
| } | ||||
|  | ||||
| .metric-label { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     text-transform: uppercase; | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .sprint-progress { | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .sprint-dates { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| /* Roadmap View */ | ||||
| .roadmap-view { | ||||
|     height: 100%; | ||||
|     overflow-y: auto; | ||||
|     padding: 8px 40px; | ||||
| } | ||||
|  | ||||
| .roadmap-timeline { | ||||
|     position: relative; | ||||
|     padding-left: 40px; | ||||
| } | ||||
|  | ||||
| .roadmap-timeline::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     left: 20px; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     width: 2px; | ||||
|     background: var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .roadmap-item { | ||||
|     position: relative; | ||||
|     margin-bottom: 32px; | ||||
|     padding-left: 40px; | ||||
| } | ||||
|  | ||||
| .roadmap-marker { | ||||
|     position: absolute; | ||||
|     left: -28px; | ||||
|     top: 8px; | ||||
|     width: 16px; | ||||
|     height: 16px; | ||||
|     border-radius: 50%; | ||||
|     border: 4px solid var(--surface-dark); /* Common.css variable */ | ||||
|     box-shadow: 0 0 0 2px var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .roadmap-content { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-lg); /* Was 20px */ | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .roadmap-content:hover { | ||||
|     transform: translateX(8px); | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .roadmap-content h4 { | ||||
|     margin: 0 0 8px 0; | ||||
|     font-size: 16px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .roadmap-content p { | ||||
|     margin: 0 0 16px 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     line-height: 1.5; | ||||
| } | ||||
|  | ||||
| .roadmap-progress { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 12px; | ||||
| } | ||||
|  | ||||
| .roadmap-progress .progress-bar { | ||||
|     flex: 1; | ||||
| } | ||||
|  | ||||
| .roadmap-progress span { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| /* Analytics View */ | ||||
| .analytics-view { | ||||
|     height: 100%; | ||||
|     overflow-y: auto; | ||||
|     padding: 8px; | ||||
| } | ||||
|  | ||||
| .analytics-grid { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | ||||
|     gap: 20px; | ||||
| } | ||||
|  | ||||
| .analytics-card { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-lg); /* Was 24px */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 16px; | ||||
|     transition: all 0.2s ease; | ||||
|     cursor: pointer; | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .analytics-card::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     background: linear-gradient(135deg, rgba(255,255,255,0.05), transparent); | ||||
|     opacity: 0; | ||||
|     transition: opacity 0.2s ease; | ||||
| } | ||||
|  | ||||
| .analytics-card:hover::before { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| .analytics-card:hover { | ||||
|     transform: translateY(-4px); | ||||
|     box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); | ||||
| } | ||||
|  | ||||
| .card-icon { | ||||
|     width: 48px; | ||||
|     height: 48px; | ||||
|     border-radius: 12px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     font-size: 20px; | ||||
|     color: white; | ||||
|     position: relative; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .card-icon.tasks { background: var(--primary-accent); } /* Common.css variable */ | ||||
| .card-icon.completed { background: #10b981; } /* Literal success color */ | ||||
| .card-icon.progress { background: #f59e0b; } /* Literal warning color */ | ||||
| .card-icon.epics { background: #8b5cf6; } /* Literal accent color */ | ||||
| .card-icon.sprints { background: #06b6d4; } /* Literal info color */ | ||||
| /* Text color for .card-icon.progress should be var(--bg-dark) for contrast */ | ||||
| .card-icon.progress { color: var(--bg-dark); } | ||||
| .card-icon.sprints { color: var(--bg-dark); } /* Text on cyan */ | ||||
|  | ||||
| .card-content h3 { | ||||
|     margin: 0 0 4px 0; | ||||
|     font-size: 28px; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .card-content p { | ||||
|     margin: 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-secondary); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| /* Gantt View */ | ||||
| .gantt-view { | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     overflow: hidden; /* Allow internal scrolling for chart */ | ||||
|     padding: 8px; | ||||
| } | ||||
|  | ||||
| .gantt-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 16px; | ||||
|     padding: 0 8px; | ||||
| } | ||||
|  | ||||
| .gantt-header h3 { | ||||
|     margin: 0; | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .gantt-controls { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .gantt-zoom-btn { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 8px 12px; | ||||
|     border-radius: var(--border-radius-medium); /* Was 6px */ | ||||
|     cursor: pointer; | ||||
|     font-size: 13px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .gantt-zoom-btn:hover { | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .gantt-zoom-btn.active { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text on primary accent */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
|     box-shadow: 0 0 10px color-mix(in srgb, var(--primary-accent) 30%, transparent); /* Glow with common primary */ | ||||
| } | ||||
|  | ||||
| .gantt-chart { | ||||
|     flex: 1; | ||||
|     overflow-x: auto; /* Horizontal scroll for timeline */ | ||||
|     overflow-y: auto; /* Vertical scroll for rows */ | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-md); /* Was 16px */ | ||||
| } | ||||
|  | ||||
| .gantt-timeline { | ||||
|     position: relative; | ||||
|     min-width: 1200px; /* Ensure there's enough space for a year view, adjust as needed */ | ||||
| } | ||||
|  | ||||
| .timeline-header { | ||||
|     position: sticky; | ||||
|     top: 0; | ||||
|     background: var(--surface-light); /* Common.css variable */ | ||||
|     z-index: 10; | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding-bottom: var(--spacing-sm); /* Was 8px */ | ||||
|     margin-bottom: 8px; | ||||
| } | ||||
|  | ||||
| .timeline-months { | ||||
|     display: flex; | ||||
|     white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .timeline-month { | ||||
|     flex: 1 0 auto; /* Allow shrinking but prefer base size */ | ||||
|     min-width: 80px; /* Approximate width for a month, adjust as needed */ | ||||
|     text-align: center; | ||||
|     padding: 8px 0; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     font-size: 12px; | ||||
|     font-weight: 500; | ||||
|     border-right: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-month:last-child { | ||||
|     border-right: none; | ||||
| } | ||||
|  | ||||
| .gantt-rows { | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .gantt-row { | ||||
|     display: flex; | ||||
|     align-items: stretch; /* Make label and timeline same height */ | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     transition: background-color var(--transition-speed) ease; | ||||
| } | ||||
|  | ||||
| .gantt-row:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .gantt-row:hover { | ||||
|     background-color: var(--surface-medium); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .gantt-row-label { | ||||
|     width: 250px; /* Fixed width for labels */ | ||||
|     padding: 12px 16px; | ||||
|     border-right: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     background-color: var(--surface-light); /* Common.css variable */ | ||||
|     flex-shrink: 0; /* Prevent label from shrinking */ | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
| } | ||||
|  | ||||
| .epic-info h4 { | ||||
|     margin: 0 0 4px 0; | ||||
|     font-size: 14px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
|     white-space: nowrap; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .epic-progress-text { | ||||
|     font-size: 11px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .gantt-row-timeline { | ||||
|     flex: 1; | ||||
|     position: relative; | ||||
|     padding: 12px 0; /* Vertical padding for bars */ | ||||
|     min-height: 40px; /* Ensure row has some height for the bar */ | ||||
| } | ||||
|  | ||||
| .gantt-bar { | ||||
|     position: absolute; | ||||
|     height: 24px; /* Height of the bar */ | ||||
|     top: 50%; | ||||
|     transform: translateY(-50%); | ||||
|     border-radius: var(--border-radius-small); /* Common.css variable */ | ||||
|     background-color: var(--primary-accent); /* Default bar color, Common.css variable */ | ||||
|     box-shadow: 0 2px 5px rgba(0,0,0,0.2); /* Specific shadow */ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     overflow: hidden; | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .gantt-bar:hover { | ||||
|     opacity: 1 !important; /* Ensure hover is visible */ | ||||
|     transform: translateY(-50%) scale(1.02); | ||||
| } | ||||
|  | ||||
| .gantt-progress { | ||||
|     height: 100%; | ||||
|     background-color: #10b981; /* Literal success color */ | ||||
|     border-radius: var(--border-radius-small) 0 0 var(--border-radius-small); /* Common.css variable */ | ||||
|     opacity: 0.7; | ||||
|     transition: width 0.3s ease; | ||||
| } | ||||
|  | ||||
| .gantt-bar .gantt-progress[style*="width: 100%"] { | ||||
|     border-radius: var(--border-radius-small); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Scrollbar styling is now handled globally by common.css */ | ||||
|  | ||||
| /* Responsive design */ | ||||
| @media (max-width: 768px) { | ||||
|     .projects-view-container { | ||||
|         margin: 20px; | ||||
|         height: calc(100vh - 80px); | ||||
|     } | ||||
|      | ||||
|     .projects-header { | ||||
|         flex-direction: column; | ||||
|         gap: 16px; | ||||
|         align-items: stretch; | ||||
|     } | ||||
|      | ||||
|     .projects-tabs { | ||||
|         justify-content: center; | ||||
|     } | ||||
|      | ||||
|     .kanban-board { | ||||
|         flex-direction: column; | ||||
|         gap: 16px; | ||||
|     } | ||||
|      | ||||
|     .kanban-column { | ||||
|         min-width: auto; | ||||
|         max-height: 400px; | ||||
|     } | ||||
|      | ||||
|     .epics-grid { | ||||
|         grid-template-columns: 1fr; | ||||
|     } | ||||
|      | ||||
|     .sprint-metrics { | ||||
|         grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
|      | ||||
|     .analytics-grid { | ||||
|         grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @media (max-width: 480px) { | ||||
|     .projects-tabs { | ||||
|         flex-wrap: wrap; | ||||
|     } | ||||
|      | ||||
|     .tab-btn { | ||||
|         flex: 1; | ||||
|         min-width: 0; | ||||
|         justify-content: center; | ||||
|     } | ||||
|      | ||||
|     .tab-btn span { | ||||
|         display: none; | ||||
|     } | ||||
|      | ||||
|     .sprint-metrics, | ||||
|     .analytics-grid { | ||||
|         grid-template-columns: 1fr; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1477
									
								
								src/app/static/css/publishing_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1477
									
								
								src/app/static/css/publishing_view.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										320
									
								
								src/app/static/css/timeline_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										320
									
								
								src/app/static/css/timeline_view.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,320 @@ | ||||
| /* Timeline View - Ultra Minimalistic Design */ | ||||
| /* :root variables moved to common.css or are view-specific if necessary */ | ||||
|  | ||||
| .timeline-view-container { | ||||
|     /* Extends .view-container from common.css but with flex-direction: row */ | ||||
|     flex-direction: row; /* Specific direction for this view */ | ||||
|     height: calc(100vh - 120px); /* Specific height */ | ||||
|     margin: 60px 40px 60px 40px; /* Specific margins */ | ||||
|     gap: var(--spacing-lg); /* Was 24px */ | ||||
|     /* font-family will be inherited from common.css body */ | ||||
|     /* Other .view-container properties are inherited or set by common.css */ | ||||
| } | ||||
|  | ||||
| .timeline-sidebar { | ||||
|     width: 280px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-lg); /* Was 24px */ | ||||
|     overflow-y: auto; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .timeline-sidebar h4 { | ||||
|     margin: 0 0 16px 0; | ||||
|     font-size: 14px; | ||||
|     color: var(--text-primary); | ||||
|     font-weight: 600; | ||||
|     letter-spacing: 0.5px; | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .sidebar-section { | ||||
|     margin-bottom: 32px; | ||||
| } | ||||
|  | ||||
| .sidebar-section:last-child { | ||||
|     margin-bottom: 0; | ||||
| } | ||||
|  | ||||
| .filter-options, | ||||
| .time-range-options { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 8px; | ||||
| } | ||||
|  | ||||
| .filter-btn { | ||||
|     background: transparent; | ||||
|     color: var(--text-secondary); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     padding: 12px 16px; | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     cursor: pointer; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     transition: all 0.2s ease; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| .filter-btn:hover { | ||||
|     background: var(--surface-medium); /* Common.css variable for hover */ | ||||
|     color: var(--text-primary); /* Common.css variable */ | ||||
|     border-color: var(--primary-accent); /* Common interaction color */ | ||||
| } | ||||
|  | ||||
| .filter-btn.active { | ||||
|     background: var(--primary-accent); /* Common.css variable */ | ||||
|     color: var(--bg-dark); /* Text color on primary accent */ | ||||
|     border-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-content { | ||||
|     flex: 1; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     overflow-y: auto; | ||||
|     padding: var(--spacing-lg); /* Was 24px */ | ||||
| } | ||||
|  | ||||
| .timeline-feed { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 32px; | ||||
| } | ||||
|  | ||||
| .timeline-day-group { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .timeline-date-header { | ||||
|     margin-bottom: 20px; | ||||
|     padding-bottom: 12px; | ||||
|     border-bottom: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-date-header h3 { | ||||
|     margin: 0; | ||||
|     font-size: 18px; | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .timeline-day-actions { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 20px; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .timeline-day-actions::before { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     left: 32px; | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     width: 2px; | ||||
|     background: var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-action { | ||||
|     display: flex; | ||||
|     gap: 16px; | ||||
|     position: relative; | ||||
|     padding-left: 8px; | ||||
| } | ||||
|  | ||||
| .timeline-action-avatar { | ||||
|     position: relative; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .timeline-action-avatar img { | ||||
|     width: 48px; | ||||
|     height: 48px; | ||||
|     border-radius: 50%; | ||||
|     border: 2px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-action-icon { | ||||
|     position: absolute; | ||||
|     bottom: -4px; | ||||
|     right: -4px; | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     border-radius: 50%; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     border: 2px solid var(--surface-dark); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-action-icon i { | ||||
|     font-size: 10px; | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| .timeline-action-content { | ||||
|     flex: 1; | ||||
|     background: var(--surface-medium); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-large); /* Common.css variable */ | ||||
|     padding: var(--spacing-md); /* Was 16px */ | ||||
|     transition: all 0.2s ease; | ||||
| } | ||||
|  | ||||
| .timeline-action-content:hover { | ||||
|     border-color: var(--primary-accent); /* Common interaction color */ | ||||
| } | ||||
|  | ||||
| .timeline-action-header { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     align-items: center; | ||||
|     gap: 6px; | ||||
|     margin-bottom: 8px; | ||||
|     font-size: 14px; | ||||
|     line-height: 1.4; | ||||
| } | ||||
|  | ||||
| .timeline-actor-name { | ||||
|     font-weight: 600; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .timeline-action-title { | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .timeline-circle-name { | ||||
|     color: var(--text-muted); | ||||
|     font-size: 13px; | ||||
| } | ||||
|  | ||||
| .timeline-action-description { | ||||
|     margin: 12px 0; | ||||
|     font-size: 14px; | ||||
|     line-height: 1.5; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .timeline-action-target { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: var(--spacing-sm); /* Was 8px */ | ||||
|     margin: 12px 0; | ||||
|     padding: 8px 12px; | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
|     border-radius: var(--border-radius-medium); /* Common.css variable */ | ||||
|     font-size: 13px; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .timeline-action-target i { | ||||
|     color: var(--text-muted); | ||||
|     font-size: 12px; | ||||
| } | ||||
|  | ||||
| .timeline-action-footer { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-top: 12px; | ||||
|     padding-top: 12px; | ||||
|     border-top: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-timestamp { | ||||
|     font-size: 12px; | ||||
|     color: var(--text-muted); | ||||
|     font-weight: 500; | ||||
| } | ||||
|  | ||||
| .timeline-metadata { | ||||
|     display: flex; | ||||
|     gap: 8px; | ||||
|     flex-wrap: wrap; | ||||
| } | ||||
|  | ||||
| .metadata-tag { | ||||
|     background: var(--surface-dark); /* Common.css variable */ | ||||
|     color: var(--text-muted); /* Common.css variable */ | ||||
|     padding: 4px 8px; | ||||
|     border-radius: var(--border-radius-small); /* Common.css variable */ | ||||
|     font-size: 11px; | ||||
|     font-weight: 500; | ||||
|     border: 1px solid var(--border-color); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .empty-state { | ||||
|     text-align: center; | ||||
|     color: var(--text-secondary); | ||||
|     font-size: 14px; | ||||
|     margin-top: 40px; | ||||
| } | ||||
|  | ||||
| /* Action type color variations */ | ||||
| .timeline-action-icon.primary { | ||||
|     background-color: var(--primary-accent); /* Common.css variable */ | ||||
| } | ||||
|  | ||||
| .timeline-action-icon.secondary { | ||||
|     background-color: #6b7280; /* Literal secondary color */ | ||||
| } | ||||
|  | ||||
| .timeline-action-icon.success { | ||||
|     background-color: #10b981; /* Literal success color */ | ||||
| } | ||||
|  | ||||
| .timeline-action-icon.warning { | ||||
|     background-color: #f59e0b; /* Literal warning color */ | ||||
| } | ||||
| .timeline-action-icon.warning i { color: var(--bg-dark); } /* Adjust icon color for contrast */ | ||||
|  | ||||
| .timeline-action-icon.info { | ||||
|     background-color: #06b6d4; /* Literal info color */ | ||||
| } | ||||
| .timeline-action-icon.info i { color: var(--bg-dark); } /* Adjust icon color for contrast */ | ||||
|  | ||||
| .timeline-action-icon.accent { | ||||
|     background-color: #8b5cf6; /* Literal accent color */ | ||||
| } | ||||
|  | ||||
| /* Scrollbar styling is now handled globally by common.css */ | ||||
|  | ||||
| /* Responsive design */ | ||||
| @media (max-width: 768px) { | ||||
|     .timeline-view-container { | ||||
|         flex-direction: column; | ||||
|         margin: 20px; | ||||
|         height: calc(100vh - 80px); | ||||
|     } | ||||
|      | ||||
|     .timeline-sidebar { | ||||
|         width: 100%; | ||||
|         height: auto; | ||||
|         max-height: 200px; | ||||
|     } | ||||
|      | ||||
|     .timeline-day-actions::before { | ||||
|         left: 24px; | ||||
|     } | ||||
|      | ||||
|     .timeline-action-avatar img { | ||||
|         width: 40px; | ||||
|         height: 40px; | ||||
|     } | ||||
|      | ||||
|     .timeline-action-icon { | ||||
|         width: 20px; | ||||
|         height: 20px; | ||||
|     } | ||||
|      | ||||
|     .timeline-action-icon i { | ||||
|         font-size: 8px; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1652
									
								
								src/app/static/css/treasury_view.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1652
									
								
								src/app/static/css/treasury_view.css
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										113
									
								
								src/app/static/shokunin_World_Map.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/app/static/shokunin_World_Map.html
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| After Width: | Height: | Size: 36 KiB | 
							
								
								
									
										45
									
								
								src/app/static/styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/static/styles.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| /* app/static/styles.css */ | ||||
| /* Contains remaining global variables or styles not covered by common.css or specific view CSS files. */ | ||||
|  | ||||
| .auth-view-container { | ||||
|     position: absolute; | ||||
|     top: 10px; | ||||
|     right: 10px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 10px; | ||||
|     background-color: rgba(255, 255, 255, 0.1); | ||||
|     padding: 5px 10px; | ||||
|     border-radius: 5px; | ||||
| } | ||||
|  | ||||
| .public-key { | ||||
|     color: #a0a0a0; | ||||
|     font-family: 'Courier New', Courier, monospace; | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .logout-button { | ||||
|     background: none; | ||||
|     border: none; | ||||
|     color: #a0a0a0; | ||||
|     cursor: pointer; | ||||
|     font-size: 1.2em; | ||||
| } | ||||
|  | ||||
| .logout-button:hover { | ||||
|     color: #ffffff; | ||||
| } | ||||
|  | ||||
| :root { | ||||
|     /* Shadow for the navigation island, if still used and distinct from common shadows */ | ||||
|     --island-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); | ||||
|  | ||||
|     /* Any other app-specific global variables that don't fit in common.css can go here. */ | ||||
| } | ||||
|  | ||||
| /* | ||||
|   Global reset (*), body styles, and most common variables have been moved to common.css. | ||||
|   Styles for .circles-view have been moved to circles_view.css. | ||||
|   Styles for .dashboard-view have been moved to dashboard_view.css. | ||||
| */ | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user