diff --git a/index.html b/index.html index c60e235..ce27b9c 100644 --- a/index.html +++ b/index.html @@ -1,148 +1,379 @@ - + - - + + - + - eframe template + Waffle - Template - + + + - - - - - - + + + + + + + + + + + + + + + - + - +
-

- Loading… -

-
+

Loading…

+
- + + + + +
+
+
+ +
+
+ + + diff --git a/src/app.rs b/src/app.rs index 08cd25c..d84b367 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,15 +3,11 @@ use crate::utility::show_loading_spinner_custom; use egui::Id; use crate::db::github::{GithubDb, Repository}; use crate::db::idb::LANGUAGES; +use crate::erust::uiux::search::SearchWidget; +use crate::erust::state::{AppState, WaffleState}; +use crate::erust::uiux::auth::AuthWidget; #[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)] -pub enum AppState { - Init, - Normal, - Empty, -} - -#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)] pub enum LoadingState { Idle, Loading { @@ -58,9 +54,15 @@ pub struct TemplateApp { #[serde(skip)] pending_app_state: Option, #[serde(skip)] + waffle_state: WaffleState, + #[serde(skip)] filtered_repos: Option>, #[serde(skip)] filter_loading: bool, + #[serde(skip)] + search_widget: Option, + #[serde(skip)] + auth_widget: AuthWidget, } impl Default for TemplateApp { @@ -77,8 +79,11 @@ impl Default for TemplateApp { toast_timer: 0.0, app_state: AppState::Init, pending_app_state: None, + waffle_state: WaffleState::new(), filtered_repos: None, filter_loading: false, + search_widget: Some(SearchWidget::new()), + auth_widget: AuthWidget::new(false), } } } @@ -141,17 +146,10 @@ impl TemplateApp { pub fn filter_repos_async(&mut self, query: &str, ctx: &egui::Context) { self.filter_loading = true; self.filtered_repos = None; - let query = query.to_string(); - let language = self.db.get_language(); - let ctx = ctx.clone(); // keep this, as it is used in the async block - wasm_bindgen_futures::spawn_local(async move { - let result = match crate::db::idb::open_waffle_db().await { - Ok(db_conn) => crate::db::idb::filter_repos_in_idb::(&db_conn, &language, &query).await.unwrap_or_default(), - Err(_) => vec![], - }; - ctx.data_mut(|d| d.insert_temp(Id::new("waffle_filtered_repos"), result)); - ctx.request_repaint(); - }); + if let Some(widget) = &mut self.search_widget { + widget.query = query.to_string(); + widget.search(&self.db.get_language(), ctx); + } } pub fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -300,6 +298,14 @@ impl TemplateApp { let filtered = self.filtered_repos.as_ref().cloned().unwrap_or_default(); ui.separator(); ui.label(format!("Results: {}", filtered.len())); + // --- Show app state at the bottom --- + ui.separator(); + ui.label(format!("App State: {:?}", self.waffle_state.app_state)); + if !self.waffle_state.log.is_empty() { + ui.separator(); + ui.label("App Log:"); + ui.label(&self.waffle_state.log); + } }); egui::CentralPanel::default().show(ctx, |ui| { // --- Logo image loading and display using egui_extras loader system --- @@ -337,16 +343,26 @@ impl TemplateApp { } }); + // --- Authentication Widget (Login/Register) --- + egui::Window::new("Authentication") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, egui::Vec2::new(0.0, 40.0)) + .show(ctx, |ui| { + self.auth_widget.show(ctx, ui); + }); + // Update filtered_repos from egui context temp data if available - if let Some(repos) = ctx.data(|d| d.get_temp::>(Id::new("waffle_filtered_repos"))) { - let is_empty = repos.is_empty(); - self.filtered_repos = Some(repos.clone()); - // Set app_state based on whether there is data - if is_empty { - self.app_state = AppState::Empty; + if let Some(widget) = &mut self.search_widget { + widget.update_results_from_ctx(ctx); + // Use WaffleState to manage app state + if !widget.results.is_empty() { + self.waffle_state.set_ready(widget.results.clone()); } else { - self.app_state = AppState::Normal; + self.waffle_state.set_empty(); } + self.filtered_repos = Some(self.waffle_state.filtered_repos.clone()); + self.app_state = self.waffle_state.app_state.clone(); } // Show welcome dialog if DB is empty diff --git a/src/core/mod.rs b/src/core/mod.rs index 037eb02..e69de29 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1 +0,0 @@ -pub mod uiux; \ No newline at end of file diff --git a/src/core/uiux/mod.rs b/src/core/uiux/mod.rs index e092c24..e69de29 100644 --- a/src/core/uiux/mod.rs +++ b/src/core/uiux/mod.rs @@ -1 +0,0 @@ -pub mod search; \ No newline at end of file diff --git a/src/core/uiux/search.rs b/src/core/uiux/search.rs index b8e494d..e69de29 100644 --- a/src/core/uiux/search.rs +++ b/src/core/uiux/search.rs @@ -1,41 +0,0 @@ -use crate::db::github::Repository; -use crate::db::idb; -use egui::{Context, Id}; - -pub struct SearchWidget { - pub query: String, - pub results: Vec, - pub loading: bool, -} - -impl SearchWidget { - pub fn new() -> Self { - Self { - query: String::new(), - results: Vec::new(), - loading: false, - } - } - - pub fn search(&mut self, language: &str, ctx: &Context) { - let query = self.query.clone(); - let language = language.to_string(); - let ctx = ctx.clone(); - self.loading = true; - wasm_bindgen_futures::spawn_local(async move { - let result = match idb::open_waffle_db().await { - Ok(db_conn) => idb::filter_repos_in_idb::(&db_conn, &language, &query).await.unwrap_or_default(), - Err(_) => vec![], - }; - ctx.data_mut(|d| d.insert_temp(Id::new("waffle_search_results"), result)); - ctx.request_repaint(); - }); - } - - pub fn update_results_from_ctx(&mut self, ctx: &Context) { - if let Some(results) = ctx.data(|d| d.get_temp::>(Id::new("waffle_search_results"))) { - self.results = results.clone(); - self.loading = false; - } - } -} diff --git a/src/erust/mod.rs b/src/erust/mod.rs new file mode 100644 index 0000000..43dc6d8 --- /dev/null +++ b/src/erust/mod.rs @@ -0,0 +1,2 @@ +pub mod uiux; +pub mod state; \ No newline at end of file diff --git a/src/state.rs b/src/erust/state.rs similarity index 60% rename from src/state.rs rename to src/erust/state.rs index afc8bec..ab82a5c 100644 --- a/src/state.rs +++ b/src/erust/state.rs @@ -3,8 +3,10 @@ use crate::db::github::Repository; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppState { Init, - Normal, Empty, + Syncing, + Normal, + Ready, Error(String), } @@ -12,6 +14,7 @@ pub enum AppState { pub struct WaffleState { pub app_state: AppState, pub filtered_repos: Vec, + pub log: String, } impl WaffleState { @@ -19,6 +22,7 @@ impl WaffleState { Self { app_state: AppState::Init, filtered_repos: Vec::new(), + log: String::new(), } } @@ -27,21 +31,31 @@ impl WaffleState { self.filtered_repos.clear(); } - pub fn set_normal(&mut self, repos: Vec) { + pub fn set_syncing(&mut self) { + self.app_state = AppState::Syncing; + } + + pub fn set_ready(&mut self, repos: Vec) { if repos.is_empty() { self.set_empty(); } else { - self.app_state = AppState::Normal; + self.app_state = AppState::Ready; self.filtered_repos = repos; } } pub fn set_error(&mut self, msg: String) { - self.app_state = AppState::Error(msg); + self.app_state = AppState::Error(msg.clone()); + self.log.push_str(&format!("Error: {}\n", msg)); self.filtered_repos.clear(); } + pub fn log(&mut self, msg: &str) { + self.log.push_str(msg); + self.log.push('\n'); + } + pub fn is_ready(&self) -> bool { - matches!(self.app_state, AppState::Normal) + matches!(self.app_state, AppState::Ready) } } diff --git a/src/erust/uiux/auth.rs b/src/erust/uiux/auth.rs new file mode 100644 index 0000000..36ee717 --- /dev/null +++ b/src/erust/uiux/auth.rs @@ -0,0 +1,197 @@ +// erust/uiux/auth.rs +use egui::{Context, Ui}; +use crate::erust::uiux::hcaptcha; +use crate::erust::uiux::javascript_interop; + +pub enum AuthView { + Login, + Register, + Help, +} + +pub struct AuthWidget { + pub email: String, + pub password: String, + pub confirm_password: String, // Only used for Register + pub captcha_token: Option, + pub error: Option, + pub view: AuthView, + pub is_registered: bool, +} + +impl AuthWidget { + pub fn new(is_registered: bool) -> Self { + Self { + email: String::new(), + password: String::new(), + confirm_password: String::new(), + captcha_token: None, + error: None, + view: if is_registered { AuthView::Login } else { AuthView::Register }, + is_registered, + } + } + + pub fn show(&mut self, ctx: &Context, ui: &mut Ui) { + match self.view { + AuthView::Login => self.show_login(ctx, ui), + AuthView::Register => self.show_register(ctx, ui), + AuthView::Help => self.show_help(ctx, ui), + } + } + + fn show_login(&mut self, _ctx: &Context, ui: &mut Ui) { + ui.heading("Login"); + ui.separator(); + ui.label("Email:"); + ui.text_edit_singleline(&mut self.email); + ui.label("Password:"); + ui.add(egui::TextEdit::singleline(&mut self.password).password(true)); + ui.separator(); + + if ui.button("Solve Captcha").clicked() { + hcaptcha::open_captcha(); + } + // Always show the captcha status message, and force a repaint if token is set + // Only show the solved message, not the token + if hcaptcha::get_captcha_token().is_some() { + ui.colored_label(egui::Color32::GREEN, "Captcha Solved: Token Set"); + ui.ctx().request_repaint(); + } else { + ui.label("Captcha required"); + } + if ui.button("Login").clicked() { + if self.email.is_empty() || self.password.is_empty() || hcaptcha::get_captcha_token().is_none() { + self.error = Some("All fields and captcha are required".to_string()); + } else { + let _token = hcaptcha::take_captcha_token(); + // self.error = Some("(Stub) Would call Supabase login here".to_string()); + login_with_js(self.email.as_str(), self.password.as_str(), _token.as_deref().unwrap_or("")); + } + } + if let Some(err) = &self.error { + ui.colored_label(egui::Color32::RED, err); + } + ui.horizontal(|ui| { + if ui.button("Register").clicked() { + self.view = AuthView::Register; + self.error = None; + } + if ui.button("Help").clicked() { + self.view = AuthView::Help; + self.error = None; + } + }); + } + + fn show_register(&mut self, _ctx: &Context, ui: &mut Ui) { + ui.heading("Register"); + ui.label("Email:"); + ui.text_edit_singleline(&mut self.email); + ui.label("Password:"); + ui.add(egui::TextEdit::singleline(&mut self.password).password(true)); + ui.label("Confirm Password:"); + ui.add(egui::TextEdit::singleline(&mut self.confirm_password).password(true)); + if ui.button("Solve Captcha").clicked() { + hcaptcha::open_captcha(); + } + // Always show the captcha status message, and force a repaint if token is set + // Only show the solved message, not the token + if hcaptcha::get_captcha_token().is_some() { + ui.colored_label(egui::Color32::GREEN, "Captcha Solved: Token Set"); + ui.ctx().request_repaint(); + } else { + ui.label("Captcha required"); + } + if ui.button("Register").clicked() { + if self.email.is_empty() || self.password.is_empty() || self.confirm_password.is_empty() || hcaptcha::get_captcha_token().is_none() { + self.error = Some("All fields and captcha are required".to_string()); + } else if self.password != self.confirm_password { + self.error = Some("Passwords do not match".to_string()); + } else { + let token = hcaptcha::take_captcha_token(); + if let Some(token) = token { + let email = self.email.clone(); + let password = self.password.clone(); + self.error = Some("Processing registration...".to_string()); + crate::erust::uiux::auth::register_with_js( + email.as_str(), + password.as_str(), + token.as_str(), + ); + } else { + self.error = Some("Captcha token missing".to_string()); + } + } + } + // Show spinner if processing + if let Some(err) = &self.error { + if err == "Processing registration..." { + ui.horizontal(|ui| { + ui.label("Processing registration..."); + ui.add(egui::Spinner::default()); + }); + } else { + ui.colored_label(egui::Color32::RED, err); + } + } + ui.horizontal(|ui| { + if ui.button("Back to Login").clicked() { + self.view = AuthView::Login; + self.error = None; + } + if ui.button("Help").clicked() { + self.view = AuthView::Help; + self.error = None; + } + }); + } + + fn show_help(&mut self, _ctx: &Context, ui: &mut Ui) { + ui.heading("Forgot your password?"); + ui.label("Enter your email to receive a reset link:"); + ui.text_edit_singleline(&mut self.email); + if ui.button("Send Reset Link").clicked() { + if self.email.is_empty() { + self.error = Some("Email is required".to_string()); + } else { + self.error = Some("(Stub) Would call Supabase password reset here".to_string()); + } + } + if let Some(err) = &self.error { + ui.colored_label(egui::Color32::RED, err); + } + ui.horizontal(|ui| { + if ui.button("Back to Login").clicked() { + self.view = AuthView::Login; + self.error = None; + } + if ui.button("Register").clicked() { + self.view = AuthView::Register; + self.error = None; + } + }); + } +} + +/// Call this to trigger a register action via JS handler +pub fn register_with_js(email: &str, password: &str, captcha_token: &str) { + javascript_interop::send_action_message( + "register", + email, + password, + captcha_token, + ); +} + +/// Call this to trigger a login action via JS handler +pub fn login_with_js(email: &str, password: &str, captcha_token: &str) { + javascript_interop::send_action_message( + "login", + email, + password, + captcha_token, + ); +} + +// Make AuthWidget public for use in other modules diff --git a/src/erust/uiux/hcaptcha.rs b/src/erust/uiux/hcaptcha.rs new file mode 100644 index 0000000..e5e29cb --- /dev/null +++ b/src/erust/uiux/hcaptcha.rs @@ -0,0 +1,47 @@ +// hcaptcha.rs - hCaptcha integration for WASM/egui +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; +use web_sys::js_sys; +use std::cell::RefCell; + +thread_local! { + static LAST_TOKEN: RefCell> = RefCell::new(None); +} + +#[wasm_bindgen] +pub fn pass_captcha_token(token: &str) { + LAST_TOKEN.with(|t| t.replace(Some(token.to_string()))); +} + +pub fn get_captcha_token() -> Option { + LAST_TOKEN.with(|t| t.borrow().clone()) +} + +/// Returns and clears the captcha token (consume-on-use) +pub fn take_captcha_token() -> Option { + LAST_TOKEN.with(|t| t.borrow_mut().take()) +} + +pub fn clear_captcha_token() { + LAST_TOKEN.with(|t| t.replace(None)); +} + +/// Call this from Rust to open the captcha overlay +pub fn open_captcha() { + let _ = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("JSRust")) + .and_then(|f| if f.is_function() { + let func = js_sys::Function::from(f); + func.call1(&JsValue::NULL, &JsValue::from_str("openCaptcha")).ok(); + Ok(()) + } else { Ok(()) }); +} + +/// Call this from Rust to close the captcha overlay +pub fn close_captcha() { + let _ = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("JSRust")) + .and_then(|f| if f.is_function() { + let func = js_sys::Function::from(f); + func.call1(&JsValue::NULL, &JsValue::from_str("closeCaptcha")).ok(); + Ok(()) + } else { Ok(()) }); +} diff --git a/src/erust/uiux/javascript_interop.rs b/src/erust/uiux/javascript_interop.rs new file mode 100644 index 0000000..b4c69c4 --- /dev/null +++ b/src/erust/uiux/javascript_interop.rs @@ -0,0 +1,53 @@ +// javascript_interop.rs - Rust <-> JS message bridge for unified JS calls +use wasm_bindgen::JsValue; +use web_sys::js_sys; + +/// Send a message object to the global JSRust JS handler. +pub fn send_jsrust_message(message: &js_sys::Object) { + let _ = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("JSRust")) + .and_then(|f| { + if f.is_function() { + let func = js_sys::Function::from(f); + func.call1(&JsValue::NULL, message).ok(); + Ok(()) + } else { + Ok(()) + } + }); +} + +/// Helper to build and send a login/register message to JSRust +pub fn send_action_message(action: &str, email: &str, password: &str, captcha_token: &str) { + let msg = js_sys::Object::new(); + js_sys::Reflect::set(&msg, &JsValue::from_str("action"), &JsValue::from_str(action)).ok(); + js_sys::Reflect::set(&msg, &JsValue::from_str("email"), &JsValue::from_str(email)).ok(); + js_sys::Reflect::set(&msg, &JsValue::from_str("password"), &JsValue::from_str(password)).ok(); + js_sys::Reflect::set(&msg, &JsValue::from_str("captcha_token"), &JsValue::from_str(captcha_token)).ok(); + send_jsrust_message(&msg); +} + +/// Register a JS callback handler for responses from JS to Rust +/// The callback will be called with a JsValue (the response object) +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub fn set_jsrust_response_handler(cb: &js_sys::Function) { + // Store the callback globally in JS (window.JSRustResponseHandler) + let _ = js_sys::Reflect::set( + &js_sys::global(), + &JsValue::from_str("JSRustResponseHandler"), + cb, + ); +} + +/// Call this from JS to send a response back to Rust +#[wasm_bindgen] +pub fn handle_jsrust_response(response: &JsValue) { + // If a handler is set, call it with the response + if let Ok(handler) = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("JSRustResponseHandler")) { + if handler.is_function() { + let func = js_sys::Function::from(handler); + let _ = func.call1(&JsValue::NULL, response); + } + } +} diff --git a/src/erust/uiux/mod.rs b/src/erust/uiux/mod.rs new file mode 100644 index 0000000..59fe10b --- /dev/null +++ b/src/erust/uiux/mod.rs @@ -0,0 +1,5 @@ +pub mod search; +pub mod hcaptcha; +pub mod auth; +pub mod supabase; +pub mod javascript_interop; \ No newline at end of file diff --git a/src/erust/uiux/search.rs b/src/erust/uiux/search.rs new file mode 100644 index 0000000..b8e494d --- /dev/null +++ b/src/erust/uiux/search.rs @@ -0,0 +1,41 @@ +use crate::db::github::Repository; +use crate::db::idb; +use egui::{Context, Id}; + +pub struct SearchWidget { + pub query: String, + pub results: Vec, + pub loading: bool, +} + +impl SearchWidget { + pub fn new() -> Self { + Self { + query: String::new(), + results: Vec::new(), + loading: false, + } + } + + pub fn search(&mut self, language: &str, ctx: &Context) { + let query = self.query.clone(); + let language = language.to_string(); + let ctx = ctx.clone(); + self.loading = true; + wasm_bindgen_futures::spawn_local(async move { + let result = match idb::open_waffle_db().await { + Ok(db_conn) => idb::filter_repos_in_idb::(&db_conn, &language, &query).await.unwrap_or_default(), + Err(_) => vec![], + }; + ctx.data_mut(|d| d.insert_temp(Id::new("waffle_search_results"), result)); + ctx.request_repaint(); + }); + } + + pub fn update_results_from_ctx(&mut self, ctx: &Context) { + if let Some(results) = ctx.data(|d| d.get_temp::>(Id::new("waffle_search_results"))) { + self.results = results.clone(); + self.loading = false; + } + } +} diff --git a/src/erust/uiux/supabase.rs b/src/erust/uiux/supabase.rs new file mode 100644 index 0000000..f9a16d6 --- /dev/null +++ b/src/erust/uiux/supabase.rs @@ -0,0 +1,49 @@ +use serde::Serialize; +use serde::Deserialize; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SupabaseResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AuthPayload { + pub email: String, + pub password: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ProfilePayload { + pub user_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct RegisterPayload { + pub email: String, + pub password: String, + pub captcha_token: String, +} + +// Remove all #[wasm_bindgen] functions related to auth, since JS handler is now used + +// Add a public Rust function for register that calls the JS handler +pub fn register(email: &str, password: &str, captcha_token: &str) { + crate::erust::uiux::javascript_interop::send_action_message( + "register", + email, + password, + captcha_token, + ); +} + +// Add a public Rust function for login that calls the JS handler +pub fn login(email: &str, password: &str, captcha_token: &str) { + crate::erust::uiux::javascript_interop::send_action_message( + "login", + email, + password, + captcha_token, + ); +} diff --git a/src/lib.rs b/src/lib.rs index 1d6e2f3..6a6e0f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ mod app; mod db; pub use app::TemplateApp; pub use db::*; -pub mod utility; \ No newline at end of file +pub mod utility; +pub mod erust; \ No newline at end of file diff --git a/src/mod.rs b/src/mod.rs new file mode 100644 index 0000000..3a21617 --- /dev/null +++ b/src/mod.rs @@ -0,0 +1,2 @@ +pub mod erust; +pub mod state; \ No newline at end of file