feat(db): Added database setup and basic types (#5)
All checks were successful
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 2m21s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 2m55s
CI / Deploy (push) Successful in 2s

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-02-20 14:58:14 +00:00
parent 5ce600e32b
commit e68f840f2b
23 changed files with 1375 additions and 480 deletions

View File

@@ -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> {} }
}
}
}

View File

@@ -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 }
}
}
}
}