feat(db): Added database setup and basic types (#5)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,21 +1,65 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::sidebar::Sidebar;
|
||||
use crate::infrastructure::auth_check::check_auth;
|
||||
use crate::models::AuthInfo;
|
||||
use crate::Route;
|
||||
|
||||
/// Application shell layout that wraps all authenticated pages.
|
||||
///
|
||||
/// Renders a fixed sidebar on the left and the active child route
|
||||
/// in the scrollable main content area via `Outlet`.
|
||||
/// Calls [`check_auth`] on mount to fetch the current user's session.
|
||||
/// If unauthenticated, redirects to `/auth`. Otherwise renders the
|
||||
/// sidebar with real user data and the active child route.
|
||||
#[component]
|
||||
pub fn AppShell() -> Element {
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
email: "user@example.com".to_string(),
|
||||
avatar_url: String::new(),
|
||||
// use_resource memoises the async call and avoids infinite re-render
|
||||
// loops that use_effect + spawn + signal writes can cause.
|
||||
#[allow(clippy::redundant_closure)]
|
||||
let auth = use_resource(move || check_auth());
|
||||
|
||||
// Clone the inner value out of the Signal to avoid holding the
|
||||
// borrow across the rsx! return (Dioxus lifetime constraint).
|
||||
let auth_snapshot: Option<Result<AuthInfo, ServerFnError>> = auth.read().clone();
|
||||
|
||||
match auth_snapshot {
|
||||
Some(Ok(info)) if info.authenticated => {
|
||||
rsx! {
|
||||
div { class: "app-shell",
|
||||
Sidebar {
|
||||
email: info.email,
|
||||
name: info.name,
|
||||
avatar_url: info.avatar_url,
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(_)) => {
|
||||
// Not authenticated -- redirect to login.
|
||||
let nav = navigator();
|
||||
nav.push(NavigationTarget::<Route>::External("/auth".into()));
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { "Redirecting to login..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
let msg = e.to_string();
|
||||
rsx! {
|
||||
div { class: "auth-error",
|
||||
p { "Authentication error: {msg}" }
|
||||
a { href: "/auth", "Login" }
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Still loading.
|
||||
rsx! {
|
||||
div { class: "app-shell loading",
|
||||
p { "Loading..." }
|
||||
}
|
||||
}
|
||||
main { class: "main-content", Outlet::<Route> {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::bs_icons::{
|
||||
BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub,
|
||||
BsGrid, BsHouseDoor, BsPuzzle,
|
||||
BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill,
|
||||
};
|
||||
use dioxus_free_icons::Icon;
|
||||
|
||||
@@ -19,10 +19,11 @@ struct NavItem {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name (shown in header if non-empty).
|
||||
/// * `email` - Email address displayed beneath the avatar placeholder.
|
||||
/// * `avatar_url` - URL for the avatar image (unused placeholder for now).
|
||||
#[component]
|
||||
pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element {
|
||||
let nav_items: Vec<NavItem> = vec![
|
||||
NavItem {
|
||||
label: "Dashboard",
|
||||
@@ -66,7 +67,7 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
|
||||
rsx! {
|
||||
aside { class: "sidebar",
|
||||
SidebarHeader { email: email.clone(), avatar_url }
|
||||
SidebarHeader { name, email: email.clone(), avatar_url }
|
||||
|
||||
nav { class: "sidebar-nav",
|
||||
for item in nav_items {
|
||||
@@ -93,13 +94,14 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "sidebar-logout",
|
||||
div { class: "sidebar-bottom-actions",
|
||||
Link {
|
||||
to: NavigationTarget::<Route>::External("/auth/logout".into()),
|
||||
to: NavigationTarget::<Route>::External("/logout".into()),
|
||||
class: "sidebar-link logout-btn",
|
||||
Icon { icon: BsBoxArrowRight, width: 18, height: 18 }
|
||||
span { "Logout" }
|
||||
}
|
||||
ThemeToggle {}
|
||||
}
|
||||
|
||||
SidebarFooter {}
|
||||
@@ -107,30 +109,123 @@ pub fn Sidebar(email: String, avatar_url: String) -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar circle and email display at the top of the sidebar.
|
||||
/// Avatar circle, name, and email display at the top of the sidebar.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - User display name. If non-empty, shown above the email.
|
||||
/// * `email` - User email to display.
|
||||
/// * `avatar_url` - Placeholder for future avatar image URL.
|
||||
#[component]
|
||||
fn SidebarHeader(email: String, avatar_url: String) -> Element {
|
||||
// Extract initials from email (first two chars before @).
|
||||
let initials: String = email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("U")
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element {
|
||||
// Derive initials: prefer name words, fall back to email prefix.
|
||||
let initials: String = if name.is_empty() {
|
||||
email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("U")
|
||||
.chars()
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
} else {
|
||||
name.split_whitespace()
|
||||
.filter_map(|w| w.chars().next())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div { class: "sidebar-header",
|
||||
div { class: "avatar-circle",
|
||||
span { class: "avatar-initials", "{initials}" }
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
div { class: "sidebar-user-info",
|
||||
if !name.is_empty() {
|
||||
p { class: "sidebar-name", "{name}" }
|
||||
}
|
||||
p { class: "sidebar-email", "{email}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle button that switches between dark and light themes.
|
||||
///
|
||||
/// Sets `data-theme` on the `<html>` element and persists the choice
|
||||
/// in `localStorage` so it survives page reloads.
|
||||
#[component]
|
||||
fn ThemeToggle() -> Element {
|
||||
let mut is_dark = use_signal(|| {
|
||||
// Read persisted preference from localStorage on first render.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok().flatten())
|
||||
.and_then(|s| s.get_item("theme").ok().flatten())
|
||||
.is_none_or(|v| v != "certifai-light")
|
||||
}
|
||||
#[cfg(not(feature = "web"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Apply the persisted theme to the DOM on first render so the
|
||||
// page doesn't flash dark if the user previously chose light.
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let dark = *is_dark.read();
|
||||
use_effect(move || {
|
||||
let theme = if dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let toggle = move |_| {
|
||||
let new_dark = !*is_dark.read();
|
||||
is_dark.set(new_dark);
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let theme = if new_dark {
|
||||
"certifai-dark"
|
||||
} else {
|
||||
"certifai-light"
|
||||
};
|
||||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||||
if let Some(el) = doc.document_element() {
|
||||
let _ = el.set_attribute("data-theme", theme);
|
||||
}
|
||||
}
|
||||
if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("theme", theme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let dark = *is_dark.read();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "theme-toggle-btn",
|
||||
title: if dark { "Switch to light mode" } else { "Switch to dark mode" },
|
||||
onclick: toggle,
|
||||
if dark {
|
||||
Icon { icon: BsSunFill, width: 16, height: 16 }
|
||||
} else {
|
||||
Icon { icon: BsMoonFill, width: 16, height: 16 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user