add circles app and libraries
This commit is contained in:
parent
ae3077033b
commit
32bcef1d1d
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
Loading…
Reference in New Issue
Block a user