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