chore: update gitignore and fix content
Some checks failed
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Performance Benchmarks (push) Has been cancelled
CI/CD Pipeline / Cleanup (push) Has been cancelled
Some checks failed
CI/CD Pipeline / Test Suite (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Performance Benchmarks (push) Has been cancelled
CI/CD Pipeline / Cleanup (push) Has been cancelled
This commit is contained in:
parent
afae617013
commit
d3a47108af
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,3 +1,11 @@
|
|||||||
|
wrks
|
||||||
|
ROOT
|
||||||
|
OLD
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
.claude
|
||||||
|
.opencode
|
||||||
|
.coder
|
||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
debug/
|
debug/
|
||||||
@ -96,3 +104,6 @@ Thumbs.db
|
|||||||
book-output/
|
book-output/
|
||||||
# Generated setup report
|
# Generated setup report
|
||||||
SETUP_COMPLETE.md
|
SETUP_COMPLETE.md
|
||||||
|
|
||||||
|
# Archive and working directory
|
||||||
|
.wrks/
|
||||||
|
|||||||
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the Rustelo project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **rustelo_server**: Resolved 302 compilation errors after dependency updates
|
||||||
|
- Removed duplicate module declarations (`auth`, `content`) in lib.rs
|
||||||
|
- Fixed incorrect imports: `rand_rustelo_core_lib` → `rand_core`
|
||||||
|
- Fixed incorrect imports: `rustelo_client` → `client` in email providers
|
||||||
|
- Removed non-existent `rustelo_utils` import from version_control module
|
||||||
|
- Corrected RBAC module paths: `rbac_config` → `config`, `rbac_service` → `service`
|
||||||
|
- Added missing tower-http features: `cors` and `trace` in Cargo.toml
|
||||||
|
- Implemented `Display` trait for `ResourceType` enum in rustelo_core_lib
|
||||||
|
- Fixed clippy warnings: unused variables, redundant closures, needless borrows
|
||||||
|
- Fixed trailing whitespace in all source files
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Temporarily disabled RBAC module (requires refactoring)
|
||||||
|
- All workspace crates now compile with zero clippy warnings in strict mode (`-D warnings`)
|
||||||
|
- Code formatted with `cargo +nightly fmt`
|
||||||
|
|
||||||
|
## [0.1.0] - Initial Release
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial Rustelo framework architecture
|
||||||
|
- Modular workspace with 17 crates
|
||||||
|
- Foundation layer (core-lib, components, pages, client, server)
|
||||||
|
- Framework layer (core, auth, content, web, cli)
|
||||||
|
- Feature-based architecture with cargo features
|
||||||
|
- Language-agnostic routing system
|
||||||
|
- Configuration-driven content management
|
||||||
|
- Authentication support (JWT, OAuth2, 2FA, RBAC)
|
||||||
|
- Database abstraction (PostgreSQL, SQLite)
|
||||||
|
- Email system with multiple providers
|
||||||
|
- Cryptography utilities (AES-GCM)
|
||||||
|
- Content processing (Markdown, templating)
|
||||||
|
- Development tooling and CLI
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/yourusername/rustelo/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://github.com/yourusername/rustelo/releases/tag/v0.1.0
|
||||||
696
crates/foundation/FOUNDATION_INTEGRATION_GUIDE.md
Normal file
696
crates/foundation/FOUNDATION_INTEGRATION_GUIDE.md
Normal file
@ -0,0 +1,696 @@
|
|||||||
|
# Rustelo Foundation Integration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Rustelo Foundation provides a complete ecosystem of library crates for building modern web applications. This guide shows how all foundation crates work together to create powerful, maintainable applications.
|
||||||
|
|
||||||
|
## Foundation Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Application Layer (Your Implementation)
|
||||||
|
├── main.rs (imports and uses foundation libraries)
|
||||||
|
├── build.rs (uses foundation build utilities)
|
||||||
|
└── Cargo.toml (depends on foundation crates)
|
||||||
|
|
||||||
|
Foundation Library Layer
|
||||||
|
├── server/ # Server-side library with importable main functions
|
||||||
|
├── client/ # Client-side library with app mounting functions
|
||||||
|
├── components/ # Reusable UI component library
|
||||||
|
├── pages/ # Page generation and template system
|
||||||
|
├── core-lib/ # Shared utilities and business logic
|
||||||
|
└── core-types/ # Shared type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Application Example
|
||||||
|
|
||||||
|
### 1. Application Structure
|
||||||
|
```
|
||||||
|
my-rustelo-app/
|
||||||
|
├── Cargo.toml # Workspace and dependencies
|
||||||
|
├── build.rs # Build-time page generation
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Application entry point
|
||||||
|
│ └── lib.rs # Application library code
|
||||||
|
├── content/ # Markdown content and configuration
|
||||||
|
├── templates/ # Page templates
|
||||||
|
└── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Cargo.toml - Foundation Dependencies
|
||||||
|
```toml
|
||||||
|
[package]
|
||||||
|
name = "my-rustelo-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Foundation crates
|
||||||
|
server = { path = "path/to/rustelo/crates/foundation/crates/server" }
|
||||||
|
client = { path = "path/to/rustelo/crates/foundation/crates/client" }
|
||||||
|
components = { path = "path/to/rustelo/crates/foundation/crates/components" }
|
||||||
|
pages = { path = "path/to/rustelo/crates/foundation/crates/pages" }
|
||||||
|
core-lib = { path = "path/to/rustelo/crates/foundation/crates/core-lib" }
|
||||||
|
core-types = { path = "path/to/rustelo/crates/foundation/crates/core-types" }
|
||||||
|
|
||||||
|
# Leptos framework
|
||||||
|
leptos = { version = "0.8", features = ["ssr", "hydrate"] }
|
||||||
|
leptos_router = "0.8"
|
||||||
|
leptos_axum = "0.8"
|
||||||
|
|
||||||
|
# Additional dependencies
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
axum = "0.8"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
ssr = ["leptos/ssr", "server/ssr", "pages/ssr", "components/ssr"]
|
||||||
|
hydrate = ["leptos/hydrate", "client/hydrate", "pages/hydrate", "components/hydrate"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["ssr"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "my_rustelo_app"
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. build.rs - Foundation Build Integration
|
||||||
|
```rust
|
||||||
|
//! Build script using foundation build utilities
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
// Use server foundation build utilities
|
||||||
|
server::build::build_foundation()?;
|
||||||
|
|
||||||
|
// Use pages foundation for page generation
|
||||||
|
pages::build::generate_pages_from_content("content/")?;
|
||||||
|
|
||||||
|
// Use core-lib for configuration processing
|
||||||
|
core_lib::build::process_configuration("config/")?;
|
||||||
|
|
||||||
|
// Set up rerun conditions
|
||||||
|
println!("cargo:rerun-if-changed=content/");
|
||||||
|
println!("cargo:rerun-if-changed=templates/");
|
||||||
|
println!("cargo:rerun-if-changed=config/");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. src/main.rs - Complete Application Integration
|
||||||
|
```rust
|
||||||
|
//! Complete Rustelo application using all foundation crates
|
||||||
|
|
||||||
|
use server::{run_server_with_config, ServerConfig};
|
||||||
|
use client::{create_base_app, ClientConfig};
|
||||||
|
use components::{
|
||||||
|
navigation::{BrandHeader, Footer},
|
||||||
|
content::{UnifiedContentCard, ContentManager},
|
||||||
|
theme::{ThemeProvider, ThemeConfig},
|
||||||
|
};
|
||||||
|
use pages::{
|
||||||
|
HomePage, AboutPage, ContactPage,
|
||||||
|
content::{ContentIndexPage, ContentCategoryPage},
|
||||||
|
PostViewerPage,
|
||||||
|
};
|
||||||
|
use core_lib::{
|
||||||
|
config::{load_app_config, AppConfig},
|
||||||
|
i18n::{setup_translations, TranslationManager},
|
||||||
|
content::{ContentService, MarkdownProcessor},
|
||||||
|
};
|
||||||
|
use core_types::{
|
||||||
|
ContentItem, Route, User, AppError,
|
||||||
|
config::{DatabaseConfig, I18nConfig},
|
||||||
|
};
|
||||||
|
|
||||||
|
use leptos::*;
|
||||||
|
use leptos_router::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), AppError> {
|
||||||
|
// 1. Load application configuration using core-lib
|
||||||
|
let app_config: AppConfig = load_app_config("config/app.toml")?;
|
||||||
|
|
||||||
|
// 2. Setup internationalization using core-lib
|
||||||
|
let translation_manager = setup_translations(&app_config.i18n)?;
|
||||||
|
|
||||||
|
// 3. Initialize content service using core-lib
|
||||||
|
let content_service = ContentService::new(&app_config.content_path)?;
|
||||||
|
|
||||||
|
// 4. Configure server using server foundation
|
||||||
|
let server_config = ServerConfig::builder()
|
||||||
|
.from_app_config(&app_config)
|
||||||
|
.content_service(content_service.clone())
|
||||||
|
.translation_manager(translation_manager.clone())
|
||||||
|
.enable_features(["content", "auth", "i18n"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 5. Start server using foundation main function
|
||||||
|
run_server_with_config(server_config).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application component integrating all foundation components
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
// Load app configuration
|
||||||
|
let app_config = use_context::<AppConfig>().unwrap();
|
||||||
|
|
||||||
|
// Setup theme configuration
|
||||||
|
let theme_config = ThemeConfig::builder()
|
||||||
|
.from_app_config(&app_config)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// Theme provider from components foundation
|
||||||
|
<ThemeProvider config=theme_config>
|
||||||
|
// Router setup
|
||||||
|
<Router>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
// Header using components foundation
|
||||||
|
<AppHeader />
|
||||||
|
|
||||||
|
// Main content with routing using pages foundation
|
||||||
|
<main class="flex-1">
|
||||||
|
<Routes>
|
||||||
|
// Home page from pages foundation
|
||||||
|
<Route path="/" view=HomePage />
|
||||||
|
|
||||||
|
// Static pages from pages foundation
|
||||||
|
<Route path="/about" view=AboutPage />
|
||||||
|
<Route path="/contact" view=ContactPage />
|
||||||
|
|
||||||
|
// Content pages using pages foundation
|
||||||
|
<Route path="/blog" view=|| {
|
||||||
|
ContentIndexPage {
|
||||||
|
content_type: "blog".to_string(),
|
||||||
|
layout: "grid".to_string(),
|
||||||
|
per_page: Some(10),
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
|
||||||
|
<Route path="/blog/:category" view=ContentCategoryPage />
|
||||||
|
<Route path="/blog/post/:slug" view=|| {
|
||||||
|
PostViewerPage {
|
||||||
|
content_type: "blog".to_string(),
|
||||||
|
show_related: true,
|
||||||
|
enable_comments: true,
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
|
||||||
|
// Portfolio section
|
||||||
|
<Route path="/portfolio" view=|| {
|
||||||
|
ContentIndexPage {
|
||||||
|
content_type: "portfolio".to_string(),
|
||||||
|
layout: "cards".to_string(),
|
||||||
|
per_page: Some(12),
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
|
||||||
|
// 404 page
|
||||||
|
<Route path="/*any" view=pages::NotFoundPage />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
// Footer using components foundation
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application header component
|
||||||
|
#[component]
|
||||||
|
fn AppHeader() -> impl IntoView {
|
||||||
|
let app_config = use_context::<AppConfig>().unwrap();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<BrandHeader
|
||||||
|
brand_name=app_config.site_name.clone()
|
||||||
|
logo_url=app_config.logo_url.clone()
|
||||||
|
>
|
||||||
|
// Navigation using components foundation
|
||||||
|
<components::navigation::NavMenu orientation="horizontal">
|
||||||
|
<components::ui::SpaLink href="/" active_class="text-blue-600">
|
||||||
|
"Home"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/about" active_class="text-blue-600">
|
||||||
|
"About"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/blog" active_class="text-blue-600">
|
||||||
|
"Blog"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/portfolio" active_class="text-blue-600">
|
||||||
|
"Portfolio"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/contact" active_class="text-blue-600">
|
||||||
|
"Contact"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
</components::navigation::NavMenu>
|
||||||
|
|
||||||
|
// Language selector if i18n enabled
|
||||||
|
{if app_config.i18n.enabled {
|
||||||
|
view! {
|
||||||
|
<components::navigation::LanguageSelector
|
||||||
|
current_lang=app_config.i18n.default_language.clone()
|
||||||
|
available_langs=app_config.i18n.supported_languages.clone()
|
||||||
|
/>
|
||||||
|
}.into_view()
|
||||||
|
} else {
|
||||||
|
view! {}.into_view()
|
||||||
|
}}
|
||||||
|
</BrandHeader>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application footer component
|
||||||
|
#[component]
|
||||||
|
fn AppFooter() -> impl IntoView {
|
||||||
|
let app_config = use_context::<AppConfig>().unwrap();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Footer copyright=format!("© 2024 {}", app_config.site_name)>
|
||||||
|
<div class="grid md:grid-cols-3 gap-8">
|
||||||
|
// Site links
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">"Site"</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<components::ui::SpaLink href="/about" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"About"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/blog" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Blog"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/portfolio" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Portfolio"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Content links
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">"Content"</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<components::ui::SpaLink href="/blog/category/tutorials" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Tutorials"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/blog/category/news" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"News"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/portfolio" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Work"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Legal links
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">"Legal"</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<components::ui::SpaLink href="/privacy" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Privacy Policy"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
<components::ui::SpaLink href="/terms" class="block text-gray-600 hover:text-gray-800">
|
||||||
|
"Terms of Service"
|
||||||
|
</components::ui::SpaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side hydration using client foundation
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
use client::{hydrate_app_with_config, ClientConfig};
|
||||||
|
|
||||||
|
let client_config = ClientConfig::builder()
|
||||||
|
.mount_selector("#app")
|
||||||
|
.enable_hydration(true)
|
||||||
|
.enable_router(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
hydrate_app_with_config(|| view! { <App /> }, client_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side rendering setup
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub fn render_app() -> String {
|
||||||
|
leptos::ssr::render_to_string(|| view! { <App /> })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-Crate Communication Patterns
|
||||||
|
|
||||||
|
### 1. Configuration Flow
|
||||||
|
```rust
|
||||||
|
// core-lib loads and validates configuration
|
||||||
|
let app_config = core_lib::config::load_app_config("config/app.toml")?;
|
||||||
|
|
||||||
|
// server uses configuration for setup
|
||||||
|
let server_config = server::ServerConfig::from_app_config(&app_config);
|
||||||
|
|
||||||
|
// components use configuration for theming
|
||||||
|
let theme_config = components::theme::ThemeConfig::from_app_config(&app_config);
|
||||||
|
|
||||||
|
// pages use configuration for content processing
|
||||||
|
let page_config = pages::PageConfig::from_app_config(&app_config);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Content Processing Pipeline
|
||||||
|
```rust
|
||||||
|
// core-lib processes raw content
|
||||||
|
let processor = core_lib::content::MarkdownProcessor::new(&app_config.content_path);
|
||||||
|
let content_items = processor.process_all().await?;
|
||||||
|
|
||||||
|
// pages generates pages from processed content
|
||||||
|
let page_generator = pages::PageGenerator::new()
|
||||||
|
.with_content_items(content_items)
|
||||||
|
.with_templates_from_config(&app_config);
|
||||||
|
|
||||||
|
// components display the content
|
||||||
|
let content_display = components::content::ContentManager::new()
|
||||||
|
.with_content_source(content_items)
|
||||||
|
.with_layout("grid");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Type Safety Across Crates
|
||||||
|
```rust
|
||||||
|
// core-types defines shared types
|
||||||
|
use core_types::{ContentItem, User, Route, AppError};
|
||||||
|
|
||||||
|
// All crates use the same types for consistency
|
||||||
|
fn process_content(item: ContentItem) -> Result<ContentItem, AppError> {
|
||||||
|
// Processing logic
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_user_profile(user: User) -> impl IntoView {
|
||||||
|
// Rendering logic
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_route(route: Route) -> Result<(), AppError> {
|
||||||
|
// Routing logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build System Integration
|
||||||
|
|
||||||
|
### 1. Multi-Stage Build Process
|
||||||
|
```rust
|
||||||
|
// build.rs orchestrates all foundation build utilities
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Stage 1: Core configuration processing
|
||||||
|
core_lib::build::process_configuration()?;
|
||||||
|
|
||||||
|
// Stage 2: Content processing and validation
|
||||||
|
core_lib::build::process_content()?;
|
||||||
|
|
||||||
|
// Stage 3: Page generation
|
||||||
|
pages::build::generate_pages()?;
|
||||||
|
|
||||||
|
// Stage 4: Route generation
|
||||||
|
server::build::generate_routes()?;
|
||||||
|
|
||||||
|
// Stage 5: Asset processing
|
||||||
|
process_static_assets()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Feature Flag Coordination
|
||||||
|
```toml
|
||||||
|
# Features are coordinated across all foundation crates
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
|
||||||
|
# Core features
|
||||||
|
auth = ["server/auth", "core-lib/auth", "core-types/auth"]
|
||||||
|
i18n = ["server/i18n", "client/i18n", "pages/i18n", "core-lib/i18n"]
|
||||||
|
content-db = ["server/content-db", "pages/content-db", "core-lib/content-db"]
|
||||||
|
|
||||||
|
# Rendering features
|
||||||
|
ssr = ["server/ssr", "pages/ssr", "components/ssr"]
|
||||||
|
hydrate = ["client/hydrate", "pages/hydrate", "components/hydrate"]
|
||||||
|
|
||||||
|
# Development features
|
||||||
|
dev-tools = ["server/dev-tools", "client/dev-tools"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Integration Patterns
|
||||||
|
|
||||||
|
### 1. Plugin Architecture
|
||||||
|
```rust
|
||||||
|
// Define plugin traits in core-types
|
||||||
|
pub trait ContentProcessor {
|
||||||
|
fn process(&self, content: &str) -> Result<String, ProcessingError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement plugins in various crates
|
||||||
|
impl ContentProcessor for pages::MarkdownProcessor {
|
||||||
|
fn process(&self, content: &str) -> Result<String, ProcessingError> {
|
||||||
|
self.process_markdown(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register and use plugins through core-lib
|
||||||
|
let processor_registry = core_lib::plugins::ProcessorRegistry::new()
|
||||||
|
.register("markdown", Box::new(pages::MarkdownProcessor::new()))
|
||||||
|
.register("handlebars", Box::new(templates::HandlebarsProcessor::new()));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Event System
|
||||||
|
```rust
|
||||||
|
// Define events in core-types
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
ContentUpdated(ContentItem),
|
||||||
|
UserAuthenticated(User),
|
||||||
|
RouteChanged(Route),
|
||||||
|
ThemeChanged(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit events from various crates
|
||||||
|
// In pages crate
|
||||||
|
self.event_bus.emit(AppEvent::ContentUpdated(content_item));
|
||||||
|
|
||||||
|
// In server crate
|
||||||
|
self.event_bus.emit(AppEvent::UserAuthenticated(user));
|
||||||
|
|
||||||
|
// Listen to events in components
|
||||||
|
self.event_bus.subscribe(|event| match event {
|
||||||
|
AppEvent::ThemeChanged(theme) => update_theme(theme),
|
||||||
|
AppEvent::ContentUpdated(item) => refresh_content_display(item),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. State Management Integration
|
||||||
|
```rust
|
||||||
|
// Global state defined in core-types
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub user: Option<User>,
|
||||||
|
pub theme: Theme,
|
||||||
|
pub language: Language,
|
||||||
|
pub content_cache: HashMap<String, ContentItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// State management in core-lib
|
||||||
|
pub struct StateManager {
|
||||||
|
state: RwSignal<AppState>,
|
||||||
|
event_bus: EventBus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateManager {
|
||||||
|
pub fn update_user(&self, user: Option<User>) {
|
||||||
|
self.state.update(|state| state.user = user);
|
||||||
|
if let Some(user) = user {
|
||||||
|
self.event_bus.emit(AppEvent::UserAuthenticated(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use state in components
|
||||||
|
#[component]
|
||||||
|
pub fn UserProfile() -> impl IntoView {
|
||||||
|
let state = use_context::<StateManager>().unwrap();
|
||||||
|
let user = create_memo(move |_| state.get_user());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || user.get().is_some()>
|
||||||
|
// Render user profile
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Integration
|
||||||
|
|
||||||
|
### 1. Cross-Crate Testing
|
||||||
|
```rust
|
||||||
|
// Integration tests that span multiple crates
|
||||||
|
#[cfg(test)]
|
||||||
|
mod integration_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_full_content_pipeline() {
|
||||||
|
// Use core-lib to load configuration
|
||||||
|
let config = core_lib::config::load_test_config().await.unwrap();
|
||||||
|
|
||||||
|
// Use pages to process content
|
||||||
|
let content = pages::process_test_content(&config).await.unwrap();
|
||||||
|
|
||||||
|
// Use components to render content
|
||||||
|
let rendered = components::render_test_content(content).await.unwrap();
|
||||||
|
|
||||||
|
// Verify the full pipeline
|
||||||
|
assert!(rendered.contains("expected content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_type_consistency() {
|
||||||
|
// Verify types work across crate boundaries
|
||||||
|
let content_item = core_types::ContentItem::new("test", "Test Content");
|
||||||
|
let processed = core_lib::content::process_item(content_item.clone()).unwrap();
|
||||||
|
let rendered = components::content::render_item(&processed);
|
||||||
|
|
||||||
|
assert_eq!(processed.id, content_item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. End-to-End Testing
|
||||||
|
```rust
|
||||||
|
// E2E tests using all foundation crates together
|
||||||
|
#[cfg(test)]
|
||||||
|
mod e2e_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_complete_application() {
|
||||||
|
// Start server using server foundation
|
||||||
|
let server_handle = server::test_utils::start_test_server().await;
|
||||||
|
|
||||||
|
// Generate test content using pages foundation
|
||||||
|
pages::test_utils::generate_test_pages().await.unwrap();
|
||||||
|
|
||||||
|
// Test client-side functionality
|
||||||
|
let client_test = client::test_utils::TestClient::new()
|
||||||
|
.navigate_to("/")
|
||||||
|
.expect_content("Welcome")
|
||||||
|
.navigate_to("/blog")
|
||||||
|
.expect_content("Blog Posts")
|
||||||
|
.run()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(client_test.passed());
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
server_handle.stop().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Integration
|
||||||
|
|
||||||
|
### 1. Production Build
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Build script using all foundation capabilities
|
||||||
|
|
||||||
|
# Build server with all features
|
||||||
|
cargo build --release --features "ssr,auth,content-db,i18n,metrics"
|
||||||
|
|
||||||
|
# Build client for WebAssembly
|
||||||
|
wasm-pack build --target web --features "hydrate,router,i18n"
|
||||||
|
|
||||||
|
# Generate static pages
|
||||||
|
cargo run --bin page-generator --features "generation"
|
||||||
|
|
||||||
|
# Process and optimize assets
|
||||||
|
cargo run --bin asset-processor
|
||||||
|
|
||||||
|
# Create deployment package
|
||||||
|
tar -czf rustelo-app.tar.gz target/release/server pkg/ generated/ public/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Docker Integration
|
||||||
|
```dockerfile
|
||||||
|
# Multi-stage Docker build using foundation crates
|
||||||
|
FROM rust:1.70 as builder
|
||||||
|
|
||||||
|
# Copy foundation source
|
||||||
|
COPY rustelo/crates/foundation /app/foundation
|
||||||
|
COPY . /app/src
|
||||||
|
|
||||||
|
WORKDIR /app/src
|
||||||
|
|
||||||
|
# Build with all production features
|
||||||
|
RUN cargo build --release --features "production"
|
||||||
|
|
||||||
|
# Build client WASM
|
||||||
|
RUN wasm-pack build --target web --features "hydrate,router,i18n"
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy server binary
|
||||||
|
COPY --from=builder /app/src/target/release/server /usr/local/bin/
|
||||||
|
|
||||||
|
# Copy static assets and generated pages
|
||||||
|
COPY --from=builder /app/src/pkg /var/www/html/pkg
|
||||||
|
COPY --from=builder /app/src/generated /var/www/html/generated
|
||||||
|
COPY --from=builder /app/src/public /var/www/html/public
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Start script that runs both server and nginx
|
||||||
|
COPY start.sh /start.sh
|
||||||
|
RUN chmod +x /start.sh
|
||||||
|
|
||||||
|
CMD ["/start.sh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices for Foundation Integration
|
||||||
|
|
||||||
|
### 1. Dependency Management
|
||||||
|
- Use workspace dependencies for version consistency
|
||||||
|
- Enable only the features you need
|
||||||
|
- Use path dependencies during development
|
||||||
|
- Switch to published crates for production
|
||||||
|
|
||||||
|
### 2. Configuration Management
|
||||||
|
- Centralize configuration in core-lib
|
||||||
|
- Use type-safe configuration structs from core-types
|
||||||
|
- Validate configuration at startup
|
||||||
|
- Support environment-specific configs
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
- Define common errors in core-types
|
||||||
|
- Implement From traits for error conversion
|
||||||
|
- Use Result types consistently across crates
|
||||||
|
- Provide meaningful error contexts
|
||||||
|
|
||||||
|
### 4. Performance Optimization
|
||||||
|
- Use foundation build utilities for optimization
|
||||||
|
- Enable appropriate feature flags
|
||||||
|
- Leverage foundation caching mechanisms
|
||||||
|
- Profile across crate boundaries
|
||||||
|
|
||||||
|
### 5. Testing Strategy
|
||||||
|
- Test individual crates in isolation
|
||||||
|
- Write integration tests for crate interactions
|
||||||
|
- Use foundation test utilities
|
||||||
|
- Implement E2E testing for complete flows
|
||||||
|
|
||||||
|
The Rustelo Foundation provides a complete, integrated development experience where each crate is designed to work seamlessly with the others while maintaining clear separation of concerns and reusability.
|
||||||
282
crates/foundation/README.md
Normal file
282
crates/foundation/README.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
# Rustelo Foundation Library System
|
||||||
|
|
||||||
|
A complete, modular foundation for building modern web applications with Rust, Leptos, and WebAssembly.
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
The Rustelo Foundation provides a comprehensive set of library crates that work together to create powerful web applications. Each crate is designed as a reusable library with importable functions, extensive documentation, and practical examples.
|
||||||
|
|
||||||
|
## 📦 Foundation Crates
|
||||||
|
|
||||||
|
### Core System
|
||||||
|
- **[`server/`](crates/server/)** - Server-side library with importable main functions, routing, and middleware
|
||||||
|
- **[`client/`](crates/client/)** - Client-side library with app mounting, hydration, and state management
|
||||||
|
- **[`core-lib/`](crates/core-lib/)** - Shared utilities, configuration, i18n, and business logic
|
||||||
|
- **[`core-types/`](crates/core-types/)** - Common type definitions and data structures
|
||||||
|
|
||||||
|
### UI System
|
||||||
|
- **[`components/`](crates/components/)** - Reusable UI component library with theming support
|
||||||
|
- **[`pages/`](crates/pages/)** - Page generation system with templates and content processing
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Import Foundation Libraries
|
||||||
|
```rust
|
||||||
|
// In your application's Cargo.toml
|
||||||
|
[dependencies]
|
||||||
|
server = { path = "path/to/rustelo/crates/foundation/crates/server" }
|
||||||
|
client = { path = "path/to/rustelo/crates/foundation/crates/client" }
|
||||||
|
components = { path = "path/to/rustelo/crates/foundation/crates/components" }
|
||||||
|
pages = { path = "path/to/rustelo/crates/foundation/crates/pages" }
|
||||||
|
core-lib = { path = "path/to/rustelo/crates/foundation/crates/core-lib" }
|
||||||
|
core-types = { path = "path/to/rustelo/crates/foundation/crates/core-types" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Foundation Main Functions
|
||||||
|
```rust
|
||||||
|
// src/main.rs - Import and use server main function
|
||||||
|
use server::{run_server, ServerConfig};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Simple usage - use foundation server directly
|
||||||
|
run_server().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build with Foundation Utilities
|
||||||
|
```rust
|
||||||
|
// build.rs - Use foundation build functions
|
||||||
|
use server::build::generate_routes;
|
||||||
|
use pages::build::generate_pages;
|
||||||
|
use core_lib::build::process_configuration;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Use foundation build utilities
|
||||||
|
process_configuration("config/")?;
|
||||||
|
generate_routes("content/routes/")?;
|
||||||
|
generate_pages("content/")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create UI with Foundation Components
|
||||||
|
```rust
|
||||||
|
// Use foundation components and pages
|
||||||
|
use components::{
|
||||||
|
navigation::{BrandHeader, Footer},
|
||||||
|
content::UnifiedContentCard,
|
||||||
|
ui::SpaLink,
|
||||||
|
};
|
||||||
|
use pages::{HomePage, AboutPage, ContactPage};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<BrandHeader brand_name="My App">
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/about">"About"</SpaLink>
|
||||||
|
</BrandHeader>
|
||||||
|
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" view=HomePage />
|
||||||
|
<Route path="/about" view=AboutPage />
|
||||||
|
<Route path="/contact" view=ContactPage />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
<Footer copyright="© 2024 My Company" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Usage Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Direct Import (Simplest)
|
||||||
|
Perfect for getting started quickly:
|
||||||
|
```rust
|
||||||
|
use server::run_server;
|
||||||
|
use components::navigation::BrandHeader;
|
||||||
|
use pages::HomePage;
|
||||||
|
|
||||||
|
// Use foundation functions directly
|
||||||
|
run_server().await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Configuration-Based
|
||||||
|
Customize behavior through configuration:
|
||||||
|
```rust
|
||||||
|
use server::{run_server_with_config, ServerConfig};
|
||||||
|
use core_lib::config::load_app_config;
|
||||||
|
|
||||||
|
let app_config = load_app_config("config.toml")?;
|
||||||
|
let server_config = ServerConfig::from_app_config(&app_config);
|
||||||
|
run_server_with_config(server_config).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Modular Composition
|
||||||
|
Compose applications from individual foundation functions:
|
||||||
|
```rust
|
||||||
|
use server::{create_base_app, setup_tracing, serve_custom_app};
|
||||||
|
use components::{theme::ThemeProvider, navigation::BrandHeader};
|
||||||
|
use pages::{PageGenerator, BlogTemplate};
|
||||||
|
|
||||||
|
// Compose your application
|
||||||
|
let app = create_base_app()
|
||||||
|
.merge(custom_routes())
|
||||||
|
.layer(custom_middleware());
|
||||||
|
|
||||||
|
serve_custom_app(app, config).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Full Integration
|
||||||
|
Complete integration across all foundation crates:
|
||||||
|
```rust
|
||||||
|
use server::{run_server_with_config, ServerConfig};
|
||||||
|
use client::{ClientConfig, hydrate_app};
|
||||||
|
use components::theme::{ThemeProvider, ThemeConfig};
|
||||||
|
use pages::{generate_pages, PageGeneratorConfig};
|
||||||
|
use core_lib::{config::AppConfig, i18n::setup_translations};
|
||||||
|
use core_types::{ContentItem, User, Route};
|
||||||
|
|
||||||
|
// Complete application using all foundation crates
|
||||||
|
// See FOUNDATION_INTEGRATION_GUIDE.md for full example
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
### Individual Crate Documentation
|
||||||
|
- **[Server Foundation](crates/server/README.md)** - Server-side library usage
|
||||||
|
- **[Client Foundation](crates/client/README.md)** - Client-side library usage
|
||||||
|
- **[Components Foundation](crates/components/README.md)** - UI component library
|
||||||
|
- **[Pages Foundation](crates/pages/README.md)** - Page generation system
|
||||||
|
- **[Core Lib Foundation](crates/core-lib/README.md)** - Shared utilities
|
||||||
|
- **[Core Types Foundation](crates/core-types/README.md)** - Type definitions
|
||||||
|
|
||||||
|
### Integration Guides
|
||||||
|
- **[Foundation Integration Guide](FOUNDATION_INTEGRATION_GUIDE.md)** - Complete integration examples
|
||||||
|
- **[Cross-Crate Patterns](docs/CROSS_CRATE_PATTERNS.md)** - Communication patterns
|
||||||
|
- **[Build System Integration](docs/BUILD_INTEGRATION.md)** - Build system usage
|
||||||
|
- **[Testing Strategies](docs/TESTING_STRATEGIES.md)** - Testing across crates
|
||||||
|
|
||||||
|
## 💡 Examples
|
||||||
|
|
||||||
|
Each crate includes comprehensive examples:
|
||||||
|
|
||||||
|
### Server Examples
|
||||||
|
- [Basic Server](crates/server/examples/basic_server.rs) - Direct main import
|
||||||
|
- [Custom Config Server](crates/server/examples/custom_config_server.rs) - Configuration-based
|
||||||
|
- [Extended Server](crates/server/examples/extended_server.rs) - Adding custom functionality
|
||||||
|
- [Modular Server](crates/server/examples/modular_server.rs) - Composition patterns
|
||||||
|
|
||||||
|
### Components Examples
|
||||||
|
- [Basic Layout](crates/components/examples/basic_layout.rs) - Simple component usage
|
||||||
|
- [Navigation Demo](crates/components/examples/navigation_demo.rs) - Navigation patterns
|
||||||
|
- [Content Showcase](crates/components/examples/content_showcase.rs) - Content components
|
||||||
|
|
||||||
|
### Pages Examples
|
||||||
|
- [Basic Pages](crates/pages/examples/basic_pages.rs) - Simple page usage
|
||||||
|
- [Blog System](crates/pages/examples/blog_system.rs) - Complete blog implementation
|
||||||
|
- [Multi-language Pages](crates/pages/examples/multilang_pages.rs) - I18n support
|
||||||
|
|
||||||
|
## 🎨 Architecture Benefits
|
||||||
|
|
||||||
|
### 1. **True Library Pattern**
|
||||||
|
- Import main functions instead of copying code
|
||||||
|
- Reusable across multiple applications
|
||||||
|
- Clean separation between foundation and implementation
|
||||||
|
|
||||||
|
### 2. **Flexible Usage**
|
||||||
|
- Use as much or as little as needed
|
||||||
|
- Start simple, add complexity gradually
|
||||||
|
- Multiple integration patterns supported
|
||||||
|
|
||||||
|
### 3. **Comprehensive Documentation**
|
||||||
|
- Every crate includes usage patterns and examples
|
||||||
|
- Cross-crate integration guides
|
||||||
|
- Progressive complexity from beginner to advanced
|
||||||
|
|
||||||
|
### 4. **Type Safety**
|
||||||
|
- Shared types across all crates via core-types
|
||||||
|
- Consistent APIs and error handling
|
||||||
|
- Full Rust compiler guarantees
|
||||||
|
|
||||||
|
### 5. **Build System Integration**
|
||||||
|
- Exportable build functions from each crate
|
||||||
|
- Coordinated build processes
|
||||||
|
- Feature flag coordination
|
||||||
|
|
||||||
|
## 🛠️ Development Workflow
|
||||||
|
|
||||||
|
### For Application Developers
|
||||||
|
1. **Choose your pattern** - Direct import, configuration, or full integration
|
||||||
|
2. **Import foundation crates** - Add to Cargo.toml with needed features
|
||||||
|
3. **Use foundation functions** - Import main functions, components, and utilities
|
||||||
|
4. **Extend as needed** - Add custom functionality on top of foundation
|
||||||
|
5. **Build with foundation** - Use foundation build utilities in build.rs
|
||||||
|
|
||||||
|
### For Foundation Contributors
|
||||||
|
1. **Maintain library pattern** - Exportable functions, not executables
|
||||||
|
2. **Comprehensive examples** - Every usage pattern documented
|
||||||
|
3. **Cross-crate consistency** - Shared types and patterns
|
||||||
|
4. **Backward compatibility** - Stable APIs for applications
|
||||||
|
5. **Integration testing** - Test across crate boundaries
|
||||||
|
|
||||||
|
## 🏁 Getting Started
|
||||||
|
|
||||||
|
### Quick Setup
|
||||||
|
```bash
|
||||||
|
# Clone or copy foundation crates
|
||||||
|
git clone https://github.com/yourusername/rustelo.git
|
||||||
|
|
||||||
|
# Create new application
|
||||||
|
cargo new my-rustelo-app
|
||||||
|
cd my-rustelo-app
|
||||||
|
|
||||||
|
# Add foundation dependencies to Cargo.toml
|
||||||
|
# See Quick Start section above
|
||||||
|
|
||||||
|
# Import and use foundation functions
|
||||||
|
# See examples in each crate's examples/ directory
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. **Read individual crate READMEs** for specific usage patterns
|
||||||
|
2. **Study the examples** to understand integration approaches
|
||||||
|
3. **Review the Integration Guide** for complete application patterns
|
||||||
|
4. **Start with simple patterns** and add complexity as needed
|
||||||
|
5. **Contribute improvements** back to the foundation
|
||||||
|
|
||||||
|
## 📈 Migration from Legacy
|
||||||
|
|
||||||
|
### From Executable Crates
|
||||||
|
If you were using foundation crates as executables:
|
||||||
|
1. **Change imports** - Import functions instead of running binaries
|
||||||
|
2. **Update build.rs** - Use foundation build functions
|
||||||
|
3. **Adapt main.rs** - Import and call foundation main functions
|
||||||
|
4. **Review examples** - See new usage patterns
|
||||||
|
|
||||||
|
### From Custom Implementations
|
||||||
|
If you built custom implementations:
|
||||||
|
1. **Identify foundation equivalents** - Check what foundation provides
|
||||||
|
2. **Gradually migrate** - Replace custom code with foundation functions
|
||||||
|
3. **Extend foundation** - Add your custom logic on top of foundation
|
||||||
|
4. **Contribute back** - Share useful extensions with the community
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
The foundation system is designed to grow:
|
||||||
|
|
||||||
|
1. **Add new patterns** - Document additional usage approaches
|
||||||
|
2. **Extend examples** - Show more integration scenarios
|
||||||
|
3. **Improve documentation** - Clarify usage and patterns
|
||||||
|
4. **Add features** - Enhance foundation capabilities
|
||||||
|
5. **Fix issues** - Improve reliability and performance
|
||||||
|
|
||||||
|
## 📜 License
|
||||||
|
|
||||||
|
MIT License - See individual crate LICENSE files for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**The Rustelo Foundation: Building the future of Rust web applications, one library at a time.** 🦀✨
|
||||||
72
crates/foundation/crates/rustelo_client/Cargo.toml
Normal file
72
crates/foundation/crates/rustelo_client/Cargo.toml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
[package]
|
||||||
|
name = "rustelo_client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Rustelo Contributors"]
|
||||||
|
license = "MIT"
|
||||||
|
description = "Client-side components for Rustelo web application template"
|
||||||
|
documentation = "https://docs.rs/client"
|
||||||
|
repository = "https://github.com/yourusername/rustelo"
|
||||||
|
homepage = "https://jesusperez.pro"
|
||||||
|
readme = "../../README.md"
|
||||||
|
keywords = [
|
||||||
|
"rust",
|
||||||
|
"web",
|
||||||
|
"leptos",
|
||||||
|
"wasm",
|
||||||
|
"frontend",
|
||||||
|
]
|
||||||
|
categories = [
|
||||||
|
"web-programming",
|
||||||
|
"wasm",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos = { workspace = true, features = ["hydrate"] }
|
||||||
|
leptos_router = { workspace = true }
|
||||||
|
leptos_meta = { workspace = true }
|
||||||
|
leptos_config = { workspace = true }
|
||||||
|
wasm-bindgen = { workspace = true }
|
||||||
|
js-sys = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
reqwasm = { workspace = true }
|
||||||
|
gloo-net = { workspace = true }
|
||||||
|
web-sys = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
console_error_panic_hook = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
fluent = { workspace = true }
|
||||||
|
fluent-bundle = { workspace = true }
|
||||||
|
unic-langid = { workspace = true }
|
||||||
|
|
||||||
|
rustelo_core_lib = { workspace = true }
|
||||||
|
rustelo_core_types = { workspace = true }
|
||||||
|
rustelo_components = { workspace = true }
|
||||||
|
|
||||||
|
rustelo_pages = { workspace = true }
|
||||||
|
gloo-timers = { workspace = true }
|
||||||
|
wasm-bindgen-futures = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
urlencoding = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
paste = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hydrate = ["leptos/hydrate"]
|
||||||
|
content-static = []
|
||||||
|
content-watcher = ["content-static"]
|
||||||
|
tls = []
|
||||||
|
console-log = []
|
||||||
|
console-log-production = []
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
# Configuration for docs.rs
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
713
crates/foundation/crates/rustelo_client/README.md
Normal file
713
crates/foundation/crates/rustelo_client/README.md
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
# Client Foundation Library
|
||||||
|
|
||||||
|
Client-side WebAssembly foundation for Rustelo's configuration-driven web applications, built with Leptos and hydration.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The client crate transforms your **TOML configuration** into a reactive WebAssembly client application. It provides zero-configuration hydration, client-side routing, and state management - all generated from your configuration files.
|
||||||
|
|
||||||
|
> **🎯 Configuration-Driven**: 90% TOML configuration → generated client, 10% custom extensions when needed
|
||||||
|
|
||||||
|
## Architecture Pattern (PAP)
|
||||||
|
|
||||||
|
### Primary Usage: Configuration → Generated Client (90%)
|
||||||
|
|
||||||
|
Your TOML configuration becomes a complete client application:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/routes/en.toml
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
title_key = "dashboard-title"
|
||||||
|
description_key = "dashboard-description"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated WebAssembly Client:**
|
||||||
|
```rust
|
||||||
|
// Generated automatically from your TOML configuration
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/generated_routes.rs"));
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/generated_components.rs"));
|
||||||
|
|
||||||
|
// Your app now has:
|
||||||
|
// ✅ Hydration for /dashboard route
|
||||||
|
// ✅ Reactive component: Dashboard
|
||||||
|
// ✅ i18n with dashboard-title/dashboard-description
|
||||||
|
// ✅ Client-side navigation
|
||||||
|
// ✅ Theme system integration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secondary Usage: Extension Patterns (10%)
|
||||||
|
|
||||||
|
When configuration isn't sufficient, extend with custom client code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Custom client behavior for complex interactions
|
||||||
|
use client::{hydrate_with_config, ClientConfig, CustomHydrationConfig};
|
||||||
|
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Foundation handles 90% through configuration
|
||||||
|
let config = ClientConfig::from_generated()
|
||||||
|
// Add 10% custom behavior when needed
|
||||||
|
.with_custom_analytics()
|
||||||
|
.with_advanced_animations()
|
||||||
|
.with_realtime_features();
|
||||||
|
|
||||||
|
client::hydrate_with_config(config);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Zero-Config Hydration (Recommended)
|
||||||
|
|
||||||
|
Most applications need only configuration, no client code:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In your application's src/main.rs (WASM target)
|
||||||
|
use client;
|
||||||
|
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Hydrates based on your TOML route configuration
|
||||||
|
// Components generated from site/config/routes/*.toml
|
||||||
|
client::hydrate();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this provides:**
|
||||||
|
- ✅ Automatic hydration for all configured routes
|
||||||
|
- ✅ Client-side navigation between configured pages
|
||||||
|
- ✅ Reactive i18n from your fluent files
|
||||||
|
- ✅ Theme system with CSS custom properties
|
||||||
|
- ✅ Authentication state management (if auth feature enabled)
|
||||||
|
|
||||||
|
### Pattern 2: Configuration Validation
|
||||||
|
|
||||||
|
Validate your TOML configuration during client build:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::{validate_client_config, ClientConfigError};
|
||||||
|
use core_types::RoutesConfig;
|
||||||
|
|
||||||
|
fn main() -> Result<(), ClientConfigError> {
|
||||||
|
// Load your route configuration from TOML
|
||||||
|
let toml_content = include_str!("../site/config/routes/en.toml");
|
||||||
|
let routes_config: RoutesConfig = toml::from_str(toml_content)?;
|
||||||
|
|
||||||
|
// Validate configuration before building WebAssembly
|
||||||
|
validate_client_config(&routes_config)?;
|
||||||
|
|
||||||
|
println!("✅ Client configuration is valid");
|
||||||
|
println!("📦 Ready to build WebAssembly with {} routes",
|
||||||
|
routes_config.routes.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Custom Client Extensions
|
||||||
|
|
||||||
|
For the 10% case where configuration can't express your needs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::{AppComponent, ClientBuilder, HydrationOptions};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Foundation provides 90% from configuration
|
||||||
|
let base_app = client::create_configured_app();
|
||||||
|
|
||||||
|
// Add 10% custom client functionality
|
||||||
|
let custom_app = ClientBuilder::from(base_app)
|
||||||
|
.with_custom_component("Dashboard", || {
|
||||||
|
view! {
|
||||||
|
// Generated base from TOML config
|
||||||
|
<client::GeneratedDashboard />
|
||||||
|
// Plus custom client-side features
|
||||||
|
<RealtimeMetrics />
|
||||||
|
<AdvancedCharts />
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with_client_side_analytics()
|
||||||
|
.with_service_worker()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Hydrate with both generated + custom functionality
|
||||||
|
leptos::mount::hydrate_body(|| custom_app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration-Driven Features
|
||||||
|
|
||||||
|
### Route-Based Hydration
|
||||||
|
|
||||||
|
Your TOML routes automatically become hydration targets:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/routes/en.toml
|
||||||
|
[[routes]]
|
||||||
|
path = "/products/{category}/{slug}"
|
||||||
|
component = "ProductDetail"
|
||||||
|
title_key = "product-title"
|
||||||
|
requires_auth = false
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
path = "/admin/dashboard"
|
||||||
|
component = "AdminDashboard"
|
||||||
|
requires_auth = true
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Client Behavior:**
|
||||||
|
```rust
|
||||||
|
// Automatically generated from your TOML
|
||||||
|
fn handle_route_hydration(path: &str) {
|
||||||
|
match resolve_configured_route(path) {
|
||||||
|
Some(RouteInfo { component: ProductDetail, requires_auth: false, .. }) => {
|
||||||
|
hydrate_product_detail_component(path);
|
||||||
|
}
|
||||||
|
Some(RouteInfo { component: AdminDashboard, requires_auth: true, .. }) => {
|
||||||
|
if user_is_authenticated() {
|
||||||
|
hydrate_admin_dashboard_component(path);
|
||||||
|
} else {
|
||||||
|
redirect_to_login();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => hydrate_not_found_component(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content-Type Integration
|
||||||
|
|
||||||
|
Your `content-kinds.toml` drives client-side content handling:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# content/content-kinds.toml
|
||||||
|
[blog]
|
||||||
|
title = "Blog Posts"
|
||||||
|
directory = "blog"
|
||||||
|
features = ["categories", "tags", "comments", "search"]
|
||||||
|
client_features = ["infinite-scroll", "live-search", "social-sharing"]
|
||||||
|
|
||||||
|
[documentation]
|
||||||
|
title = "Documentation"
|
||||||
|
directory = "docs"
|
||||||
|
features = ["toc", "search", "versioning"]
|
||||||
|
client_features = ["collapsible-nav", "keyboard-shortcuts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Client Features:**
|
||||||
|
```rust
|
||||||
|
// Blog posts get client-side enhancements automatically
|
||||||
|
impl BlogPostClient {
|
||||||
|
// Generated from content-kinds.toml client_features
|
||||||
|
fn enable_infinite_scroll(&self) { /* ... */ }
|
||||||
|
fn enable_live_search(&self) { /* ... */ }
|
||||||
|
fn enable_social_sharing(&self) { /* ... */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documentation gets different client features
|
||||||
|
impl DocumentationClient {
|
||||||
|
// Generated from different client_features configuration
|
||||||
|
fn enable_collapsible_nav(&self) { /* ... */ }
|
||||||
|
fn enable_keyboard_shortcuts(&self) { /* ... */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Theme System Integration
|
||||||
|
|
||||||
|
Themes configured in TOML become CSS custom properties:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/theme.toml
|
||||||
|
[themes.light]
|
||||||
|
primary = "#3B82F6"
|
||||||
|
background = "#FFFFFF"
|
||||||
|
text = "#1F2937"
|
||||||
|
|
||||||
|
[themes.dark]
|
||||||
|
primary = "#60A5FA"
|
||||||
|
background = "#111827"
|
||||||
|
text = "#F9FAFB"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Client Theme System:**
|
||||||
|
```rust
|
||||||
|
// Theme switching generated from TOML configuration
|
||||||
|
pub fn apply_theme(theme_name: &str) {
|
||||||
|
let theme_config = get_theme_from_config(theme_name);
|
||||||
|
|
||||||
|
// Updates CSS custom properties automatically
|
||||||
|
set_css_property("--ds-primary", &theme_config.primary);
|
||||||
|
set_css_property("--ds-bg", &theme_config.background);
|
||||||
|
set_css_property("--ds-text", &theme_config.text);
|
||||||
|
|
||||||
|
// Persists theme choice in localStorage
|
||||||
|
store_theme_preference(theme_name);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebAssembly Integration
|
||||||
|
|
||||||
|
### Zero-Config WASM Build
|
||||||
|
|
||||||
|
The client crate builds to WebAssembly automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Your routes configuration drives what gets compiled to WASM
|
||||||
|
cargo leptos build
|
||||||
|
|
||||||
|
# Generated files:
|
||||||
|
# - target/site/pkg/client.wasm (your configured app)
|
||||||
|
# - target/site/pkg/client.js (generated bindings)
|
||||||
|
# - target/site/client_bg.wasm (optimized binary)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bundle Size Optimization
|
||||||
|
|
||||||
|
Configuration drives what gets included in the WASM bundle:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Only enabled features are compiled to WASM
|
||||||
|
[features]
|
||||||
|
default = ["hydrate"]
|
||||||
|
auth = ["oauth2", "jwt"]
|
||||||
|
analytics = ["tracking", "metrics"]
|
||||||
|
performance = ["lazy-loading", "code-splitting"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Bundle Strategy:**
|
||||||
|
- **Base bundle**: Routes + components from TOML configuration
|
||||||
|
- **Feature bundles**: Only enabled features (auth, analytics, etc.)
|
||||||
|
- **Lazy bundles**: Route-based code splitting for large applications
|
||||||
|
- **Shared chunks**: Common dependencies across generated components
|
||||||
|
|
||||||
|
## Hydration System
|
||||||
|
|
||||||
|
### SSR-to-Client Handoff
|
||||||
|
|
||||||
|
The client seamlessly takes over from server-rendered HTML:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Server renders this (from your TOML config) -->
|
||||||
|
<div id="app" data-leptos-config="...">
|
||||||
|
<header><!-- Navigation from routes configuration --></header>
|
||||||
|
<main><!-- Page content from component configuration --></main>
|
||||||
|
<footer><!-- Footer from layout configuration --></footer>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Client hydrates this automatically
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Reads data-leptos-config to understand server state
|
||||||
|
// Hydrates components based on route configuration
|
||||||
|
// Activates client-side routing for configured routes
|
||||||
|
client::hydrate();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration Debugging
|
||||||
|
|
||||||
|
Built-in debugging for development:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::hydration;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
fn debug_hydration() {
|
||||||
|
// Shows which components are being hydrated
|
||||||
|
hydration::enable_debug_logging();
|
||||||
|
|
||||||
|
// Validates server-client state consistency
|
||||||
|
hydration::validate_state_consistency();
|
||||||
|
|
||||||
|
// Reports hydration performance metrics
|
||||||
|
hydration::report_performance_metrics();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client State Management
|
||||||
|
|
||||||
|
### Configuration-Driven State
|
||||||
|
|
||||||
|
Your TOML configuration determines what state is managed:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/client-state.toml
|
||||||
|
[global_state]
|
||||||
|
theme = { type = "persisted", default = "light" }
|
||||||
|
language = { type = "reactive", default = "en" }
|
||||||
|
auth = { type = "secure", default = "unauthenticated" }
|
||||||
|
|
||||||
|
[page_state.dashboard]
|
||||||
|
filters = { type = "local", default = {} }
|
||||||
|
sort_order = { type = "persisted", default = "date_desc" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated State Management:**
|
||||||
|
```rust
|
||||||
|
// State providers generated from configuration
|
||||||
|
#[component]
|
||||||
|
pub fn StateProviders(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
// Generated from global_state configuration
|
||||||
|
<ThemeProvider default="light" persisted=true>
|
||||||
|
<LanguageProvider default="en" reactive=true>
|
||||||
|
<AuthProvider default="unauthenticated" secure=true>
|
||||||
|
{children()}
|
||||||
|
</AuthProvider>
|
||||||
|
</LanguageProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reactive i18n Integration
|
||||||
|
|
||||||
|
Language switching triggers reactive updates:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Generated from your fluent files and route configuration
|
||||||
|
use client::i18n::{use_i18n, switch_language};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageSwitcher() -> impl IntoView {
|
||||||
|
let i18n = use_i18n(); // Connected to route configuration
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<select on:change=move |ev| {
|
||||||
|
let new_lang = event_target_value(&ev);
|
||||||
|
// Updates route, reloads content, persists choice
|
||||||
|
switch_language(&new_lang);
|
||||||
|
}>
|
||||||
|
// Options generated from available fluent files
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Extension Patterns
|
||||||
|
|
||||||
|
### Custom Component Integration
|
||||||
|
|
||||||
|
Extend generated components with custom behavior:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::extensions::{ComponentExtension, extend_component};
|
||||||
|
|
||||||
|
// Extend a generated component with custom client functionality
|
||||||
|
#[component]
|
||||||
|
pub fn ExtendedDashboard() -> impl IntoView {
|
||||||
|
// Get the base component from TOML configuration
|
||||||
|
let base_dashboard = client::get_generated_component("Dashboard");
|
||||||
|
|
||||||
|
// Add custom client-side features
|
||||||
|
let (realtime_data, _) = create_signal(Vec::new());
|
||||||
|
|
||||||
|
// Custom WebSocket connection for real-time updates
|
||||||
|
create_effect(move |_| {
|
||||||
|
establish_websocket_connection("wss://api.example.com/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// Base component (90% from configuration)
|
||||||
|
{base_dashboard}
|
||||||
|
|
||||||
|
// Custom extensions (10% when needed)
|
||||||
|
<RealtimeMetricsPanel data=realtime_data />
|
||||||
|
<AdvancedCharting />
|
||||||
|
<CustomNotifications />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build-Time Extensions
|
||||||
|
|
||||||
|
Extend the build system for custom client generation:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In your build.rs
|
||||||
|
use client::build_extensions::{ClientBuildExtension, register_extension};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Foundation generates 90% from TOML configuration
|
||||||
|
client::generate_from_configuration()?;
|
||||||
|
|
||||||
|
// Add 10% custom build-time generation
|
||||||
|
register_extension(CustomDashboardExtension)?;
|
||||||
|
register_extension(RealtimeComponentsExtension)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CustomDashboardExtension;
|
||||||
|
|
||||||
|
impl ClientBuildExtension for CustomDashboardExtension {
|
||||||
|
fn generate_code(&self) -> Result<String, String> {
|
||||||
|
Ok(r#"
|
||||||
|
// Custom generated client code
|
||||||
|
#[component]
|
||||||
|
pub fn CustomDashboard() -> impl IntoView {
|
||||||
|
view! { /* custom dashboard implementation */ }
|
||||||
|
}
|
||||||
|
"#.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Configuration-Based Optimization
|
||||||
|
|
||||||
|
Performance features enabled through configuration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/performance.toml
|
||||||
|
[client_optimization]
|
||||||
|
bundle_splitting = true
|
||||||
|
lazy_loading = true
|
||||||
|
preload_critical = true
|
||||||
|
tree_shaking = true
|
||||||
|
compression = "gzip"
|
||||||
|
|
||||||
|
[wasm_optimization]
|
||||||
|
size_optimization = "aggressive"
|
||||||
|
debug_symbols = false
|
||||||
|
lto = true
|
||||||
|
panic_strategy = "abort"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Optimization Strategy:**
|
||||||
|
```rust
|
||||||
|
// Build configuration drives WASM optimization
|
||||||
|
[profile.release]
|
||||||
|
lto = true # From wasm_optimization.lto
|
||||||
|
panic = "abort" # From wasm_optimization.panic_strategy
|
||||||
|
codegen-units = 1 # From size_optimization = "aggressive"
|
||||||
|
opt-level = "s" # Size optimization
|
||||||
|
|
||||||
|
// Runtime optimization based on client_optimization
|
||||||
|
lazy_static! {
|
||||||
|
static ref PRELOAD_ROUTES: Vec<&'static str> = vec![
|
||||||
|
"/dashboard", // From preload_critical configuration
|
||||||
|
"/profile", // High-traffic routes from analytics
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route-Based Code Splitting
|
||||||
|
|
||||||
|
TOML routes automatically generate code splits:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[routes]]
|
||||||
|
path = "/admin/*"
|
||||||
|
component = "AdminSection"
|
||||||
|
lazy_load = true # Generates separate WASM chunk
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
path = "/*"
|
||||||
|
component = "PublicSection"
|
||||||
|
lazy_load = false # Included in main bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Code Splitting:**
|
||||||
|
```rust
|
||||||
|
// Automatically generated lazy loading
|
||||||
|
async fn load_admin_component() -> Result<AdminComponent, LoadError> {
|
||||||
|
// Loads admin.wasm chunk only when needed
|
||||||
|
let module = wasm_bindgen_futures::JsFuture::from(
|
||||||
|
js_sys::eval("import('./chunks/admin.wasm')")
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(AdminComponent::from_module(module))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Integration
|
||||||
|
|
||||||
|
### Web APIs Access
|
||||||
|
|
||||||
|
Safe WebAssembly access to browser APIs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::web_apis::{LocalStorage, SessionStorage, Notifications, Location};
|
||||||
|
|
||||||
|
// Safe wrappers generated for each configured feature
|
||||||
|
#[component]
|
||||||
|
pub fn ClientFeatures() -> impl IntoView {
|
||||||
|
let storage = LocalStorage::new(); // Type-safe localStorage wrapper
|
||||||
|
let notifications = Notifications::new(); // Permission handling
|
||||||
|
let location = Location::new(); // URL manipulation
|
||||||
|
|
||||||
|
// All browser APIs are safely wrapped for WASM usage
|
||||||
|
view! {
|
||||||
|
<button on:click=move |_| {
|
||||||
|
storage.set("theme", "dark").unwrap();
|
||||||
|
notifications.show("Theme changed").unwrap();
|
||||||
|
location.reload().unwrap();
|
||||||
|
}>
|
||||||
|
"Change Theme"
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Worker Integration
|
||||||
|
|
||||||
|
Optional service worker for offline functionality:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/pwa.toml
|
||||||
|
[service_worker]
|
||||||
|
enabled = true
|
||||||
|
cache_routes = ["all"] # Cache all configured routes
|
||||||
|
offline_fallbacks = true
|
||||||
|
background_sync = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Service Worker:**
|
||||||
|
```javascript
|
||||||
|
// Generated from your route configuration
|
||||||
|
const CACHE_ROUTES = [
|
||||||
|
'/dashboard', // From your routes TOML
|
||||||
|
'/profile', // All enabled routes cached
|
||||||
|
'/settings' // Automatically included
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes become offline-available automatically
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
if (CACHE_ROUTES.includes(url.pathname)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request)
|
||||||
|
.then(response => response || fetch(event.request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Integration
|
||||||
|
|
||||||
|
### Configuration-Based Testing
|
||||||
|
|
||||||
|
Test your TOML configuration directly:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod client_config_tests {
|
||||||
|
use super::*;
|
||||||
|
use client::testing::{mock_hydration, validate_routes};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_configuration() {
|
||||||
|
let routes_config = load_test_config("routes/en.toml");
|
||||||
|
|
||||||
|
// Validate all routes are properly configured
|
||||||
|
let validation = validate_routes(&routes_config);
|
||||||
|
assert!(validation.is_ok());
|
||||||
|
|
||||||
|
// Test hydration for each configured route
|
||||||
|
for route in &routes_config.routes {
|
||||||
|
let result = mock_hydration(&route.path);
|
||||||
|
assert!(result.is_ok(), "Failed to hydrate route: {}", route.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_state_management() {
|
||||||
|
let state_config = load_test_config("client-state.toml");
|
||||||
|
|
||||||
|
// Validate state providers work correctly
|
||||||
|
let providers = create_state_providers(&state_config);
|
||||||
|
assert!(providers.theme_provider.is_some());
|
||||||
|
assert!(providers.i18n_provider.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Testing in WASM
|
||||||
|
|
||||||
|
Test generated components in WebAssembly environment:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use wasm_bindgen_test::*;
|
||||||
|
use client::testing::WasmTestHarness;
|
||||||
|
|
||||||
|
wasm_bindgen_test_configure!(run_in_browser);
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_dashboard_hydration() {
|
||||||
|
let harness = WasmTestHarness::new();
|
||||||
|
|
||||||
|
// Test hydration of configured component
|
||||||
|
let dashboard = harness.hydrate_component("Dashboard", "/dashboard").await;
|
||||||
|
assert!(dashboard.is_ok());
|
||||||
|
|
||||||
|
// Test client-side navigation
|
||||||
|
let result = harness.navigate_to("/profile").await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Test reactive state updates
|
||||||
|
harness.update_theme("dark").await;
|
||||||
|
let theme = harness.get_current_theme().await;
|
||||||
|
assert_eq!(theme, "dark");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Core Functions
|
||||||
|
- **`client::hydrate()`** - Zero-config hydration from TOML routes
|
||||||
|
- **`client::hydrate_with_config(config)`** - Custom hydration configuration
|
||||||
|
- **`client::create_configured_app()`** - App from TOML configuration
|
||||||
|
- **`client::validate_client_config(config)`** - Validate TOML configuration
|
||||||
|
|
||||||
|
### Configuration Types
|
||||||
|
- **`ClientConfig`** - Client-side configuration builder
|
||||||
|
- **`HydrationOptions`** - Hydration behavior configuration
|
||||||
|
- **`StateConfig`** - Client state management configuration
|
||||||
|
- **`PerformanceConfig`** - WASM optimization configuration
|
||||||
|
|
||||||
|
### Extension Points
|
||||||
|
- **`ComponentExtension`** - Extend generated components
|
||||||
|
- **`ClientBuildExtension`** - Extend build-time generation
|
||||||
|
- **`StateExtension`** - Extend state management
|
||||||
|
- **`RouteExtension`** - Extend routing behavior
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Complete usage examples are available in the `examples/` directory:
|
||||||
|
|
||||||
|
- **[Basic Hydration](examples/basic_hydration.rs)** - Zero-config TOML → WASM
|
||||||
|
- **[Custom Extensions](examples/custom_extensions.rs)** - 10% custom client code
|
||||||
|
- **[State Management](examples/state_management.rs)** - Reactive state patterns
|
||||||
|
- **[Performance Optimization](examples/performance_optimization.rs)** - WASM optimization
|
||||||
|
- **[Testing Strategies](examples/testing_strategies.rs)** - Client testing patterns
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Start with configuration** - Use TOML files to define your client behavior
|
||||||
|
2. **Validate early** - Use `validate_client_config()` in build scripts
|
||||||
|
3. **Extend sparingly** - Only add custom code when configuration can't express needs
|
||||||
|
4. **Test configurations** - Validate TOML configs with automated tests
|
||||||
|
5. **Monitor bundle size** - Use configuration to control what's included in WASM
|
||||||
|
6. **Profile hydration** - Use built-in debugging to optimize hydration performance
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please see our [Contributing Guidelines](https://github.com/yourusername/rustelo/blob/main/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](https://github.com/yourusername/rustelo/blob/main/LICENSE) file for details.
|
||||||
670
crates/foundation/crates/rustelo_client/docs/HYDRATION_GUIDE.md
Normal file
670
crates/foundation/crates/rustelo_client/docs/HYDRATION_GUIDE.md
Normal file
@ -0,0 +1,670 @@
|
|||||||
|
# Client Hydration Guide
|
||||||
|
|
||||||
|
Comprehensive guide to Rustelo's configuration-driven client hydration system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Rustelo's client hydration transforms server-rendered HTML into a fully reactive WebAssembly application. The hydration process is **90% configuration-driven** from your TOML files, with **10% custom extension** capabilities for complex requirements.
|
||||||
|
|
||||||
|
## Core Hydration Flow
|
||||||
|
|
||||||
|
### 1. Configuration-Driven Hydration (90% Use Case)
|
||||||
|
|
||||||
|
Your TOML configuration drives the entire hydration process:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# site/config/routes/en.toml
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
title_key = "dashboard-title"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
hydration_priority = "high"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
path = "/profile"
|
||||||
|
component = "Profile"
|
||||||
|
title_key = "profile-title"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
hydration_priority = "normal"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Hydration Code:**
|
||||||
|
```rust
|
||||||
|
// Generated from your TOML configuration
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Read server-rendered page data
|
||||||
|
let page_data = extract_server_state();
|
||||||
|
let current_path = get_current_path();
|
||||||
|
|
||||||
|
// Route resolution from TOML configuration
|
||||||
|
match resolve_configured_route(¤t_path) {
|
||||||
|
Some(route_info) => {
|
||||||
|
hydrate_configured_component(&route_info, page_data);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
hydrate_not_found_component(¤t_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate client-side routing for all configured routes
|
||||||
|
activate_client_routing();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Server-Client State Handoff
|
||||||
|
|
||||||
|
The hydration system seamlessly transitions from server state to client state:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Server-rendered HTML with embedded state -->
|
||||||
|
<div id="app" data-leptos-hydration="true">
|
||||||
|
<script type="application/json" id="__LEPTOS_STATE__">
|
||||||
|
{
|
||||||
|
"route": "/dashboard",
|
||||||
|
"component": "Dashboard",
|
||||||
|
"props": { "user_id": "123" },
|
||||||
|
"i18n_data": { "language": "en", "messages": {...} },
|
||||||
|
"theme": "light"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Server-rendered component HTML -->
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<!-- ... more server-rendered content -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Client hydration reads and uses server state
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
let server_state = read_embedded_state();
|
||||||
|
|
||||||
|
// Hydrate with exact server state to prevent mismatches
|
||||||
|
leptos::mount::hydrate_body(move || {
|
||||||
|
view! {
|
||||||
|
<AppComponent
|
||||||
|
initial_route=server_state.route
|
||||||
|
initial_props=server_state.props
|
||||||
|
initial_theme=server_state.theme
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hydration Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Zero-Config Hydration
|
||||||
|
|
||||||
|
Most common pattern - pure configuration-driven:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Reads your TOML routes and hydrates automatically
|
||||||
|
client::hydrate();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this provides:**
|
||||||
|
- ✅ Automatic component hydration for all configured routes
|
||||||
|
- ✅ State management providers (theme, i18n, auth)
|
||||||
|
- ✅ Client-side routing activation
|
||||||
|
- ✅ Error boundary setup
|
||||||
|
- ✅ Development debugging tools
|
||||||
|
|
||||||
|
### Pattern 2: Hydration with Validation
|
||||||
|
|
||||||
|
Validate configuration before hydration:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::{hydrate_with_validation, HydrationConfig};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
let config = HydrationConfig::from_toml_files()
|
||||||
|
.validate_routes()
|
||||||
|
.validate_components()
|
||||||
|
.validate_i18n_keys();
|
||||||
|
|
||||||
|
match config.build() {
|
||||||
|
Ok(validated_config) => {
|
||||||
|
client::hydrate_with_config(validated_config);
|
||||||
|
}
|
||||||
|
Err(validation_errors) => {
|
||||||
|
console::error!("Hydration validation failed: {:?}", validation_errors);
|
||||||
|
client::hydrate_with_fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Custom Hydration Extensions (10% Case)
|
||||||
|
|
||||||
|
When you need custom hydration behavior:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::{HydrationBuilder, CustomHydrationHooks};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
let hooks = CustomHydrationHooks::builder()
|
||||||
|
.with_pre_hydration(|| {
|
||||||
|
// Custom initialization before hydration
|
||||||
|
setup_analytics_tracking();
|
||||||
|
initialize_performance_monitoring();
|
||||||
|
})
|
||||||
|
.with_post_hydration(|| {
|
||||||
|
// Custom setup after hydration
|
||||||
|
activate_service_worker();
|
||||||
|
setup_keyboard_shortcuts();
|
||||||
|
})
|
||||||
|
.with_component_hydration(|component_name, element| {
|
||||||
|
// Custom per-component hydration logic
|
||||||
|
match component_name {
|
||||||
|
"Dashboard" => setup_realtime_dashboard(element),
|
||||||
|
"Chart" => initialize_chart_interactions(element),
|
||||||
|
_ => {} // Use default hydration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
client::hydrate_with_hooks(hooks);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hydration Lifecycle
|
||||||
|
|
||||||
|
### Phase 1: Initialization
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 1. WebAssembly module loads
|
||||||
|
wasm_bindgen::start();
|
||||||
|
|
||||||
|
// 2. Panic hook setup for debugging
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
// 3. Read server-embedded configuration
|
||||||
|
let server_config = read_leptos_state();
|
||||||
|
let route_config = load_toml_routes();
|
||||||
|
|
||||||
|
// 4. Validate server-client consistency
|
||||||
|
validate_state_consistency(&server_config, &route_config)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2: Component Hydration
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 5. Resolve current route from URL
|
||||||
|
let current_path = window().location().pathname();
|
||||||
|
let route_info = resolve_route(¤t_path, &route_config)?;
|
||||||
|
|
||||||
|
// 6. Hydrate matching component
|
||||||
|
match route_info.component {
|
||||||
|
RouteComponent::Dashboard => hydrate_dashboard_component(),
|
||||||
|
RouteComponent::Profile => hydrate_profile_component(),
|
||||||
|
RouteComponent::Settings => hydrate_settings_component(),
|
||||||
|
_ => hydrate_not_found_component(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Activate reactive signals and effects
|
||||||
|
activate_component_reactivity();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: System Activation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 8. Setup state providers from configuration
|
||||||
|
setup_theme_provider(&route_config.theme);
|
||||||
|
setup_i18n_provider(&route_config.i18n);
|
||||||
|
setup_auth_provider(&route_config.auth);
|
||||||
|
|
||||||
|
// 9. Activate client-side routing
|
||||||
|
let router = create_configured_router(&route_config.routes);
|
||||||
|
activate_client_routing(router);
|
||||||
|
|
||||||
|
// 10. Setup cross-component communication
|
||||||
|
setup_event_bus();
|
||||||
|
|
||||||
|
// 11. Mark hydration as complete
|
||||||
|
mark_hydration_complete();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hydration Debugging
|
||||||
|
|
||||||
|
### Development Debug Mode
|
||||||
|
|
||||||
|
Enable comprehensive hydration debugging:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
use client::debug::{HydrationDebugger, DebugLevel};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
let debugger = HydrationDebugger::new()
|
||||||
|
.with_level(DebugLevel::Verbose)
|
||||||
|
.with_performance_tracking(true)
|
||||||
|
.with_state_validation(true)
|
||||||
|
.with_component_tracing(true);
|
||||||
|
|
||||||
|
client::hydrate_with_debugger(debugger);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debug Output:**
|
||||||
|
```
|
||||||
|
🔍 [HYDRATION] Starting hydration process
|
||||||
|
📊 [PERFORMANCE] Hydration start time: 1634567890123
|
||||||
|
🔧 [STATE] Reading server state: {"route": "/dashboard", "theme": "light"}
|
||||||
|
📋 [CONFIG] Loading TOML routes: found 5 routes
|
||||||
|
✅ [VALIDATION] Server-client state consistent
|
||||||
|
🎯 [ROUTING] Resolving route: /dashboard → DashboardComponent
|
||||||
|
💧 [COMPONENT] Hydrating DashboardComponent
|
||||||
|
⚡ [SIGNALS] Activating 12 reactive signals
|
||||||
|
🎨 [THEME] Setting up theme provider: light mode
|
||||||
|
🌐 [I18N] Loading translations: en locale
|
||||||
|
🔒 [AUTH] Initializing auth context: unauthenticated
|
||||||
|
🛣️ [ROUTER] Activating client-side routing for 5 routes
|
||||||
|
✨ [COMPLETE] Hydration completed in 234ms
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration Error Handling
|
||||||
|
|
||||||
|
Graceful error handling for hydration issues:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use client::{HydrationError, HydrationRecovery};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
match client::hydrate_with_recovery() {
|
||||||
|
Ok(()) => {
|
||||||
|
console::log!("✅ Hydration successful");
|
||||||
|
}
|
||||||
|
Err(HydrationError::StateMismatch { server, client }) => {
|
||||||
|
console::warn!("⚠️ State mismatch detected");
|
||||||
|
console::debug!("Server state: {:?}", server);
|
||||||
|
console::debug!("Client state: {:?}", client);
|
||||||
|
|
||||||
|
// Attempt recovery
|
||||||
|
client::recover_from_state_mismatch(server, client);
|
||||||
|
}
|
||||||
|
Err(HydrationError::ComponentNotFound { component }) => {
|
||||||
|
console::error!("❌ Component not found: {}", component);
|
||||||
|
|
||||||
|
// Fall back to generic component
|
||||||
|
client::hydrate_with_fallback_component();
|
||||||
|
}
|
||||||
|
Err(HydrationError::ConfigurationError { message }) => {
|
||||||
|
console::error!("🔧 Configuration error: {}", message);
|
||||||
|
|
||||||
|
// Fall back to minimal hydration
|
||||||
|
client::hydrate_minimal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Hydration Priority
|
||||||
|
|
||||||
|
Configure hydration priority in TOML:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# High priority - hydrate immediately
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
hydration_priority = "critical"
|
||||||
|
preload_data = true
|
||||||
|
|
||||||
|
# Normal priority - hydrate after critical
|
||||||
|
[[routes]]
|
||||||
|
path = "/profile"
|
||||||
|
component = "Profile"
|
||||||
|
hydration_priority = "normal"
|
||||||
|
|
||||||
|
# Low priority - hydrate lazily
|
||||||
|
[[routes]]
|
||||||
|
path = "/settings"
|
||||||
|
component = "Settings"
|
||||||
|
hydration_priority = "lazy"
|
||||||
|
defer_hydration = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Hydration Strategy:**
|
||||||
|
```rust
|
||||||
|
// Prioritized hydration based on configuration
|
||||||
|
async fn hydrate_with_priorities() {
|
||||||
|
// Phase 1: Critical components (blocking)
|
||||||
|
hydrate_critical_components().await;
|
||||||
|
|
||||||
|
// Phase 2: Normal components (non-blocking)
|
||||||
|
spawn_local(async {
|
||||||
|
hydrate_normal_components().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 3: Lazy components (on-demand)
|
||||||
|
setup_lazy_hydration_triggers();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bundle Splitting for Hydration
|
||||||
|
|
||||||
|
Route-based code splitting:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Main bundle
|
||||||
|
[[routes]]
|
||||||
|
path = "/"
|
||||||
|
component = "Home"
|
||||||
|
bundle = "main"
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
bundle = "main"
|
||||||
|
|
||||||
|
# Admin bundle (lazy-loaded)
|
||||||
|
[[routes]]
|
||||||
|
path = "/admin/*"
|
||||||
|
component = "AdminSection"
|
||||||
|
bundle = "admin"
|
||||||
|
lazy_load = true
|
||||||
|
|
||||||
|
# Feature bundles
|
||||||
|
[[routes]]
|
||||||
|
path = "/analytics"
|
||||||
|
component = "Analytics"
|
||||||
|
bundle = "analytics"
|
||||||
|
lazy_load = true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generated Loading Strategy:**
|
||||||
|
```rust
|
||||||
|
// Main bundle hydrates immediately
|
||||||
|
hydrate_main_bundle();
|
||||||
|
|
||||||
|
// Lazy bundles load on demand
|
||||||
|
async fn navigate_to_admin() {
|
||||||
|
let admin_module = load_bundle("admin").await?;
|
||||||
|
hydrate_bundle_components(admin_module).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Hydration Patterns
|
||||||
|
|
||||||
|
### Progressive Hydration
|
||||||
|
|
||||||
|
Hydrate components progressively based on viewport:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
hydration_strategy = "progressive"
|
||||||
|
viewport_priority = true
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Progressive hydration implementation
|
||||||
|
#[component]
|
||||||
|
pub fn ProgressivelyHydratedDashboard() -> impl IntoView {
|
||||||
|
let (is_in_viewport, set_is_in_viewport) = create_signal(false);
|
||||||
|
let dashboard_ref = create_node_ref::<html::Div>();
|
||||||
|
|
||||||
|
// Hydrate when component enters viewport
|
||||||
|
create_effect(move |_| {
|
||||||
|
if let Some(element) = dashboard_ref.get() {
|
||||||
|
let intersection_observer = create_intersection_observer(
|
||||||
|
move |entries| {
|
||||||
|
for entry in entries {
|
||||||
|
if entry.is_intersecting() {
|
||||||
|
set_is_in_viewport.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
intersection_observer.observe(&element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div node_ref=dashboard_ref>
|
||||||
|
{move || {
|
||||||
|
if is_in_viewport.get() {
|
||||||
|
view! { <FullDashboardComponent /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <DashboardPlaceholder /> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration with Service Workers
|
||||||
|
|
||||||
|
Offline-capable hydration:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[service_worker]
|
||||||
|
enabled = true
|
||||||
|
cache_hydration_data = true
|
||||||
|
offline_fallback = true
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Service worker integration for hydration
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
spawn_local(async {
|
||||||
|
// Try to get cached hydration data
|
||||||
|
if let Ok(cached_data) = get_cached_hydration_data().await {
|
||||||
|
hydrate_with_cached_data(cached_data);
|
||||||
|
} else if navigator.on_line() {
|
||||||
|
// Online: normal hydration
|
||||||
|
client::hydrate();
|
||||||
|
} else {
|
||||||
|
// Offline: fallback hydration
|
||||||
|
client::hydrate_offline_mode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Hydration
|
||||||
|
|
||||||
|
### Hydration Tests
|
||||||
|
|
||||||
|
Test hydration behavior:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod hydration_tests {
|
||||||
|
use super::*;
|
||||||
|
use wasm_bindgen_test::*;
|
||||||
|
|
||||||
|
wasm_bindgen_test_configure!(run_in_browser);
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_dashboard_hydration() {
|
||||||
|
// Setup server-rendered HTML
|
||||||
|
let server_html = create_server_rendered_html("/dashboard");
|
||||||
|
document().body().unwrap().set_inner_html(&server_html);
|
||||||
|
|
||||||
|
// Test hydration
|
||||||
|
let result = client::hydrate_route("/dashboard").await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Verify component is interactive
|
||||||
|
let dashboard = document()
|
||||||
|
.query_selector(".dashboard-container")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Test that click events work (hydration successful)
|
||||||
|
let button = dashboard
|
||||||
|
.query_selector("button")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
// Verify reactive state updated
|
||||||
|
assert_eq!(get_dashboard_state().active_tab, "metrics");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_hydration_error_recovery() {
|
||||||
|
// Create mismatched server state
|
||||||
|
set_server_state("dashboard", serde_json::json!({
|
||||||
|
"version": "1.0",
|
||||||
|
"data": {"user_id": "123"}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Simulate client with different version
|
||||||
|
let result = client::hydrate_with_validation().await;
|
||||||
|
|
||||||
|
// Should recover gracefully
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(get_hydration_status(), HydrationStatus::RecoveredFromMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration Performance Tests
|
||||||
|
|
||||||
|
Monitor hydration performance:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_hydration_performance() {
|
||||||
|
let start_time = js_sys::Date::now();
|
||||||
|
|
||||||
|
client::hydrate().await;
|
||||||
|
|
||||||
|
let hydration_time = js_sys::Date::now() - start_time;
|
||||||
|
|
||||||
|
// Hydration should complete within performance budget
|
||||||
|
assert!(hydration_time < 500.0, "Hydration took {}ms, expected < 500ms", hydration_time);
|
||||||
|
|
||||||
|
// Verify all critical components hydrated
|
||||||
|
assert!(is_component_hydrated("Navigation"));
|
||||||
|
assert!(is_component_hydrated("MainContent"));
|
||||||
|
assert!(is_component_hydrated("Footer"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Configuration-First Approach
|
||||||
|
- Use TOML configuration for 90% of hydration behavior
|
||||||
|
- Only add custom hydration code for complex requirements
|
||||||
|
- Validate configuration at build time
|
||||||
|
|
||||||
|
### 2. Performance Optimization
|
||||||
|
- Use hydration priorities to control loading order
|
||||||
|
- Implement progressive hydration for large applications
|
||||||
|
- Monitor hydration performance in production
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
- Always include fallback hydration strategies
|
||||||
|
- Implement graceful degradation for hydration failures
|
||||||
|
- Log hydration errors for debugging
|
||||||
|
|
||||||
|
### 4. Development Experience
|
||||||
|
- Enable debug mode during development
|
||||||
|
- Use comprehensive logging for troubleshooting
|
||||||
|
- Test hydration behavior across different scenarios
|
||||||
|
|
||||||
|
### 5. State Consistency
|
||||||
|
- Ensure server and client render identical HTML
|
||||||
|
- Validate state consistency during hydration
|
||||||
|
- Handle state mismatches gracefully
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Issue: Hydration Mismatches
|
||||||
|
|
||||||
|
**Symptom:** Console errors about DOM structure differences
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```rust
|
||||||
|
// Ensure server and client render identical content
|
||||||
|
#[component]
|
||||||
|
pub fn ConsistentComponent() -> impl IntoView {
|
||||||
|
// Use server-safe default values
|
||||||
|
let (client_only_state, set_client_only_state) = create_signal(None);
|
||||||
|
|
||||||
|
// Initialize client state after hydration
|
||||||
|
create_effect(move |_| {
|
||||||
|
if !is_server_rendering() {
|
||||||
|
set_client_only_state.set(Some(initialize_client_state()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
// Always render server-safe content first
|
||||||
|
<h1>"Title"</h1>
|
||||||
|
|
||||||
|
// Client-only features after hydration
|
||||||
|
{move || {
|
||||||
|
if let Some(state) = client_only_state.get() {
|
||||||
|
view! { <ClientOnlyFeatures state=state /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Slow Hydration
|
||||||
|
|
||||||
|
**Symptom:** Long time to interactive
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```toml
|
||||||
|
# Configure progressive hydration
|
||||||
|
[hydration]
|
||||||
|
strategy = "progressive"
|
||||||
|
critical_components = ["Navigation", "MainContent"]
|
||||||
|
defer_non_critical = true
|
||||||
|
viewport_based = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Bundle Size
|
||||||
|
|
||||||
|
**Symptom:** Large WebAssembly bundle affecting hydration speed
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```toml
|
||||||
|
# Enable code splitting
|
||||||
|
[optimization]
|
||||||
|
bundle_splitting = true
|
||||||
|
tree_shaking = true
|
||||||
|
lazy_loading = true
|
||||||
|
|
||||||
|
# Route-based splitting
|
||||||
|
[[routes]]
|
||||||
|
path = "/admin/*"
|
||||||
|
bundle = "admin"
|
||||||
|
lazy_load = true
|
||||||
|
```
|
||||||
|
|
||||||
|
This guide covers the essential patterns and best practices for Rustelo's configuration-driven hydration system. The 90/10 rule applies: use TOML configuration for standard hydration needs, and custom code only for complex requirements that configuration can't express.
|
||||||
@ -0,0 +1,258 @@
|
|||||||
|
//! Basic Hydration Example
|
||||||
|
//!
|
||||||
|
//! Demonstrates zero-configuration client hydration using TOML route configuration.
|
||||||
|
//! This is the 90% use case - pure configuration-driven approach with no custom client code.
|
||||||
|
|
||||||
|
use client;
|
||||||
|
|
||||||
|
/// Zero-configuration WebAssembly hydration
|
||||||
|
///
|
||||||
|
/// This function demonstrates the most common client pattern:
|
||||||
|
/// - Read TOML route configuration from site/config/routes/*.toml
|
||||||
|
/// - Generate WebAssembly hydration automatically
|
||||||
|
/// - No custom client code needed
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
println!("🚀 BASIC HYDRATION EXAMPLE");
|
||||||
|
println!("==========================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📝 Step 1: Your TOML Configuration");
|
||||||
|
println!(" File: site/config/routes/en.toml");
|
||||||
|
println!(" [[routes]]");
|
||||||
|
println!(" path = \"/dashboard\"");
|
||||||
|
println!(" component = \"Dashboard\"");
|
||||||
|
println!(" title_key = \"dashboard-title\"");
|
||||||
|
println!(" language = \"en\"");
|
||||||
|
println!(" enabled = true");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("⚙️ Step 2: Build System Generation");
|
||||||
|
println!(" cargo leptos build");
|
||||||
|
println!(" → Reads your TOML configuration");
|
||||||
|
println!(" → Generates route handling code");
|
||||||
|
println!(" → Creates component hydration logic");
|
||||||
|
println!(" → Builds optimized WebAssembly");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🔧 Step 3: Zero-Config Hydration");
|
||||||
|
println!(" rustelo_client::hydrate() provides:");
|
||||||
|
println!(" ✅ Hydration for /dashboard route");
|
||||||
|
println!(" ✅ Client-side navigation");
|
||||||
|
println!(" ✅ i18n with dashboard-title");
|
||||||
|
println!(" ✅ Reactive component updates");
|
||||||
|
println!(" ✅ Theme system integration");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// The actual hydration call - this is all that's needed!
|
||||||
|
rustelo_client::hydrate();
|
||||||
|
|
||||||
|
println!("✨ Hydration complete! Your TOML routes are now interactive.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Example of configuration validation before hydration
|
||||||
|
///
|
||||||
|
/// This shows how to validate your TOML configuration at build time
|
||||||
|
/// to catch configuration errors before deploying WebAssembly.
|
||||||
|
fn validate_configuration_example() {
|
||||||
|
use rustelo_core_types::RoutesConfig;
|
||||||
|
|
||||||
|
println!("🔍 CONFIGURATION VALIDATION");
|
||||||
|
println!("===========================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Simulate loading TOML configuration (in real usage, this would be from files)
|
||||||
|
let toml_content = r#"
|
||||||
|
[[routes]]
|
||||||
|
path = "/dashboard"
|
||||||
|
component = "Dashboard"
|
||||||
|
title_key = "dashboard-title"
|
||||||
|
description_key = "dashboard-description"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
[[routes]]
|
||||||
|
path = "/profile"
|
||||||
|
component = "Profile"
|
||||||
|
title_key = "profile-title"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("📋 Loading route configuration from TOML:");
|
||||||
|
println!("{}", toml_content);
|
||||||
|
|
||||||
|
// Parse TOML into strongly-typed configuration
|
||||||
|
match toml::from_str::<RoutesConfig>(toml_content) {
|
||||||
|
Ok(routes_config) => {
|
||||||
|
println!("✅ Configuration parsed successfully");
|
||||||
|
println!("📊 Found {} routes", routes_config.routes.len());
|
||||||
|
|
||||||
|
// Validate each route
|
||||||
|
for route in &routes_config.routes {
|
||||||
|
println!(" Route: {} → {}", route.path, route.component);
|
||||||
|
|
||||||
|
// Validate route configuration
|
||||||
|
if route.path.is_empty() {
|
||||||
|
println!(" ❌ Error: Empty path");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !route.path.starts_with('/') {
|
||||||
|
println!(" ❌ Error: Path must start with '/'");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if route.component.is_empty() {
|
||||||
|
println!(" ❌ Error: Empty component name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if route.title_key.is_empty() {
|
||||||
|
println!(" ❌ Error: Empty title key");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" ✅ Valid route configuration");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("🎯 Ready to build WebAssembly with validated configuration");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("❌ Configuration parsing failed: {}", e);
|
||||||
|
println!(" Please fix your TOML configuration before building");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Example of inspecting generated hydration behavior
|
||||||
|
///
|
||||||
|
/// Shows what happens during the hydration process and how it relates
|
||||||
|
/// to your TOML configuration.
|
||||||
|
fn inspect_hydration_process() {
|
||||||
|
println!("🔬 HYDRATION PROCESS INSPECTION");
|
||||||
|
println!("===============================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🌊 Hydration Flow (generated from your TOML config):");
|
||||||
|
println!("1. Browser loads server-rendered HTML");
|
||||||
|
println!("2. WebAssembly module initializes");
|
||||||
|
println!("3. rustelo_client::hydrate() reads data-leptos-config");
|
||||||
|
println!("4. Route resolution using configured routes");
|
||||||
|
println!("5. Component hydration based on current path");
|
||||||
|
println!("6. State providers activation (theme, i18n, auth)");
|
||||||
|
println!("7. Event handlers attachment");
|
||||||
|
println!("8. Client-side routing activation");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🎯 What gets hydrated (determined by your routes TOML):");
|
||||||
|
println!(" /dashboard → DashboardComponent");
|
||||||
|
println!(" /profile → ProfileComponent");
|
||||||
|
println!(" /settings → SettingsComponent");
|
||||||
|
println!(" /* → NotFoundComponent");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🔄 Reactive Features (automatic from configuration):");
|
||||||
|
println!(" • Language switching triggers content updates");
|
||||||
|
println!(" • Theme changes update CSS custom properties");
|
||||||
|
println!(" • Route navigation updates without page refresh");
|
||||||
|
println!(" • Authentication state syncs across components");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📦 Bundle Analysis (based on enabled routes):");
|
||||||
|
println!(" • Main bundle: Common components + routing");
|
||||||
|
println!(" • Route chunks: Lazy-loaded per major route");
|
||||||
|
println!(" • Feature bundles: Auth, analytics, etc.");
|
||||||
|
println!(" • Shared chunks: Common dependencies");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_basic_configuration_loading() {
|
||||||
|
// Test that we can parse basic route configuration
|
||||||
|
let toml_content = r#"
|
||||||
|
[[routes]]
|
||||||
|
path = "/test"
|
||||||
|
component = "Test"
|
||||||
|
title_key = "test-title"
|
||||||
|
language = "en"
|
||||||
|
enabled = true
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let result: Result<rustelo_core_types::RoutesConfig, _> = toml::from_str(toml_content);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let config = result.unwrap();
|
||||||
|
assert_eq!(config.routes.len(), 1);
|
||||||
|
assert_eq!(config.routes[0].path, "/test");
|
||||||
|
assert_eq!(config.routes[0].component, "Test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_route_validation() {
|
||||||
|
use rustelo_core_types::RouteConfigToml;
|
||||||
|
|
||||||
|
// Test valid route
|
||||||
|
let valid_route = RouteConfigToml {
|
||||||
|
path: "/dashboard".to_string(),
|
||||||
|
component: "Dashboard".to_string(),
|
||||||
|
title_key: "dashboard-title".to_string(),
|
||||||
|
language: "en".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!valid_route.path.is_empty());
|
||||||
|
assert!(valid_route.path.starts_with('/'));
|
||||||
|
assert!(!valid_route.component.is_empty());
|
||||||
|
assert!(!valid_route.title_key.is_empty());
|
||||||
|
|
||||||
|
// Test invalid route
|
||||||
|
let invalid_route = RouteConfigToml {
|
||||||
|
path: "invalid-path".to_string(), // Missing leading slash
|
||||||
|
component: "".to_string(), // Empty component
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!invalid_route.path.starts_with('/')); // Should fail validation
|
||||||
|
assert!(invalid_route.component.is_empty()); // Should fail validation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main function for testing this example
|
||||||
|
fn main() {
|
||||||
|
println!("🎯 RUSTELO CLIENT BASIC HYDRATION EXAMPLE");
|
||||||
|
println!("==========================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("This example demonstrates the foundational pattern of Rustelo client applications:");
|
||||||
|
println!("TOML Configuration → Generated WebAssembly → Interactive Client Application");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
validate_configuration_example();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
inspect_hydration_process();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("💡 Key Takeaways:");
|
||||||
|
println!(" • 90% of client functionality comes from TOML configuration");
|
||||||
|
println!(" • Zero custom code needed for basic hydration");
|
||||||
|
println!(" • Configuration validation catches errors early");
|
||||||
|
println!(" • Generated code handles routing, i18n, themes automatically");
|
||||||
|
println!(" • WebAssembly bundle size optimized by configuration");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("▶️ To see this in action:");
|
||||||
|
println!(" 1. Configure routes in site/config/routes/*.toml");
|
||||||
|
println!(" 2. Run: cargo leptos build");
|
||||||
|
println!(" 3. Open browser to see hydrated application");
|
||||||
|
println!(" 4. View browser console for hydration logging");
|
||||||
|
|
||||||
|
// Note: In a real WASM environment, you would call hydrate() here
|
||||||
|
// hydrate();
|
||||||
|
}
|
||||||
@ -0,0 +1,862 @@
|
|||||||
|
//! Custom Extensions Example
|
||||||
|
//!
|
||||||
|
//! Demonstrates the 10% extension pattern - when TOML configuration isn't sufficient
|
||||||
|
//! and you need custom client-side behavior beyond what configuration can express.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_client::{AppComponent, ClientBuilder, HydrationOptions};
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use web_sys::{console, window};
|
||||||
|
|
||||||
|
/// Custom client hydration with extensions
|
||||||
|
///
|
||||||
|
/// This demonstrates extending the foundation's 90% configuration-driven approach
|
||||||
|
/// with 10% custom client functionality for complex use cases.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate_with_custom_extensions() {
|
||||||
|
console::log_1(&"🚀 CUSTOM EXTENSIONS EXAMPLE".into());
|
||||||
|
console::log_1(&"============================".into());
|
||||||
|
|
||||||
|
// Foundation provides 90% through TOML configuration
|
||||||
|
let base_config = rustelo_client::ClientConfig::from_generated_routes();
|
||||||
|
|
||||||
|
// Add 10% custom extensions when configuration isn't sufficient
|
||||||
|
let extended_config = base_config
|
||||||
|
.with_custom_hydration_hooks(setup_custom_hydration_hooks())
|
||||||
|
.with_realtime_features(setup_websocket_connections())
|
||||||
|
.with_advanced_analytics(setup_client_analytics())
|
||||||
|
.with_service_worker(setup_offline_capabilities());
|
||||||
|
|
||||||
|
// Hydrate with both generated + custom functionality
|
||||||
|
rustelo_client::hydrate_with_config(extended_config);
|
||||||
|
|
||||||
|
console::log_1(&"✨ Extended hydration complete with custom features!".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom hydration hooks for special client behavior
|
||||||
|
///
|
||||||
|
/// These hooks extend the normal hydration process with custom logic
|
||||||
|
/// that can't be expressed in TOML configuration.
|
||||||
|
fn setup_custom_hydration_hooks() -> HydrationOptions {
|
||||||
|
HydrationOptions::builder()
|
||||||
|
.with_pre_hydration_hook(|| {
|
||||||
|
console::log_1(&"🔧 Pre-hydration: Setting up custom client state".into());
|
||||||
|
|
||||||
|
// Custom client initialization that runs before hydration
|
||||||
|
initialize_custom_client_storage();
|
||||||
|
setup_performance_monitoring();
|
||||||
|
register_custom_error_handlers();
|
||||||
|
})
|
||||||
|
.with_post_hydration_hook(|| {
|
||||||
|
console::log_1(&"🎉 Post-hydration: Activating custom features".into());
|
||||||
|
|
||||||
|
// Custom features that activate after hydration completes
|
||||||
|
start_background_sync();
|
||||||
|
initialize_keyboard_shortcuts();
|
||||||
|
setup_client_side_caching();
|
||||||
|
})
|
||||||
|
.with_component_hydration_hook(|component_name: &str, element: &web_sys::Element| {
|
||||||
|
console::log_1(&format!("💧 Hydrating component: {}", component_name).into());
|
||||||
|
|
||||||
|
// Custom per-component hydration logic
|
||||||
|
match component_name {
|
||||||
|
"Dashboard" => enhance_dashboard_component(element),
|
||||||
|
"Chart" => setup_realtime_chart_updates(element),
|
||||||
|
"Form" => add_advanced_form_validation(element),
|
||||||
|
_ => {} // Default hydration for other components
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebSocket connections for real-time features
|
||||||
|
///
|
||||||
|
/// Example of extending the client with real-time capabilities
|
||||||
|
/// that go beyond what TOML configuration can express.
|
||||||
|
fn setup_websocket_connections() -> RealtimeConfig {
|
||||||
|
RealtimeConfig::builder()
|
||||||
|
.with_endpoint("wss://api.example.com/dashboard")
|
||||||
|
.with_reconnection_policy(ReconnectionPolicy::ExponentialBackoff)
|
||||||
|
.with_message_handler(|message: &str| {
|
||||||
|
// Handle real-time messages from server
|
||||||
|
console::log_1(&format!("📡 Realtime message: {}", message).into());
|
||||||
|
|
||||||
|
// Update reactive signals based on server messages
|
||||||
|
if let Ok(data) = serde_json::from_str::<RealtimeData>(message) {
|
||||||
|
update_dashboard_metrics(data.metrics);
|
||||||
|
update_notification_badge(data.notifications);
|
||||||
|
update_user_presence(data.users_online);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.with_connection_handler(|status: ConnectionStatus| match status {
|
||||||
|
ConnectionStatus::Connected => {
|
||||||
|
console::log_1(&"🟢 WebSocket connected".into());
|
||||||
|
show_connection_status("Connected", "success");
|
||||||
|
}
|
||||||
|
ConnectionStatus::Disconnected => {
|
||||||
|
console::log_1(&"🔴 WebSocket disconnected".into());
|
||||||
|
show_connection_status("Disconnected", "error");
|
||||||
|
}
|
||||||
|
ConnectionStatus::Reconnecting => {
|
||||||
|
console::log_1(&"🟡 WebSocket reconnecting...".into());
|
||||||
|
show_connection_status("Reconnecting...", "warning");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advanced client-side analytics
|
||||||
|
///
|
||||||
|
/// Custom analytics that track user behavior beyond basic page views,
|
||||||
|
/// providing insights that standard configuration can't capture.
|
||||||
|
fn setup_client_analytics() -> AnalyticsConfig {
|
||||||
|
AnalyticsConfig::builder()
|
||||||
|
.with_user_journey_tracking(true)
|
||||||
|
.with_performance_monitoring(true)
|
||||||
|
.with_error_tracking(true)
|
||||||
|
.with_custom_events(vec![
|
||||||
|
// Track custom business events
|
||||||
|
CustomEvent::new("dashboard_interaction").with_properties(|interaction| {
|
||||||
|
json!({
|
||||||
|
"component": interaction.component_name,
|
||||||
|
"action": interaction.action_type,
|
||||||
|
"timestamp": js_sys::Date::now(),
|
||||||
|
"user_session": get_session_id()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
CustomEvent::new("feature_usage").with_properties(|usage| {
|
||||||
|
json!({
|
||||||
|
"feature_name": usage.feature,
|
||||||
|
"usage_duration": usage.duration_ms,
|
||||||
|
"success": usage.completed_successfully,
|
||||||
|
"browser": get_browser_info()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.with_heatmap_tracking(HeatmapConfig {
|
||||||
|
track_clicks: true,
|
||||||
|
track_scrolling: true,
|
||||||
|
track_form_interactions: true,
|
||||||
|
sample_rate: 0.1, // 10% of users
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service Worker setup for offline capabilities
|
||||||
|
///
|
||||||
|
/// Extends the client with Progressive Web App features
|
||||||
|
/// that provide offline functionality and background sync.
|
||||||
|
fn setup_offline_capabilities() -> ServiceWorkerConfig {
|
||||||
|
ServiceWorkerConfig::builder()
|
||||||
|
.with_cache_strategy(CacheStrategy::NetworkFirst)
|
||||||
|
.with_offline_pages(vec![
|
||||||
|
"/dashboard".to_string(),
|
||||||
|
"/profile".to_string(),
|
||||||
|
"/settings".to_string(),
|
||||||
|
])
|
||||||
|
.with_background_sync(BackgroundSyncConfig {
|
||||||
|
queue_name: "rustelo-sync".to_string(),
|
||||||
|
retry_policy: RetryPolicy::ExponentialBackoff {
|
||||||
|
initial_delay: 1000,
|
||||||
|
max_delay: 30000,
|
||||||
|
max_retries: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.with_push_notifications(PushNotificationConfig {
|
||||||
|
vapid_public_key: get_vapid_public_key(),
|
||||||
|
notification_handler: |notification| {
|
||||||
|
// Handle push notifications when app is not active
|
||||||
|
show_system_notification(¬ification.title, ¬ification.body);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enhanced Dashboard Component
|
||||||
|
///
|
||||||
|
/// Example of extending a generated component with custom client-side features
|
||||||
|
/// that go beyond what the TOML configuration can provide.
|
||||||
|
#[component]
|
||||||
|
pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
|
console::log_1(&"🎨 Rendering Enhanced Dashboard with custom extensions".into());
|
||||||
|
|
||||||
|
// Get the base component generated from TOML configuration (90%)
|
||||||
|
let base_dashboard = rustelo_client::get_generated_component("Dashboard");
|
||||||
|
|
||||||
|
// Add custom client-side state for extensions (10%)
|
||||||
|
let (realtime_metrics, set_realtime_metrics) = create_signal(Vec::new());
|
||||||
|
let (notification_count, set_notification_count) = create_signal(0);
|
||||||
|
let (is_offline, set_is_offline) = create_signal(false);
|
||||||
|
|
||||||
|
// Custom WebSocket effect for real-time data
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
match establish_websocket_connection().await {
|
||||||
|
Ok(ws_stream) => {
|
||||||
|
console::log_1(&"📡 Real-time connection established".into());
|
||||||
|
|
||||||
|
// Listen for real-time updates
|
||||||
|
while let Some(message) = ws_stream.next().await {
|
||||||
|
if let Ok(metrics) = parse_metrics_message(&message) {
|
||||||
|
set_realtime_metrics.set(metrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
console::log_1(&format!("❌ WebSocket error: {}", e).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom offline detection
|
||||||
|
create_effect(move |_| {
|
||||||
|
let navigator = window().unwrap().navigator();
|
||||||
|
let is_online = navigator.on_line();
|
||||||
|
set_is_offline.set(!is_online);
|
||||||
|
|
||||||
|
if !is_online {
|
||||||
|
console::log_1(&"🔌 Application is now offline - enabling offline features".into());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcuts for power users
|
||||||
|
create_effect(move |_| {
|
||||||
|
let window = window().unwrap();
|
||||||
|
let document = window.document().unwrap();
|
||||||
|
|
||||||
|
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
|
||||||
|
if event.ctrl_key() {
|
||||||
|
match event.key().as_str() {
|
||||||
|
"d" => {
|
||||||
|
event.prevent_default();
|
||||||
|
console::log_1(&"⌨️ Keyboard shortcut: Focus dashboard".into());
|
||||||
|
// Custom dashboard focus logic
|
||||||
|
}
|
||||||
|
"n" => {
|
||||||
|
event.prevent_default();
|
||||||
|
console::log_1(&"⌨️ Keyboard shortcut: New item".into());
|
||||||
|
// Custom new item creation logic
|
||||||
|
}
|
||||||
|
"?" => {
|
||||||
|
event.prevent_default();
|
||||||
|
show_keyboard_shortcuts_modal();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
document
|
||||||
|
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
closure.forget(); // Keep the closure alive
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="enhanced-dashboard">
|
||||||
|
// Status indicators for custom features
|
||||||
|
<div class="extension-status-bar">
|
||||||
|
<div class="realtime-indicator" class:connected=move || !realtime_metrics.get().is_empty()>
|
||||||
|
"📡 " {move || if realtime_metrics.get().is_empty() { "Connecting..." } else { "Live" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notification-badge" class:has-notifications=move || notification_count.get() > 0>
|
||||||
|
"🔔 " {notification_count}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="offline-indicator" class:offline=is_offline>
|
||||||
|
{move || if is_offline.get() { "🔌 Offline Mode" } else { "🌐 Online" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Base component from TOML configuration (90%)
|
||||||
|
{base_dashboard}
|
||||||
|
|
||||||
|
// Custom extensions (10%)
|
||||||
|
<div class="custom-extensions">
|
||||||
|
<RealtimeMetricsPanel metrics=realtime_metrics />
|
||||||
|
<AdvancedCharting />
|
||||||
|
<CustomNotificationCenter />
|
||||||
|
<OfflineQueueStatus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Custom overlay features
|
||||||
|
<KeyboardShortcutsModal />
|
||||||
|
<OfflineDataSyncIndicator />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-time Metrics Panel
|
||||||
|
///
|
||||||
|
/// Custom component that displays live data from WebSocket connections
|
||||||
|
#[component]
|
||||||
|
fn RealtimeMetricsPanel(metrics: ReadSignal<Vec<Metric>>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="realtime-metrics-panel">
|
||||||
|
<h3>"📊 Live Metrics"</h3>
|
||||||
|
<div class="metrics-grid">
|
||||||
|
{move || {
|
||||||
|
metrics.get().iter().map(|metric| {
|
||||||
|
view! {
|
||||||
|
<div class="metric-card" data-metric-type=&metric.metric_type>
|
||||||
|
<div class="metric-value">{&metric.value}</div>
|
||||||
|
<div class="metric-label">{&metric.label}</div>
|
||||||
|
<div class="metric-trend" class:positive=metric.trend > 0.0 class:negative=metric.trend < 0.0>
|
||||||
|
{format!("{:+.1}%", metric.trend)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advanced Charting Component
|
||||||
|
///
|
||||||
|
/// Custom charting with advanced interactivity
|
||||||
|
#[component]
|
||||||
|
fn AdvancedCharting() -> impl IntoView {
|
||||||
|
let (chart_data, set_chart_data) = create_signal(Vec::new());
|
||||||
|
let (chart_type, set_chart_type) = create_signal(ChartType::Line);
|
||||||
|
|
||||||
|
// Custom chart initialization
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
match load_advanced_chart_data().await {
|
||||||
|
Ok(data) => set_chart_data.set(data),
|
||||||
|
Err(e) => console::log_1(&format!("Chart loading error: {}", e).into()),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="advanced-chart-container">
|
||||||
|
<div class="chart-controls">
|
||||||
|
<select on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
set_chart_type.set(ChartType::from_str(&value));
|
||||||
|
}>
|
||||||
|
<option value="line">"Line Chart"</option>
|
||||||
|
<option value="bar">"Bar Chart"</option>
|
||||||
|
<option value="scatter">"Scatter Plot"</option>
|
||||||
|
<option value="heatmap">"Heatmap"</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button on:click=move |_| export_chart_data()>
|
||||||
|
"📊 Export Data"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button on:click=move |_| toggle_chart_animation()>
|
||||||
|
"🎬 Toggle Animation"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<canvas id="advanced-chart" class="chart-canvas"></canvas>
|
||||||
|
|
||||||
|
// Custom chart interactions
|
||||||
|
<div class="chart-interactions">
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button on:click=move |_| zoom_in()>"🔍 +"</button>
|
||||||
|
<button on:click=move |_| zoom_out()>"🔍 -"</button>
|
||||||
|
<button on:click=move |_| reset_zoom()>"🔄 Reset"</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-range-selector">
|
||||||
|
// Custom date range picker implementation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom build-time extension
|
||||||
|
///
|
||||||
|
/// Example of extending the build system to generate custom client code
|
||||||
|
/// beyond what the standard TOML configuration can express.
|
||||||
|
#[cfg(feature = "build-extensions")]
|
||||||
|
pub mod build_extensions {
|
||||||
|
use rustelo_client::build::{BuildContext, ClientBuildExtension};
|
||||||
|
|
||||||
|
pub struct DashboardExtension;
|
||||||
|
|
||||||
|
impl ClientBuildExtension for DashboardExtension {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"dashboard-realtime-extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_code(&self, context: &BuildContext) -> Result<String, String> {
|
||||||
|
// Generate custom client code based on build context
|
||||||
|
let custom_code = format!(
|
||||||
|
r#"
|
||||||
|
// Generated custom dashboard extensions
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn initialize_dashboard_extensions() {{
|
||||||
|
setup_websocket_connection("{}");
|
||||||
|
register_keyboard_shortcuts();
|
||||||
|
initialize_offline_storage();
|
||||||
|
}}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_dashboard_config() -> JsValue {{
|
||||||
|
let config = DashboardConfig {{
|
||||||
|
realtime_endpoint: "{}".to_string(),
|
||||||
|
analytics_enabled: true,
|
||||||
|
offline_capable: true,
|
||||||
|
keyboard_shortcuts: true,
|
||||||
|
}};
|
||||||
|
|
||||||
|
serde_wasm_bindgen::to_value(&config).unwrap()
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
context
|
||||||
|
.get_env("WEBSOCKET_ENDPOINT")
|
||||||
|
.unwrap_or("ws://localhost:8080"),
|
||||||
|
context
|
||||||
|
.get_env("WEBSOCKET_ENDPOINT")
|
||||||
|
.unwrap_or("ws://localhost:8080")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(custom_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"serde-wasm-bindgen".to_string(),
|
||||||
|
"web-sys".to_string(),
|
||||||
|
"wasm-bindgen-futures".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn features(&self) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"realtime".to_string(),
|
||||||
|
"offline".to_string(),
|
||||||
|
"analytics".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CustomAnalyticsExtension;
|
||||||
|
|
||||||
|
impl ClientBuildExtension for CustomAnalyticsExtension {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"custom-analytics-extension"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_code(&self, context: &BuildContext) -> Result<String, String> {
|
||||||
|
let analytics_config = context.get_config("analytics.toml")?;
|
||||||
|
|
||||||
|
let custom_code = format!(
|
||||||
|
r#"
|
||||||
|
// Generated analytics integration
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn track_custom_event(event_name: &str, properties: JsValue) {{
|
||||||
|
let props: serde_json::Value = serde_wasm_bindgen::from_value(properties).unwrap();
|
||||||
|
|
||||||
|
// Send to custom analytics endpoint
|
||||||
|
spawn_local(async move {{
|
||||||
|
let _ = send_analytics_event("{}".to_string(), event_name, props).await;
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn initialize_user_journey_tracking() {{
|
||||||
|
setup_page_view_tracking();
|
||||||
|
setup_user_interaction_tracking();
|
||||||
|
setup_performance_monitoring();
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
analytics_config
|
||||||
|
.get("endpoint")
|
||||||
|
.unwrap_or("https://analytics.example.com")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(custom_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<String> {
|
||||||
|
vec!["serde_json".to_string(), "reqwasm".to_string()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use wasm_bindgen_test::*;
|
||||||
|
|
||||||
|
wasm_bindgen_test_configure!(run_in_browser);
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_custom_hydration_extensions() {
|
||||||
|
// Test that custom extensions work correctly
|
||||||
|
let config = setup_custom_hydration_hooks();
|
||||||
|
assert!(config.has_pre_hydration_hook());
|
||||||
|
assert!(config.has_post_hydration_hook());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_realtime_connection() {
|
||||||
|
// Test WebSocket connection setup
|
||||||
|
let realtime_config = setup_websocket_connections();
|
||||||
|
assert_eq!(
|
||||||
|
realtime_config.endpoint(),
|
||||||
|
"wss://api.example.com/dashboard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_enhanced_dashboard_rendering() {
|
||||||
|
// Test that enhanced dashboard renders correctly
|
||||||
|
let dashboard = EnhancedDashboard();
|
||||||
|
// Add assertions for custom features
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main function for demonstration
|
||||||
|
fn main() {
|
||||||
|
println!("🎯 RUSTELO CLIENT CUSTOM EXTENSIONS EXAMPLE");
|
||||||
|
println!("============================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("This example demonstrates extending Rustelo's configuration-driven approach");
|
||||||
|
println!("with custom client functionality for the 10% of use cases that require it.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📋 Extension Patterns Demonstrated:");
|
||||||
|
println!(" • Custom hydration hooks for special initialization");
|
||||||
|
println!(" • Real-time features with WebSocket connections");
|
||||||
|
println!(" • Advanced analytics beyond basic page tracking");
|
||||||
|
println!(" • Offline capabilities with service workers");
|
||||||
|
println!(" • Enhanced components with custom interactions");
|
||||||
|
println!(" • Build-time extensions for code generation");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🏗️ Architecture Balance:");
|
||||||
|
println!(" • 90%: TOML configuration → generated client code");
|
||||||
|
println!(" • 10%: Custom extensions for complex requirements");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("💡 Key Extension Points:");
|
||||||
|
println!(" • Hydration hooks (pre/post hydration custom logic)");
|
||||||
|
println!(" • Component enhancement (extend generated components)");
|
||||||
|
println!(" • Build-time generation (custom code generation)");
|
||||||
|
println!(" • State management (custom reactive patterns)");
|
||||||
|
println!(" • Browser API integration (advanced web features)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("⚖️ When to Use Extensions:");
|
||||||
|
println!(" ✅ Real-time features (WebSockets, Server-Sent Events)");
|
||||||
|
println!(" ✅ Complex client-side business logic");
|
||||||
|
println!(" ✅ Advanced browser API integration");
|
||||||
|
println!(" ✅ Custom performance optimization");
|
||||||
|
println!(" ✅ Third-party service integration");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("❌ When NOT to Use Extensions:");
|
||||||
|
println!(" ❌ Basic routing (use TOML configuration)");
|
||||||
|
println!(" ❌ Standard components (use generated components)");
|
||||||
|
println!(" ❌ Simple state management (use configuration)");
|
||||||
|
println!(" ❌ Basic theming (use TOML theme configuration)");
|
||||||
|
println!(" ❌ Standard i18n (use fluent files)");
|
||||||
|
|
||||||
|
// Note: In real WASM environment, would call:
|
||||||
|
// hydrate_with_custom_extensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supporting types and functions for the example
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct RealtimeData {
|
||||||
|
metrics: Vec<Metric>,
|
||||||
|
notifications: u32,
|
||||||
|
users_online: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Metric {
|
||||||
|
metric_type: String,
|
||||||
|
value: String,
|
||||||
|
label: String,
|
||||||
|
trend: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum ChartType {
|
||||||
|
Line,
|
||||||
|
Bar,
|
||||||
|
Scatter,
|
||||||
|
Heatmap,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum ConnectionStatus {
|
||||||
|
Connected,
|
||||||
|
Disconnected,
|
||||||
|
Reconnecting,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock implementations for example purposes
|
||||||
|
impl ChartType {
|
||||||
|
fn from_str(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"line" => ChartType::Line,
|
||||||
|
"bar" => ChartType::Bar,
|
||||||
|
"scatter" => ChartType::Scatter,
|
||||||
|
"heatmap" => ChartType::Heatmap,
|
||||||
|
_ => ChartType::Line,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock function implementations
|
||||||
|
fn initialize_custom_client_storage() {}
|
||||||
|
fn setup_performance_monitoring() {}
|
||||||
|
fn register_custom_error_handlers() {}
|
||||||
|
fn start_background_sync() {}
|
||||||
|
fn initialize_keyboard_shortcuts() {}
|
||||||
|
fn setup_client_side_caching() {}
|
||||||
|
fn enhance_dashboard_component(_element: &web_sys::Element) {}
|
||||||
|
fn setup_realtime_chart_updates(_element: &web_sys::Element) {}
|
||||||
|
fn add_advanced_form_validation(_element: &web_sys::Element) {}
|
||||||
|
fn update_dashboard_metrics(_metrics: Vec<Metric>) {}
|
||||||
|
fn update_notification_badge(_count: u32) {}
|
||||||
|
fn update_user_presence(_count: u32) {}
|
||||||
|
fn show_connection_status(_status: &str, _level: &str) {}
|
||||||
|
fn get_session_id() -> String {
|
||||||
|
"session_123".to_string()
|
||||||
|
}
|
||||||
|
fn get_browser_info() -> String {
|
||||||
|
"Chrome".to_string()
|
||||||
|
}
|
||||||
|
fn get_vapid_public_key() -> String {
|
||||||
|
"vapid_key".to_string()
|
||||||
|
}
|
||||||
|
fn show_system_notification(_title: &str, _body: &str) {}
|
||||||
|
async fn establish_websocket_connection() -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn parse_metrics_message(_message: &str) -> Result<Vec<Metric>, String> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
fn show_keyboard_shortcuts_modal() {}
|
||||||
|
async fn load_advanced_chart_data() -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
fn export_chart_data() {}
|
||||||
|
fn toggle_chart_animation() {}
|
||||||
|
fn zoom_in() {}
|
||||||
|
fn zoom_out() {}
|
||||||
|
fn reset_zoom() {}
|
||||||
|
async fn send_analytics_event(
|
||||||
|
_endpoint: String,
|
||||||
|
_event: &str,
|
||||||
|
_props: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn setup_page_view_tracking() {}
|
||||||
|
fn setup_user_interaction_tracking() {}
|
||||||
|
|
||||||
|
// Mock configuration builders
|
||||||
|
struct HydrationOptions;
|
||||||
|
struct RealtimeConfig;
|
||||||
|
struct AnalyticsConfig;
|
||||||
|
struct ServiceWorkerConfig;
|
||||||
|
|
||||||
|
impl HydrationOptions {
|
||||||
|
fn builder() -> HydrationOptionsBuilder {
|
||||||
|
HydrationOptionsBuilder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeConfig {
|
||||||
|
fn builder() -> RealtimeConfigBuilder {
|
||||||
|
RealtimeConfigBuilder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyticsConfig {
|
||||||
|
fn builder() -> AnalyticsConfigBuilder {
|
||||||
|
AnalyticsConfigBuilder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceWorkerConfig {
|
||||||
|
fn builder() -> ServiceWorkerConfigBuilder {
|
||||||
|
ServiceWorkerConfigBuilder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HydrationOptionsBuilder;
|
||||||
|
struct RealtimeConfigBuilder;
|
||||||
|
struct AnalyticsConfigBuilder;
|
||||||
|
struct ServiceWorkerConfigBuilder;
|
||||||
|
|
||||||
|
impl HydrationOptionsBuilder {
|
||||||
|
fn with_pre_hydration_hook<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: FnOnce(),
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_post_hydration_hook<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: FnOnce(),
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_component_hydration_hook<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&str, &web_sys::Element),
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn build(self) -> HydrationOptions {
|
||||||
|
HydrationOptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HydrationOptions {
|
||||||
|
fn has_pre_hydration_hook(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn has_post_hydration_hook(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeConfigBuilder {
|
||||||
|
fn with_endpoint(self, _endpoint: &str) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_reconnection_policy(self, _policy: ReconnectionPolicy) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_message_handler<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&str),
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_connection_handler<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(ConnectionStatus),
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn build(self) -> RealtimeConfig {
|
||||||
|
RealtimeConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealtimeConfig {
|
||||||
|
fn endpoint(&self) -> &str {
|
||||||
|
"wss://api.example.com/dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum ReconnectionPolicy {
|
||||||
|
ExponentialBackoff,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyticsConfigBuilder {
|
||||||
|
fn with_user_journey_tracking(self, _enabled: bool) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_performance_monitoring(self, _enabled: bool) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_error_tracking(self, _enabled: bool) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_custom_events(self, _events: Vec<CustomEvent>) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_heatmap_tracking(self, _config: HeatmapConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn build(self) -> AnalyticsConfig {
|
||||||
|
AnalyticsConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServiceWorkerConfigBuilder {
|
||||||
|
fn with_cache_strategy(self, _strategy: CacheStrategy) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_offline_pages(self, _pages: Vec<String>) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_background_sync(self, _config: BackgroundSyncConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn with_push_notifications(self, _config: PushNotificationConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn build(self) -> ServiceWorkerConfig {
|
||||||
|
ServiceWorkerConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CustomEvent {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomEvent {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn with_properties<F>(self, _f: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&Interaction) -> serde_json::Value,
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeatmapConfig {
|
||||||
|
track_clicks: bool,
|
||||||
|
track_scrolling: bool,
|
||||||
|
track_form_interactions: bool,
|
||||||
|
sample_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CacheStrategy {
|
||||||
|
NetworkFirst,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackgroundSyncConfig {
|
||||||
|
queue_name: String,
|
||||||
|
retry_policy: RetryPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RetryPolicy {
|
||||||
|
ExponentialBackoff {
|
||||||
|
initial_delay: u64,
|
||||||
|
max_delay: u64,
|
||||||
|
max_retries: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushNotificationConfig {
|
||||||
|
vapid_public_key: String,
|
||||||
|
notification_handler: fn(&Notification),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Interaction {
|
||||||
|
component_name: String,
|
||||||
|
action_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Notification {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
@ -0,0 +1,872 @@
|
|||||||
|
//! State Management Example
|
||||||
|
//!
|
||||||
|
//! Demonstrates configuration-driven client state management patterns
|
||||||
|
//! and how to extend them with custom reactive patterns when needed.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_client::state::{GlobalState, LocalState, StateConfig, StateProvider};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Configuration-Driven State Management Example
|
||||||
|
///
|
||||||
|
/// This example shows how Rustelo's client state management works:
|
||||||
|
/// 1. Define state configuration in TOML files (90%)
|
||||||
|
/// 2. Generate reactive state providers automatically
|
||||||
|
/// 3. Extend with custom state logic when needed (10%)
|
||||||
|
fn main() {
|
||||||
|
println!("🎯 RUSTELO CLIENT STATE MANAGEMENT EXAMPLE");
|
||||||
|
println!("==========================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("This example demonstrates Rustelo's configuration-driven state management:");
|
||||||
|
println!("• TOML configuration defines what state to manage");
|
||||||
|
println!("• Reactive providers generated automatically");
|
||||||
|
println!("• Custom state logic for complex requirements");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_toml_driven_state();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_generated_providers();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_custom_state_extensions();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_state_persistence();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_state_synchronization();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 1: TOML Configuration → State Management
|
||||||
|
///
|
||||||
|
/// Shows how TOML configuration defines what state the client manages
|
||||||
|
fn demonstrate_toml_driven_state() {
|
||||||
|
println!("📋 STEP 1: TOML CONFIGURATION → STATE MANAGEMENT");
|
||||||
|
println!("===============================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📝 File: site/config/client-state.toml");
|
||||||
|
let toml_config = r#"
|
||||||
|
[global_state]
|
||||||
|
theme = { type = "persisted", default = "light", storage = "localStorage" }
|
||||||
|
language = { type = "reactive", default = "en", sync = "url" }
|
||||||
|
auth = { type = "secure", default = "unauthenticated", encryption = true }
|
||||||
|
notifications = { type = "transient", default = [] }
|
||||||
|
|
||||||
|
[page_state]
|
||||||
|
[page_state.dashboard]
|
||||||
|
filters = { type = "local", default = {} }
|
||||||
|
sort_order = { type = "persisted", default = "date_desc" }
|
||||||
|
chart_config = { type = "local", default = { type = "line", period = "7d" } }
|
||||||
|
|
||||||
|
[page_state.profile]
|
||||||
|
edit_mode = { type = "local", default = false }
|
||||||
|
unsaved_changes = { type = "transient", default = false }
|
||||||
|
|
||||||
|
[state_sync]
|
||||||
|
cross_tab_sync = true
|
||||||
|
server_sync_enabled = false
|
||||||
|
offline_queue = true
|
||||||
|
conflict_resolution = "client_wins"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", toml_config);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("⚙️ Generated State Management:");
|
||||||
|
println!(" • ThemeProvider (persisted in localStorage)");
|
||||||
|
println!(" • LanguageProvider (reactive, synced to URL)");
|
||||||
|
println!(" • AuthProvider (secure, encrypted storage)");
|
||||||
|
println!(" • NotificationProvider (transient, in-memory)");
|
||||||
|
println!(" • PageStateProvider (per-page local state)");
|
||||||
|
println!(" • StateSyncProvider (cross-tab synchronization)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2: Generated State Providers
|
||||||
|
///
|
||||||
|
/// Shows the reactive state providers that get generated from TOML configuration
|
||||||
|
fn demonstrate_generated_providers() {
|
||||||
|
println!("🏗️ STEP 2: GENERATED STATE PROVIDERS");
|
||||||
|
println!("===================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📦 Generated Provider Structure:");
|
||||||
|
|
||||||
|
let generated_example = r#"
|
||||||
|
// Generated from your client-state.toml configuration
|
||||||
|
#[component]
|
||||||
|
pub fn AppStateProviders(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
// Global state providers (from [global_state])
|
||||||
|
<ThemeProvider default="light" storage="localStorage">
|
||||||
|
<LanguageProvider default="en" sync="url">
|
||||||
|
<AuthProvider default="unauthenticated" secure=true>
|
||||||
|
<NotificationProvider>
|
||||||
|
// Page-specific providers (from [page_state])
|
||||||
|
<PageStateProvider>
|
||||||
|
// Sync providers (from [state_sync])
|
||||||
|
<StateSyncProvider
|
||||||
|
cross_tab=true
|
||||||
|
offline_queue=true
|
||||||
|
conflict_resolution="client_wins">
|
||||||
|
{children()}
|
||||||
|
</StateSyncProvider>
|
||||||
|
</PageStateProvider>
|
||||||
|
</NotificationProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</LanguageProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated hooks for each state type
|
||||||
|
pub fn use_theme_state() -> ThemeState { /* ... */ }
|
||||||
|
pub fn use_language_state() -> LanguageState { /* ... */ }
|
||||||
|
pub fn use_auth_state() -> AuthState { /* ... */ }
|
||||||
|
pub fn use_notification_state() -> NotificationState { /* ... */ }
|
||||||
|
pub fn use_page_state<T>() -> PageState<T> { /* ... */ }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", generated_example);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🎯 Usage in Components:");
|
||||||
|
let usage_example = r#"
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardComponent() -> impl IntoView {
|
||||||
|
// Use generated state hooks
|
||||||
|
let theme = use_theme_state();
|
||||||
|
let language = use_language_state();
|
||||||
|
let auth = use_auth_state();
|
||||||
|
|
||||||
|
// Page-specific state (automatically scoped to dashboard)
|
||||||
|
let dashboard_state = use_page_state::<DashboardState>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class={move || format!("dashboard theme-{}", theme.current())}>
|
||||||
|
<h1>{move || language.t("dashboard-title")}</h1>
|
||||||
|
|
||||||
|
// State automatically persists and syncs
|
||||||
|
<FilterPanel filters=dashboard_state.filters />
|
||||||
|
<ChartDisplay config=dashboard_state.chart_config />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", usage_example);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 3: Custom State Extensions
|
||||||
|
///
|
||||||
|
/// Shows how to extend generated state providers with custom logic (10% case)
|
||||||
|
fn demonstrate_custom_state_extensions() {
|
||||||
|
println!("🔧 STEP 3: CUSTOM STATE EXTENSIONS");
|
||||||
|
println!("==================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("For complex state requirements that TOML can't express:");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let custom_extension = r#"
|
||||||
|
// Custom state extension for complex business logic
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AdvancedDashboardState {
|
||||||
|
// Generated base state from TOML
|
||||||
|
pub base: DashboardState,
|
||||||
|
|
||||||
|
// Custom complex state that needs special handling
|
||||||
|
pub realtime_metrics: Vec<Metric>,
|
||||||
|
pub user_preferences: UserPreferences,
|
||||||
|
pub computed_insights: ComputedInsights,
|
||||||
|
pub collaboration_state: CollaborationState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdvancedDashboard() -> impl IntoView {
|
||||||
|
// Use generated base state (90%)
|
||||||
|
let base_state = use_page_state::<DashboardState>();
|
||||||
|
|
||||||
|
// Add custom state management (10%)
|
||||||
|
let (realtime_metrics, set_realtime_metrics) = create_signal(Vec::new());
|
||||||
|
let (collaboration_state, set_collaboration_state) = create_signal(CollaborationState::default());
|
||||||
|
|
||||||
|
// Custom complex state logic
|
||||||
|
let computed_insights = create_memo(move |_| {
|
||||||
|
let metrics = realtime_metrics.get();
|
||||||
|
let preferences = base_state.user_preferences.get();
|
||||||
|
|
||||||
|
// Complex computation that can't be expressed in TOML
|
||||||
|
compute_advanced_insights(&metrics, &preferences)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom state synchronization
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
// Sync with real-time data source
|
||||||
|
if let Ok(new_metrics) = fetch_realtime_metrics().await {
|
||||||
|
set_realtime_metrics.set(new_metrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync collaboration state with other users
|
||||||
|
if let Ok(collab_state) = fetch_collaboration_state().await {
|
||||||
|
set_collaboration_state.set(collab_state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom state persistence for complex data
|
||||||
|
create_effect(move |_| {
|
||||||
|
let insights = computed_insights.get();
|
||||||
|
let collab = collaboration_state.get();
|
||||||
|
|
||||||
|
// Custom persistence logic for complex state
|
||||||
|
spawn_local(async move {
|
||||||
|
let _ = persist_advanced_state(&insights, &collab).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="advanced-dashboard">
|
||||||
|
// Base functionality from generated state
|
||||||
|
<StandardDashboard state=base_state />
|
||||||
|
|
||||||
|
// Custom extensions with complex state
|
||||||
|
<RealtimeMetricsPanel metrics=realtime_metrics />
|
||||||
|
<InsightsPanel insights=computed_insights />
|
||||||
|
<CollaborationPanel state=collaboration_state />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", custom_extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 4: State Persistence Patterns
|
||||||
|
///
|
||||||
|
/// Shows different persistence strategies based on TOML configuration
|
||||||
|
fn demonstrate_state_persistence() {
|
||||||
|
println!("💾 STEP 4: STATE PERSISTENCE PATTERNS");
|
||||||
|
println!("====================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("Different persistence strategies from TOML configuration:");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let persistence_examples = r#"
|
||||||
|
// Generated persistence based on TOML config
|
||||||
|
match state_config.storage_type {
|
||||||
|
"localStorage" => {
|
||||||
|
// Persists across browser sessions
|
||||||
|
let theme = LocalStorage::get("theme").unwrap_or_default();
|
||||||
|
let sort_order = LocalStorage::get("dashboard_sort").unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
"sessionStorage" => {
|
||||||
|
// Persists for current session only
|
||||||
|
let filters = SessionStorage::get("dashboard_filters").unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
"indexedDB" => {
|
||||||
|
// For large/complex data
|
||||||
|
let user_data = IndexedDB::get("user_preferences").await.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
"memory" => {
|
||||||
|
// Transient state, lost on page reload
|
||||||
|
let notifications = in_memory_store.get("current_notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
"encrypted" => {
|
||||||
|
// Encrypted sensitive data
|
||||||
|
let auth_tokens = EncryptedStorage::get("auth_state").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatic persistence hooks generated from config
|
||||||
|
#[hook]
|
||||||
|
pub fn use_persistent_state<T>(key: &str, default: T) -> (ReadSignal<T>, WriteSignal<T>)
|
||||||
|
where
|
||||||
|
T: Serialize + for<'de> Deserialize<'de> + Clone + 'static
|
||||||
|
{
|
||||||
|
let (state, set_state) = create_signal(default);
|
||||||
|
|
||||||
|
// Load initial value from configured storage
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(stored_value) = load_from_configured_storage(key).await {
|
||||||
|
set_state.set(stored_value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save on changes
|
||||||
|
create_effect(move |_| {
|
||||||
|
let current_value = state.get();
|
||||||
|
spawn_local(async move {
|
||||||
|
let _ = save_to_configured_storage(key, ¤t_value).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(state, set_state)
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", persistence_examples);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 5: State Synchronization
|
||||||
|
///
|
||||||
|
/// Shows cross-tab and server synchronization patterns
|
||||||
|
fn demonstrate_state_synchronization() {
|
||||||
|
println!("🔄 STEP 5: STATE SYNCHRONIZATION");
|
||||||
|
println!("===============================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("Generated synchronization based on TOML config:");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let sync_examples = r#"
|
||||||
|
// Cross-tab synchronization (from state_sync.cross_tab_sync = true)
|
||||||
|
#[hook]
|
||||||
|
pub fn use_synced_state<T>(key: &str, default: T) -> (ReadSignal<T>, WriteSignal<T>)
|
||||||
|
where
|
||||||
|
T: Serialize + for<'de> Deserialize<'de> + Clone + 'static
|
||||||
|
{
|
||||||
|
let (state, set_state) = create_signal(default);
|
||||||
|
|
||||||
|
// Listen for changes from other tabs
|
||||||
|
create_effect(move |_| {
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let storage_listener = Closure::wrap(Box::new(move |event: web_sys::StorageEvent| {
|
||||||
|
if event.key().as_deref() == Some(key) {
|
||||||
|
if let Some(new_value) = event.new_value() {
|
||||||
|
if let Ok(parsed) = serde_json::from_str(&new_value) {
|
||||||
|
set_state.set(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
window.add_event_listener_with_callback(
|
||||||
|
"storage",
|
||||||
|
storage_listener.as_ref().unchecked_ref()
|
||||||
|
).unwrap();
|
||||||
|
|
||||||
|
storage_listener.forget();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast changes to other tabs
|
||||||
|
create_effect(move |_| {
|
||||||
|
let current_value = state.get();
|
||||||
|
let serialized = serde_json::to_string(¤t_value).unwrap();
|
||||||
|
|
||||||
|
web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.local_storage()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.set_item(key, &serialized)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
(state, set_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline queue (from state_sync.offline_queue = true)
|
||||||
|
#[component]
|
||||||
|
pub fn OfflineStateManager() -> impl IntoView {
|
||||||
|
let (is_online, set_is_online) = create_signal(true);
|
||||||
|
let (pending_updates, set_pending_updates) = create_signal(Vec::<StateUpdate>::new());
|
||||||
|
|
||||||
|
// Monitor online/offline status
|
||||||
|
create_effect(move |_| {
|
||||||
|
let window = web_sys::window().unwrap();
|
||||||
|
let navigator = window.navigator();
|
||||||
|
set_is_online.set(navigator.on_line());
|
||||||
|
|
||||||
|
let online_listener = Closure::wrap(Box::new(move |_| {
|
||||||
|
set_is_online.set(true);
|
||||||
|
// Process queued updates when back online
|
||||||
|
spawn_local(async move {
|
||||||
|
process_offline_queue().await;
|
||||||
|
});
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
let offline_listener = Closure::wrap(Box::new(move |_| {
|
||||||
|
set_is_online.set(false);
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
window.add_event_listener_with_callback("online", online_listener.as_ref().unchecked_ref()).unwrap();
|
||||||
|
window.add_event_listener_with_callback("offline", offline_listener.as_ref().unchecked_ref()).unwrap();
|
||||||
|
|
||||||
|
online_listener.forget();
|
||||||
|
offline_listener.forget();
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="offline-state-indicator" class:offline=move || !is_online.get()>
|
||||||
|
{move || if is_online.get() {
|
||||||
|
"🌐 Online"
|
||||||
|
} else {
|
||||||
|
format!("🔌 Offline ({} pending)", pending_updates.get().len())
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict resolution (from state_sync.conflict_resolution = "client_wins")
|
||||||
|
async fn resolve_state_conflict<T>(
|
||||||
|
local_state: &T,
|
||||||
|
server_state: &T,
|
||||||
|
last_sync_time: chrono::DateTime<chrono::Utc>
|
||||||
|
) -> T
|
||||||
|
where
|
||||||
|
T: Clone + Serialize + for<'de> Deserialize<'de>
|
||||||
|
{
|
||||||
|
match get_conflict_resolution_strategy() {
|
||||||
|
ConflictResolution::ClientWins => local_state.clone(),
|
||||||
|
ConflictResolution::ServerWins => server_state.clone(),
|
||||||
|
ConflictResolution::LastWriteWins => {
|
||||||
|
if local_state.last_modified > server_state.last_modified {
|
||||||
|
local_state.clone()
|
||||||
|
} else {
|
||||||
|
server_state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConflictResolution::Merge => {
|
||||||
|
// Custom merge logic based on state type
|
||||||
|
merge_states(local_state, server_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", sync_examples);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("💡 Key State Management Principles:");
|
||||||
|
println!(" • Configuration defines what state to manage (90%)");
|
||||||
|
println!(" • Providers generated automatically from TOML");
|
||||||
|
println!(" • Persistence strategy configurable per state");
|
||||||
|
println!(" • Cross-tab sync enabled by configuration");
|
||||||
|
println!(" • Custom logic for complex state requirements (10%)");
|
||||||
|
println!(" • Offline queue for reliable state updates");
|
||||||
|
println!(" • Configurable conflict resolution strategies");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example components demonstrating different state patterns
|
||||||
|
|
||||||
|
/// Theme State Management Example
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeManager() -> impl IntoView {
|
||||||
|
// Generated from theme state configuration
|
||||||
|
let theme_state = use_theme_state();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="theme-manager">
|
||||||
|
<h3>"🎨 Theme Management"</h3>
|
||||||
|
|
||||||
|
<div class="theme-controls">
|
||||||
|
<button
|
||||||
|
class:active=move || theme_state.current() == "light"
|
||||||
|
on:click=move |_| theme_state.set("light")
|
||||||
|
>
|
||||||
|
"☀️ Light"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class:active=move || theme_state.current() == "dark"
|
||||||
|
on:click=move |_| theme_state.set("dark")
|
||||||
|
>
|
||||||
|
"🌙 Dark"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class:active=move || theme_state.current() == "auto"
|
||||||
|
on:click=move |_| theme_state.set("auto")
|
||||||
|
>
|
||||||
|
"🔄 Auto"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-info">
|
||||||
|
<p>"Current theme: " {move || theme_state.current()}</p>
|
||||||
|
<p>"Persisted: " {move || if theme_state.is_persisted() { "✅" } else { "❌" }}</p>
|
||||||
|
<p>"System preference: " {move || theme_state.system_preference()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dashboard State Management Example
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardStateDemo() -> impl IntoView {
|
||||||
|
// Page state automatically scoped to dashboard
|
||||||
|
let dashboard_state = use_page_state::<DashboardPageState>();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="dashboard-state-demo">
|
||||||
|
<h3>"📊 Dashboard State"</h3>
|
||||||
|
|
||||||
|
<div class="state-controls">
|
||||||
|
<div class="filter-controls">
|
||||||
|
<label>"Date Range:"</label>
|
||||||
|
<select on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
dashboard_state.set_date_range(value);
|
||||||
|
}>
|
||||||
|
<option value="7d">"Last 7 days"</option>
|
||||||
|
<option value="30d">"Last 30 days"</option>
|
||||||
|
<option value="90d">"Last 90 days"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sort-controls">
|
||||||
|
<label>"Sort by:"</label>
|
||||||
|
<select on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev);
|
||||||
|
dashboard_state.set_sort_order(value);
|
||||||
|
}>
|
||||||
|
<option value="date_desc">"Date (newest first)"</option>
|
||||||
|
<option value="date_asc">"Date (oldest first)"</option>
|
||||||
|
<option value="value_desc">"Value (high to low)"</option>
|
||||||
|
<option value="value_asc">"Value (low to high)"</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="state-info">
|
||||||
|
<p>"Filters: " {move || format!("{:?}", dashboard_state.filters())}</p>
|
||||||
|
<p>"Sort: " {move || dashboard_state.sort_order()}</p>
|
||||||
|
<p>"Auto-saved: " {move || if dashboard_state.is_dirty() { "❌" } else { "✅" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom Complex State Example
|
||||||
|
#[component]
|
||||||
|
pub fn ComplexStateDemo() -> impl IntoView {
|
||||||
|
// Custom state for requirements TOML can't express
|
||||||
|
let (user_activity, set_user_activity) = create_signal(UserActivity::default());
|
||||||
|
let (collaboration_state, set_collaboration_state) =
|
||||||
|
create_signal(HashMap::<String, CollaboratorState>::new());
|
||||||
|
|
||||||
|
// Complex computed state
|
||||||
|
let activity_insights = create_memo(move |_| {
|
||||||
|
let activity = user_activity.get();
|
||||||
|
let collaborators = collaboration_state.get();
|
||||||
|
|
||||||
|
ActivityInsights::compute(&activity, &collaborators)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real-time synchronization
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
// Connect to real-time collaboration service
|
||||||
|
if let Ok(mut stream) = connect_to_collaboration_service().await {
|
||||||
|
while let Some(update) = stream.next().await {
|
||||||
|
match update {
|
||||||
|
CollaborationUpdate::UserJoined(user) => {
|
||||||
|
set_collaboration_state.update(|state| {
|
||||||
|
state.insert(user.id.clone(), user.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CollaborationUpdate::UserLeft(user_id) => {
|
||||||
|
set_collaboration_state.update(|state| {
|
||||||
|
state.remove(&user_id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CollaborationUpdate::ActivityUpdate(activity) => {
|
||||||
|
set_user_activity.set(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="complex-state-demo">
|
||||||
|
<h3>"🤝 Complex State Management"</h3>
|
||||||
|
|
||||||
|
<div class="activity-panel">
|
||||||
|
<h4>"User Activity"</h4>
|
||||||
|
<div class="activity-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">"Actions today:"</span>
|
||||||
|
<span class="value">{move || user_activity.get().actions_today}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="label">"Time active:"</span>
|
||||||
|
<span class="value">{move || format!("{}m", user_activity.get().time_active_minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collaboration-panel">
|
||||||
|
<h4>"Collaboration"</h4>
|
||||||
|
<div class="collaborators">
|
||||||
|
{move || {
|
||||||
|
collaboration_state.get()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, state)| {
|
||||||
|
view! {
|
||||||
|
<div class="collaborator" data-id=id>
|
||||||
|
<span class="avatar">{&state.avatar}</span>
|
||||||
|
<span class="name">{&state.name}</span>
|
||||||
|
<span class="status" class:online=state.is_online>
|
||||||
|
{if state.is_online { "🟢" } else { "⭕" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="insights-panel">
|
||||||
|
<h4>"Activity Insights"</h4>
|
||||||
|
<div class="insights">
|
||||||
|
{move || {
|
||||||
|
let insights = activity_insights.get();
|
||||||
|
view! {
|
||||||
|
<div class="insight">
|
||||||
|
<span class="metric">"Productivity Score:"</span>
|
||||||
|
<span class="value">{insights.productivity_score}</span>
|
||||||
|
</div>
|
||||||
|
<div class="insight">
|
||||||
|
<span class="metric">"Collaboration Index:"</span>
|
||||||
|
<span class="value">{insights.collaboration_index}</span>
|
||||||
|
</div>
|
||||||
|
<div class="insight">
|
||||||
|
<span class="metric">"Focus Time:"</span>
|
||||||
|
<span class="value">{format!("{}%", insights.focus_time_percentage)}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supporting types and mock implementations
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct DashboardPageState {
|
||||||
|
filters: HashMap<String, serde_json::Value>,
|
||||||
|
sort_order: String,
|
||||||
|
date_range: String,
|
||||||
|
chart_config: ChartConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct ChartConfig {
|
||||||
|
chart_type: String,
|
||||||
|
period: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ChartConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
chart_type: "line".to_string(),
|
||||||
|
period: "7d".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
struct UserActivity {
|
||||||
|
actions_today: u32,
|
||||||
|
time_active_minutes: u32,
|
||||||
|
last_action_timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct CollaboratorState {
|
||||||
|
name: String,
|
||||||
|
avatar: String,
|
||||||
|
is_online: bool,
|
||||||
|
last_seen: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ActivityInsights {
|
||||||
|
productivity_score: f64,
|
||||||
|
collaboration_index: f64,
|
||||||
|
focus_time_percentage: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityInsights {
|
||||||
|
fn compute(
|
||||||
|
_activity: &UserActivity,
|
||||||
|
_collaborators: &HashMap<String, CollaboratorState>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
productivity_score: 85.0,
|
||||||
|
collaboration_index: 7.5,
|
||||||
|
focus_time_percentage: 65.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CollaborationUpdate {
|
||||||
|
UserJoined(User),
|
||||||
|
UserLeft(String),
|
||||||
|
ActivityUpdate(UserActivity),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct User {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
avatar: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<User> for CollaboratorState {
|
||||||
|
fn from(user: User) -> Self {
|
||||||
|
Self {
|
||||||
|
name: user.name,
|
||||||
|
avatar: user.avatar,
|
||||||
|
is_online: true,
|
||||||
|
last_seen: js_sys::Date::now() as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct StateUpdate {
|
||||||
|
key: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConflictResolution {
|
||||||
|
ClientWins,
|
||||||
|
ServerWins,
|
||||||
|
LastWriteWins,
|
||||||
|
Merge,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock function implementations
|
||||||
|
fn use_theme_state() -> ThemeState {
|
||||||
|
ThemeState::default()
|
||||||
|
}
|
||||||
|
fn use_language_state() -> LanguageState {
|
||||||
|
LanguageState::default()
|
||||||
|
}
|
||||||
|
fn use_auth_state() -> AuthState {
|
||||||
|
AuthState::default()
|
||||||
|
}
|
||||||
|
fn use_page_state<T>() -> PageState<T>
|
||||||
|
where
|
||||||
|
T: Default,
|
||||||
|
{
|
||||||
|
PageState::default()
|
||||||
|
}
|
||||||
|
async fn fetch_realtime_metrics() -> Result<Vec<Metric>, String> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn fetch_collaboration_state() -> Result<CollaborationState, String> {
|
||||||
|
Ok(CollaborationState {
|
||||||
|
name: "".to_string(),
|
||||||
|
avatar: "".to_string(),
|
||||||
|
is_online: false,
|
||||||
|
last_seen: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn persist_advanced_state(
|
||||||
|
_insights: &ActivityInsights,
|
||||||
|
_collab: &HashMap<String, CollaboratorState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn compute_advanced_insights(
|
||||||
|
_metrics: &[Metric],
|
||||||
|
_preferences: &UserPreferences,
|
||||||
|
) -> ActivityInsights {
|
||||||
|
ActivityInsights::compute(&UserActivity::default(), &HashMap::new())
|
||||||
|
}
|
||||||
|
async fn load_from_configured_storage<T>(_key: &str) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: Default,
|
||||||
|
{
|
||||||
|
Ok(T::default())
|
||||||
|
}
|
||||||
|
async fn save_to_configured_storage<T>(_key: &str, _value: &T) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn process_offline_queue() -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn get_conflict_resolution_strategy() -> ConflictResolution {
|
||||||
|
ConflictResolution::ClientWins
|
||||||
|
}
|
||||||
|
fn merge_states<T>(_local: &T, _server: &T) -> T
|
||||||
|
where
|
||||||
|
T: Clone,
|
||||||
|
{
|
||||||
|
_local.clone()
|
||||||
|
}
|
||||||
|
async fn connect_to_collaboration_service() -> Result<CollaborationStream, String> {
|
||||||
|
Ok(CollaborationStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct ThemeState;
|
||||||
|
impl ThemeState {
|
||||||
|
fn current(&self) -> String {
|
||||||
|
"light".to_string()
|
||||||
|
}
|
||||||
|
fn set(&self, _theme: &str) {}
|
||||||
|
fn is_persisted(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn system_preference(&self) -> String {
|
||||||
|
"light".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct LanguageState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct AuthState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct PageState<T>(std::marker::PhantomData<T>);
|
||||||
|
|
||||||
|
impl DashboardPageState {
|
||||||
|
fn filters(&self) -> HashMap<String, serde_json::Value> {
|
||||||
|
self.filters.clone()
|
||||||
|
}
|
||||||
|
fn sort_order(&self) -> String {
|
||||||
|
self.sort_order.clone()
|
||||||
|
}
|
||||||
|
fn set_date_range(&self, _range: String) {}
|
||||||
|
fn set_sort_order(&self, _order: String) {}
|
||||||
|
fn is_dirty(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct Metric;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct UserPreferences;
|
||||||
|
|
||||||
|
struct CollaborationStream;
|
||||||
|
|
||||||
|
impl CollaborationStream {
|
||||||
|
async fn next(&mut self) -> Option<CollaborationUpdate> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
208
crates/foundation/crates/rustelo_client/src/app.rs
Normal file
208
crates/foundation/crates/rustelo_client/src/app.rs
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
//! Client-side App component with proper routing
|
||||||
|
//!
|
||||||
|
//! This module provides the main App component for the client that uses
|
||||||
|
//! the shared routing system and renders actual page components.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::provide_meta_context;
|
||||||
|
use rustelo_components::{
|
||||||
|
navigation::{footer::unified::Footer, navmenu::unified::NavMenu},
|
||||||
|
theme::ThemeProvider,
|
||||||
|
};
|
||||||
|
use rustelo_core_lib::{
|
||||||
|
i18n::{provide_unified_i18n, UnifiedI18n},
|
||||||
|
routing::utils::detect_language_from_path,
|
||||||
|
state::LanguageProvider,
|
||||||
|
};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Main App component for client-side that implements proper routing
|
||||||
|
#[component]
|
||||||
|
pub fn AppComponent(#[prop(default = String::new())] initial_path: String) -> impl IntoView {
|
||||||
|
// Provide meta context for client-side behavior
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
// Setup navigation signals for client-side routing
|
||||||
|
let (path, set_path) = signal(initial_path.clone());
|
||||||
|
|
||||||
|
// Provide set_path context for other components
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
provide_context(set_path);
|
||||||
|
|
||||||
|
// Initialize meta config preloader early for WASM
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
rustelo_core_lib::init_meta_preloader();
|
||||||
|
|
||||||
|
// Use the LanguageProvider and setup effects inside it
|
||||||
|
view! {
|
||||||
|
<LanguageProvider>
|
||||||
|
<AppContent initial_path=initial_path path=path set_path=set_path />
|
||||||
|
</LanguageProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner app content that has access to the language context
|
||||||
|
#[component]
|
||||||
|
fn AppContent(
|
||||||
|
initial_path: String,
|
||||||
|
path: ReadSignal<String>,
|
||||||
|
set_path: WriteSignal<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Now we can use the shared language context
|
||||||
|
let language_context = rustelo_core_lib::state::use_language();
|
||||||
|
|
||||||
|
// Reactively detect language from current path and update context
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let current_path = path.get();
|
||||||
|
let detected_language = detect_language_from_path(¤t_path);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔄 App Path Change: path='{}' → language='{}'",
|
||||||
|
current_path, detected_language
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update language context when path changes
|
||||||
|
if language_context.current.get_untracked() != detected_language {
|
||||||
|
language_context.current.set(detected_language);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial language from initial path
|
||||||
|
let initial_language = detect_language_from_path(&initial_path);
|
||||||
|
language_context.current.set(initial_language);
|
||||||
|
|
||||||
|
// Create and provide unified i18n context that updates with language changes
|
||||||
|
let unified_i18n = Memo::new(move |_| {
|
||||||
|
let current_lang = language_context.current.get();
|
||||||
|
let current_path = path.get();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔄 App i18n Memo: language='{}', path='{}'",
|
||||||
|
current_lang, current_path
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
UnifiedI18n::new(¤t_lang, ¤t_path)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide the reactive i18n context
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let i18n = unified_i18n.get();
|
||||||
|
provide_unified_i18n(i18n);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup page transition effects
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let (is_first_render, set_is_first_render) = signal(true);
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let current_path = path.get();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🎬 App transition Effect: path='{}', first_render={}",
|
||||||
|
current_path,
|
||||||
|
is_first_render.get()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if is_first_render.get() {
|
||||||
|
set_is_first_render.set(false);
|
||||||
|
} else {
|
||||||
|
apply_page_transition();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch custom event to reinitialize components
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"⏰ Dispatching reinitializeComponents event after 200ms for path: {}",
|
||||||
|
current_path
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(event) = web_sys::CustomEvent::new("reinitializeComponents") {
|
||||||
|
let _ = window.dispatch_event(&event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Duration::from_millis(200),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the client routing with actual page components
|
||||||
|
view! {
|
||||||
|
<ThemeProvider>
|
||||||
|
<div class="min-h-screen ds-bg-page flex flex-col">
|
||||||
|
<NavMenu _set_path=set_path _path=path />
|
||||||
|
<main class="max-w-7xl mx-auto py-2 sm:ds-container flex-grow page-content fade-out">
|
||||||
|
{
|
||||||
|
move || {
|
||||||
|
let current_path = path.get();
|
||||||
|
let current_lang = language_context.current.get();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🎬 App main content render: path='{}', language='{}' (content re-rendering)", current_path, current_lang).into());
|
||||||
|
|
||||||
|
crate::routing::render_page_content_with_language(¤t_path, ¤t_lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
{
|
||||||
|
move || {
|
||||||
|
let lang = language_context.current.get();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Footer language=lang _set_path=set_path _path=path />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply fade transition to page content
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn apply_page_transition() {
|
||||||
|
if let Some(document) = web_sys::window().and_then(|w| w.document()) {
|
||||||
|
if let Ok(main_element) = document.query_selector("main.page-content") {
|
||||||
|
if let Some(element) = main_element {
|
||||||
|
let _ = element.class_list().add_1("fade-out");
|
||||||
|
|
||||||
|
let element_clone = element.clone();
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
let _ = element_clone.class_list().remove_1("fade-out");
|
||||||
|
let _ = element_clone.class_list().add_1("fade-in");
|
||||||
|
},
|
||||||
|
std::time::Duration::from_millis(150),
|
||||||
|
);
|
||||||
|
|
||||||
|
let element_clone2 = element.clone();
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
let _ = element_clone2.class_list().remove_1("fade-in");
|
||||||
|
},
|
||||||
|
std::time::Duration::from_millis(300),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
372
crates/foundation/crates/rustelo_client/src/auth/context.rs
Normal file
372
crates/foundation/crates/rustelo_client/src/auth/context.rs
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
use crate::i18n::use_i18n;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// use leptos_router::use_navigate;
|
||||||
|
use rustelo_core_lib::auth::User;
|
||||||
|
use std::sync::Arc;
|
||||||
|
// wasm_bindgen_futures::spawn_local removed since not used in placeholder implementation
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn spawn_local<F>(_fut: F)
|
||||||
|
where
|
||||||
|
F: std::future::Future<Output = ()> + 'static,
|
||||||
|
{
|
||||||
|
// On server side, don't execute async operations that require browser APIs
|
||||||
|
// These operations should only run in the browser
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub user: Option<User>,
|
||||||
|
pub is_loading: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub requires_2fa: bool,
|
||||||
|
pub pending_2fa_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias for profile update callback
|
||||||
|
pub type ProfileUpdateFn = Arc<dyn Fn(String, Option<String>, Option<String>) + Send + Sync>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthActions {
|
||||||
|
pub login: Arc<dyn Fn(String, String, bool) + Send + Sync>,
|
||||||
|
pub login_with_2fa: Arc<dyn Fn(String, String, bool) + Send + Sync>,
|
||||||
|
pub logout: Arc<dyn Fn() + Send + Sync>,
|
||||||
|
pub register: Arc<dyn Fn(String, String, String, Option<String>) + Send + Sync>,
|
||||||
|
pub refresh_token: Arc<dyn Fn() + Send + Sync>,
|
||||||
|
pub update_profile: ProfileUpdateFn,
|
||||||
|
pub change_password: Arc<dyn Fn(String, String) + Send + Sync>,
|
||||||
|
pub clear_error: Arc<dyn Fn() + Send + Sync>,
|
||||||
|
pub clear_2fa_state: Arc<dyn Fn() + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
pub state: ReadSignal<AuthState>,
|
||||||
|
pub actions: AuthActions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
self.state.get().user.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_loading(&self) -> bool {
|
||||||
|
self.state.get().is_loading
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user(&self) -> Option<User> {
|
||||||
|
self.state.get().user
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> Option<String> {
|
||||||
|
self.state.get().error
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requires_2fa(&self) -> bool {
|
||||||
|
self.state.get().requires_2fa
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_2fa_email(&self) -> Option<String> {
|
||||||
|
self.state.get().pending_2fa_email
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_role(&self, role: &rustelo_core_lib::auth::Role) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user| user.has_role(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_permission(&self, permission: &rustelo_core_lib::auth::Permission) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user| user.has_permission(permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|user| user.is_admin())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_success(&self, _user: User, token: String) {
|
||||||
|
// Store token in localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.set_item("auth_token", &token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to map server errors to translation keys
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn get_error_translation_key(error_text: &str) -> &str {
|
||||||
|
let error_lower = error_text.to_lowercase();
|
||||||
|
|
||||||
|
if error_lower.contains("invalid credentials") {
|
||||||
|
"invalid-credentials"
|
||||||
|
} else if error_lower.contains("user not found") {
|
||||||
|
"user-not-found"
|
||||||
|
} else if error_lower.contains("email already exists") {
|
||||||
|
"email-already-exists"
|
||||||
|
} else if error_lower.contains("username already exists") {
|
||||||
|
"username-already-exists"
|
||||||
|
} else if error_lower.contains("invalid token") {
|
||||||
|
"invalid-token"
|
||||||
|
} else if error_lower.contains("token expired") {
|
||||||
|
"token-expired"
|
||||||
|
} else if error_lower.contains("insufficient permissions") {
|
||||||
|
"insufficient-permissions"
|
||||||
|
} else if error_lower.contains("account not verified") {
|
||||||
|
"account-not-verified"
|
||||||
|
} else if error_lower.contains("account suspended") {
|
||||||
|
"account-suspended"
|
||||||
|
} else if error_lower.contains("rate limit exceeded") {
|
||||||
|
"rate-limit-exceeded"
|
||||||
|
} else if error_lower.contains("oauth") {
|
||||||
|
"oauth-error"
|
||||||
|
} else if error_lower.contains("database") {
|
||||||
|
"database-error"
|
||||||
|
} else if error_lower.contains("validation") {
|
||||||
|
"validation-error"
|
||||||
|
} else if error_lower.contains("login failed") {
|
||||||
|
"login-failed"
|
||||||
|
} else if error_lower.contains("registration failed") {
|
||||||
|
"registration-failed"
|
||||||
|
} else if error_lower.contains("session expired") {
|
||||||
|
"session-expired"
|
||||||
|
} else if error_lower.contains("profile") && error_lower.contains("failed") {
|
||||||
|
"profile-update-failed"
|
||||||
|
} else if error_lower.contains("password") && error_lower.contains("failed") {
|
||||||
|
"password-change-failed"
|
||||||
|
} else if error_lower.contains("network") {
|
||||||
|
"network-error"
|
||||||
|
} else if error_lower.contains("server") {
|
||||||
|
"server-error"
|
||||||
|
} else if error_lower.contains("internal") {
|
||||||
|
"internal-error"
|
||||||
|
} else {
|
||||||
|
"unknown-error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to parse server error response and get localized message
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn parse_error_response(response_text: &str, i18n: &crate::i18n::UseI18n) -> String {
|
||||||
|
// Try to parse as JSON first
|
||||||
|
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(response_text) {
|
||||||
|
if let Some(message) = json_value.get("message").and_then(|m| m.as_str()) {
|
||||||
|
let key = get_error_translation_key(message);
|
||||||
|
return i18n.t(key);
|
||||||
|
}
|
||||||
|
if let Some(errors) = json_value.get("errors").and_then(|e| e.as_array()) {
|
||||||
|
if let Some(first_error) = errors.first().and_then(|e| e.as_str()) {
|
||||||
|
let key = get_error_translation_key(first_error);
|
||||||
|
return i18n.t(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct message mapping
|
||||||
|
let key = get_error_translation_key(response_text);
|
||||||
|
i18n.t(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#[allow(unused_variables)] // Placeholder implementation - variables will be used when auth is fully implemented
|
||||||
|
pub fn AuthProvider(children: leptos::prelude::Children) -> impl IntoView {
|
||||||
|
let _i18n = use_i18n();
|
||||||
|
let (state, set_state) = signal(AuthState::default());
|
||||||
|
let (_access_token, set_access_token) = signal::<Option<String>>(None);
|
||||||
|
let (_refresh_token_state, set_refresh_token) = signal::<Option<String>>(None);
|
||||||
|
|
||||||
|
// Initialize auth state from localStorage - only in browser
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
// Load access token
|
||||||
|
if let Ok(Some(token)) = storage.get_item("access_token") {
|
||||||
|
set_access_token.set(Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load refresh token
|
||||||
|
if let Ok(Some(token)) = storage.get_item("refresh_token") {
|
||||||
|
set_refresh_token.set(Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user data
|
||||||
|
if let Ok(Some(user_data)) = storage.get_item("user") {
|
||||||
|
if let Ok(user) = serde_json::from_str::<User>(&user_data) {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = Some(user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual login functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let login_action = Arc::new(
|
||||||
|
move |_email: String, _password: String, _remember_me: bool| {
|
||||||
|
tracing::debug!("Login action called - not yet implemented");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Implement actual logout functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let logout_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Logout action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual register functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let register_action = Arc::new(
|
||||||
|
move |_email: String,
|
||||||
|
_password: String,
|
||||||
|
_username: String,
|
||||||
|
_display_name: Option<String>| {
|
||||||
|
tracing::debug!("Register action called - not yet implemented");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Implement actual refresh token functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let refresh_token_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Refresh token action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual update profile functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let update_profile_action = Arc::new(
|
||||||
|
move |_display_name: String, _first_name: Option<String>, _last_name: Option<String>| {
|
||||||
|
tracing::debug!("Update profile action called - not yet implemented");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Implement actual change password functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let change_password_action =
|
||||||
|
Arc::new(move |_current_password: String, _new_password: String| {
|
||||||
|
tracing::debug!("Change password action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual 2FA login functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let login_with_2fa_action =
|
||||||
|
Arc::new(move |_email: String, _code: String, _remember_me: bool| {
|
||||||
|
tracing::debug!("Login with 2FA action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual 2FA reset functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let reset_2fa_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Reset 2FA action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual logout functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let logout_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Logout action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual register functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let register_action = Arc::new(
|
||||||
|
move |_email: String,
|
||||||
|
_password: String,
|
||||||
|
_username: String,
|
||||||
|
_display_name: Option<String>| {
|
||||||
|
tracing::debug!("Register action called - not yet implemented");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Implement actual refresh token functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let refresh_token_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Refresh token action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual update profile functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let update_profile_action = Arc::new(
|
||||||
|
move |_display_name: String, _first_name: Option<String>, _last_name: Option<String>| {
|
||||||
|
tracing::debug!("Update profile action called - not yet implemented");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Implement actual change password functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let change_password_action =
|
||||||
|
Arc::new(move |_current_password: String, _new_password: String| {
|
||||||
|
tracing::debug!("Change password action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual clear error functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let clear_error_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Clear error action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual 2FA login functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let login_with_2fa_action =
|
||||||
|
Arc::new(move |_email: String, _code: String, _remember_me: bool| {
|
||||||
|
tracing::debug!("Login with 2FA action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement actual clear 2FA state functionality
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let clear_2fa_state_action = Arc::new(move || {
|
||||||
|
tracing::debug!("Clear 2FA state action called - not yet implemented");
|
||||||
|
});
|
||||||
|
|
||||||
|
let actions = AuthActions {
|
||||||
|
login: login_action,
|
||||||
|
login_with_2fa: login_with_2fa_action,
|
||||||
|
logout: logout_action,
|
||||||
|
register: register_action,
|
||||||
|
refresh_token: refresh_token_action,
|
||||||
|
update_profile: update_profile_action,
|
||||||
|
change_password: change_password_action,
|
||||||
|
clear_error: clear_error_action,
|
||||||
|
clear_2fa_state: clear_2fa_state_action,
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = AuthContext { state, actions };
|
||||||
|
|
||||||
|
provide_context(context);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{children()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UseAuth(pub AuthContext);
|
||||||
|
|
||||||
|
impl Default for UseAuth {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UseAuth {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(expect_context::<AuthContext>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_auth() -> UseAuth {
|
||||||
|
UseAuth::new()
|
||||||
|
}
|
||||||
@ -0,0 +1,611 @@
|
|||||||
|
use crate::i18n::use_i18n;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::auth::{AuthResponse, User, UserProfile};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub user: Option<User>,
|
||||||
|
pub is_loading: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
user: None,
|
||||||
|
is_loading: false,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthActions {
|
||||||
|
pub login: Rc<dyn Fn(String, String, bool) -> ()>,
|
||||||
|
pub logout: Rc<dyn Fn() -> ()>,
|
||||||
|
pub register: Rc<dyn Fn(String, String, String, Option<String>) -> ()>,
|
||||||
|
pub refresh_token: Rc<dyn Fn() -> ()>,
|
||||||
|
pub update_profile: Rc<dyn Fn(String, Option<String>, Option<String>) -> ()>,
|
||||||
|
pub change_password: Rc<dyn Fn(String, String) -> ()>,
|
||||||
|
pub clear_error: Rc<dyn Fn() -> ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
pub state: ReadSignal<AuthState>,
|
||||||
|
pub actions: AuthActions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
self.state.get().user.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_loading(&self) -> bool {
|
||||||
|
self.state.get().is_loading
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user(&self) -> Option<User> {
|
||||||
|
self.state.get().user
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(&self) -> Option<String> {
|
||||||
|
self.state.get().error
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_role(&self, role: &rustelo_core_lib::auth::Role) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |user| user.has_role(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_permission(&self, permission: &rustelo_core_lib::auth::Permission) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |user| user.has_permission(permission))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.state
|
||||||
|
.get()
|
||||||
|
.user
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |user| user.is_admin())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to get localized error message from server response
|
||||||
|
fn get_localized_error(error_text: &str, i18n: &crate::i18n::UseI18n) -> String {
|
||||||
|
let error_lower = error_text.to_lowercase();
|
||||||
|
|
||||||
|
let key = if error_lower.contains("invalid credentials") {
|
||||||
|
"invalid-credentials"
|
||||||
|
} else if error_lower.contains("user not found") {
|
||||||
|
"user-not-found"
|
||||||
|
} else if error_lower.contains("email already exists") {
|
||||||
|
"email-already-exists"
|
||||||
|
} else if error_lower.contains("username already exists") {
|
||||||
|
"username-already-exists"
|
||||||
|
} else if error_lower.contains("invalid token") {
|
||||||
|
"invalid-token"
|
||||||
|
} else if error_lower.contains("token expired") {
|
||||||
|
"token-expired"
|
||||||
|
} else if error_lower.contains("insufficient permissions") {
|
||||||
|
"insufficient-permissions"
|
||||||
|
} else if error_lower.contains("account not verified") {
|
||||||
|
"account-not-verified"
|
||||||
|
} else if error_lower.contains("account suspended") {
|
||||||
|
"account-suspended"
|
||||||
|
} else if error_lower.contains("rate limit exceeded") {
|
||||||
|
"rate-limit-exceeded"
|
||||||
|
} else if error_lower.contains("session expired") {
|
||||||
|
"session-expired"
|
||||||
|
} else if error_lower.contains("network") {
|
||||||
|
"network-error"
|
||||||
|
} else if error_lower.contains("login") && error_lower.contains("failed") {
|
||||||
|
"login-failed"
|
||||||
|
} else if error_lower.contains("registration") && error_lower.contains("failed") {
|
||||||
|
"registration-failed"
|
||||||
|
} else if error_lower.contains("profile") && error_lower.contains("failed") {
|
||||||
|
"profile-update-failed"
|
||||||
|
} else if error_lower.contains("password") && error_lower.contains("failed") {
|
||||||
|
"password-change-failed"
|
||||||
|
} else {
|
||||||
|
"unknown-error"
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n.t(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AuthProvider(children: leptos::prelude::Children) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let (state, set_state) = signal(AuthState::default());
|
||||||
|
let (access_token, set_access_token) = signal::<Option<String>>(None);
|
||||||
|
let (refresh_token_state, set_refresh_token) = signal::<Option<String>>(None);
|
||||||
|
|
||||||
|
// Initialize auth state from localStorage - delayed to avoid SSR spawn_local issues
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
gloo_timers::callback::Timeout::new(50, move || {
|
||||||
|
// Try to load stored tokens and user data after hydration
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
// Load access token
|
||||||
|
if let Ok(Some(token)) = storage.get_item("access_token") {
|
||||||
|
set_access_token.update(|t| *t = Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load refresh token
|
||||||
|
if let Ok(Some(token)) = storage.get_item("refresh_token") {
|
||||||
|
set_refresh_token.update(|t| *t = Some(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user data
|
||||||
|
if let Ok(Some(user_data)) = storage.get_item("user") {
|
||||||
|
if let Ok(user) = serde_json::from_str::<User>(&user_data) {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = Some(user);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
let login_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
Rc::new(move |email: String, password: String, remember_me: bool| {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Set loading state immediately
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.is_loading = true;
|
||||||
|
s.error = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate login with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(1000, move || {
|
||||||
|
// Create mock user data
|
||||||
|
let mock_user = User {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
email: email.clone(),
|
||||||
|
username: email.split('@').next().unwrap_or("demo").to_string(),
|
||||||
|
display_name: Some(format!("Demo User ({})", email)),
|
||||||
|
avatar_url: None,
|
||||||
|
roles: vec![rustelo_core_lib::auth::Role::User],
|
||||||
|
is_active: true,
|
||||||
|
email_verified: true,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
last_login: Some(chrono::Utc::now()),
|
||||||
|
profile: UserProfile {
|
||||||
|
first_name: Some("Demo".to_string()),
|
||||||
|
last_name: Some("User".to_string()),
|
||||||
|
bio: None,
|
||||||
|
timezone: None,
|
||||||
|
locale: None,
|
||||||
|
preferences: HashMap::new(),
|
||||||
|
categories: vec![],
|
||||||
|
tags: vec![],
|
||||||
|
},
|
||||||
|
two_factor_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mock_access_token = "demo_access_token_123456789".to_string();
|
||||||
|
let mock_refresh_token = if remember_me {
|
||||||
|
Some("demo_refresh_token_987654321".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate login failure for demo@fail.com
|
||||||
|
if email == "demo@fail.com" {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("invalid-credentials"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
set_access_token.update(|t| *t = Some(mock_access_token.clone()));
|
||||||
|
if let Some(refresh_token) = &mock_refresh_token {
|
||||||
|
set_refresh_token.update(|t| *t = Some(refresh_token.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.set_item("access_token", &mock_access_token);
|
||||||
|
if let Some(refresh_token) = &mock_refresh_token {
|
||||||
|
let _ = storage.set_item("refresh_token", refresh_token);
|
||||||
|
}
|
||||||
|
if let Ok(user_json) = serde_json::to_string(&mock_user) {
|
||||||
|
let _ = storage.set_item("user", &user_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with mock user
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = Some(mock_user);
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let logout_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Simulate logout with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(300, move || {
|
||||||
|
// Clear local state
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = None;
|
||||||
|
s.error = None;
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
set_access_token.update(|t| *t = None);
|
||||||
|
set_refresh_token.update(|t| *t = None);
|
||||||
|
|
||||||
|
// Clear localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.remove_item("access_token");
|
||||||
|
let _ = storage.remove_item("refresh_token");
|
||||||
|
let _ = storage.remove_item("user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let register_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
Rc::new(
|
||||||
|
move |email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
display_name: Option<String>| {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let set_refresh_token = set_refresh_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Set loading state immediately
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.is_loading = true;
|
||||||
|
s.error = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate registration with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(1200, move || {
|
||||||
|
// Simulate email already exists error
|
||||||
|
if email == "existing@example.com" {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("email-already-exists"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate username already exists error
|
||||||
|
if username == "existinguser" {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("username-already-exists"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock user data
|
||||||
|
let mock_user = User {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
email: email.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
display_name: display_name.clone().or_else(|| Some(username.clone())),
|
||||||
|
avatar_url: None,
|
||||||
|
roles: vec![rustelo_core_lib::auth::Role::User],
|
||||||
|
is_active: true,
|
||||||
|
email_verified: false, // New registrations start unverified
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
last_login: None,
|
||||||
|
profile: UserProfile {
|
||||||
|
first_name: None,
|
||||||
|
last_name: None,
|
||||||
|
bio: None,
|
||||||
|
timezone: None,
|
||||||
|
locale: None,
|
||||||
|
preferences: HashMap::new(),
|
||||||
|
categories: vec![],
|
||||||
|
tags: vec![],
|
||||||
|
},
|
||||||
|
two_factor_enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mock_access_token = "demo_access_token_reg_123456789".to_string();
|
||||||
|
let mock_refresh_token = Some("demo_refresh_token_reg_987654321".to_string());
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
set_access_token.update(|t| *t = Some(mock_access_token.clone()));
|
||||||
|
if let Some(refresh_token) = &mock_refresh_token {
|
||||||
|
set_refresh_token.update(|t| *t = Some(refresh_token.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.set_item("access_token", &mock_access_token);
|
||||||
|
if let Some(refresh_token) = &mock_refresh_token {
|
||||||
|
let _ = storage.set_item("refresh_token", refresh_token);
|
||||||
|
}
|
||||||
|
if let Ok(user_json) = serde_json::to_string(&mock_user) {
|
||||||
|
let _ = storage.set_item("user", &user_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with mock user
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = Some(mock_user);
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let refresh_token_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let refresh_token_state = refresh_token_state.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let set_access_token = set_access_token.clone();
|
||||||
|
let refresh_token_state = refresh_token_state.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Simulate token refresh with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(500, move || {
|
||||||
|
if let Some(_refresh_token) = refresh_token_state.get() {
|
||||||
|
// Simulate successful token refresh most of the time
|
||||||
|
let should_succeed = true;
|
||||||
|
|
||||||
|
if should_succeed {
|
||||||
|
let new_access_token = "demo_refreshed_access_token_123456789".to_string();
|
||||||
|
|
||||||
|
set_access_token.update(|t| *t = Some(new_access_token.clone()));
|
||||||
|
|
||||||
|
// Update localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.set_item("access_token", &new_access_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Simulate refresh failure - logout user
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = None;
|
||||||
|
s.error = Some(i18n.t("session-expired"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No refresh token available - logout user
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = None;
|
||||||
|
s.error = Some(i18n.t("session-expired"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let update_profile_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let access_token = access_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
Rc::new(
|
||||||
|
move |display_name: String, first_name: Option<String>, last_name: Option<String>| {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let access_token = access_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Set loading state immediately
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.is_loading = true;
|
||||||
|
s.error = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate profile update with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(800, move || {
|
||||||
|
// Check if user is authenticated
|
||||||
|
if access_token.get().is_none() {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("session-expired"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user and update it
|
||||||
|
let current_state = set_state.get();
|
||||||
|
if let Some(mut user) = current_state.user {
|
||||||
|
user.display_name = Some(display_name);
|
||||||
|
user.profile.first_name = first_name;
|
||||||
|
user.profile.last_name = last_name;
|
||||||
|
user.updated_at = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Update localStorage
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
if let Ok(user_json) = serde_json::to_string(&user) {
|
||||||
|
let _ = storage.set_item("user", &user_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with modified user
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.user = Some(user);
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("profile-update-failed"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let change_password_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let access_token = access_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
Rc::new(move |current_password: String, new_password: String| {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
let access_token = access_token.clone();
|
||||||
|
let i18n = i18n.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Set loading state immediately
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.is_loading = true;
|
||||||
|
s.error = None;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate password change with timeout - no spawn_local
|
||||||
|
gloo_timers::callback::Timeout::new(1000, move || {
|
||||||
|
// Check if user is authenticated
|
||||||
|
if access_token.get().is_none() {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("session-expired"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate current password validation failure
|
||||||
|
if current_password == "wrongpassword" {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some(i18n.t("invalid-credentials"));
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate weak password error
|
||||||
|
if new_password.len() < 8 {
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.error = Some("Password must be at least 8 characters long".to_string());
|
||||||
|
s.is_loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate successful password change
|
||||||
|
set_state.update(|s| {
|
||||||
|
s.is_loading = false;
|
||||||
|
s.error = None;
|
||||||
|
});
|
||||||
|
}).forget();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let clear_error_action = {
|
||||||
|
let set_state = set_state.clone();
|
||||||
|
Rc::new(move || {
|
||||||
|
set_state.update(|s| s.error = None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let actions = AuthActions {
|
||||||
|
login: login_action,
|
||||||
|
logout: logout_action,
|
||||||
|
register: register_action,
|
||||||
|
refresh_token: refresh_token_action,
|
||||||
|
update_profile: update_profile_action,
|
||||||
|
change_password: change_password_action,
|
||||||
|
clear_error: clear_error_action,
|
||||||
|
};
|
||||||
|
|
||||||
|
let context = AuthContext {
|
||||||
|
state: state.into(),
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
provide_context(context);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UseAuth(pub AuthContext);
|
||||||
|
|
||||||
|
impl UseAuth {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(expect_context::<AuthContext>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_auth() -> UseAuth {
|
||||||
|
UseAuth::new()
|
||||||
|
}
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
use crate::i18n::use_i18n;
|
||||||
|
use gloo_timers::callback::Timeout;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// A component that displays authentication errors with proper internationalization
|
||||||
|
#[component]
|
||||||
|
pub fn AuthErrorDisplay(
|
||||||
|
/// The error message to display (optional)
|
||||||
|
#[prop(optional)]
|
||||||
|
error: Option<String>,
|
||||||
|
/// Whether to show the error in a dismissible alert
|
||||||
|
#[prop(default = true)]
|
||||||
|
dismissible: bool,
|
||||||
|
/// Additional CSS classes to apply
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Callback when error is dismissed
|
||||||
|
#[prop(optional)]
|
||||||
|
on_dismiss: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || error.is_some()>
|
||||||
|
<div class=move || format!(
|
||||||
|
"bg-red-50 border border-red-200 ds-rounded-md p-4 mb-ds-4 {}",
|
||||||
|
class.as_deref().unwrap_or("")
|
||||||
|
)>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<p class="ds-caption font-medium ds-text">
|
||||||
|
{move || error.clone().unwrap_or_default()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when=move || dismissible && on_dismiss.is_some()>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex bg-red-50 ds-rounded-md p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(callback) = on_dismiss {
|
||||||
|
callback.call(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("dismiss")}</span>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toast notification component for displaying errors
|
||||||
|
#[component]
|
||||||
|
pub fn AuthErrorToast(
|
||||||
|
/// The error message to display
|
||||||
|
error: String,
|
||||||
|
/// Duration in milliseconds before auto-dismiss (0 = no auto-dismiss)
|
||||||
|
#[prop(default = 5000)]
|
||||||
|
duration: u32,
|
||||||
|
/// Callback when toast is dismissed
|
||||||
|
#[prop(optional)]
|
||||||
|
on_dismiss: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let (visible, set_visible) = signal(true);
|
||||||
|
|
||||||
|
// Auto-dismiss after duration
|
||||||
|
if duration > 0 {
|
||||||
|
let timeout = Timeout::new(duration, move || {
|
||||||
|
set_visible.set(false);
|
||||||
|
if let Some(callback) = on_dismiss {
|
||||||
|
callback.call(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
timeout.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || visible.get()>
|
||||||
|
<div class="fixed top-4 right-4 z-50 max-w-sm w-full">
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 ds-rounded-lg ds-shadow-lg">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1">
|
||||||
|
<p class="ds-caption font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<div class="-mx-1.5 -my-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex bg-red-100 ds-rounded-md p-1.5 text-red-500 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-100 focus:ring-red-600"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_visible.set(false);
|
||||||
|
if let Some(callback) = on_dismiss {
|
||||||
|
callback.call(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("dismiss")}</span>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A more compact inline error display
|
||||||
|
#[component]
|
||||||
|
pub fn InlineAuthError(
|
||||||
|
/// The error message to display
|
||||||
|
error: String,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class=move || format!(
|
||||||
|
"ds-caption ds-text mt-1 {}",
|
||||||
|
class.as_deref().unwrap_or("")
|
||||||
|
)>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="h-4 w-4 mr-1 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Example usage component showing how to integrate with the auth context
|
||||||
|
#[component]
|
||||||
|
pub fn AuthErrorExample() -> impl IntoView {
|
||||||
|
let auth = crate::auth::use_auth();
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="ds-body font-medium ds-text">
|
||||||
|
{i18n.t("authentication-errors")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
// Display current auth error if any
|
||||||
|
<AuthErrorDisplay
|
||||||
|
error=move || auth.0.error()
|
||||||
|
on_dismiss=Callback::new(move |_| {
|
||||||
|
(auth.0.actions.clear_error)();
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Example of inline error display
|
||||||
|
<Show when=move || auth.0.error().is_some()>
|
||||||
|
<InlineAuthError
|
||||||
|
error=move || auth.0.error().unwrap_or_default()
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
// Example of toast notification
|
||||||
|
<Show when=move || auth.0.error().is_some()>
|
||||||
|
<AuthErrorToast
|
||||||
|
error=move || auth.0.error().unwrap_or_default()
|
||||||
|
duration=3000
|
||||||
|
on_dismiss=Callback::new(move |_| {
|
||||||
|
(auth.0.actions.clear_error)();
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
163
crates/foundation/crates/rustelo_client/src/auth/errors.rs
Normal file
163
crates/foundation/crates/rustelo_client/src/auth/errors.rs
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
use crate::i18n::UseI18n;
|
||||||
|
use serde_json;
|
||||||
|
use rustelo_core_lib::auth::AuthError;
|
||||||
|
|
||||||
|
/// Helper struct for handling authentication errors with internationalization
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AuthErrorHandler {
|
||||||
|
i18n: UseI18n,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthErrorHandler {
|
||||||
|
pub fn new(i18n: UseI18n) -> Self {
|
||||||
|
Self { i18n }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a server response error to a localized error message
|
||||||
|
pub async fn handle_response_error(&self, response: &reqwasm::http::Response) -> String {
|
||||||
|
if let Ok(error_text) = response.text().await {
|
||||||
|
self.map_error_to_localized_message(&error_text)
|
||||||
|
} else {
|
||||||
|
self.i18n.t("unknown-error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map error text to localized message
|
||||||
|
pub fn map_error_to_localized_message(&self, error_text: &str) -> String {
|
||||||
|
let translation_key = self.map_error_to_translation_key(error_text);
|
||||||
|
self.i18n.t(&translation_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map server errors to translation keys
|
||||||
|
pub fn map_error_to_translation_key(&self, error_text: &str) -> String {
|
||||||
|
// Try to parse as JSON first (standard API error response)
|
||||||
|
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(error_text) {
|
||||||
|
if let Some(message) = json_value.get("message").and_then(|m| m.as_str()) {
|
||||||
|
return self.map_error_message_to_key(message);
|
||||||
|
}
|
||||||
|
if let Some(errors) = json_value.get("errors").and_then(|e| e.as_array()) {
|
||||||
|
if let Some(first_error) = errors.first().and_then(|e| e.as_str()) {
|
||||||
|
return self.map_error_message_to_key(first_error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct message mapping
|
||||||
|
self.map_error_message_to_key(error_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map error messages to translation keys
|
||||||
|
fn map_error_message_to_key(&self, message: &str) -> String {
|
||||||
|
let message_lower = message.to_lowercase();
|
||||||
|
|
||||||
|
match message_lower.as_str() {
|
||||||
|
msg if msg.contains("invalid credentials") => "invalid-credentials".to_string(),
|
||||||
|
msg if msg.contains("user not found") => "user-not-found".to_string(),
|
||||||
|
msg if msg.contains("email already exists") => "email-already-exists".to_string(),
|
||||||
|
msg if msg.contains("username already exists") => "username-already-exists".to_string(),
|
||||||
|
msg if msg.contains("invalid token") => "invalid-token".to_string(),
|
||||||
|
msg if msg.contains("token expired") => "token-expired".to_string(),
|
||||||
|
msg if msg.contains("insufficient permissions") => {
|
||||||
|
"insufficient-permissions".to_string()
|
||||||
|
}
|
||||||
|
msg if msg.contains("account not verified") => "account-not-verified".to_string(),
|
||||||
|
msg if msg.contains("account suspended") => "account-suspended".to_string(),
|
||||||
|
msg if msg.contains("rate limit exceeded") => "rate-limit-exceeded".to_string(),
|
||||||
|
msg if msg.contains("oauth") => "oauth-error".to_string(),
|
||||||
|
msg if msg.contains("database") => "database-error".to_string(),
|
||||||
|
msg if msg.contains("validation") => "validation-error".to_string(),
|
||||||
|
msg if msg.contains("login failed") => "login-failed".to_string(),
|
||||||
|
msg if msg.contains("registration failed") => "registration-failed".to_string(),
|
||||||
|
msg if msg.contains("session expired") => "session-expired".to_string(),
|
||||||
|
msg if msg.contains("profile") && msg.contains("failed") => {
|
||||||
|
"profile-update-failed".to_string()
|
||||||
|
}
|
||||||
|
msg if msg.contains("password") && msg.contains("failed") => {
|
||||||
|
"password-change-failed".to_string()
|
||||||
|
}
|
||||||
|
msg if msg.contains("network") => "network-error".to_string(),
|
||||||
|
msg if msg.contains("server") => "server-error".to_string(),
|
||||||
|
msg if msg.contains("internal") => "internal-error".to_string(),
|
||||||
|
_ => "unknown-error".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle AuthError enum directly
|
||||||
|
pub fn handle_auth_error(&self, error: &AuthError) -> String {
|
||||||
|
let translation_key = match error {
|
||||||
|
AuthError::InvalidCredentials => "invalid-credentials",
|
||||||
|
AuthError::UserNotFound => "user-not-found",
|
||||||
|
AuthError::EmailAlreadyExists => "email-already-exists",
|
||||||
|
AuthError::UsernameAlreadyExists => "username-already-exists",
|
||||||
|
AuthError::InvalidToken => "invalid-token",
|
||||||
|
AuthError::TokenExpired => "token-expired",
|
||||||
|
AuthError::InsufficientPermissions => "insufficient-permissions",
|
||||||
|
AuthError::AccountNotVerified => "account-not-verified",
|
||||||
|
AuthError::AccountSuspended => "account-suspended",
|
||||||
|
AuthError::RateLimitExceeded => "rate-limit-exceeded",
|
||||||
|
AuthError::OAuthError(_) => "oauth-error",
|
||||||
|
AuthError::DatabaseError => "database-error",
|
||||||
|
AuthError::InternalError => "internal-error",
|
||||||
|
AuthError::ValidationError(_) => "validation-error",
|
||||||
|
};
|
||||||
|
|
||||||
|
self.i18n.t(translation_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle network errors
|
||||||
|
pub fn handle_network_error(&self) -> String {
|
||||||
|
self.i18n.t("network-error")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle generic request failures
|
||||||
|
pub fn handle_request_failure(&self, operation: &str) -> String {
|
||||||
|
match operation {
|
||||||
|
"login" => self.i18n.t("login-failed"),
|
||||||
|
"register" => self.i18n.t("registration-failed"),
|
||||||
|
"profile-update" => self.i18n.t("profile-update-failed"),
|
||||||
|
"password-change" => self.i18n.t("password-change-failed"),
|
||||||
|
_ => self.i18n.t("request-failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if an error indicates session expiration
|
||||||
|
pub fn is_session_expired(&self, error_text: &str) -> bool {
|
||||||
|
let error_lower = error_text.to_lowercase();
|
||||||
|
error_lower.contains("session expired")
|
||||||
|
|| error_lower.contains("token expired")
|
||||||
|
|| error_lower.contains("invalid token")
|
||||||
|
|| error_lower.contains("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get appropriate error message for session expiration
|
||||||
|
pub fn get_session_expired_message(&self) -> String {
|
||||||
|
self.i18n.t("session-expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to create an AuthErrorHandler
|
||||||
|
pub fn create_auth_error_handler(i18n: UseI18n) -> AuthErrorHandler {
|
||||||
|
AuthErrorHandler::new(i18n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait for handling authentication errors consistently
|
||||||
|
pub trait AuthErrorHandling {
|
||||||
|
fn handle_auth_error(&self, error: &str) -> String;
|
||||||
|
fn handle_network_error(&self) -> String;
|
||||||
|
fn handle_session_expired(&self) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthErrorHandling for UseI18n {
|
||||||
|
fn handle_auth_error(&self, error: &str) -> String {
|
||||||
|
let handler = create_auth_error_handler(self.clone());
|
||||||
|
handler.map_error_to_localized_message(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_network_error(&self) -> String {
|
||||||
|
self.t("network-error")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_session_expired(&self) -> String {
|
||||||
|
self.t("session-expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
254
crates/foundation/crates/rustelo_client/src/auth/login.rs
Normal file
254
crates/foundation/crates/rustelo_client/src/auth/login.rs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
use leptos::html::Input;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use web_sys::SubmitEvent;
|
||||||
|
|
||||||
|
use super::context::use_auth;
|
||||||
|
use crate::i18n::use_i18n;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginForm() -> impl IntoView {
|
||||||
|
let auth = use_auth();
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
// Store contexts in StoredValue to avoid move issues
|
||||||
|
let auth_stored = StoredValue::new(auth);
|
||||||
|
let i18n_stored = StoredValue::new(i18n);
|
||||||
|
|
||||||
|
let (email, set_email) = signal(String::new());
|
||||||
|
let (password, set_password) = signal(String::new());
|
||||||
|
let (remember_me, set_remember_me) = signal(false);
|
||||||
|
let (show_password, set_show_password) = signal(false);
|
||||||
|
|
||||||
|
let email_ref = NodeRef::<Input>::new();
|
||||||
|
let password_ref = NodeRef::<Input>::new();
|
||||||
|
|
||||||
|
let on_submit = move |ev: SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
let email_val = email.get();
|
||||||
|
let password_val = password.get();
|
||||||
|
let remember_val = remember_me.get();
|
||||||
|
|
||||||
|
if !email_val.is_empty() && !password_val.is_empty() {
|
||||||
|
(auth_stored.get_value().0.actions.login)(email_val, password_val, remember_val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let toggle_password_visibility = move |_| {
|
||||||
|
set_show_password.update(|show| *show = !*show);
|
||||||
|
};
|
||||||
|
|
||||||
|
let clear_error = move |_| {
|
||||||
|
(auth_stored.get_value().0.actions.clear_error)();
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="w-full max-w-md mx-auto">
|
||||||
|
<div class="ds-bg ds-shadow-lg ds-rounded-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-bold ds-text">{move || i18n_stored.get_value().t("sign-in")}</h2>
|
||||||
|
<p class="ds-text-secondary mt-2">{move || i18n_stored.get_value().t("welcome-back")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || auth_stored.get_value().0.error().is_some()>
|
||||||
|
<div class="bg-red-50 border border-red-200 ds-rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="ds-caption ds-text">
|
||||||
|
{move || auth_stored.get_value().0.error().unwrap_or_default()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex text-red-400 hover:ds-text"
|
||||||
|
on:click=clear_error
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<form on:submit=on_submit class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("email-address")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
node_ref=email_ref
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("enter-email")
|
||||||
|
prop:value=email
|
||||||
|
on:input=move |ev| set_email.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("password")}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
node_ref=password_ref
|
||||||
|
type=move || if show_password.get() { "text" } else { "password" }
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 pr-10 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("enter-password")
|
||||||
|
prop:value=password
|
||||||
|
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
on:click=toggle_password_visibility
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when=move || show_password.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 ds-border ds-rounded"
|
||||||
|
prop:checked=remember_me
|
||||||
|
on:change=move |ev| set_remember_me.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
<label for="remember-me" class="ml-2 block ds-caption ds-text">
|
||||||
|
{move || i18n_stored.get_value().t("remember-me")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ds-caption">
|
||||||
|
<a href="/auth/forgot-password" class="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
{move || i18n_stored.get_value().t("forgot-password")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled=move || auth_stored.get_value().0.is_loading()
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent ds-rounded-md ds-shadow-sm ds-caption font-medium ds-text bg-ds-brand-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when=move || auth_stored.get_value().0.is_loading()
|
||||||
|
fallback=move || view! { {i18n_stored.get_value().t("sign-in")} }
|
||||||
|
>
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 ds-text" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{i18n_stored.get_value().t("signing-in")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-ds-6">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t ds-border"/>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center ds-caption">
|
||||||
|
<span class="px-2 ds-bg ds-text-muted">{move || i18n_stored.get_value().t("continue-with")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-ds-6 grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth login
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/google/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to Google OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign in with Google</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth login
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/github/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to GitHub OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign in with GitHub</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth login
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/discord/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to Discord OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign in with Discord</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-ds-6 text-center">
|
||||||
|
<p class="ds-caption ds-text-secondary">
|
||||||
|
{move || i18n_stored.get_value().t("dont-have-account")}{" "}
|
||||||
|
<a href="/auth/register" class="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
{move || i18n_stored.get_value().t("sign-up")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/foundation/crates/rustelo_client/src/auth/mod.rs
Normal file
11
crates/foundation/crates/rustelo_client/src/auth/mod.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
pub mod context;
|
||||||
|
pub mod login;
|
||||||
|
pub mod register;
|
||||||
|
// pub mod two_factor;
|
||||||
|
// pub mod two_factor_login;
|
||||||
|
|
||||||
|
pub use context::{use_auth, AuthContext, AuthProvider, AuthState, UseAuth};
|
||||||
|
pub use login::LoginForm;
|
||||||
|
pub use register::RegisterForm;
|
||||||
|
// pub use two_factor::TwoFactorSetup;
|
||||||
|
// pub use two_factor_login::{TwoFactorLoginForm, TwoFactorLoginPage};
|
||||||
484
crates/foundation/crates/rustelo_client/src/auth/register.rs
Normal file
484
crates/foundation/crates/rustelo_client/src/auth/register.rs
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
use leptos::html::Input;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use web_sys::SubmitEvent;
|
||||||
|
|
||||||
|
use super::context::use_auth;
|
||||||
|
use crate::i18n::use_i18n;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn RegisterForm() -> impl IntoView {
|
||||||
|
let auth = use_auth();
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
// Store contexts in StoredValue to avoid move issues
|
||||||
|
let auth_stored = StoredValue::new(auth);
|
||||||
|
let i18n_stored = StoredValue::new(i18n);
|
||||||
|
|
||||||
|
let (email, set_email) = signal(String::new());
|
||||||
|
let (username, set_username) = signal(String::new());
|
||||||
|
let (password, set_password) = signal(String::new());
|
||||||
|
let (confirm_password, set_confirm_password) = signal(String::new());
|
||||||
|
let (display_name, set_display_name) = signal(String::new());
|
||||||
|
let (show_password, set_show_password) = signal(false);
|
||||||
|
let (show_confirm_password, set_show_confirm_password) = signal(false);
|
||||||
|
|
||||||
|
let email_ref = NodeRef::<Input>::new();
|
||||||
|
let username_ref = NodeRef::<Input>::new();
|
||||||
|
let password_ref = NodeRef::<Input>::new();
|
||||||
|
let confirm_password_ref = NodeRef::<Input>::new();
|
||||||
|
|
||||||
|
let password_strength = Memo::new(move |_| {
|
||||||
|
let pwd = password.get();
|
||||||
|
if pwd.is_empty() {
|
||||||
|
return ("", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut score = 0;
|
||||||
|
let mut feedback = Vec::new();
|
||||||
|
|
||||||
|
if pwd.len() >= 8 {
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
feedback.push("At least 8 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if pwd.chars().any(|c| c.is_uppercase()) {
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
feedback.push("One uppercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
if pwd.chars().any(|c| c.is_lowercase()) {
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
feedback.push("One lowercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
if pwd.chars().any(|c| c.is_numeric()) {
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
feedback.push("One number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if pwd.chars().any(|c| !c.is_alphanumeric()) {
|
||||||
|
score += 1;
|
||||||
|
} else {
|
||||||
|
feedback.push("One special character");
|
||||||
|
}
|
||||||
|
|
||||||
|
let strength = match score {
|
||||||
|
0..=1 => ("Very Weak", "bg-red-500"),
|
||||||
|
2 => ("Weak", "bg-orange-500"),
|
||||||
|
3 => ("Fair", "bg-yellow-500"),
|
||||||
|
4 => ("Good", "bg-blue-500"),
|
||||||
|
5 => ("Strong", "bg-green-500"),
|
||||||
|
_ => ("Strong", "bg-green-500"),
|
||||||
|
};
|
||||||
|
|
||||||
|
(strength.0, strength.1)
|
||||||
|
});
|
||||||
|
|
||||||
|
let passwords_match = move || {
|
||||||
|
let pwd = password.get();
|
||||||
|
let confirm = confirm_password.get();
|
||||||
|
pwd == confirm && !pwd.is_empty()
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_is_valid = move || {
|
||||||
|
!email.get().is_empty()
|
||||||
|
&& !username.get().is_empty()
|
||||||
|
&& !password.get().is_empty()
|
||||||
|
&& passwords_match()
|
||||||
|
&& password.get().len() >= 8
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |ev: SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
if form_is_valid() {
|
||||||
|
let email_val = email.get();
|
||||||
|
let username_val = username.get();
|
||||||
|
let password_val = password.get();
|
||||||
|
let display_name_val = if display_name.get().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(display_name.get())
|
||||||
|
};
|
||||||
|
|
||||||
|
(auth_stored.get_value().0.actions.register)(
|
||||||
|
email_val,
|
||||||
|
username_val,
|
||||||
|
password_val,
|
||||||
|
display_name_val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let toggle_password_visibility = move |_| {
|
||||||
|
set_show_password.update(|show| *show = !*show);
|
||||||
|
};
|
||||||
|
|
||||||
|
let toggle_confirm_password_visibility = move |_| {
|
||||||
|
set_show_confirm_password.update(|show| *show = !*show);
|
||||||
|
};
|
||||||
|
|
||||||
|
let clear_error = move |_| {
|
||||||
|
(auth_stored.get_value().0.actions.clear_error)();
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="w-full max-w-md mx-auto">
|
||||||
|
<div class="ds-bg ds-shadow-lg ds-rounded-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-bold ds-text">{move || i18n_stored.get_value().t("create-account")}</h2>
|
||||||
|
<p class="ds-text-secondary mt-2">{move || i18n_stored.get_value().t("join-us-today")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || auth_stored.get_value().0.error().is_some()>
|
||||||
|
<div class="bg-red-50 border border-red-200 ds-rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="ds-caption ds-text">
|
||||||
|
{move || auth_stored.get_value().0.error().unwrap_or_default()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto pl-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex text-red-400 hover:ds-text"
|
||||||
|
on:click=clear_error
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<form on:submit=on_submit class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("email-address")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
node_ref=email_ref
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("enter-email")
|
||||||
|
prop:value=email
|
||||||
|
on:input=move |ev| set_email.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("username")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
node_ref=username_ref
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("enter-username")
|
||||||
|
prop:value=username
|
||||||
|
on:input=move |ev| set_username.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<p class="mt-1 ds-caption ds-text-muted">
|
||||||
|
{move || i18n_stored.get_value().t("username-format")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="display_name" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("display-name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="display_name"
|
||||||
|
name="display_name"
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("how-should-we-call-you")
|
||||||
|
prop:value=display_name
|
||||||
|
on:input=move |ev| set_display_name.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("password")}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
node_ref=password_ref
|
||||||
|
type=move || if show_password.get() { "text" } else { "password" }
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 pr-10 border ds-border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder=move || i18n_stored.get_value().t("enter-password")
|
||||||
|
prop:value=password
|
||||||
|
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
on:click=toggle_password_visibility
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when=move || show_password.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || !password.get().is_empty()>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="flex items-center justify-between ds-caption">
|
||||||
|
<span class="ds-text-secondary">{move || i18n_stored.get_value().t("password-strength")}</span>
|
||||||
|
<span class=move || format!("font-medium {}", match password_strength.get().0 {
|
||||||
|
"Very Weak" => "ds-text",
|
||||||
|
"Weak" => "text-orange-600",
|
||||||
|
"Fair" => "text-yellow-600",
|
||||||
|
"Good" => "text-blue-600",
|
||||||
|
"Strong" => "text-green-600",
|
||||||
|
_ => "ds-text-secondary",
|
||||||
|
})>
|
||||||
|
{move || {
|
||||||
|
let strength = password_strength.get().0;
|
||||||
|
match strength {
|
||||||
|
"Very Weak" => i18n_stored.get_value().t("very-weak"),
|
||||||
|
"Weak" => i18n_stored.get_value().t("weak"),
|
||||||
|
"Fair" => i18n_stored.get_value().t("fair"),
|
||||||
|
"Good" => i18n_stored.get_value().t("good"),
|
||||||
|
"Strong" => i18n_stored.get_value().t("strong"),
|
||||||
|
_ => strength.to_string(),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 h-2 ds-rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class=move || format!("h-full transition-all duration-300 {}", password_strength.get().1)
|
||||||
|
style=move || {
|
||||||
|
let width = match password_strength.get().0 {
|
||||||
|
"Very Weak" => "20%",
|
||||||
|
"Weak" => "40%",
|
||||||
|
"Fair" => "60%",
|
||||||
|
"Good" => "80%",
|
||||||
|
"Strong" => "100%",
|
||||||
|
_ => "0%",
|
||||||
|
};
|
||||||
|
format!("width: {width}")
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<p class="mt-1 ds-caption ds-text-muted">
|
||||||
|
{move || i18n_stored.get_value().t("password-requirements")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm-password" class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || i18n_stored.get_value().t("confirm-password")}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
node_ref=confirm_password_ref
|
||||||
|
type=move || if show_confirm_password.get() { "text" } else { "password" }
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
class=move || format!("w-full px-3 py-2 pr-10 border ds-rounded-md ds-shadow-sm placeholder-ds-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
|
||||||
|
if confirm_password.get().is_empty() {
|
||||||
|
"ds-border"
|
||||||
|
} else if passwords_match() {
|
||||||
|
"border-green-300"
|
||||||
|
} else {
|
||||||
|
"border-red-300"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
placeholder=move || i18n_stored.get_value().t("confirm-password")
|
||||||
|
prop:value=confirm_password
|
||||||
|
on:input=move |ev| set_confirm_password.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
on:click=toggle_confirm_password_visibility
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when=move || show_confirm_password.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 ds-text-muted hover:ds-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21"/>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when=move || !confirm_password.get().is_empty()>
|
||||||
|
<div class="mt-1 flex items-center">
|
||||||
|
<Show
|
||||||
|
when=move || passwords_match()
|
||||||
|
fallback=move || view! {
|
||||||
|
<svg class="h-4 w-4 text-red-500 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ds-caption ds-text">{move || i18n_stored.get_value().t("passwords-dont-match")}</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 text-green-500 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ds-caption text-green-600">{move || i18n_stored.get_value().t("passwords-match")}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="terms"
|
||||||
|
name="terms"
|
||||||
|
type="checkbox"
|
||||||
|
required
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 ds-border ds-rounded"
|
||||||
|
/>
|
||||||
|
<label for="terms" class="ml-2 block ds-caption ds-text">
|
||||||
|
{move || i18n_stored.get_value().t("i-agree-to-the")}{" "}
|
||||||
|
<a href="/terms" class="text-blue-600 hover:text-blue-500">
|
||||||
|
{move || i18n_stored.get_value().t("terms-of-service")}
|
||||||
|
</a>
|
||||||
|
{" "}{move || i18n_stored.get_value().t("and")}{" "}
|
||||||
|
<a href="/privacy" class="text-blue-600 hover:text-blue-500">
|
||||||
|
{move || i18n_stored.get_value().t("privacy-policy")}
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled=move || auth_stored.get_value().0.is_loading() || !form_is_valid()
|
||||||
|
class="w-full flex justify-center py-2 px-4 border border-transparent ds-rounded-md ds-shadow-sm ds-caption font-medium ds-text bg-ds-brand-primary hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when=move || auth_stored.get_value().0.is_loading()
|
||||||
|
fallback=move || view! { {i18n_stored.get_value().t("create-account")} }
|
||||||
|
>
|
||||||
|
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 ds-text" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{i18n_stored.get_value().t("creating-account")}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-ds-6">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<div class="w-full border-t ds-border"/>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center ds-caption">
|
||||||
|
<span class="px-2 ds-bg ds-text-muted">{move || i18n_stored.get_value().t("continue-with")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-ds-6 grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth registration
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/google/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to Google OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||||
|
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||||
|
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
|
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign up with Google</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth registration
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/github/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to GitHub OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign up with GitHub</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full inline-flex justify-center py-2 px-4 border ds-border ds-rounded-md ds-shadow-sm ds-bg ds-caption font-medium ds-text-muted hover:"
|
||||||
|
on:click=move |_| {
|
||||||
|
// TODO: Implement OAuth registration
|
||||||
|
if let Err(e) = window().location().set_href("/api/auth/oauth/discord/authorize") {
|
||||||
|
web_sys::console::error_1(&format!("Failed to redirect to Discord OAuth: {e:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Sign up with Discord</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-ds-6 text-center">
|
||||||
|
<p class="ds-caption ds-text-secondary">
|
||||||
|
{move || i18n_stored.get_value().t("already-have-account")}{" "}
|
||||||
|
<a href="/auth/login" class="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
{move || i18n_stored.get_value().t("sign-in")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
318
crates/foundation/crates/rustelo_client/src/auth/two_factor.rs
Normal file
318
crates/foundation/crates/rustelo_client/src/auth/two_factor.rs
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use rustelo_core_lib::auth::{Setup2FARequest, Setup2FAResponse, TwoFactorStatus, Verify2FARequest};
|
||||||
|
|
||||||
|
use crate::utils::api_request;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
pub success: bool,
|
||||||
|
pub data: Option<T>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub errors: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum TwoFactorSetupState {
|
||||||
|
Loading,
|
||||||
|
Error,
|
||||||
|
NotEnabled,
|
||||||
|
PendingVerification(Setup2FAResponse),
|
||||||
|
Enabled(TwoFactorStatus),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TwoFactorSetup() -> impl IntoView {
|
||||||
|
let (setup_state, set_setup_state) = signal(TwoFactorSetupState::Loading);
|
||||||
|
let (password, set_password) = signal(String::new());
|
||||||
|
let (verification_code, set_verification_code) = signal(String::new());
|
||||||
|
let (error_message, set_error_message) = signal(Option::<String>::None);
|
||||||
|
let (success_message, set_success_message) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
// Load 2FA status on component mount
|
||||||
|
let load_2fa_status = Action::new(move |_: &()| async move {
|
||||||
|
match api_request::<(), ApiResponse<TwoFactorStatus>>("/api/auth/2fa/status", "GET", None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
if response.success {
|
||||||
|
if let Some(status) = response.data {
|
||||||
|
if status.is_enabled {
|
||||||
|
set_setup_state.set(TwoFactorSetupState::Enabled(status));
|
||||||
|
} else {
|
||||||
|
set_setup_state.set(TwoFactorSetupState::NotEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_error_message.set(Some(
|
||||||
|
response
|
||||||
|
.message
|
||||||
|
.unwrap_or_else(|| "Failed to load 2FA status".to_string()),
|
||||||
|
));
|
||||||
|
set_setup_state.set(TwoFactorSetupState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error_message.set(Some(format!("Failed to load 2FA status: {}", e)));
|
||||||
|
set_setup_state.set(TwoFactorSetupState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup 2FA action
|
||||||
|
let setup_2fa_action = Action::new(move |password: &String| {
|
||||||
|
let password = password.clone();
|
||||||
|
async move {
|
||||||
|
let request = Setup2FARequest { password };
|
||||||
|
match api_request::<Setup2FARequest, ApiResponse<Setup2FAResponse>>(
|
||||||
|
"/api/auth/2fa/setup",
|
||||||
|
"POST",
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
if response.success {
|
||||||
|
if let Some(setup_response) = response.data {
|
||||||
|
set_setup_state
|
||||||
|
.set(TwoFactorSetupState::PendingVerification(setup_response));
|
||||||
|
set_success_message.set(response.message);
|
||||||
|
set_error_message.set(None);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_error_message.set(response.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error_message.set(Some(format!("Failed to setup 2FA: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify 2FA setup action
|
||||||
|
let verify_2fa_action = Action::new(move |code: &String| {
|
||||||
|
let code = code.clone();
|
||||||
|
async move {
|
||||||
|
let request = Verify2FARequest { code };
|
||||||
|
match api_request::<Verify2FARequest, ApiResponse<()>>(
|
||||||
|
"/api/auth/2fa/verify",
|
||||||
|
"POST",
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
if response.success {
|
||||||
|
set_success_message.set(Some("2FA enabled successfully!".to_string()));
|
||||||
|
set_error_message.set(None);
|
||||||
|
load_2fa_status.dispatch(());
|
||||||
|
} else {
|
||||||
|
set_error_message.set(response.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error_message.set(Some(format!("Failed to verify 2FA: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load status on mount
|
||||||
|
Effect::new(move |_| {
|
||||||
|
load_2fa_status.dispatch(());
|
||||||
|
});
|
||||||
|
|
||||||
|
let handle_setup_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
if !password.get().is_empty() {
|
||||||
|
setup_2fa_action.dispatch(password.get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_verify_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
if !verification_code.get().is_empty() {
|
||||||
|
verify_2fa_action.dispatch(verification_code.get());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="max-w-2xl mx-auto p-ds-6">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">"Two-Factor Authentication"</h1>
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
{move || {
|
||||||
|
if let Some(msg) = error_message.get() {
|
||||||
|
view! {
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 ds-rounded mb-ds-4">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
{move || {
|
||||||
|
if let Some(msg) = success_message.get() {
|
||||||
|
view! {
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 ds-rounded mb-ds-4">
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Main content based on setup state
|
||||||
|
{move || match setup_state.get() {
|
||||||
|
TwoFactorSetupState::Loading => view! {
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="animate-spin ds-rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||||
|
<p class="mt-2 ds-text-secondary">"Loading 2FA status..."</p>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
TwoFactorSetupState::Error => view! {
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="ds-text">"Failed to load 2FA status"</p>
|
||||||
|
<button
|
||||||
|
class="mt-4 px-ds-4 py-ds-2 bg-blue-500 ds-text ds-rounded hover:bg-ds-brand-primary"
|
||||||
|
on:click=move |_| load_2fa_status.dispatch(())
|
||||||
|
>
|
||||||
|
"Retry"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
TwoFactorSetupState::NotEnabled => view! {
|
||||||
|
<div class="bg-blue-50 border border-blue-200 ds-rounded-lg p-ds-6 mb-6">
|
||||||
|
<h2 class="ds-heading-4 mb-ds-4">"Enable Two-Factor Authentication"</h2>
|
||||||
|
<p class="ds-text-secondary mb-ds-4">
|
||||||
|
"Add an extra layer of security to your account by enabling two-factor authentication."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form on:submit=handle_setup_submit>
|
||||||
|
<div class="mb-ds-4">
|
||||||
|
<label class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
"Current Password"
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
prop:value=password
|
||||||
|
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-blue-500 ds-text py-2 px-4 ds-rounded-md"
|
||||||
|
disabled=move || setup_2fa_action.pending().get()
|
||||||
|
>
|
||||||
|
{move || if setup_2fa_action.pending().get() {
|
||||||
|
"Setting up..."
|
||||||
|
} else {
|
||||||
|
"Setup 2FA"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
TwoFactorSetupState::PendingVerification(setup_response) => view! {
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 ds-rounded-lg p-ds-6 mb-6">
|
||||||
|
<h2 class="ds-heading-4 mb-ds-4">"Verify Two-Factor Authentication"</h2>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="ds-body font-medium mb-2">"Step 1: Scan QR Code"</h3>
|
||||||
|
<p class="ds-text-secondary mb-ds-4">
|
||||||
|
"Scan this QR code with your authenticator app."
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center mb-ds-4">
|
||||||
|
<img
|
||||||
|
src=setup_response.qr_code_url.clone()
|
||||||
|
alt="QR Code for 2FA setup"
|
||||||
|
class="border ds-border ds-rounded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=" p-3 ds-rounded">
|
||||||
|
<p class="ds-caption ds-text-secondary mb-2">"Secret:"</p>
|
||||||
|
<code class="ds-caption font-mono ds-bg p-ds-2 ds-rounded border">
|
||||||
|
{setup_response.secret.clone()}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="ds-body font-medium mb-2">"Step 2: Save Backup Codes"</h3>
|
||||||
|
<div class=" p-4 ds-rounded">
|
||||||
|
<p class="ds-caption ds-text-secondary mb-2">
|
||||||
|
"Backup codes: " {setup_response.backup_codes.len().to_string()} " codes generated"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="ds-body font-medium mb-2">"Step 3: Verify Setup"</h3>
|
||||||
|
<form on:submit=handle_verify_submit>
|
||||||
|
<div class="mb-ds-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-md text-center"
|
||||||
|
placeholder="000000"
|
||||||
|
maxlength="6"
|
||||||
|
prop:value=verification_code
|
||||||
|
on:input=move |ev| set_verification_code.set(event_target_value(&ev))
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-green-500 ds-text py-2 px-4 ds-rounded-md"
|
||||||
|
disabled=move || verify_2fa_action.pending().get()
|
||||||
|
>
|
||||||
|
{move || if verify_2fa_action.pending().get() {
|
||||||
|
"Verifying..."
|
||||||
|
} else {
|
||||||
|
"Enable 2FA"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
|
||||||
|
TwoFactorSetupState::Enabled(status) => view! {
|
||||||
|
<div class="bg-green-50 border border-green-200 ds-rounded-lg p-ds-6 mb-6">
|
||||||
|
<h2 class="ds-heading-4 mb-ds-4 text-green-800">
|
||||||
|
"Two-Factor Authentication Enabled"
|
||||||
|
</h2>
|
||||||
|
<p class="text-green-700 mb-ds-4">
|
||||||
|
"Your account is protected with two-factor authentication."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-ds-4">
|
||||||
|
<p class="ds-caption ds-text-secondary">
|
||||||
|
"Backup codes remaining: " {status.backup_codes_remaining.to_string()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="ds-caption ds-text-secondary">
|
||||||
|
"Use the API endpoints to manage backup codes and disable 2FA."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,246 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use rustelo_core_lib::auth::Login2FARequest;
|
||||||
|
|
||||||
|
use crate::auth::context::use_auth;
|
||||||
|
use crate::utils::api_request;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiResponse<T> {
|
||||||
|
pub success: bool,
|
||||||
|
pub data: Option<T>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub errors: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TwoFactorLoginForm(
|
||||||
|
/// The email address from the first login step
|
||||||
|
email: String,
|
||||||
|
/// Whether to remember the user
|
||||||
|
remember_me: bool,
|
||||||
|
/// Callback when login is successful
|
||||||
|
#[prop(optional)]
|
||||||
|
on_success: Option<Callback<()>>,
|
||||||
|
/// Callback when user wants to go back to regular login
|
||||||
|
#[prop(optional)]
|
||||||
|
on_back: Option<Callback<()>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (code, set_code) = signal(String::new());
|
||||||
|
let (error_message, set_error_message) = signal(Option::<String>::None);
|
||||||
|
let (is_submitting, set_is_submitting) = signal(false);
|
||||||
|
let (is_backup_code, set_is_backup_code) = signal(false);
|
||||||
|
|
||||||
|
let auth_context = use_auth();
|
||||||
|
|
||||||
|
let submit_2fa = Action::new(move |request: &Login2FARequest| {
|
||||||
|
let request = request.clone();
|
||||||
|
let auth_context = auth_context.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
set_is_submitting.set(true);
|
||||||
|
set_error_message.set(None);
|
||||||
|
|
||||||
|
match api_request::<Login2FARequest, ApiResponse<rustelo_core_lib::auth::AuthResponse>>(
|
||||||
|
"/api/auth/login/2fa",
|
||||||
|
"POST",
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(response) => {
|
||||||
|
if response.success {
|
||||||
|
if let Some(auth_response) = response.data {
|
||||||
|
// Update auth context with the successful login
|
||||||
|
// Note: You'll need to implement login_success method in auth context
|
||||||
|
// auth_context.login_success(auth_response.user, auth_response.access_token);
|
||||||
|
|
||||||
|
// Call success callback if provided
|
||||||
|
if let Some(callback) = on_success {
|
||||||
|
callback(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let error_msg = response.message.unwrap_or_else(|| {
|
||||||
|
response
|
||||||
|
.errors
|
||||||
|
.map(|errs| errs.join(", "))
|
||||||
|
.unwrap_or_else(|| "Invalid 2FA code".to_string())
|
||||||
|
});
|
||||||
|
set_error_message.set(Some(error_msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_error_message.set(Some(format!("Network error: {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_is_submitting.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
let code_value = code.get().trim().to_string();
|
||||||
|
if code_value.is_empty() {
|
||||||
|
set_error_message.set(Some("Please enter your 2FA code".to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = Login2FARequest {
|
||||||
|
email: email.clone(),
|
||||||
|
code: code_value,
|
||||||
|
remember_me,
|
||||||
|
};
|
||||||
|
|
||||||
|
submit_2fa.dispatch(request);
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle_back = move |_| {
|
||||||
|
if let Some(callback) = on_back {
|
||||||
|
callback(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let toggle_backup_code = move |_| {
|
||||||
|
set_is_backup_code.set(!is_backup_code.get());
|
||||||
|
set_code.set(String::new());
|
||||||
|
set_error_message.set(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="max-w-md mx-auto ds-bg ds-rounded-lg ds-shadow-md p-ds-6">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold ds-text mb-2">
|
||||||
|
"Two-Factor Authentication"
|
||||||
|
</h1>
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
"Enter the code from your authenticator app"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Show the email being used
|
||||||
|
<div class="mb-ds-4 p-3 ds-rounded-lg">
|
||||||
|
<p class="ds-caption ds-text-secondary">
|
||||||
|
"Signing in as: "
|
||||||
|
<span class="font-medium ds-text">{email.clone()}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
{move || {
|
||||||
|
if let Some(msg) = error_message.get() {
|
||||||
|
view! {
|
||||||
|
<div class="mb-ds-4 p-3 bg-red-50 border border-red-200 ds-rounded-lg">
|
||||||
|
<p class="ds-caption ds-text">{msg}</p>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
<form on:submit=handle_submit class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block ds-caption font-medium ds-text-secondary mb-2">
|
||||||
|
{move || if is_backup_code.get() {
|
||||||
|
"Backup Code"
|
||||||
|
} else {
|
||||||
|
"Authentication Code"
|
||||||
|
}}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 border ds-border ds-rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center ds-body font-mono"
|
||||||
|
placeholder=move || if is_backup_code.get() {
|
||||||
|
"Enter backup code"
|
||||||
|
} else {
|
||||||
|
"000000"
|
||||||
|
}
|
||||||
|
maxlength=move || if is_backup_code.get() { "8" } else { "6" }
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
prop:value=code
|
||||||
|
on:input=move |ev| set_code.set(event_target_value(&ev))
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs ds-text-muted">
|
||||||
|
{move || if is_backup_code.get() {
|
||||||
|
"Use one of your 8-digit backup codes"
|
||||||
|
} else {
|
||||||
|
"Enter the 6-digit code from your authenticator app"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ds-caption text-blue-600 hover:text-blue-800 underline"
|
||||||
|
on:click=toggle_backup_code
|
||||||
|
>
|
||||||
|
{move || if is_backup_code.get() {
|
||||||
|
"Use authenticator code"
|
||||||
|
} else {
|
||||||
|
"Use backup code"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ds-caption ds-text-muted hover:ds-text-secondary underline"
|
||||||
|
on:click=handle_back
|
||||||
|
>
|
||||||
|
"Back to login"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-ds-brand-primary ds-text py-2 px-4 ds-rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled=move || is_submitting.get()
|
||||||
|
>
|
||||||
|
{move || if is_submitting.get() {
|
||||||
|
"Verifying..."
|
||||||
|
} else {
|
||||||
|
"Sign In"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
<div class="mt-ds-6 text-center">
|
||||||
|
<p class="text-xs ds-text-muted">
|
||||||
|
"Lost your device? "
|
||||||
|
<a href="/help/2fa" class="text-blue-600 hover:text-blue-800 underline">
|
||||||
|
"Contact support"
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TwoFactorLoginPage() -> impl IntoView {
|
||||||
|
// Simple implementation - in a real app you'd get these from URL params or state
|
||||||
|
let email = "user@example.com".to_string();
|
||||||
|
let remember_me = false;
|
||||||
|
|
||||||
|
let handle_back = move |_| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.location().set_href("/login");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:ds-container ">
|
||||||
|
<TwoFactorLoginForm
|
||||||
|
email=email
|
||||||
|
remember_me=remember_me
|
||||||
|
on_back=handle_back
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
101
crates/foundation/crates/rustelo_client/src/config/mod.rs
Normal file
101
crates/foundation/crates/rustelo_client/src/config/mod.rs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::config::{AppConfig, LegalConfig, LogoConfig};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfigContext {
|
||||||
|
pub config: Memo<AppConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigContext {
|
||||||
|
/// Get legal configuration
|
||||||
|
pub fn legal(&self) -> LegalConfig {
|
||||||
|
self.config.get_untracked().legal
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get legal configuration (reactive)
|
||||||
|
pub fn legal_reactive(&self) -> impl Fn() -> LegalConfig + Clone {
|
||||||
|
let config = self.config;
|
||||||
|
move || config.get().legal.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logo configuration
|
||||||
|
pub fn logo(&self) -> LogoConfig {
|
||||||
|
self.config.get_untracked().logo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logo configuration (reactive)
|
||||||
|
pub fn logo_reactive(&self) -> impl Fn() -> LogoConfig + Clone {
|
||||||
|
let config = self.config;
|
||||||
|
move || config.get().logo.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConfigProvider(children: leptos::prelude::Children) -> impl IntoView {
|
||||||
|
// Load configuration safely for SSR and client contexts
|
||||||
|
let config = Memo::new(move |_| {
|
||||||
|
// Always use explicit hardcoded logo paths to ensure SSR/client consistency
|
||||||
|
// These should match the values in .env file to avoid hydration mismatches
|
||||||
|
AppConfig {
|
||||||
|
logo: LogoConfig {
|
||||||
|
light_path: String::from(""),
|
||||||
|
dark_path: String::from(""),
|
||||||
|
icon_path: String::from(""),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let context = ConfigContext { config };
|
||||||
|
|
||||||
|
provide_context(context);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{children()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UseConfig(pub ConfigContext);
|
||||||
|
|
||||||
|
impl Default for UseConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UseConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(expect_context::<ConfigContext>())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get legal configuration
|
||||||
|
pub fn legal(&self) -> LegalConfig {
|
||||||
|
self.0.legal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get legal configuration (reactive)
|
||||||
|
pub fn legal_reactive(&self) -> impl Fn() -> LegalConfig + Clone {
|
||||||
|
self.0.legal_reactive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logo configuration
|
||||||
|
pub fn logo(&self) -> LogoConfig {
|
||||||
|
self.0.logo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get logo configuration (reactive)
|
||||||
|
pub fn logo_reactive(&self) -> impl Fn() -> LogoConfig + Clone {
|
||||||
|
self.0.logo_reactive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get contact email
|
||||||
|
pub fn contact_email(&self) -> String {
|
||||||
|
self.0.config.get_untracked().legal.contact_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook to use configuration
|
||||||
|
pub fn use_config() -> UseConfig {
|
||||||
|
UseConfig::new()
|
||||||
|
}
|
||||||
@ -0,0 +1,512 @@
|
|||||||
|
//! Admin Integration Example
|
||||||
|
//!
|
||||||
|
//! Demonstrates configuration-driven admin functionality integration.
|
||||||
|
//! Shows how admin features are generated from TOML configuration rather than hardcoded.
|
||||||
|
|
||||||
|
use rustelo_client::{hydrate_with_config, ClientConfig, AdminConfig};
|
||||||
|
|
||||||
|
/// Configuration-Driven Admin Integration
|
||||||
|
///
|
||||||
|
/// This example shows how admin functionality is generated from TOML configuration
|
||||||
|
/// rather than manually creating admin routes and components.
|
||||||
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
println!("🎯 RUSTELO CLIENT ADMIN INTEGRATION EXAMPLE");
|
||||||
|
println!("===========================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("This example demonstrates how admin functionality is generated");
|
||||||
|
println!("from TOML configuration rather than manual component creation.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_toml_admin_config();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_generated_admin_functionality();
|
||||||
|
println!();
|
||||||
|
|
||||||
|
demonstrate_admin_extensions();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 1: TOML Configuration → Admin Features
|
||||||
|
///
|
||||||
|
/// Shows how admin functionality is defined in configuration files
|
||||||
|
fn demonstrate_toml_admin_config() {
|
||||||
|
println!("📋 STEP 1: TOML CONFIGURATION → ADMIN FEATURES");
|
||||||
|
println!("==============================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📝 File: site/config/admin.toml");
|
||||||
|
let admin_config = r#"
|
||||||
|
[admin]
|
||||||
|
enabled = true
|
||||||
|
base_path = "/admin"
|
||||||
|
require_auth = true
|
||||||
|
require_role = "admin"
|
||||||
|
|
||||||
|
[admin.features]
|
||||||
|
user_management = true
|
||||||
|
content_management = true
|
||||||
|
system_monitoring = true
|
||||||
|
analytics_dashboard = true
|
||||||
|
configuration_editor = false
|
||||||
|
|
||||||
|
[admin.routes]
|
||||||
|
dashboard = { path = "/admin/dashboard", component = "AdminDashboard" }
|
||||||
|
users = { path = "/admin/users", component = "UserManagement" }
|
||||||
|
content = { path = "/admin/content", component = "ContentManagement" }
|
||||||
|
monitoring = { path = "/admin/monitoring", component = "SystemMonitoring" }
|
||||||
|
analytics = { path = "/admin/analytics", component = "AnalyticsDashboard" }
|
||||||
|
|
||||||
|
[admin.permissions]
|
||||||
|
user_management = ["admin", "user_manager"]
|
||||||
|
content_management = ["admin", "content_editor"]
|
||||||
|
system_monitoring = ["admin", "system_operator"]
|
||||||
|
analytics_dashboard = ["admin", "analyst"]
|
||||||
|
|
||||||
|
[admin.theme]
|
||||||
|
layout = "admin_sidebar"
|
||||||
|
color_scheme = "dark"
|
||||||
|
navigation_style = "collapsible"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", admin_config);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("⚙️ Generated Admin Features:");
|
||||||
|
println!(" • AdminProvider (auth + role checking)");
|
||||||
|
println!(" • AdminRouter (5 configured routes)");
|
||||||
|
println!(" • AdminLayout (dark theme, collapsible nav)");
|
||||||
|
println!(" • Permission guards (role-based access)");
|
||||||
|
println!(" • Admin components (generated from routes)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2: Generated Admin Functionality
|
||||||
|
///
|
||||||
|
/// Shows what gets generated automatically from the admin TOML configuration
|
||||||
|
fn demonstrate_generated_admin_functionality() {
|
||||||
|
println!("🏗️ STEP 2: GENERATED ADMIN FUNCTIONALITY");
|
||||||
|
println!("=======================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("📦 Generated Admin Code (from admin.toml):");
|
||||||
|
|
||||||
|
let generated_example = r#"
|
||||||
|
// Generated from your admin.toml configuration
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate_admin_app() {
|
||||||
|
let admin_config = AdminConfig::from_toml();
|
||||||
|
|
||||||
|
// Only generate admin features if enabled in config
|
||||||
|
if admin_config.enabled {
|
||||||
|
let client_config = ClientConfig::builder()
|
||||||
|
.with_admin_features(admin_config)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
rustelo_client::hydrate_with_config(client_config);
|
||||||
|
} else {
|
||||||
|
// No admin features - regular hydration only
|
||||||
|
rustelo_client::hydrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated admin provider from configuration
|
||||||
|
#[component]
|
||||||
|
pub fn GeneratedAdminProvider(children: Children) -> impl IntoView {
|
||||||
|
let auth = use_auth_state();
|
||||||
|
|
||||||
|
// Permission checking based on admin.toml config
|
||||||
|
let has_admin_access = create_memo(move |_| {
|
||||||
|
let user = auth.current_user();
|
||||||
|
user.map(|u| admin_config.check_permission(&u.role))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<AdminContextProvider has_access=has_admin_access>
|
||||||
|
{children()}
|
||||||
|
</AdminContextProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generated admin routing from admin.routes configuration
|
||||||
|
fn generate_admin_routes() -> Vec<AdminRoute> {
|
||||||
|
vec![
|
||||||
|
AdminRoute::new("/admin/dashboard", AdminDashboard, vec!["admin"]),
|
||||||
|
AdminRoute::new("/admin/users", UserManagement, vec!["admin", "user_manager"]),
|
||||||
|
AdminRoute::new("/admin/content", ContentManagement, vec!["admin", "content_editor"]),
|
||||||
|
AdminRoute::new("/admin/monitoring", SystemMonitoring, vec!["admin", "system_operator"]),
|
||||||
|
AdminRoute::new("/admin/analytics", AnalyticsDashboard, vec!["admin", "analyst"]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", generated_example);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("🎯 Client Hydration with Admin Features:");
|
||||||
|
let client_usage = r#"
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
// Admin functionality generated from TOML configuration
|
||||||
|
let config = ClientConfig::from_generated_admin_config();
|
||||||
|
|
||||||
|
// Hydrate with admin features if user has permissions
|
||||||
|
rustelo_client::hydrate_with_config(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in your application components
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
// Generated admin provider handles all permission logic
|
||||||
|
<GeneratedAdminProvider>
|
||||||
|
// Regular app content
|
||||||
|
<MainContent />
|
||||||
|
|
||||||
|
// Admin features automatically available if configured
|
||||||
|
<AdminNavigation /> // Only shows if user has admin role
|
||||||
|
<AdminDashboard /> // Only accessible with proper permissions
|
||||||
|
</GeneratedAdminProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", client_usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 3: Admin Extensions (10% Custom Code)
|
||||||
|
///
|
||||||
|
/// Shows how to extend generated admin functionality with custom features
|
||||||
|
fn demonstrate_admin_extensions() {
|
||||||
|
println!("🔧 STEP 3: ADMIN EXTENSIONS (10% CUSTOM)");
|
||||||
|
println!("========================================");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("For complex admin requirements beyond TOML configuration:");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let extension_example = r#"
|
||||||
|
// Custom admin extension for complex business logic
|
||||||
|
use rustelo_client::{AdminExtension, CustomAdminFeatures};
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn hydrate_with_custom_admin() {
|
||||||
|
// Foundation provides 90% from admin.toml
|
||||||
|
let base_config = ClientConfig::from_admin_toml();
|
||||||
|
|
||||||
|
// Add 10% custom admin functionality
|
||||||
|
let extended_config = base_config
|
||||||
|
.extend_with_custom_admin_features(CustomAdminFeatures {
|
||||||
|
advanced_user_analytics: AdvancedUserAnalytics::new(),
|
||||||
|
real_time_monitoring: RealTimeSystemMonitoring::new(),
|
||||||
|
custom_reporting: CustomReportingDashboard::new(),
|
||||||
|
audit_trail_visualization: AuditTrailVisualizer::new(),
|
||||||
|
})
|
||||||
|
.with_custom_admin_layout(CustomAdminLayout::new())
|
||||||
|
.with_advanced_permissions(CustomPermissionEngine::new());
|
||||||
|
|
||||||
|
rustelo_client::hydrate_with_config(extended_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom admin component that extends generated functionality
|
||||||
|
#[component]
|
||||||
|
pub fn AdvancedAdminDashboard() -> impl IntoView {
|
||||||
|
// Use generated base admin component (90%)
|
||||||
|
let base_dashboard = rustelo_client::get_generated_admin_component("AdminDashboard");
|
||||||
|
|
||||||
|
// Add custom real-time features (10%)
|
||||||
|
let (realtime_metrics, set_realtime_metrics) = create_signal(Vec::new());
|
||||||
|
let (system_alerts, set_system_alerts) = create_signal(Vec::new());
|
||||||
|
|
||||||
|
// Custom WebSocket connection for real-time admin data
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Ok(admin_stream) = connect_to_admin_monitoring().await {
|
||||||
|
while let Some(update) = admin_stream.next().await {
|
||||||
|
match update {
|
||||||
|
AdminUpdate::SystemMetrics(metrics) => {
|
||||||
|
set_realtime_metrics.set(metrics);
|
||||||
|
}
|
||||||
|
AdminUpdate::SecurityAlert(alert) => {
|
||||||
|
set_system_alerts.update(|alerts| alerts.push(alert));
|
||||||
|
}
|
||||||
|
AdminUpdate::UserActivity(activity) => {
|
||||||
|
update_user_activity_dashboard(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom admin audit trail
|
||||||
|
create_effect(move |_| {
|
||||||
|
spawn_local(async move {
|
||||||
|
// Track admin actions for compliance
|
||||||
|
if let Ok(actions) = fetch_admin_audit_trail().await {
|
||||||
|
update_audit_visualization(actions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="advanced-admin-dashboard">
|
||||||
|
// Base admin functionality (90% from configuration)
|
||||||
|
{base_dashboard}
|
||||||
|
|
||||||
|
// Custom extensions (10% when configuration isn't sufficient)
|
||||||
|
<RealTimeSystemMonitoring metrics=realtime_metrics />
|
||||||
|
<SecurityAlertsPanel alerts=system_alerts />
|
||||||
|
<AdvancedUserAnalytics />
|
||||||
|
<AuditTrailVisualizer />
|
||||||
|
<CustomReportingTools />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom admin permission system for complex requirements
|
||||||
|
#[component]
|
||||||
|
pub fn CustomAdminGuard(
|
||||||
|
required_permissions: Vec<String>,
|
||||||
|
children: Children
|
||||||
|
) -> impl IntoView {
|
||||||
|
let auth = use_auth_state();
|
||||||
|
let admin_context = use_admin_context();
|
||||||
|
|
||||||
|
// Complex permission logic that can't be expressed in TOML
|
||||||
|
let has_permission = create_memo(move |_| {
|
||||||
|
let user = auth.current_user()?;
|
||||||
|
let admin_data = admin_context.get_admin_data()?;
|
||||||
|
|
||||||
|
// Custom business logic for permissions
|
||||||
|
check_complex_admin_permissions(&user, &admin_data, &required_permissions)
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{move || {
|
||||||
|
if has_permission.get() {
|
||||||
|
children().into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="access-denied">
|
||||||
|
<h3>"Access Denied"</h3>
|
||||||
|
<p>"Insufficient permissions for this admin feature."</p>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build-time extension for generating custom admin code
|
||||||
|
#[cfg(feature = "build-extensions")]
|
||||||
|
pub struct CustomAdminExtension;
|
||||||
|
|
||||||
|
impl AdminBuildExtension for CustomAdminExtension {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"custom-admin-features"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_admin_code(&self, admin_config: &AdminConfig) -> Result<String, String> {
|
||||||
|
let custom_code = format!(r#"
|
||||||
|
// Generated custom admin integration
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn initialize_custom_admin_features() {{
|
||||||
|
setup_admin_websocket_connection("{}");
|
||||||
|
register_admin_keyboard_shortcuts();
|
||||||
|
initialize_admin_audit_logging();
|
||||||
|
setup_admin_theme_customization();
|
||||||
|
}}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn get_admin_feature_config() -> JsValue {{
|
||||||
|
let config = CustomAdminConfig {{
|
||||||
|
realtime_monitoring: {},
|
||||||
|
advanced_analytics: {},
|
||||||
|
audit_trail: {},
|
||||||
|
custom_reporting: {},
|
||||||
|
}};
|
||||||
|
|
||||||
|
serde_wasm_bindgen::to_value(&config).unwrap()
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
admin_config.websocket_endpoint.unwrap_or("ws://localhost:8080/admin".to_string()),
|
||||||
|
admin_config.features.contains("realtime_monitoring"),
|
||||||
|
admin_config.features.contains("advanced_analytics"),
|
||||||
|
admin_config.features.contains("audit_trail"),
|
||||||
|
admin_config.features.contains("custom_reporting")
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(custom_code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
println!("{}", extension_example);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("💡 Key Admin Integration Principles:");
|
||||||
|
println!(" • Admin features defined in admin.toml configuration (90%)");
|
||||||
|
println!(" • Automatic generation of admin routes, components, and permissions");
|
||||||
|
println!(" • Role-based access control from configuration");
|
||||||
|
println!(" • Admin theme and layout from configuration");
|
||||||
|
println!(" • Custom extensions for complex admin requirements (10%)");
|
||||||
|
println!(" • Build-time generation of admin-specific code");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("⚖️ When to Use Admin Extensions:");
|
||||||
|
println!(" ✅ Real-time monitoring and alerts");
|
||||||
|
println!(" ✅ Advanced analytics and reporting");
|
||||||
|
println!(" ✅ Custom audit trail visualization");
|
||||||
|
println!(" ✅ Complex permission systems");
|
||||||
|
println!(" ✅ Third-party admin tool integration");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
println!("❌ When NOT to Use Extensions:");
|
||||||
|
println!(" ❌ Basic CRUD operations (use configuration)");
|
||||||
|
println!(" ❌ Standard admin navigation (use admin.toml)");
|
||||||
|
println!(" ❌ Simple role-based permissions (use configuration)");
|
||||||
|
println!(" ❌ Basic admin theming (use admin.theme in TOML)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supporting types and mock implementations for the example
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AdminConfig {
|
||||||
|
enabled: bool,
|
||||||
|
base_path: String,
|
||||||
|
require_auth: bool,
|
||||||
|
require_role: String,
|
||||||
|
features: std::collections::HashSet<String>,
|
||||||
|
websocket_endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminConfig {
|
||||||
|
fn from_toml() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
base_path: "/admin".to_string(),
|
||||||
|
require_auth: true,
|
||||||
|
require_role: "admin".to_string(),
|
||||||
|
features: ["user_management", "content_management", "system_monitoring", "analytics_dashboard"]
|
||||||
|
.iter().map(|s| s.to_string()).collect(),
|
||||||
|
websocket_endpoint: Some("ws://localhost:8080/admin".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_permission(&self, role: &str) -> bool {
|
||||||
|
role == "admin" || role == "system_operator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AdminRoute {
|
||||||
|
path: String,
|
||||||
|
component: String,
|
||||||
|
required_roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminRoute {
|
||||||
|
fn new(path: &str, component: impl ToString, roles: Vec<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
path: path.to_string(),
|
||||||
|
component: component.to_string(),
|
||||||
|
required_roles: roles.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock component names for the example
|
||||||
|
struct AdminDashboard;
|
||||||
|
struct UserManagement;
|
||||||
|
struct ContentManagement;
|
||||||
|
struct SystemMonitoring;
|
||||||
|
struct AnalyticsDashboard;
|
||||||
|
|
||||||
|
impl ToString for AdminDashboard { fn to_string(&self) -> String { "AdminDashboard".to_string() }}
|
||||||
|
impl ToString for UserManagement { fn to_string(&self) -> String { "UserManagement".to_string() }}
|
||||||
|
impl ToString for ContentManagement { fn to_string(&self) -> String { "ContentManagement".to_string() }}
|
||||||
|
impl ToString for SystemMonitoring { fn to_string(&self) -> String { "SystemMonitoring".to_string() }}
|
||||||
|
impl ToString for AnalyticsDashboard { fn to_string(&self) -> String { "AnalyticsDashboard".to_string() }}
|
||||||
|
|
||||||
|
// Mock structures for custom extensions
|
||||||
|
struct CustomAdminFeatures {
|
||||||
|
advanced_user_analytics: AdvancedUserAnalytics,
|
||||||
|
real_time_monitoring: RealTimeSystemMonitoring,
|
||||||
|
custom_reporting: CustomReportingDashboard,
|
||||||
|
audit_trail_visualization: AuditTrailVisualizer,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdvancedUserAnalytics;
|
||||||
|
struct RealTimeSystemMonitoring;
|
||||||
|
struct CustomReportingDashboard;
|
||||||
|
struct AuditTrailVisualizer;
|
||||||
|
struct CustomAdminLayout;
|
||||||
|
struct CustomPermissionEngine;
|
||||||
|
|
||||||
|
impl AdvancedUserAnalytics { fn new() -> Self { Self }}
|
||||||
|
impl RealTimeSystemMonitoring { fn new() -> Self { Self }}
|
||||||
|
impl CustomReportingDashboard { fn new() -> Self { Self }}
|
||||||
|
impl AuditTrailVisualizer { fn new() -> Self { Self }}
|
||||||
|
impl CustomAdminLayout { fn new() -> Self { Self }}
|
||||||
|
impl CustomPermissionEngine { fn new() -> Self { Self }}
|
||||||
|
|
||||||
|
// Mock types for admin updates
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
enum AdminUpdate {
|
||||||
|
SystemMetrics(Vec<SystemMetric>),
|
||||||
|
SecurityAlert(SecurityAlert),
|
||||||
|
UserActivity(UserActivity),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SystemMetric {
|
||||||
|
name: String,
|
||||||
|
value: f64,
|
||||||
|
timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SecurityAlert {
|
||||||
|
severity: String,
|
||||||
|
message: String,
|
||||||
|
timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct UserActivity {
|
||||||
|
user_id: String,
|
||||||
|
action: String,
|
||||||
|
timestamp: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct CustomAdminConfig {
|
||||||
|
realtime_monitoring: bool,
|
||||||
|
advanced_analytics: bool,
|
||||||
|
audit_trail: bool,
|
||||||
|
custom_reporting: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock function implementations
|
||||||
|
async fn connect_to_admin_monitoring() -> Result<AdminStream, String> { Ok(AdminStream) }
|
||||||
|
async fn fetch_admin_audit_trail() -> Result<Vec<AdminAction>, String> { Ok(vec![]) }
|
||||||
|
fn update_audit_visualization(_actions: Vec<AdminAction>) {}
|
||||||
|
fn update_user_activity_dashboard(_activity: UserActivity) {}
|
||||||
|
fn check_complex_admin_permissions(_user: &User, _admin_data: &AdminData, _permissions: &[String]) -> Option<bool> { Some(true) }
|
||||||
|
|
||||||
|
struct AdminStream;
|
||||||
|
impl AdminStream {
|
||||||
|
async fn next(&mut self) -> Option<AdminUpdate> { None }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdminAction;
|
||||||
|
struct User;
|
||||||
|
struct AdminData;
|
||||||
|
|
||||||
|
trait AdminBuildExtension {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn generate_admin_code(&self, admin_config: &AdminConfig) -> Result<String, String>;
|
||||||
|
}
|
||||||
115
crates/foundation/crates/rustelo_client/src/highlight.rs
Normal file
115
crates/foundation/crates/rustelo_client/src/highlight.rs
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
use leptos::children::Children;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = window)]
|
||||||
|
fn highlightCode();
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_namespace = window)]
|
||||||
|
fn highlightContainer(container: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utility functions for managing highlight.js in dynamic content
|
||||||
|
pub struct HighlightManager;
|
||||||
|
|
||||||
|
impl HighlightManager {
|
||||||
|
/// Trigger highlighting for all new, unhighlighted code blocks
|
||||||
|
pub fn highlight_all() {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
highlightCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger highlighting for code blocks within a specific container
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn highlight_container(selector: &str) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
highlightContainer(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a custom event to trigger highlighting
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn dispatch_highlight_event(container: Option<&str>) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Simplified version - just trigger highlighting without custom events
|
||||||
|
if let Some(container_sel) = container {
|
||||||
|
Self::highlight_container(container_sel);
|
||||||
|
} else {
|
||||||
|
Self::highlight_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leptos effect to trigger highlighting when content changes
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn use_highlight_effect<F>(content_signal: F)
|
||||||
|
where
|
||||||
|
F: Fn() -> String + 'static,
|
||||||
|
{
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _content = content_signal();
|
||||||
|
// Small delay to ensure DOM is updated
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
HighlightManager::highlight_all();
|
||||||
|
},
|
||||||
|
std::time::Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook for components that load dynamic content with code
|
||||||
|
#[component]
|
||||||
|
pub fn WithHighlighting(
|
||||||
|
children: Children,
|
||||||
|
#[prop(optional)] container_id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let container_class = if container_id.is_empty() {
|
||||||
|
"highlight-container dynamic-content".to_string()
|
||||||
|
} else {
|
||||||
|
format!("highlight-container {container_id}")
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let container_class_for_effect = container_class.clone();
|
||||||
|
|
||||||
|
// Effect to highlight content after mount
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let container_selector = format!(".{}", container_class_for_effect);
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
HighlightManager::highlight_container(&container_selector);
|
||||||
|
},
|
||||||
|
std::time::Duration::from_millis(50),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=container_class>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_highlight_manager_methods() {
|
||||||
|
// These functions should not panic when called
|
||||||
|
HighlightManager::highlight_all();
|
||||||
|
HighlightManager::highlight_container(".test");
|
||||||
|
HighlightManager::dispatch_highlight_event(Some(".test"));
|
||||||
|
HighlightManager::dispatch_highlight_event(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
621
crates/foundation/crates/rustelo_client/src/i18n.rs
Normal file
621
crates/foundation/crates/rustelo_client/src/i18n.rs
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
// Client-side i18n system (WASM only)
|
||||||
|
// This module provides reactive translation functionality for client-side hydration
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::{load_texts_from_ftl, Texts};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
// wasm_bindgen import removed - not needed for basic types
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Language {
|
||||||
|
pub code: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub is_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
pub fn code(&self) -> &str {
|
||||||
|
&self.code
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
&self.display_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_code(code: &str) -> Self {
|
||||||
|
let language_registry = rustelo_core_lib::i18n::language_config::get_language_registry();
|
||||||
|
let available_languages = language_registry.get_available_languages();
|
||||||
|
let default_language = language_registry.get_default_language();
|
||||||
|
|
||||||
|
// Debug logging for language registry
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 Language registry - looking for '{}', available: {:?}, default: '{}'",
|
||||||
|
code, available_languages, default_language
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the language in the registry
|
||||||
|
if let Some(lang_info) = language_registry.get_language(code) {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"✅ Found language config for '{}': {}",
|
||||||
|
code, lang_info.native_name
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
Language {
|
||||||
|
code: code.to_string(),
|
||||||
|
display_name: lang_info.native_name.clone(),
|
||||||
|
is_default: code == default_language,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to default language
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"❌ Language '{}' not found in registry, falling back to default '{}'",
|
||||||
|
code, default_language
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
if let Some(default_info) = language_registry.get_language(default_language) {
|
||||||
|
Language {
|
||||||
|
code: default_language.to_string(),
|
||||||
|
display_name: default_info.native_name.clone(),
|
||||||
|
is_default: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ultimate fallback
|
||||||
|
Language {
|
||||||
|
code: "en".to_string(),
|
||||||
|
display_name: "English".to_string(),
|
||||||
|
is_default: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all() -> Vec<Language> {
|
||||||
|
let language_registry = rustelo_core_lib::i18n::language_config::get_language_registry();
|
||||||
|
let _available_languages = language_registry.get_available_languages();
|
||||||
|
let default_language = language_registry.get_default_language();
|
||||||
|
|
||||||
|
_available_languages
|
||||||
|
.iter()
|
||||||
|
.map(|lang_code| {
|
||||||
|
if let Some(lang_info) = language_registry.get_language(lang_code) {
|
||||||
|
Language {
|
||||||
|
code: lang_code.to_string(),
|
||||||
|
display_name: lang_info.native_name.clone(),
|
||||||
|
is_default: lang_code == default_language,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Language {
|
||||||
|
code: lang_code.to_string(),
|
||||||
|
display_name: lang_code.to_string(),
|
||||||
|
is_default: lang_code == default_language,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Language {
|
||||||
|
fn default() -> Self {
|
||||||
|
let language_registry = rustelo_core_lib::i18n::language_config::get_language_registry();
|
||||||
|
let default_language = language_registry.get_default_language();
|
||||||
|
Language::from_code(default_language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct I18nContext {
|
||||||
|
pub language: ReadSignal<Language>,
|
||||||
|
pub set_language: WriteSignal<Language>,
|
||||||
|
pub texts: Memo<Texts>,
|
||||||
|
pub hydration_synced: ReadSignal<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl I18nContext {
|
||||||
|
/// Get translated text (non-reactive version)
|
||||||
|
pub fn t(&self, key: &str, _args: Option<&HashMap<&str, &str>>) -> String {
|
||||||
|
// Use get_untracked to avoid reactivity tracking in non-reactive contexts
|
||||||
|
let texts = self.texts.get_untracked();
|
||||||
|
let binding = self.language.get_untracked();
|
||||||
|
let lang_code = binding.code();
|
||||||
|
|
||||||
|
let translations = texts.get_translations_for_language(lang_code);
|
||||||
|
|
||||||
|
let result = translations.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
if translations.is_empty() {
|
||||||
|
error!(
|
||||||
|
"No translations loaded at all for language: '{}'",
|
||||||
|
lang_code
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"Missing translation key: '{}' for language: '{}' (total keys loaded: {})",
|
||||||
|
key,
|
||||||
|
lang_code,
|
||||||
|
translations.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the key as fallback, prefixed to indicate missing translation
|
||||||
|
format!("[{key}]")
|
||||||
|
});
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text (reactive version for use in memos)
|
||||||
|
pub fn t_reactive_memo(&self, key: &str) -> String {
|
||||||
|
// Use reactive get() to track language and text changes
|
||||||
|
let texts = self.texts.get();
|
||||||
|
let binding = self.language.get();
|
||||||
|
let lang_code = binding.code();
|
||||||
|
|
||||||
|
let translations = texts.get_translations_for_language(lang_code);
|
||||||
|
|
||||||
|
translations.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
"Missing translation key: '{}' for language: '{}'",
|
||||||
|
key, lang_code
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the key as fallback, prefixed to indicate missing translation
|
||||||
|
format!("[{key}]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text (reactive version) - returns a reactive closure
|
||||||
|
pub fn t_reactive(&self, key: &'static str) -> impl Fn() -> String + Clone {
|
||||||
|
let texts = self.texts;
|
||||||
|
let language = self.language;
|
||||||
|
move || {
|
||||||
|
let texts = texts.get();
|
||||||
|
let binding = language.get();
|
||||||
|
let lang_code = binding.code();
|
||||||
|
|
||||||
|
let translations = texts.get_translations_for_language(lang_code);
|
||||||
|
|
||||||
|
translations.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
"Missing translation key: '{}' for language: '{}'",
|
||||||
|
key, lang_code
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the key as fallback, prefixed to indicate missing translation
|
||||||
|
format!("[{key}]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text as a reactive signal - proper reactive solution
|
||||||
|
pub fn t_signal(&self, key: &str) -> String {
|
||||||
|
// Simplified: just return the current translation without memo
|
||||||
|
self.t(key, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current language code (non-reactive)
|
||||||
|
pub fn current_lang(&self) -> String {
|
||||||
|
self.language.get_untracked().code().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current language code (reactive) - returns a Signal for proper reactivity
|
||||||
|
pub fn current_lang_reactive(&self) -> Memo<String> {
|
||||||
|
let language = self.language;
|
||||||
|
Memo::new(move |_| language.get().code().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if current language is specific language (non-reactive)
|
||||||
|
pub fn is_language(&self, lang: Language) -> bool {
|
||||||
|
self.language.get_untracked() == lang
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if current language is specific language (reactive)
|
||||||
|
pub fn is_language_reactive(&self, lang: Language) -> Memo<bool> {
|
||||||
|
let language = self.language;
|
||||||
|
Memo::new(move |_| language.get() == lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper functions for cookie-based language persistence
|
||||||
|
pub fn get_stored_language() -> Option<Language> {
|
||||||
|
// Use JavaScript evaluation to read cookies since web_sys Document API is limited
|
||||||
|
match js_sys::eval("document.cookie") {
|
||||||
|
Ok(cookie_value) => {
|
||||||
|
if let Some(cookie_string) = cookie_value.as_string() {
|
||||||
|
// Parse cookies to find preferred_language
|
||||||
|
for cookie in cookie_string.split(';') {
|
||||||
|
let cookie = cookie.trim();
|
||||||
|
if let Some(value) = cookie.strip_prefix("preferred_language=") {
|
||||||
|
return Some(Language::from_code(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
web_sys::console::log_1(&"Failed to read cookies".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_language(language: &Language) {
|
||||||
|
if let Some(_window) = web_sys::window() {
|
||||||
|
// Use only cookies for consistent SSR/client behavior
|
||||||
|
// Set cookie with 1 year expiration
|
||||||
|
let cookie_script = format!(
|
||||||
|
"document.cookie = 'preferred_language={}; path=/; max-age={}; SameSite=Lax'",
|
||||||
|
language.code(),
|
||||||
|
365 * 24 * 60 * 60 // 1 year in seconds
|
||||||
|
);
|
||||||
|
let _ = js_sys::eval(&cookie_script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn I18nProvider(children: leptos::prelude::Children) -> impl IntoView {
|
||||||
|
// Initialize language with priority: cookies > URL detection > default
|
||||||
|
let initial_language = {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
// PRIORITY 1: Check cookies first (user preference, works for SSR)
|
||||||
|
if let Some(stored_lang) = get_stored_language() {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🌐 Using cookie language preference: '{}'",
|
||||||
|
stored_lang.display_name()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
stored_lang
|
||||||
|
} else if let Ok(pathname) = window.location().pathname() {
|
||||||
|
// PRIORITY 2: Use URL-based detection if no cookie preference
|
||||||
|
let (detected_lang, _) = crate::utils::extract_lang_and_path(&pathname);
|
||||||
|
let detected_language = Language::from_code(&detected_lang);
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🌐 No cookie preference, detected language '{}' ({}) from URL: {}",
|
||||||
|
detected_lang,
|
||||||
|
detected_language.display_name(),
|
||||||
|
pathname
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
detected_language
|
||||||
|
} else {
|
||||||
|
// PRIORITY 3: Default fallback
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"🌐 No cookie preference or URL detection, defaulting to English".into(),
|
||||||
|
);
|
||||||
|
Language::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Language::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug the initial language setting
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🌐 I18nProvider initialized with language: {initial_language:?}").into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (language, set_language) = signal(initial_language);
|
||||||
|
|
||||||
|
// Track if we've synced the language after hydration
|
||||||
|
let (hydration_synced, set_hydration_synced) = signal(false); // Start as not synced, will sync after DOM ready
|
||||||
|
|
||||||
|
// Mark as hydrated after DOM is ready
|
||||||
|
{
|
||||||
|
gloo_timers::callback::Timeout::new(200, move || {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"🌐 I18n hydration complete, enabling reactive NavMenu".into(),
|
||||||
|
);
|
||||||
|
set_hydration_synced.set(true);
|
||||||
|
})
|
||||||
|
.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side language detection and redirect handles initial language preference via cookies
|
||||||
|
// Client-side language sync happens only during SPA navigation through LanguageSelector
|
||||||
|
// No automatic URL-based language detection to avoid overriding user preferences
|
||||||
|
|
||||||
|
// Load texts from FTL files - reactive to language changes, with fallback to emergency hardcoded translations
|
||||||
|
let texts = Memo::new(move |_| {
|
||||||
|
// Make this reactive to language changes
|
||||||
|
let current_lang = language.get(); // Reactive on client
|
||||||
|
let lang_code = current_lang.code();
|
||||||
|
|
||||||
|
match load_texts_from_ftl(lang_code) {
|
||||||
|
Ok(texts) => {
|
||||||
|
debug!(
|
||||||
|
"Successfully loaded translations for language {}: {} total languages with keys: {:?}",
|
||||||
|
lang_code,
|
||||||
|
texts.translations.len(),
|
||||||
|
texts.translations.iter().map(|(lang, keys)| format!("{}: {}", lang, keys.len())).collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
texts
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(
|
||||||
|
"Failed to load texts from FTL for language {}: {}, returning empty translations",
|
||||||
|
lang_code, e
|
||||||
|
);
|
||||||
|
Texts::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let context = I18nContext {
|
||||||
|
language,
|
||||||
|
set_language,
|
||||||
|
texts,
|
||||||
|
hydration_synced,
|
||||||
|
};
|
||||||
|
|
||||||
|
provide_context(context);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{children()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct UseI18n(pub I18nContext);
|
||||||
|
|
||||||
|
impl Default for UseI18n {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UseI18n {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// During SSR, provide a minimal fallback context to avoid panics
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// Try to get context first, if available
|
||||||
|
if let Some(context) = use_context::<I18nContext>() {
|
||||||
|
return Self(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for SSR when no context is provided
|
||||||
|
let (language, set_language) = signal(Language::default());
|
||||||
|
let (hydration_synced, _) = signal(true);
|
||||||
|
|
||||||
|
// Use static texts without Memo to avoid reactive context issues during SSR
|
||||||
|
let static_texts = load_texts_from_ftl("en").unwrap_or_default();
|
||||||
|
let (texts_signal, _) = signal(static_texts);
|
||||||
|
let texts = Memo::new(move |_| texts_signal.get());
|
||||||
|
|
||||||
|
let fallback_context = I18nContext {
|
||||||
|
language,
|
||||||
|
set_language,
|
||||||
|
texts,
|
||||||
|
hydration_synced,
|
||||||
|
};
|
||||||
|
Self(fallback_context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On client-side, use the real context
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Try to get context first - if it fails, we need to provide a fallback
|
||||||
|
if let Some(context) = use_context::<I18nContext>() {
|
||||||
|
// Debug when components access i18n context
|
||||||
|
// web_sys::console::log_1(
|
||||||
|
// &format!(
|
||||||
|
// "🌐 Component accessing i18n context, current language: {:?}",
|
||||||
|
// context.language.get_untracked()
|
||||||
|
// )
|
||||||
|
// .into(),
|
||||||
|
// );
|
||||||
|
Self(context)
|
||||||
|
} else {
|
||||||
|
// Create a minimal fallback context without memos
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&"⚠️ No I18nProvider found, creating fallback context".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (language, set_language) = signal(Language::default());
|
||||||
|
let (hydration_synced, _) = signal(false);
|
||||||
|
|
||||||
|
// Use static texts without complex reactive logic for fallback
|
||||||
|
let static_texts = load_texts_from_ftl("en").unwrap_or_default();
|
||||||
|
let (texts_signal, _) = signal(static_texts);
|
||||||
|
let texts = Memo::new(move |_| texts_signal.get());
|
||||||
|
|
||||||
|
let fallback_context = I18nContext {
|
||||||
|
language,
|
||||||
|
set_language,
|
||||||
|
texts,
|
||||||
|
hydration_synced,
|
||||||
|
};
|
||||||
|
Self(fallback_context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a localized URL based on current language and path
|
||||||
|
pub fn localized_url(&self, path: &str) -> String {
|
||||||
|
let lang_code = self.lang_code();
|
||||||
|
if lang_code == "es" && !path.starts_with("/es") {
|
||||||
|
if path == "/" {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
format!("/es{path}")
|
||||||
|
}
|
||||||
|
} else if lang_code == "en" && path.starts_with("/es") {
|
||||||
|
if path == "/es" {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
path.strip_prefix("/es").unwrap_or("/").to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get clean path without language prefix
|
||||||
|
pub fn clean_path(&self, path: &str) -> String {
|
||||||
|
if path.starts_with("/es/") {
|
||||||
|
path.strip_prefix("/es").unwrap_or("/").to_string()
|
||||||
|
} else if path == "/es" {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text
|
||||||
|
pub fn t(&self, key: &str) -> String {
|
||||||
|
self.0.t(key, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text with arguments
|
||||||
|
pub fn t_with_args(&self, key: &str, args: &HashMap<&str, &str>) -> String {
|
||||||
|
self.0.t(key, Some(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text (reactive version) - returns a reactive closure
|
||||||
|
pub fn t_reactive(&self, key: &'static str) -> impl Fn() -> String + Clone {
|
||||||
|
self.0.t_reactive(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text as a reactive signal - proper reactive solution
|
||||||
|
pub fn t_signal(&self, key: &str) -> String {
|
||||||
|
self.0.t_signal(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get translated text for use in reactive contexts (moves)
|
||||||
|
/// Usage: {move || i18n.tr("key")}
|
||||||
|
pub fn tr(&self, key: &str) -> String {
|
||||||
|
let texts = self.0.texts.get_untracked();
|
||||||
|
let binding = self.0.language.get_untracked();
|
||||||
|
let lang_code = binding.code();
|
||||||
|
|
||||||
|
let translations = texts.get_translations_for_language(lang_code);
|
||||||
|
|
||||||
|
translations.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
warn!(
|
||||||
|
"Missing translation key: '{}' for language: '{}'",
|
||||||
|
key, lang_code
|
||||||
|
);
|
||||||
|
// Return the key as fallback, prefixed to indicate missing translation
|
||||||
|
format!("[{key}]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change language (with user preference tracking)
|
||||||
|
pub fn set_language(&self, language: Language) {
|
||||||
|
// Store user preference immediately
|
||||||
|
store_language(&language);
|
||||||
|
self.0.set_language.set(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current language
|
||||||
|
pub fn language(&self) -> Language {
|
||||||
|
self.0.language.get_untracked()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if current language is specific language (reactive)
|
||||||
|
pub fn is_language_reactive(&self, lang: Language) -> Memo<bool> {
|
||||||
|
self.0.is_language_reactive(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current language code (non-reactive)
|
||||||
|
pub fn lang_code(&self) -> String {
|
||||||
|
self.0.current_lang()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get current language code (reactive)
|
||||||
|
pub fn lang_code_reactive(&self) -> Memo<String> {
|
||||||
|
self.0.current_lang_reactive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build reactive page content patterns (client-side only)
|
||||||
|
/// This is a reactive version of rustelo_core_lib::i18n::build_page_content_patterns
|
||||||
|
/// that properly tracks language changes for NavMenu, Footer, and other components
|
||||||
|
pub fn build_reactive_page_content_patterns(
|
||||||
|
&self,
|
||||||
|
patterns: &[&str],
|
||||||
|
) -> std::collections::HashMap<String, String> {
|
||||||
|
// Use reactive methods to track language changes
|
||||||
|
let texts = self.0.texts.get();
|
||||||
|
let binding = self.0.language.get();
|
||||||
|
let lang_code = binding.code();
|
||||||
|
|
||||||
|
let translations = texts.get_translations_for_language(lang_code);
|
||||||
|
|
||||||
|
// Discover keys matching patterns (same logic as shared version)
|
||||||
|
let mut discovered_keys: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for pattern in patterns {
|
||||||
|
let pattern_keys: Vec<String> = translations
|
||||||
|
.keys()
|
||||||
|
.filter(|key| key.starts_with(pattern))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
discovered_keys.extend(pattern_keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicates while preserving order
|
||||||
|
discovered_keys.sort();
|
||||||
|
discovered_keys.dedup();
|
||||||
|
|
||||||
|
// Build content HashMap with discovered keys using reactive translation
|
||||||
|
discovered_keys
|
||||||
|
.iter()
|
||||||
|
.map(|key| (key.clone(), self.0.t_reactive_memo(key)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if current language is specific language (non-reactive)
|
||||||
|
pub fn is_language(&self, lang: Language) -> bool {
|
||||||
|
self.0.is_language(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook to use internationalization
|
||||||
|
pub fn use_i18n() -> UseI18n {
|
||||||
|
UseI18n::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEPRECATED: Language selector components have been moved to dedicated files.
|
||||||
|
// Please use: crate::components::language_selector::{LanguageSelector, LanguageToggle}
|
||||||
|
// Those components are language-agnostic and use ZERO-MAINTENANCE pattern-based approach.
|
||||||
|
|
||||||
|
// Emergency translations system removed - proper FTL loading should always work.
|
||||||
|
// If translations fail to load, components will show [key] format indicating missing keys.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn test_language_codes() {
|
||||||
|
assert_eq!(Language::default().code(), "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn test_language_from_code() {
|
||||||
|
assert_eq!(Language::from_code("en"), Language::default());
|
||||||
|
assert_eq!(Language::from_code("invalid"), Language::default()); // Default fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn test_language_display_names() {
|
||||||
|
assert_eq!(Language::default().display_name(), "English");
|
||||||
|
}
|
||||||
|
}
|
||||||
237
crates/foundation/crates/rustelo_client/src/lib.rs
Normal file
237
crates/foundation/crates/rustelo_client/src/lib.rs
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
//! # RUSTELO Client
|
||||||
|
//!
|
||||||
|
//! <div align="center">
|
||||||
|
//! <img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
|
||||||
|
//! </div>
|
||||||
|
//!
|
||||||
|
//! Frontend client library for the RUSTELO web application framework, built with Leptos and WebAssembly.
|
||||||
|
//!
|
||||||
|
//! ## Overview
|
||||||
|
//!
|
||||||
|
//! The RUSTELO client provides a reactive, high-performance frontend experience using Rust compiled to WebAssembly.
|
||||||
|
//! It features component-based architecture, state management, internationalization, and seamless server-side rendering.
|
||||||
|
//!
|
||||||
|
//! ## Features
|
||||||
|
//!
|
||||||
|
//! - **⚡ Reactive UI** - Built with Leptos for fast, reactive user interfaces
|
||||||
|
//! - **🎨 Component System** - Reusable UI components with props and state
|
||||||
|
//! - **🌐 Internationalization** - Multi-language support with fluent
|
||||||
|
//! - **🔐 Authentication** - Complete auth flow with JWT and OAuth2
|
||||||
|
//! - **📱 Responsive Design** - Mobile-first design with Tailwind CSS
|
||||||
|
//! - **🚀 WebAssembly** - High-performance client-side rendering
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! The client is organized into several key modules:
|
||||||
|
//!
|
||||||
|
//! - [`app`] - Minimal hydration component for WebAssembly
|
||||||
|
//! - [`components`] - Reusable UI components and forms
|
||||||
|
//! - [`pages`] - Individual page components (Home, About, etc.)
|
||||||
|
//! - [`auth`] - Authentication components and context
|
||||||
|
//! - [`state`] - Global state management and themes
|
||||||
|
//! - [`i18n`] - Internationalization and language support
|
||||||
|
//! - [`utils`] - Client-side utilities and helpers
|
||||||
|
//!
|
||||||
|
//! ## Quick Start
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rustelo_client::app::App;
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! // Hydrate SSR content with client reactivity
|
||||||
|
//! leptos::mount::hydrate_body(|| view! { <App /> });
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Component Usage
|
||||||
|
//!
|
||||||
|
//! ### Authentication Components
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rustelo_client::auth::{AuthProvider, LoginForm};
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! view! {
|
||||||
|
//! <AuthProvider>
|
||||||
|
//! <LoginForm />
|
||||||
|
//! </AuthProvider>
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Form Components
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rustelo_client::rustelo_components::{ContactForm, SupportForm};
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! view! {
|
||||||
|
//! <ContactForm />
|
||||||
|
//! <SupportForm />
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## State Management
|
||||||
|
//!
|
||||||
|
//! ### Theme Management
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rustelo_client::state::theme::{ThemeProvider, use_theme_state, Theme};
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! #[component]
|
||||||
|
//! fn MyComponent() -> impl IntoView {
|
||||||
|
//! let theme_state = use_theme_state();
|
||||||
|
//!
|
||||||
|
//! view! {
|
||||||
|
//! <button on:click=move |_| theme_state.toggle()>
|
||||||
|
//! "Toggle Theme"
|
||||||
|
//! </button>
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Internationalization
|
||||||
|
//!
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use rustelo_client::i18n::{I18nProvider, use_i18n};
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! #[component]
|
||||||
|
//! fn MyComponent() -> impl IntoView {
|
||||||
|
//! let i18n = use_i18n();
|
||||||
|
//!
|
||||||
|
//! view! {
|
||||||
|
//! <p>{i18n.t("welcome_message")}</p>
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## WebAssembly Integration
|
||||||
|
//!
|
||||||
|
//! The client is designed to run efficiently in WebAssembly environments:
|
||||||
|
//!
|
||||||
|
//! - **Small Bundle Size** - Optimized for fast loading
|
||||||
|
//! - **Memory Efficient** - Careful memory management
|
||||||
|
//! - **Browser APIs** - Safe access to web APIs through web-sys
|
||||||
|
//! - **Error Handling** - Comprehensive error boundaries
|
||||||
|
//!
|
||||||
|
//! ## Development
|
||||||
|
//!
|
||||||
|
//! ### Building
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Development build
|
||||||
|
//! cargo build --target wasm32-unknown-unknown
|
||||||
|
//!
|
||||||
|
//! # Production build
|
||||||
|
//! cargo build --release --target wasm32-unknown-unknown
|
||||||
|
//!
|
||||||
|
//! # Using cargo-leptos
|
||||||
|
//! cargo leptos build
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ### Testing
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! # Run tests
|
||||||
|
//! cargo test
|
||||||
|
//!
|
||||||
|
//! # Run tests in browser
|
||||||
|
//! wasm-pack test --headless --chrome
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Performance
|
||||||
|
//!
|
||||||
|
//! Optimized for performance with:
|
||||||
|
//!
|
||||||
|
//! - **Lazy Loading** - Components loaded on demand
|
||||||
|
//! - **Virtual DOM** - Efficient rendering with fine-grained reactivity
|
||||||
|
//! - **Code Splitting** - Reduced initial bundle size
|
||||||
|
//! - **Caching** - Smart caching of static assets
|
||||||
|
//!
|
||||||
|
//! ## Browser Support
|
||||||
|
//!
|
||||||
|
//! - **Modern Browsers** - Chrome 80+, Firefox 72+, Safari 13.1+, Edge 80+
|
||||||
|
//! - **WebAssembly** - Required for optimal performance
|
||||||
|
//! - **JavaScript Fallback** - Graceful degradation where possible
|
||||||
|
//!
|
||||||
|
//! ## Contributing
|
||||||
|
//!
|
||||||
|
//! Contributions are welcome! Please see our [Contributing Guidelines](https://github.com/yourusername/rustelo/blob/main/CONTRIBUTING.md).
|
||||||
|
//!
|
||||||
|
//! ## License
|
||||||
|
//!
|
||||||
|
//! This project is licensed under the MIT License - see the [LICENSE](https://github.com/yourusername/rustelo/blob/main/LICENSE) file for details.
|
||||||
|
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod auth;
|
||||||
|
//pub mod components;
|
||||||
|
pub mod config;
|
||||||
|
// Removed: pub mod defs; (unused route definitions)
|
||||||
|
pub mod highlight;
|
||||||
|
pub mod i18n;
|
||||||
|
//pub mod pages;
|
||||||
|
pub mod routing;
|
||||||
|
pub mod state;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
// Re-export console logging macros from shared for backward compatibility
|
||||||
|
pub use rustelo_core_lib::{safe_console_error, safe_console_log, safe_console_warn};
|
||||||
|
|
||||||
|
// Re-export console control functions for easy access
|
||||||
|
pub use rustelo_core_lib::utils::console_control;
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::PageTranslator;
|
||||||
|
|
||||||
|
// Implement PageTranslator for the client's UseI18n type
|
||||||
|
impl PageTranslator for crate::i18n::UseI18n {
|
||||||
|
fn t(&self, key: &str) -> String {
|
||||||
|
self.t(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localized_url(&self, path: &str) -> String {
|
||||||
|
self.localized_url(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lang_code(&self) -> String {
|
||||||
|
self.lang_code()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
|
||||||
|
// Mark start of hydration
|
||||||
|
crate::utils::hydration_debug::set_hydrating(true);
|
||||||
|
web_sys::console::log_1(&"[HYDRATION] Starting standard Leptos hydration process...".into());
|
||||||
|
|
||||||
|
// Get the current path from the window location
|
||||||
|
let initial_path = if let Some(window) = web_sys::window() {
|
||||||
|
window.location().pathname().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("[HYDRATION] Initial path for hydration: {}", initial_path).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use hydrate_body with __RESOLVED_RESOURCES now available
|
||||||
|
leptos::mount::hydrate_body(move || {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"[HYDRATION] Creating App component for hydration with path: {}",
|
||||||
|
initial_path
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
view! { <crate::app::AppComponent initial_path=initial_path /> }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark end of hydration
|
||||||
|
crate::utils::hydration_debug::set_hydrating(false);
|
||||||
|
web_sys::console::log_1(&"[HYDRATION] Standard Leptos hydration process completed".into());
|
||||||
|
}
|
||||||
446
crates/foundation/crates/rustelo_client/src/routing.rs
Normal file
446
crates/foundation/crates/rustelo_client/src/routing.rs
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
//! Client-side routing implementation using trait-based dependency injection
|
||||||
|
//!
|
||||||
|
//! This module implements client-side rendering using the pure trait abstractions
|
||||||
|
//! from rustelo-*-traits crates. This eliminates code generation and provides
|
||||||
|
//! runtime flexibility while maintaining route agnosticism.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::routing::engine::resolver::resolve_unified_route;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use rustelo_core_types::{
|
||||||
|
AppContext, DefaultPageProvider, LanguageDetector, PageProvider, RouteHandler,
|
||||||
|
RouteMetadataProvider, RouteParameters, RouteRenderer, RouteResolver, RouteResult,
|
||||||
|
RoutesConfig, RoutingResult,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// Load routes configuration from embedded TOML
|
||||||
|
/// This function loads the routes configuration that was generated at build time
|
||||||
|
fn load_routes_config() -> &'static RoutesConfig {
|
||||||
|
static ROUTES_CACHE: OnceLock<RoutesConfig> = OnceLock::new();
|
||||||
|
|
||||||
|
ROUTES_CACHE.get_or_init(|| {
|
||||||
|
// Try to load the embedded routes from build output
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
{
|
||||||
|
// In release builds, use embedded routes if available
|
||||||
|
if let Ok(routes_toml) = load_embedded_routes_if_available() {
|
||||||
|
if let Ok(config) = toml::from_str::<RoutesConfig>(&routes_toml) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to empty routes configuration
|
||||||
|
// This is safer than panicking and allows graceful degradation
|
||||||
|
RoutesConfig::new()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempt to load embedded routes from the build output
|
||||||
|
/// Returns empty config if not available (graceful degradation)
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn load_embedded_routes_if_available() -> Result<String, &'static str> {
|
||||||
|
// The routes TOML is generated by the website-server build.rs during compilation
|
||||||
|
// and placed in the out/generated/ directory. Since we can't use include_str! across
|
||||||
|
// crates with dynamic paths, we provide an empty fallback that allows graceful routing
|
||||||
|
// through the routing engine. The server-side routing has already resolved the correct
|
||||||
|
// component, so the client just needs to pass through the same component selection.
|
||||||
|
Err("Routes will be resolved via routing engine with existing AppContext configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side route renderer using trait-based dependency injection
|
||||||
|
pub struct ClientRouteRenderer<P: PageProvider = DefaultPageProvider> {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
app_context: AppContext,
|
||||||
|
page_provider: P,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientRouteRenderer<DefaultPageProvider> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
app_context: AppContext::new(),
|
||||||
|
page_provider: DefaultPageProvider::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_context(app_context: AppContext) -> Self {
|
||||||
|
Self {
|
||||||
|
page_provider: DefaultPageProvider::new(),
|
||||||
|
app_context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P: PageProvider> ClientRouteRenderer<P> {
|
||||||
|
pub fn with_provider(provider: P) -> Self {
|
||||||
|
Self {
|
||||||
|
app_context: AppContext::new(),
|
||||||
|
page_provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_provider_and_context(provider: P, app_context: AppContext) -> Self {
|
||||||
|
Self {
|
||||||
|
app_context,
|
||||||
|
page_provider: provider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientRouteRenderer<DefaultPageProvider> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: No specific DefaultPageProvider implementation needed.
|
||||||
|
// DefaultPageProvider uses View = String, so it won't match the generic AnyView implementations.
|
||||||
|
// This avoids trait coherence conflicts while allowing the PAP system to work correctly.
|
||||||
|
|
||||||
|
// Generic implementation for PageProviders with AnyView (like WebsitePageProvider)
|
||||||
|
// Note: This excludes DefaultPageProvider which has View = String
|
||||||
|
// Using explicit type constraint to prevent overlap with DefaultPageProvider implementation
|
||||||
|
impl<P> RouteRenderer for ClientRouteRenderer<P>
|
||||||
|
where
|
||||||
|
P: PageProvider<
|
||||||
|
Component = String,
|
||||||
|
View = AnyView,
|
||||||
|
Props = std::collections::HashMap<String, String>,
|
||||||
|
>,
|
||||||
|
P: 'static,
|
||||||
|
// NOTE: This implementation is for types where P::View = AnyView, which excludes DefaultPageProvider
|
||||||
|
{
|
||||||
|
type View = AnyView;
|
||||||
|
type Component = String;
|
||||||
|
type Parameters = RouteParameters;
|
||||||
|
|
||||||
|
fn render_component(
|
||||||
|
&self,
|
||||||
|
component: &Self::Component,
|
||||||
|
path: &str,
|
||||||
|
language: &str,
|
||||||
|
parameters: &Self::Parameters,
|
||||||
|
) -> RoutingResult<Self::View> {
|
||||||
|
// Create props from parameters and context
|
||||||
|
let mut props = parameters.clone().into_hashmap();
|
||||||
|
props.insert("path".to_string(), path.to_string());
|
||||||
|
props.insert("language".to_string(), language.to_string());
|
||||||
|
|
||||||
|
// Use the page provider to render the component
|
||||||
|
match self
|
||||||
|
.page_provider
|
||||||
|
.render_component(component, props, language)
|
||||||
|
{
|
||||||
|
Ok(rendered_view) => {
|
||||||
|
// For AnyView, we can return it directly
|
||||||
|
Ok(rendered_view)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fallback to not found
|
||||||
|
Ok(self.render_not_found(path, language)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_not_found(&self, _path: &str, language: &str) -> RoutingResult<Self::View> {
|
||||||
|
use rustelo_pages::NotFoundPage;
|
||||||
|
let lang = language.to_string();
|
||||||
|
let view = view! { <NotFoundPage _language=lang /> }.into_any();
|
||||||
|
Ok(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic RouteMetadataProvider for AnyView PageProviders (excludes DefaultPageProvider)
|
||||||
|
impl<P> RouteMetadataProvider for ClientRouteRenderer<P>
|
||||||
|
where
|
||||||
|
P: PageProvider<
|
||||||
|
Component = String,
|
||||||
|
View = AnyView,
|
||||||
|
Props = std::collections::HashMap<String, String>,
|
||||||
|
>,
|
||||||
|
P: 'static,
|
||||||
|
{
|
||||||
|
type Component = String;
|
||||||
|
|
||||||
|
fn get_title(&self, _component: &Self::Component, _language: &str) -> RoutingResult<String> {
|
||||||
|
Ok("Default Title".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_description(
|
||||||
|
&self,
|
||||||
|
_component: &Self::Component,
|
||||||
|
_language: &str,
|
||||||
|
) -> RoutingResult<String> {
|
||||||
|
Ok("Default Description".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_keywords(&self, _component: &Self::Component, _language: &str) -> RoutingResult<String> {
|
||||||
|
Ok("Default Keywords".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic RouteHandler for AnyView PageProviders (excludes DefaultPageProvider)
|
||||||
|
impl<P> RouteHandler for ClientRouteRenderer<P>
|
||||||
|
where
|
||||||
|
P: PageProvider<
|
||||||
|
Component = String,
|
||||||
|
View = AnyView,
|
||||||
|
Props = std::collections::HashMap<String, String>,
|
||||||
|
>,
|
||||||
|
P: 'static,
|
||||||
|
{
|
||||||
|
type View = AnyView;
|
||||||
|
type Component = String;
|
||||||
|
type Parameters = RouteParameters;
|
||||||
|
|
||||||
|
fn handle_route(
|
||||||
|
&self,
|
||||||
|
component: Option<&Self::Component>,
|
||||||
|
path: &str,
|
||||||
|
language: &str,
|
||||||
|
parameters: &Self::Parameters,
|
||||||
|
) -> RoutingResult<RouteResult<Self::View>> {
|
||||||
|
let (view, is_not_found) = match component {
|
||||||
|
Some(comp) => match self.render_component(comp, path, language, parameters) {
|
||||||
|
Ok(v) => (v, false),
|
||||||
|
Err(_) => (self.render_not_found(path, language)?, true),
|
||||||
|
},
|
||||||
|
None => (self.render_not_found(path, language)?, true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = component
|
||||||
|
.map(|c| {
|
||||||
|
self.get_title(c, language)
|
||||||
|
.unwrap_or_else(|_| "Rustelo".to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Page Not Found".to_string());
|
||||||
|
|
||||||
|
let description = component
|
||||||
|
.map(|c| {
|
||||||
|
self.get_description(c, language)
|
||||||
|
.unwrap_or_else(|_| "Rustelo Application".to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "The requested page was not found".to_string());
|
||||||
|
|
||||||
|
let keywords = component
|
||||||
|
.map(|c| {
|
||||||
|
self.get_keywords(c, language)
|
||||||
|
.unwrap_or_else(|_| "rustelo".to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "rustelo, not found".to_string());
|
||||||
|
|
||||||
|
Ok(RouteResult {
|
||||||
|
view,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
keywords,
|
||||||
|
language: language.to_string(),
|
||||||
|
canonical_path: path.to_string(),
|
||||||
|
is_not_found,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render page content using trait-based routing system with default provider
|
||||||
|
/// NOTE: This function uses DefaultPageProvider which generates View = String, not AnyView.
|
||||||
|
/// For PAP-compliant rendering with Leptos components, use render_page_content_with_provider instead.
|
||||||
|
pub fn render_page_content(_path: &str) -> AnyView {
|
||||||
|
// For PAP systems, this fallback just shows a simple message
|
||||||
|
// The actual implementation should use render_page_content_with_provider
|
||||||
|
view! {
|
||||||
|
<div class="ds-bg-page">
|
||||||
|
<section class="relative py-ds-4 ds-container ds-rounded-lg ds-shadow-lg">
|
||||||
|
<div class="mx-auto max-w-4xl text-center">
|
||||||
|
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||||
|
"Default Provider Not Available"
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg leading-8 ds-text-muted">
|
||||||
|
"Use render_page_content_with_provider with a custom PageProvider instead."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render page content with custom provider using trait-based routing
|
||||||
|
pub fn render_page_content_with_provider<P>(path: &str, language: &str, provider: P) -> AnyView
|
||||||
|
where
|
||||||
|
P: PageProvider<
|
||||||
|
Component = String,
|
||||||
|
View = AnyView,
|
||||||
|
Props = std::collections::HashMap<String, String>,
|
||||||
|
>,
|
||||||
|
P: 'static,
|
||||||
|
ClientRouteRenderer<P>:
|
||||||
|
RouteHandler<View = AnyView, Component = String, Parameters = RouteParameters>,
|
||||||
|
ClientRouteRenderer<P>:
|
||||||
|
RouteRenderer<View = AnyView, Component = String, Parameters = RouteParameters>,
|
||||||
|
{
|
||||||
|
let renderer = ClientRouteRenderer::with_provider(provider);
|
||||||
|
let app_context = AppContext::new();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT ROUTING (Custom Provider): Attempting to render path='{}' with language='{}'",
|
||||||
|
path, language
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use trait-based language detection
|
||||||
|
#[allow(unused_variables)] // detected_lang only used in wasm32 target
|
||||||
|
let (detected_lang, clean_path) = app_context.language_detector.extract_language(path);
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if detected_lang != language {
|
||||||
|
web_sys::console::log_1(&format!(
|
||||||
|
"⚠️ ROUTING: Language mismatch - path '{}' detected as '{}' but requested '{}'. Using requested language.",
|
||||||
|
path, detected_lang, language
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load generated routes configuration and use the proper routing engine
|
||||||
|
let routes_config = load_routes_config();
|
||||||
|
let resolution = resolve_unified_route(routes_config, path);
|
||||||
|
|
||||||
|
// Convert RouteComponent to String for the renderer
|
||||||
|
let component_name = resolution
|
||||||
|
.component
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.as_str().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
if resolution.exists {
|
||||||
|
Some("NotFound".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT ROUTING: Unified route resolution - component: {:?}, path: {}, exists: {}",
|
||||||
|
component_name, resolution.path, resolution.exists
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert HashMap parameters to RouteParameters (RouteParameters wraps HashMap)
|
||||||
|
let mut route_params = RouteParameters::new();
|
||||||
|
for (key, value) in resolution.parameters.into_iter() {
|
||||||
|
route_params.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle route with requested language override
|
||||||
|
match renderer.handle_route(
|
||||||
|
component_name.as_ref(),
|
||||||
|
&resolution.path,
|
||||||
|
language, // Use requested language
|
||||||
|
&route_params,
|
||||||
|
) {
|
||||||
|
Ok(route_result) => {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if route_result.is_not_found {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("⚠️ CLIENT ROUTING: Rendered not found page").into(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"✅ CLIENT ROUTING: Successfully rendered component for path '{}'",
|
||||||
|
resolution.path
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
route_result.view
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Fallback to not found - create directly since render_not_found may not be available
|
||||||
|
use rustelo_pages::NotFoundPage;
|
||||||
|
let lang = language.to_string();
|
||||||
|
view! { <NotFoundPage _language=lang /> }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render page content with explicit language using trait-based routing (default provider)
|
||||||
|
/// NOTE: This function uses DefaultPageProvider which generates View = String, not AnyView.
|
||||||
|
/// For PAP-compliant rendering with Leptos components, use render_page_content_with_provider instead.
|
||||||
|
pub fn render_page_content_with_language(_path: &str, language: &str) -> AnyView {
|
||||||
|
// For PAP systems, this fallback just shows a simple message with the requested language
|
||||||
|
// The actual implementation should use render_page_content_with_provider
|
||||||
|
view! {
|
||||||
|
<div class="ds-bg-page">
|
||||||
|
<section class="relative py-ds-4 ds-container ds-rounded-lg ds-shadow-lg">
|
||||||
|
<div class="mx-auto max-w-4xl text-center">
|
||||||
|
<h1 class="text-balance text-4xl font-bold tracking-tight ds-text sm:text-6xl mb-ds-4">
|
||||||
|
{format!("Default Provider Not Available ({})", language)}
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg leading-8 ds-text-muted">
|
||||||
|
"Use render_page_content_with_provider with a custom PageProvider instead."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a path looks like it should be a valid route using trait-based configuration
|
||||||
|
/// This helps prevent showing NotFound during hydration for potentially valid routes
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn looks_like_valid_route(path: &str) -> bool {
|
||||||
|
let app_context = AppContext::new();
|
||||||
|
|
||||||
|
// Try to get available routes for default language
|
||||||
|
let default_lang = app_context.language_detector.default_language();
|
||||||
|
|
||||||
|
match app_context
|
||||||
|
.route_resolver
|
||||||
|
.get_routes_for_language(default_lang)
|
||||||
|
{
|
||||||
|
Ok(routes) => {
|
||||||
|
// Check against actual configured routes
|
||||||
|
for route_path in &routes {
|
||||||
|
// Check if path matches the route pattern (simple prefix match for now)
|
||||||
|
if path.starts_with(route_path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for parametric route patterns (e.g., "/content/{slug}" matches "/content/my-post")
|
||||||
|
if route_path.contains('{') {
|
||||||
|
let pattern_parts: Vec<&str> = route_path.split('/').collect();
|
||||||
|
let path_parts: Vec<&str> = path.split('/').collect();
|
||||||
|
|
||||||
|
if pattern_parts.len() == path_parts.len() {
|
||||||
|
let matches = pattern_parts.iter().zip(path_parts.iter()).all(
|
||||||
|
|(pattern, path_part)| {
|
||||||
|
pattern.starts_with('{') && pattern.ends_with('}')
|
||||||
|
|| pattern == path_part
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if matches {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no routes matched but we have routes configured, do fallback check
|
||||||
|
!routes.is_empty() && path.split('/').filter(|s| !s.is_empty()).count() >= 2
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// If we can't get routes, do a basic heuristic
|
||||||
|
path.split('/').filter(|s| !s.is_empty()).count() >= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/foundation/crates/rustelo_client/src/state/mod.rs
Normal file
42
crates/foundation/crates/rustelo_client/src/state/mod.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
pub mod theme;
|
||||||
|
|
||||||
|
pub use theme::*;
|
||||||
|
|
||||||
|
// Re-export common state-related items
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Global state provider components
|
||||||
|
#[component]
|
||||||
|
pub fn GlobalStateProvider(children: leptos::children::Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<>{children()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeProvider(children: leptos::children::Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<>{children()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ToastProvider(children: leptos::children::Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<>{children()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn UserProvider(children: leptos::children::Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<>{children()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AppStateProvider(children: leptos::children::Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<>{children()}</>
|
||||||
|
}
|
||||||
|
}
|
||||||
238
crates/foundation/crates/rustelo_client/src/state/theme.rs
Normal file
238
crates/foundation/crates/rustelo_client/src/state/theme.rs
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Theme variants supported by the application
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum Theme {
|
||||||
|
#[default]
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
/// Get the CSS class name for the theme
|
||||||
|
pub fn as_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "theme-light",
|
||||||
|
Theme::Dark => "theme-dark",
|
||||||
|
Theme::Auto => "theme-auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the data attribute value for DaisyUI
|
||||||
|
pub fn as_data_theme(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "light",
|
||||||
|
Theme::Dark => "dark",
|
||||||
|
Theme::Auto => "light",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all available themes
|
||||||
|
pub fn all() -> Vec<Theme> {
|
||||||
|
vec![Theme::Light, Theme::Dark, Theme::Auto]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get display name for the theme
|
||||||
|
pub fn display_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "Light",
|
||||||
|
Theme::Dark => "Dark",
|
||||||
|
Theme::Auto => "Auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get icon for the theme
|
||||||
|
pub fn icon(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "i-carbon-sun",
|
||||||
|
Theme::Dark => "i-carbon-moon",
|
||||||
|
Theme::Auto => "i-carbon-settings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme state management
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ThemeState {
|
||||||
|
pub current_theme: RwSignal<Theme>,
|
||||||
|
pub system_theme: RwSignal<Theme>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current_theme: RwSignal::new(Theme::Light),
|
||||||
|
system_theme: RwSignal::new(Self::detect_system_theme()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeState {
|
||||||
|
/// Create a new theme state with initial theme
|
||||||
|
pub fn new(initial_theme: Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
current_theme: RwSignal::new(initial_theme),
|
||||||
|
system_theme: RwSignal::new(Self::detect_system_theme()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect system theme preference
|
||||||
|
fn detect_system_theme() -> Theme {
|
||||||
|
Theme::Light
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle between light and dark themes
|
||||||
|
pub fn toggle(&self) {
|
||||||
|
let current = self.current_theme.get_untracked();
|
||||||
|
let new_theme = match current {
|
||||||
|
Theme::Light => Theme::Dark,
|
||||||
|
Theme::Dark => Theme::Light,
|
||||||
|
Theme::Auto => Theme::Light,
|
||||||
|
};
|
||||||
|
self.set_theme(new_theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the current theme
|
||||||
|
pub fn set_theme(&self, theme: Theme) {
|
||||||
|
self.current_theme.set(theme);
|
||||||
|
self.apply_theme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply theme to the DOM
|
||||||
|
fn apply_theme(&self, _theme: Theme) {
|
||||||
|
// Theme application would be handled by CSS/JavaScript
|
||||||
|
// For now, we'll keep this simple
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective theme (resolves Auto to Light/Dark)
|
||||||
|
pub fn effective_theme(&self) -> Theme {
|
||||||
|
match self.current_theme.get_untracked() {
|
||||||
|
Theme::Auto => self.system_theme.get_untracked(),
|
||||||
|
theme => theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize theme system with system preference detection
|
||||||
|
pub fn init(&self) {
|
||||||
|
// Apply initial theme
|
||||||
|
self.apply_theme(self.current_theme.get_untracked());
|
||||||
|
|
||||||
|
// Set up system theme change listener
|
||||||
|
self.setup_system_theme_listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set up listener for system theme changes
|
||||||
|
fn setup_system_theme_listener(&self) {
|
||||||
|
// System theme listening would be handled by JavaScript
|
||||||
|
// For now, we'll keep this simple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme provider component
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeProvider(
|
||||||
|
#[prop(optional)] initial_theme: Option<Theme>,
|
||||||
|
children: leptos::children::Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let theme_state = ThemeState::new(initial_theme.unwrap_or_default());
|
||||||
|
theme_state.init();
|
||||||
|
|
||||||
|
provide_context(theme_state);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{children()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook to use theme state
|
||||||
|
pub fn use_theme_state() -> ThemeState {
|
||||||
|
use_context::<ThemeState>()
|
||||||
|
.expect("ThemeState context not found. Make sure ThemeProvider is set up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme toggle button component
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeToggle(#[prop(optional)] class: Option<String>) -> impl IntoView {
|
||||||
|
let theme_state = use_theme_state();
|
||||||
|
let current_theme = theme_state.current_theme;
|
||||||
|
|
||||||
|
let toggle_theme = move |_| {
|
||||||
|
theme_state.toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=move || format!("btn ds-btn-ghost btn-circle {}", class.as_deref().unwrap_or(""))
|
||||||
|
on:click=toggle_theme
|
||||||
|
title=move || format!("Switch to {} theme",
|
||||||
|
match current_theme.get_untracked() {
|
||||||
|
Theme::Light => "dark",
|
||||||
|
Theme::Dark => "light",
|
||||||
|
Theme::Auto => "light",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
>
|
||||||
|
<div class=move || format!("w-5 h-5 {}", current_theme.get_untracked().icon())></div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Theme selector dropdown component
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeSelector(#[prop(optional)] class: Option<String>) -> impl IntoView {
|
||||||
|
let theme_state = use_theme_state();
|
||||||
|
let current_theme = theme_state.current_theme;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=move || format!("dropdown dropdown-end {}", class.as_deref().unwrap_or(""))>
|
||||||
|
<div tabindex="0" role="button" class="btn ds-btn-ghost btn-circle">
|
||||||
|
<div class=move || format!("w-5 h-5 {}", current_theme.get_untracked().icon())></div>
|
||||||
|
</div>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-ds-2 shadow bg-base-100 ds-rounded-box w-52">
|
||||||
|
{Theme::all().into_iter().map(|theme| {
|
||||||
|
let theme_state = theme_state.clone();
|
||||||
|
let is_active = move || current_theme.get_untracked() == theme;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class=move || if is_active() { "active" } else { "" }
|
||||||
|
on:click=move |_| theme_state.set_theme(theme)
|
||||||
|
>
|
||||||
|
<div class=format!("w-4 h-4 {}", theme.icon())></div>
|
||||||
|
{theme.display_name()}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_display_names() {
|
||||||
|
assert_eq!(Theme::Light.display_name(), "Light");
|
||||||
|
assert_eq!(Theme::Dark.display_name(), "Dark");
|
||||||
|
assert_eq!(Theme::Auto.display_name(), "Auto");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_data_attributes() {
|
||||||
|
assert_eq!(Theme::Light.as_data_theme(), "light");
|
||||||
|
assert_eq!(Theme::Dark.as_data_theme(), "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_theme_classes() {
|
||||||
|
assert_eq!(Theme::Light.as_class(), "theme-light");
|
||||||
|
assert_eq!(Theme::Dark.as_class(), "theme-dark");
|
||||||
|
assert_eq!(Theme::Auto.as_class(), "theme-auto");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Macro to log hydration debug info
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! hydration_log {
|
||||||
|
($component:expr, $message:expr) => {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("[HYDRATION] {} - {}", $component, $message).into());
|
||||||
|
};
|
||||||
|
($component:expr, $message:expr, $($arg:tt)*) => {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("[HYDRATION] {} - {}", $component, format!($message, $($arg)*)).into());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component to help identify hydration mismatches
|
||||||
|
#[component]
|
||||||
|
pub fn HydrationDebugBoundary(#[prop(into)] name: String, children: Children) -> impl IntoView {
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
let name_for_attr = name.clone();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use leptos::prelude::Effect;
|
||||||
|
let name_for_effect = name.clone();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
web_sys::console::log_1(&format!("[HYDRATION] {} mounted", name_for_effect).into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div data-hydration-debug=name_for_attr>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if we're in hydration phase
|
||||||
|
pub fn is_hydrating() -> bool {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(val) = js_sys::Reflect::get(&window, &"__leptos_hydrating".into()) {
|
||||||
|
return val.as_bool().unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark hydration phase
|
||||||
|
pub fn set_hydrating(hydrating: bool) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = js_sys::Reflect::set(&window, &"__leptos_hydrating".into(), &hydrating.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let _ = hydrating;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
crates/foundation/crates/rustelo_client/src/utils/mod.rs
Normal file
63
crates/foundation/crates/rustelo_client/src/utils/mod.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// Re-export all navigation utilities from shared
|
||||||
|
pub use rustelo_core_lib::utils::nav::*;
|
||||||
|
|
||||||
|
// Re-export console logging macros from shared for backward compatibility
|
||||||
|
pub use rustelo_core_lib::{safe_console_error, safe_console_log, safe_console_warn};
|
||||||
|
|
||||||
|
// Hydration debugging module
|
||||||
|
pub mod hydration_debug;
|
||||||
|
|
||||||
|
// Re-export highlight utilities for easy access
|
||||||
|
pub use crate::highlight::*;
|
||||||
|
|
||||||
|
/// Generic API request function for making HTTP requests to the server
|
||||||
|
pub async fn api_request<T, R>(
|
||||||
|
url: &str,
|
||||||
|
method: &str,
|
||||||
|
body: Option<T>,
|
||||||
|
) -> Result<R, Box<dyn std::error::Error>>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
R: for<'de> Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let mut request = reqwasm::http::Request::new(url);
|
||||||
|
request = match method {
|
||||||
|
"GET" => request.method(reqwasm::http::Method::GET),
|
||||||
|
"POST" => request.method(reqwasm::http::Method::POST),
|
||||||
|
"PUT" => request.method(reqwasm::http::Method::PUT),
|
||||||
|
"DELETE" => request.method(reqwasm::http::Method::DELETE),
|
||||||
|
"PATCH" => request.method(reqwasm::http::Method::PATCH),
|
||||||
|
_ => request.method(reqwasm::http::Method::GET),
|
||||||
|
};
|
||||||
|
request = request.header("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
if let Ok(Some(token)) = storage.get_item("auth_token") {
|
||||||
|
request = request.header("Authorization", &format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add body if provided
|
||||||
|
if let Some(body) = body {
|
||||||
|
let body_str = serde_json::to_string(&body)?;
|
||||||
|
request = request.body(body_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
|
||||||
|
if response.ok() {
|
||||||
|
let json_response = response.json::<R>().await?;
|
||||||
|
Ok(json_response)
|
||||||
|
} else {
|
||||||
|
let error_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
Err(format!("API request failed: {error_text}").into())
|
||||||
|
}
|
||||||
|
}
|
||||||
64
crates/foundation/crates/rustelo_components/Cargo.toml
Normal file
64
crates/foundation/crates/rustelo_components/Cargo.toml
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
[package]
|
||||||
|
name = "rustelo_components"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Rustelo Contributors"]
|
||||||
|
license = "MIT"
|
||||||
|
description = "Reusable UI components for Rustelo web application template"
|
||||||
|
documentation = "https://docs.rs/components"
|
||||||
|
repository = "https://github.com/yourusername/rustelo"
|
||||||
|
homepage = "https://jesusperez.pro"
|
||||||
|
readme = "../../README.md"
|
||||||
|
keywords = ["rust", "web", "leptos", "components", "ui"]
|
||||||
|
categories = ["web-programming", "template-engine"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos = { workspace = true }
|
||||||
|
reactive_graph = { workspace = true }
|
||||||
|
leptos_meta = { workspace = true }
|
||||||
|
leptos_config = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
fluent = { workspace = true }
|
||||||
|
fluent-bundle = { workspace = true }
|
||||||
|
unic-langid = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
paste = { workspace = true }
|
||||||
|
once_cell = { workspace = true }
|
||||||
|
web-sys = { workspace = true }
|
||||||
|
|
||||||
|
rustelo_core_lib = { workspace = true }
|
||||||
|
rustelo_core_types = { workspace = true }
|
||||||
|
# rustelo_tools = { workspace = true } # Tools crate needs extensive fixes
|
||||||
|
|
||||||
|
# WASM-specific dependencies (client-side only)
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
wasm-bindgen = { workspace = true }
|
||||||
|
wasm-bindgen-futures = { workspace = true }
|
||||||
|
gloo-net = { workspace = true }
|
||||||
|
js-sys = { workspace = true }
|
||||||
|
web-sys = { workspace = true }
|
||||||
|
gloo-timers = { workspace = true }
|
||||||
|
console_error_panic_hook = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hydrate = ["leptos/hydrate"]
|
||||||
|
ssr = []
|
||||||
|
cache = []
|
||||||
|
# Console logging control
|
||||||
|
console-log = [] # Enable console logging (for development)
|
||||||
|
console-log-production = [] # Enable console logging in production builds
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
# Configuration for docs.rs
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
164
crates/foundation/crates/rustelo_components/README.md
Normal file
164
crates/foundation/crates/rustelo_components/README.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Components Foundation Library
|
||||||
|
|
||||||
|
Reusable UI components for Rustelo web applications built with Leptos.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Component Usage
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
navigation::{BrandHeader, Footer, NavMenu},
|
||||||
|
content::{UnifiedContentCard, ContentManager},
|
||||||
|
ui::{SpaLink, MobileMenu},
|
||||||
|
theme::{ThemeProvider, DarkModeToggle},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn MyApp() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrandHeader />
|
||||||
|
<main>
|
||||||
|
<UnifiedContentCard title="Hello World" />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Sets
|
||||||
|
```rust
|
||||||
|
// Import entire component categories
|
||||||
|
use components::navigation::*; // All navigation components
|
||||||
|
use components::forms::*; // All form components
|
||||||
|
use components::layout::*; // All layout components
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Theming
|
||||||
|
```rust
|
||||||
|
use components::theme::{ThemeProvider, ThemeConfig};
|
||||||
|
|
||||||
|
let theme = ThemeConfig::builder()
|
||||||
|
.primary_color("#007acc")
|
||||||
|
.font_family("Inter")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ThemeProvider config=theme>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Patterns
|
||||||
|
|
||||||
|
### 1. Direct Import (Simplest)
|
||||||
|
Import individual components as needed:
|
||||||
|
```rust
|
||||||
|
use components::navigation::BrandHeader;
|
||||||
|
use components::content::UnifiedContentCard;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Category Import
|
||||||
|
Import entire component categories:
|
||||||
|
```rust
|
||||||
|
use components::forms::*;
|
||||||
|
use components::navigation::*;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Themed Components
|
||||||
|
Use components with custom theming:
|
||||||
|
```rust
|
||||||
|
use components::theme::{ThemeProvider, use_theme};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CustomComponent() -> impl IntoView {
|
||||||
|
let theme = use_theme();
|
||||||
|
// Use theme.primary_color, theme.font_size, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Extended Components
|
||||||
|
Extend foundation components with custom functionality:
|
||||||
|
```rust
|
||||||
|
use components::content::UnifiedContentCard;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CustomCard(
|
||||||
|
#[prop(into)] title: String,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard title=title>
|
||||||
|
<div class="custom-wrapper">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Components
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- `BrandHeader` - Main application header
|
||||||
|
- `Footer` - Application footer
|
||||||
|
- `NavMenu` - Navigation menu
|
||||||
|
- `LanguageSelector` - Language switching
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- `UnifiedContentCard` - Flexible content card
|
||||||
|
- `ContentManager` - Content loading and display
|
||||||
|
- `HtmlContent` - Safe HTML content rendering
|
||||||
|
- `SimpleContentGrid` - Grid layout for content
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- `SpaLink` - SPA-aware navigation links
|
||||||
|
- `MobileMenu` - Mobile-responsive menu
|
||||||
|
- `PageTransition` - Page transition effects
|
||||||
|
- `DarkModeToggle` - Theme switching
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
- Coming in future versions
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- Coming in future versions
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
- `AdminLayout` - Admin panel layout
|
||||||
|
- `AdminCard` - Admin content cards
|
||||||
|
- `AdminHeader` - Admin page headers
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [📚 Usage Patterns](docs/USAGE_PATTERNS.md) - Detailed usage examples
|
||||||
|
- [🎨 Component Catalog](docs/COMPONENT_CATALOG.md) - All available components
|
||||||
|
- [🎭 Customization Guide](docs/CUSTOMIZATION_GUIDE.md) - How to extend components
|
||||||
|
- [🎨 Theming Guide](docs/THEMING_GUIDE.md) - Styling and themes
|
||||||
|
- [🔧 Integration Guide](docs/INTEGRATION_GUIDE.md) - Using with applications
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- [📁 examples/](examples/) - Complete usage examples
|
||||||
|
- [🏗️ Basic Layout](examples/basic_layout.rs) - Simple layout setup
|
||||||
|
- [📝 Custom Forms](examples/custom_forms.rs) - Form component usage
|
||||||
|
- [🧭 Navigation Demo](examples/navigation_demo.rs) - Navigation patterns
|
||||||
|
- [📄 Content Showcase](examples/content_showcase.rs) - Content components
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `hydrate` - Enable client-side hydration
|
||||||
|
- `ssr` - Enable server-side rendering
|
||||||
|
- `theme` - Theme system support
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
components = { path = "path/to/rustelo/crates/foundation/crates/components" }
|
||||||
|
|
||||||
|
# Enable features as needed
|
||||||
|
components = { path = "...", features = ["hydrate", "theme"] }
|
||||||
|
```
|
||||||
@ -0,0 +1,570 @@
|
|||||||
|
# Components Foundation Catalog
|
||||||
|
|
||||||
|
Complete reference of all available components in the foundation library.
|
||||||
|
|
||||||
|
## Navigation Components
|
||||||
|
|
||||||
|
### BrandHeader
|
||||||
|
Main application header with branding and navigation.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::navigation::BrandHeader;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<BrandHeader
|
||||||
|
brand_name="My App"
|
||||||
|
logo_url="/logo.svg"
|
||||||
|
class="custom-header"
|
||||||
|
>
|
||||||
|
// Optional: Custom header content
|
||||||
|
<nav>
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/about">"About"</SpaLink>
|
||||||
|
</nav>
|
||||||
|
</BrandHeader>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `brand_name?: String` - Application brand name
|
||||||
|
- `logo_url?: String` - Logo image URL
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children?: Children` - Custom header content
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
Application footer with links and information.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::navigation::Footer;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Footer copyright="© 2024 My Company">
|
||||||
|
<div class="footer-links">
|
||||||
|
<SpaLink href="/privacy">"Privacy"</SpaLink>
|
||||||
|
<SpaLink href="/terms">"Terms"</SpaLink>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `copyright?: String` - Copyright text
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children?: Children` - Custom footer content
|
||||||
|
|
||||||
|
### NavMenu
|
||||||
|
Flexible navigation menu component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::navigation::NavMenu;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<NavMenu
|
||||||
|
orientation="horizontal"
|
||||||
|
active_path="/current-page"
|
||||||
|
>
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/blog">"Blog"</SpaLink>
|
||||||
|
<SpaLink href="/contact">"Contact"</SpaLink>
|
||||||
|
</NavMenu>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `orientation?: "horizontal" | "vertical"` - Menu layout
|
||||||
|
- `active_path?: String` - Currently active path
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Menu items
|
||||||
|
|
||||||
|
### LanguageSelector
|
||||||
|
Language switching component for i18n applications.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::navigation::LanguageSelector;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<LanguageSelector
|
||||||
|
current_lang="en"
|
||||||
|
available_langs=vec!["en", "es", "fr"]
|
||||||
|
on_change=move |new_lang| {
|
||||||
|
// Handle language change
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `current_lang: String` - Currently selected language
|
||||||
|
- `available_langs: Vec<String>` - Available language codes
|
||||||
|
- `on_change: impl Fn(String) + 'static` - Language change handler
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
## Content Components
|
||||||
|
|
||||||
|
### UnifiedContentCard
|
||||||
|
Flexible content card for displaying structured content.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::content::UnifiedContentCard;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Card Title"
|
||||||
|
subtitle="Optional subtitle"
|
||||||
|
image_url="/image.jpg"
|
||||||
|
class="custom-card"
|
||||||
|
>
|
||||||
|
<p>"Card content goes here"</p>
|
||||||
|
<SpaLink href="/read-more">"Read More"</SpaLink>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `title: String` - Card title
|
||||||
|
- `subtitle?: String` - Optional subtitle
|
||||||
|
- `image_url?: String` - Header image URL
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Card content
|
||||||
|
|
||||||
|
### ContentManager
|
||||||
|
Dynamic content loading and management component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::content::ContentManager;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ContentManager
|
||||||
|
content_type="blog"
|
||||||
|
filter="featured"
|
||||||
|
limit=5
|
||||||
|
layout="grid"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `content_type: String` - Type of content to load
|
||||||
|
- `filter?: String` - Content filter criteria
|
||||||
|
- `limit?: usize` - Maximum items to display
|
||||||
|
- `layout?: "list" | "grid" | "cards"` - Display layout
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
### HtmlContent
|
||||||
|
Safe HTML content rendering with sanitization.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::content::HtmlContent;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<HtmlContent
|
||||||
|
html=r#"<p>Safe <strong>HTML</strong> content</p>"#
|
||||||
|
sanitize=true
|
||||||
|
class="prose"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `html: String` - HTML content to render
|
||||||
|
- `sanitize?: bool` - Enable HTML sanitization (default: true)
|
||||||
|
- `allowed_tags?: Vec<String>` - Custom allowed HTML tags
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
### SimpleContentGrid
|
||||||
|
Grid layout for content collections.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::content::SimpleContentGrid;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SimpleContentGrid
|
||||||
|
columns=3
|
||||||
|
gap="1rem"
|
||||||
|
responsive=true
|
||||||
|
>
|
||||||
|
<UnifiedContentCard title="Item 1" />
|
||||||
|
<UnifiedContentCard title="Item 2" />
|
||||||
|
<UnifiedContentCard title="Item 3" />
|
||||||
|
</SimpleContentGrid>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `columns?: usize` - Number of grid columns (default: 3)
|
||||||
|
- `gap?: String` - Grid gap spacing
|
||||||
|
- `responsive?: bool` - Enable responsive behavior
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Grid items
|
||||||
|
|
||||||
|
## UI Components
|
||||||
|
|
||||||
|
### SpaLink
|
||||||
|
SPA-aware navigation link with active state management.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::ui::SpaLink;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SpaLink
|
||||||
|
href="/about"
|
||||||
|
class="nav-link"
|
||||||
|
active_class="active"
|
||||||
|
exact=true
|
||||||
|
>
|
||||||
|
"About Us"
|
||||||
|
</SpaLink>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `href: String` - Navigation URL
|
||||||
|
- `class?: String` - Base CSS classes
|
||||||
|
- `active_class?: String` - Active state CSS class
|
||||||
|
- `exact?: bool` - Exact path matching for active state
|
||||||
|
- `children: Children` - Link content
|
||||||
|
|
||||||
|
### MobileMenu
|
||||||
|
Mobile-responsive slide-out menu.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::ui::{MobileMenu, MobileMenuToggle};
|
||||||
|
|
||||||
|
let (menu_open, set_menu_open) = create_signal(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<MobileMenuToggle
|
||||||
|
is_open=menu_open
|
||||||
|
on_toggle=move |_| set_menu_open.update(|open| *open = !*open)
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MobileMenu
|
||||||
|
is_open=menu_open
|
||||||
|
on_close=move |_| set_menu_open.set(false)
|
||||||
|
position="left"
|
||||||
|
>
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/about">"About"</SpaLink>
|
||||||
|
</MobileMenu>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**MobileMenu Props**:
|
||||||
|
- `is_open: ReadSignal<bool>` - Menu visibility state
|
||||||
|
- `on_close: impl Fn() + 'static` - Close handler
|
||||||
|
- `position?: "left" | "right"` - Menu slide direction
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Menu content
|
||||||
|
|
||||||
|
**MobileMenuToggle Props**:
|
||||||
|
- `is_open: ReadSignal<bool>` - Current menu state
|
||||||
|
- `on_toggle: impl Fn() + 'static` - Toggle handler
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
### PageTransition
|
||||||
|
Smooth page transition effects.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::ui::{PageTransition, TransitionStyle};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PageTransition
|
||||||
|
style=TransitionStyle::FadeSlide
|
||||||
|
duration=300
|
||||||
|
class="page-wrapper"
|
||||||
|
>
|
||||||
|
// Page content
|
||||||
|
<div>"Current page content"</div>
|
||||||
|
</PageTransition>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `style?: TransitionStyle` - Transition animation style
|
||||||
|
- `duration?: u32` - Transition duration in milliseconds
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Page content
|
||||||
|
|
||||||
|
## Theme Components
|
||||||
|
|
||||||
|
### ThemeProvider
|
||||||
|
Global theme context provider.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::theme::{ThemeProvider, ThemeConfig};
|
||||||
|
|
||||||
|
let theme_config = ThemeConfig::builder()
|
||||||
|
.primary_color("#007acc")
|
||||||
|
.secondary_color("#f0f8ff")
|
||||||
|
.font_family("Inter")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ThemeProvider config=theme_config>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `config?: ThemeConfig` - Theme configuration
|
||||||
|
- `children: Children` - App content
|
||||||
|
|
||||||
|
### DarkModeToggle
|
||||||
|
Theme mode switching component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::theme::DarkModeToggle;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<DarkModeToggle
|
||||||
|
class="theme-toggle"
|
||||||
|
light_label="☀️"
|
||||||
|
dark_label="🌙"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `light_label?: String` - Light mode indicator
|
||||||
|
- `dark_label?: String` - Dark mode indicator
|
||||||
|
|
||||||
|
### ThemeUtils Hook
|
||||||
|
Access current theme in components.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::theme::use_theme;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ThemedComponent() -> impl IntoView {
|
||||||
|
let theme = use_theme();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div style=format!(
|
||||||
|
"color: {}; background: {}; font-family: {}",
|
||||||
|
theme.text_color(),
|
||||||
|
theme.background_color(),
|
||||||
|
theme.font_family()
|
||||||
|
)>
|
||||||
|
"Themed content"
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Theme Methods**:
|
||||||
|
- `primary_color() -> String`
|
||||||
|
- `secondary_color() -> String`
|
||||||
|
- `background_color() -> String`
|
||||||
|
- `text_color() -> String`
|
||||||
|
- `text_muted() -> String`
|
||||||
|
- `font_family() -> String`
|
||||||
|
- `font_size_base() -> String`
|
||||||
|
- `border_radius() -> String`
|
||||||
|
- `is_dark_mode() -> bool`
|
||||||
|
|
||||||
|
## Filter Components
|
||||||
|
|
||||||
|
### UnifiedCategoryFilter
|
||||||
|
Multi-category content filtering component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::filter::{UnifiedCategoryFilter, FilterItem};
|
||||||
|
|
||||||
|
let filter_items = vec![
|
||||||
|
FilterItem::new("all", "All Items"),
|
||||||
|
FilterItem::new("blog", "Blog Posts"),
|
||||||
|
FilterItem::new("news", "News"),
|
||||||
|
];
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
items=filter_items
|
||||||
|
on_filter=move |category| {
|
||||||
|
// Handle filter change
|
||||||
|
}
|
||||||
|
class="content-filter"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `items: Vec<FilterItem>` - Available filter categories
|
||||||
|
- `on_filter: impl Fn(String) + 'static` - Filter change handler
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
### FilterItem
|
||||||
|
Filter category definition.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::filter::FilterItem;
|
||||||
|
|
||||||
|
let item = FilterItem::new("tech", "Technology")
|
||||||
|
.with_count(15)
|
||||||
|
.with_description("Tech articles and tutorials");
|
||||||
|
```
|
||||||
|
|
||||||
|
**Methods**:
|
||||||
|
- `new(id: &str, label: &str) -> Self`
|
||||||
|
- `with_count(count: usize) -> Self`
|
||||||
|
- `with_description(desc: &str) -> Self`
|
||||||
|
|
||||||
|
## Admin Components
|
||||||
|
|
||||||
|
### AdminLayout
|
||||||
|
Admin panel layout structure.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::admin::AdminLayout;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<AdminLayout
|
||||||
|
title="Dashboard"
|
||||||
|
subtitle="Admin Panel"
|
||||||
|
breadcrumbs=vec!["Home", "Admin", "Dashboard"]
|
||||||
|
>
|
||||||
|
// Admin content
|
||||||
|
<div>"Dashboard content"</div>
|
||||||
|
</AdminLayout>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `title: String` - Page title
|
||||||
|
- `subtitle?: String` - Page subtitle
|
||||||
|
- `breadcrumbs?: Vec<String>` - Breadcrumb navigation
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Page content
|
||||||
|
|
||||||
|
### AdminCard
|
||||||
|
Admin content card component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::admin::AdminCard;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<AdminCard
|
||||||
|
title="User Statistics"
|
||||||
|
icon="📊"
|
||||||
|
actions=view! {
|
||||||
|
<button>"Export"</button>
|
||||||
|
<button>"Refresh"</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
// Card content
|
||||||
|
<div>"Statistics content"</div>
|
||||||
|
</AdminCard>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `title: String` - Card title
|
||||||
|
- `icon?: String` - Title icon
|
||||||
|
- `actions?: View` - Header action buttons
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
- `children: Children` - Card content
|
||||||
|
|
||||||
|
### AdminHeader
|
||||||
|
Admin page header component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::admin::AdminHeader;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<AdminHeader
|
||||||
|
title="User Management"
|
||||||
|
actions=view! {
|
||||||
|
<button class="btn btn-primary">"Add User"</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `title: String` - Header title
|
||||||
|
- `actions?: View` - Header action elements
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
## Logo Components
|
||||||
|
|
||||||
|
### Logo
|
||||||
|
Application logo component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::logo::Logo;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Logo
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Company Logo"
|
||||||
|
width=120
|
||||||
|
height=40
|
||||||
|
class="brand-logo"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `src: String` - Logo image source
|
||||||
|
- `alt: String` - Alt text
|
||||||
|
- `width?: u32` - Image width
|
||||||
|
- `height?: u32` - Image height
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
### LogoLink
|
||||||
|
Clickable logo component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::logo::LogoLink;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<LogoLink
|
||||||
|
href="/"
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Home"
|
||||||
|
class="logo-link"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `href: String` - Navigation URL
|
||||||
|
- `src: String` - Logo image source
|
||||||
|
- `alt: String` - Alt text
|
||||||
|
- `width?: u32` - Image width
|
||||||
|
- `height?: u32` - Image height
|
||||||
|
- `class?: String` - Additional CSS classes
|
||||||
|
|
||||||
|
## Component Features
|
||||||
|
|
||||||
|
### SSR Support
|
||||||
|
All components support server-side rendering:
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
ssr = ["leptos/ssr"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hydration Support
|
||||||
|
Client-side hydration capabilities:
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
hydrate = ["leptos/hydrate"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
Components include ARIA attributes and keyboard navigation support by default.
|
||||||
|
|
||||||
|
### Responsive Design
|
||||||
|
Components are mobile-first responsive with breakpoint utilities.
|
||||||
|
|
||||||
|
### Theming Integration
|
||||||
|
All components integrate with the theme system for consistent styling.
|
||||||
|
|
||||||
|
## Usage Tips
|
||||||
|
|
||||||
|
1. **Import Selectively**: Only import components you use to minimize bundle size
|
||||||
|
2. **Use Theme Context**: Leverage `use_theme()` for consistent styling
|
||||||
|
3. **Compose Components**: Combine simple components to create complex layouts
|
||||||
|
4. **Handle Loading States**: Use `Suspense` for async content loading
|
||||||
|
5. **Add Error Boundaries**: Wrap components with `ErrorBoundary` for robustness
|
||||||
@ -0,0 +1,449 @@
|
|||||||
|
# Components Foundation Usage Patterns
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The components foundation provides multiple usage patterns to fit different application needs, from simple component imports to complex themed applications.
|
||||||
|
|
||||||
|
## Pattern 1: Direct Component Import (Simplest)
|
||||||
|
|
||||||
|
**Best for**: Simple applications, specific component needs
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
navigation::BrandHeader,
|
||||||
|
content::UnifiedContentCard,
|
||||||
|
ui::SpaLink,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn HomePage() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<BrandHeader />
|
||||||
|
<main>
|
||||||
|
<UnifiedContentCard title="Welcome">
|
||||||
|
<p>"Hello World"</p>
|
||||||
|
<SpaLink href="/about">"Learn More"</SpaLink>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get**:
|
||||||
|
- Individual components as needed
|
||||||
|
- Minimal bundle size
|
||||||
|
- Full component functionality
|
||||||
|
|
||||||
|
**Customization**: Component props and CSS classes
|
||||||
|
|
||||||
|
## Pattern 2: Category Import
|
||||||
|
|
||||||
|
**Best for**: Using multiple related components
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
navigation::*, // BrandHeader, Footer, NavMenu, etc.
|
||||||
|
content::*, // All content components
|
||||||
|
ui::*, // All UI utilities
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn AppLayout() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<BrandHeader>
|
||||||
|
<NavMenu />
|
||||||
|
</BrandHeader>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<UnifiedContentCard title="Content">
|
||||||
|
<SimpleContentGrid>
|
||||||
|
// Content items
|
||||||
|
</SimpleContentGrid>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get**:
|
||||||
|
- All components from specific categories
|
||||||
|
- Consistent component families
|
||||||
|
- Easy access to related functionality
|
||||||
|
|
||||||
|
**Customization**: Mix and match components from different categories
|
||||||
|
|
||||||
|
## Pattern 3: Themed Application
|
||||||
|
|
||||||
|
**Best for**: Applications with consistent branding and theming
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
theme::{ThemeProvider, ThemeConfig, use_theme},
|
||||||
|
navigation::*,
|
||||||
|
content::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ThemedApp() -> impl IntoView {
|
||||||
|
let theme_config = ThemeConfig::builder()
|
||||||
|
.primary_color("#007acc")
|
||||||
|
.secondary_color("#f0f8ff")
|
||||||
|
.font_family("Inter, sans-serif")
|
||||||
|
.font_size_base("16px")
|
||||||
|
.border_radius("8px")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<ThemeProvider config=theme_config>
|
||||||
|
<AppContent />
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn AppContent() -> impl IntoView {
|
||||||
|
let theme = use_theme();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div style=format!("background: {}", theme.primary_color())>
|
||||||
|
<BrandHeader />
|
||||||
|
<main style=format!("font-family: {}", theme.font_family())>
|
||||||
|
<UnifiedContentCard>
|
||||||
|
"Themed content"
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get**:
|
||||||
|
- Consistent theming across all components
|
||||||
|
- Theme context available to custom components
|
||||||
|
- Built-in dark/light mode support
|
||||||
|
|
||||||
|
**Customization**: Custom theme configuration, theme switching
|
||||||
|
|
||||||
|
## Pattern 4: Extended Components
|
||||||
|
|
||||||
|
**Best for**: Custom functionality built on foundation components
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
content::{UnifiedContentCard, ContentManager},
|
||||||
|
ui::SpaLink,
|
||||||
|
theme::use_theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extend foundation components with custom logic
|
||||||
|
#[component]
|
||||||
|
pub fn BlogCard(
|
||||||
|
#[prop(into)] title: String,
|
||||||
|
#[prop(into)] excerpt: String,
|
||||||
|
#[prop(into)] slug: String,
|
||||||
|
#[prop(into)] date: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let theme = use_theme();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard title=title>
|
||||||
|
<div class="blog-card-content">
|
||||||
|
<p class="excerpt">{excerpt}</p>
|
||||||
|
<div class="blog-meta">
|
||||||
|
<span class="date" style=format!("color: {}", theme.text_muted())>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
<SpaLink href=format!("/blog/{}", slug) class="read-more">
|
||||||
|
"Read More →"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create custom component collections
|
||||||
|
#[component]
|
||||||
|
pub fn BlogGrid(posts: ReadSignal<Vec<BlogPost>>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="blog-grid">
|
||||||
|
<For
|
||||||
|
each=move || posts.get()
|
||||||
|
key=|post| post.id.clone()
|
||||||
|
children=move |post| {
|
||||||
|
view! {
|
||||||
|
<BlogCard
|
||||||
|
title=post.title
|
||||||
|
excerpt=post.excerpt
|
||||||
|
slug=post.slug
|
||||||
|
date=post.published_date
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get**:
|
||||||
|
- Custom components built on foundation
|
||||||
|
- Reusable component patterns
|
||||||
|
- Integration with foundation theming
|
||||||
|
|
||||||
|
**Customization**: Full control over extended component behavior
|
||||||
|
|
||||||
|
## Pattern 5: Modular Composition
|
||||||
|
|
||||||
|
**Best for**: Complex applications with feature-based component organization
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
navigation::{BrandHeader, Footer},
|
||||||
|
content::ContentManager,
|
||||||
|
ui::{SpaLink, MobileMenu},
|
||||||
|
theme::ThemeProvider,
|
||||||
|
admin::{AdminLayout, AdminCard},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feature-based component modules
|
||||||
|
pub mod blog_components {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlogHeader() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<BrandHeader>
|
||||||
|
<nav class="blog-nav">
|
||||||
|
<SpaLink href="/blog">"Blog"</SpaLink>
|
||||||
|
<SpaLink href="/blog/categories">"Categories"</SpaLink>
|
||||||
|
<SpaLink href="/blog/archive">"Archive"</SpaLink>
|
||||||
|
</nav>
|
||||||
|
</BrandHeader>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BlogLayout(children: Children) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="blog-layout">
|
||||||
|
<BlogHeader />
|
||||||
|
<main class="blog-main">
|
||||||
|
{children()}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod admin_components {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminBlogManager() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<AdminLayout title="Blog Management">
|
||||||
|
<AdminCard title="Recent Posts">
|
||||||
|
<ContentManager content_type="blog" limit=10 />
|
||||||
|
</AdminCard>
|
||||||
|
|
||||||
|
<AdminCard title="Quick Actions">
|
||||||
|
<div class="admin-actions">
|
||||||
|
<SpaLink href="/admin/blog/new" class="btn btn-primary">
|
||||||
|
"New Post"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/admin/blog/drafts" class="btn btn-secondary">
|
||||||
|
"Drafts"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</AdminCard>
|
||||||
|
</AdminLayout>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main app composition
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<ThemeProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/blog/*" view=blog_components::BlogLayout />
|
||||||
|
<Route path="/admin/*" view=admin_components::AdminBlogManager />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</ThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you get**:
|
||||||
|
- Feature-organized component modules
|
||||||
|
- Selective component usage
|
||||||
|
- Clean separation of concerns
|
||||||
|
|
||||||
|
**Customization**: Module-based customization and feature flags
|
||||||
|
|
||||||
|
## Responsive Design Patterns
|
||||||
|
|
||||||
|
### Mobile-First Components
|
||||||
|
```rust
|
||||||
|
use components::{
|
||||||
|
navigation::BrandHeader,
|
||||||
|
ui::{MobileMenu, MobileMenuToggle},
|
||||||
|
theme::use_theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ResponsiveHeader() -> impl IntoView {
|
||||||
|
let (mobile_menu_open, set_mobile_menu_open) = create_signal(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<BrandHeader>
|
||||||
|
// Desktop navigation
|
||||||
|
<nav class="hidden md:block">
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/about">"About"</SpaLink>
|
||||||
|
<SpaLink href="/contact">"Contact"</SpaLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
// Mobile menu toggle
|
||||||
|
<div class="md:hidden">
|
||||||
|
<MobileMenuToggle
|
||||||
|
is_open=mobile_menu_open
|
||||||
|
on_toggle=move |_| set_mobile_menu_open.update(|open| *open = !*open)
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BrandHeader>
|
||||||
|
|
||||||
|
// Mobile menu
|
||||||
|
<MobileMenu
|
||||||
|
is_open=mobile_menu_open
|
||||||
|
on_close=move |_| set_mobile_menu_open.set(false)
|
||||||
|
>
|
||||||
|
<SpaLink href="/">"Home"</SpaLink>
|
||||||
|
<SpaLink href="/about">"About"</SpaLink>
|
||||||
|
<SpaLink href="/contact">"Contact"</SpaLink>
|
||||||
|
</MobileMenu>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Patterns
|
||||||
|
|
||||||
|
### Component Error Boundaries
|
||||||
|
```rust
|
||||||
|
use components::{content::UnifiedContentCard, ui::SpaLink};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SafeContentCard(
|
||||||
|
#[prop(into)] title: String,
|
||||||
|
#[prop(optional)] fallback: Option<View>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let content = create_resource(
|
||||||
|
|| (),
|
||||||
|
|_| async move { load_content().await }
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard title=title>
|
||||||
|
<Suspense fallback=|| view! { <div>"Loading..."</div> }>
|
||||||
|
<ErrorBoundary fallback=move |errors| {
|
||||||
|
match fallback {
|
||||||
|
Some(fallback_view) => fallback_view,
|
||||||
|
None => view! {
|
||||||
|
<div class="error-state">
|
||||||
|
<p>"Unable to load content"</p>
|
||||||
|
<SpaLink href="/" class="btn btn-primary">
|
||||||
|
"Go Home"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
}.into_view(),
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{move || {
|
||||||
|
content.get()
|
||||||
|
.map(|result| match result {
|
||||||
|
Ok(content) => view! { <div>{content}</div> },
|
||||||
|
Err(_) => view! { <div>"Error loading content"</div> },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization Patterns
|
||||||
|
|
||||||
|
### Lazy Loading Components
|
||||||
|
```rust
|
||||||
|
use components::{content::ContentManager, ui::SpaLink};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LazyContentSection(
|
||||||
|
#[prop(into)] section_id: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (is_visible, set_is_visible) = create_signal(false);
|
||||||
|
let section_ref = create_node_ref::<html::Div>();
|
||||||
|
|
||||||
|
// Intersection observer for lazy loading
|
||||||
|
create_effect(move |_| {
|
||||||
|
if let Some(section) = section_ref.get() {
|
||||||
|
let observer = web_sys::IntersectionObserver::new(&wasm_bindgen::Closure::wrap(
|
||||||
|
Box::new(move |entries: js_sys::Array, _observer| {
|
||||||
|
if entries.length() > 0 {
|
||||||
|
set_is_visible.set(true);
|
||||||
|
}
|
||||||
|
}) as Box<dyn Fn(js_sys::Array, web_sys::IntersectionObserver)>
|
||||||
|
).into_js_value().unchecked_into()).unwrap();
|
||||||
|
|
||||||
|
observer.observe(§ion);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div node_ref=section_ref class="lazy-section">
|
||||||
|
{move || {
|
||||||
|
if is_visible.get() {
|
||||||
|
view! {
|
||||||
|
<ContentManager
|
||||||
|
content_type="section"
|
||||||
|
filter=section_id.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="lazy-placeholder">
|
||||||
|
"Loading section..."
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Choosing the Right Pattern
|
||||||
|
|
||||||
|
| Need | Pattern | Complexity | Bundle Size | Flexibility |
|
||||||
|
|------|---------|------------|-------------|-------------|
|
||||||
|
| Single component | Direct Import | Low | Minimal | Low |
|
||||||
|
| Related components | Category Import | Low | Small | Medium |
|
||||||
|
| Consistent styling | Themed Application | Medium | Medium | Medium |
|
||||||
|
| Custom functionality | Extended Components | Medium | Variable | High |
|
||||||
|
| Complex apps | Modular Composition | High | Optimized | Maximum |
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. **Start Simple**: Use direct imports for initial development
|
||||||
|
2. **Add Theming**: Introduce ThemeProvider when styling becomes complex
|
||||||
|
3. **Create Extensions**: Build custom components as needs grow
|
||||||
|
4. **Organize Modularly**: Structure into feature modules for large apps
|
||||||
|
|
||||||
|
Each pattern builds on the foundation components, so migration is incremental and non-breaking.
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
//! # Basic Layout Example
|
||||||
|
//!
|
||||||
|
//! Demonstrates the simplest way to use foundation components to create
|
||||||
|
//! a basic application layout with header, content, and footer.
|
||||||
|
|
||||||
|
use rustelo_components::{
|
||||||
|
navigation::{BrandHeader, Footer, NavMenu},
|
||||||
|
content::UnifiedContentCard,
|
||||||
|
ui::SpaLink,
|
||||||
|
};
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
/// Basic layout using foundation components directly
|
||||||
|
#[component]
|
||||||
|
pub fn BasicLayout() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
// Header with navigation
|
||||||
|
<BrandHeader
|
||||||
|
brand_name="My Rustelo App"
|
||||||
|
logo_url="/logo.svg"
|
||||||
|
class="shadow-sm"
|
||||||
|
>
|
||||||
|
<NavMenu orientation="horizontal">
|
||||||
|
<SpaLink href="/" active_class="text-blue-600">
|
||||||
|
"Home"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/about" active_class="text-blue-600">
|
||||||
|
"About"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/contact" active_class="text-blue-600">
|
||||||
|
"Contact"
|
||||||
|
</SpaLink>
|
||||||
|
</NavMenu>
|
||||||
|
</BrandHeader>
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8">
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Welcome to Rustelo"
|
||||||
|
subtitle="A modern web framework for Rust"
|
||||||
|
>
|
||||||
|
<p class="mb-4">
|
||||||
|
"This is a basic layout example using foundation components. "
|
||||||
|
"The header, navigation, content card, and footer are all "
|
||||||
|
"provided by the components foundation library."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<SpaLink href="/docs" class="btn btn-primary">
|
||||||
|
"Documentation"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/examples" class="btn btn-secondary">
|
||||||
|
"More Examples"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Additional content sections
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Fast Development"
|
||||||
|
image_url="/features/speed.svg"
|
||||||
|
>
|
||||||
|
<p>"Build web applications quickly with pre-built components."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Type Safety"
|
||||||
|
image_url="/features/safety.svg"
|
||||||
|
>
|
||||||
|
<p>"Leverage Rust's type system for reliable web applications."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Modern Stack"
|
||||||
|
image_url="/features/modern.svg"
|
||||||
|
>
|
||||||
|
<p>"Built with Leptos, WebAssembly, and modern web standards."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
<Footer copyright="© 2024 My Company">
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<SpaLink href="/privacy" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Privacy Policy"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/terms" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Terms of Service"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/support" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Support"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example of using the layout in a full app
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<BasicLayout />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
To use this example:
|
||||||
|
|
||||||
|
1. Add to your Cargo.toml:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
components = { path = "path/to/components" }
|
||||||
|
leptos = { version = "0.8", features = ["csr"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. In your main.rs:
|
||||||
|
```rust
|
||||||
|
use basic_layout::App;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
mount_to_body(|| view! { <App /> });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides:
|
||||||
|
- ✅ Responsive header with navigation
|
||||||
|
- ✅ Flexible main content area
|
||||||
|
- ✅ Reusable content cards
|
||||||
|
- ✅ SPA-aware navigation links
|
||||||
|
- ✅ Professional footer
|
||||||
|
|
||||||
|
The layout is:
|
||||||
|
- 📱 Mobile-responsive by default
|
||||||
|
- ♿ Accessible with proper ARIA attributes
|
||||||
|
- 🎨 Styled with utility classes (works with Tailwind CSS)
|
||||||
|
- 🔗 Uses SPA routing for fast navigation
|
||||||
|
- 🧩 Composable - easy to modify and extend
|
||||||
|
*/
|
||||||
@ -0,0 +1,476 @@
|
|||||||
|
//! # Content Showcase Example
|
||||||
|
//!
|
||||||
|
//! Comprehensive demonstration of content components including
|
||||||
|
//! cards, grids, content management, and various layout patterns.
|
||||||
|
|
||||||
|
use rustelo_components::{
|
||||||
|
content::{
|
||||||
|
UnifiedContentCard,
|
||||||
|
ContentManager,
|
||||||
|
HtmlContent,
|
||||||
|
SimpleContentGrid
|
||||||
|
},
|
||||||
|
navigation::{BrandHeader, Footer},
|
||||||
|
ui::SpaLink,
|
||||||
|
filter::{UnifiedCategoryFilter, FilterItem},
|
||||||
|
};
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
/// Content showcase demonstrating various content display patterns
|
||||||
|
#[component]
|
||||||
|
pub fn ContentShowcase() -> impl IntoView {
|
||||||
|
// Content filter state
|
||||||
|
let (active_filter, set_active_filter) = create_signal("all".to_string());
|
||||||
|
|
||||||
|
// Sample content data
|
||||||
|
let content_items = create_rw_signal(vec![
|
||||||
|
ContentItem::new("1", "blog", "Getting Started with Rustelo", "Learn how to build modern web applications with Rust and Leptos.", "/blog/getting-started", Some("/images/blog1.jpg")),
|
||||||
|
ContentItem::new("2", "tutorial", "Advanced Component Patterns", "Explore advanced techniques for building reusable components.", "/tutorials/advanced-patterns", Some("/images/tutorial1.jpg")),
|
||||||
|
ContentItem::new("3", "news", "Rustelo 2.0 Released", "Major update brings new features and performance improvements.", "/news/v2-release", Some("/images/news1.jpg")),
|
||||||
|
ContentItem::new("4", "blog", "WebAssembly Performance Tips", "Optimize your WASM applications for better performance.", "/blog/wasm-performance", Some("/images/blog2.jpg")),
|
||||||
|
ContentItem::new("5", "tutorial", "Building a Blog with Rustelo", "Step-by-step guide to creating a full-featured blog.", "/tutorials/blog-tutorial", Some("/images/tutorial2.jpg")),
|
||||||
|
ContentItem::new("6", "showcase", "Community Project Spotlight", "Amazing projects built by the Rustelo community.", "/showcase/community", Some("/images/showcase1.jpg")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Filter items for the category filter
|
||||||
|
let filter_items = vec![
|
||||||
|
FilterItem::new("all", "All Content").with_count(6),
|
||||||
|
FilterItem::new("blog", "Blog Posts").with_count(2),
|
||||||
|
FilterItem::new("tutorial", "Tutorials").with_count(2),
|
||||||
|
FilterItem::new("news", "News").with_count(1),
|
||||||
|
FilterItem::new("showcase", "Showcase").with_count(1),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filtered content based on active filter
|
||||||
|
let filtered_content = create_memo(move |_| {
|
||||||
|
let filter = active_filter.get();
|
||||||
|
let items = content_items.get();
|
||||||
|
|
||||||
|
if filter == "all" {
|
||||||
|
items
|
||||||
|
} else {
|
||||||
|
items.into_iter().filter(|item| item.category == filter).collect()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<BrandHeader brand_name="Content Showcase" />
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
// Introduction section
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Content Components Showcase"
|
||||||
|
subtitle="Explore various ways to display and organize content"
|
||||||
|
class="mb-8"
|
||||||
|
>
|
||||||
|
<p class="mb-4">
|
||||||
|
"This showcase demonstrates the content components available in the Rustelo foundation library. "
|
||||||
|
"You'll see cards, grids, content management, HTML rendering, and filtering in action."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<span class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||||
|
"📋 Content Cards"
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
||||||
|
"🔍 Category Filtering"
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm">
|
||||||
|
"🎨 Grid Layouts"
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm">
|
||||||
|
"⚡ Dynamic Content"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Content filtering section
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Filterable Content Grid"</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
"Use the category filter below to see content filtering in action. "
|
||||||
|
"The grid updates dynamically based on your selection."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
items=filter_items
|
||||||
|
on_filter=move |category| {
|
||||||
|
set_active_filter.set(category);
|
||||||
|
}
|
||||||
|
class="mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Dynamic content grid
|
||||||
|
<SimpleContentGrid
|
||||||
|
columns=3
|
||||||
|
gap="1.5rem"
|
||||||
|
responsive=true
|
||||||
|
class="mb-8"
|
||||||
|
>
|
||||||
|
<For
|
||||||
|
each=move || filtered_content.get()
|
||||||
|
key=|item| item.id.clone()
|
||||||
|
children=move |item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
title=item.title.clone()
|
||||||
|
subtitle=item.excerpt.clone()
|
||||||
|
image_url=item.image_url.clone()
|
||||||
|
class="h-full"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mt-4">
|
||||||
|
<span class=format!(
|
||||||
|
"px-2 py-1 text-xs rounded-full {}",
|
||||||
|
match item.category.as_str() {
|
||||||
|
"blog" => "bg-blue-100 text-blue-800",
|
||||||
|
"tutorial" => "bg-green-100 text-green-800",
|
||||||
|
"news" => "bg-red-100 text-red-800",
|
||||||
|
"showcase" => "bg-purple-100 text-purple-800",
|
||||||
|
_ => "bg-gray-100 text-gray-800",
|
||||||
|
}
|
||||||
|
)>
|
||||||
|
{item.category.clone()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href=item.url.clone()
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
||||||
|
>
|
||||||
|
"Read More →"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SimpleContentGrid>
|
||||||
|
|
||||||
|
// Results counter
|
||||||
|
<div class="text-center text-gray-600">
|
||||||
|
{move || {
|
||||||
|
let count = filtered_content.get().len();
|
||||||
|
let filter = active_filter.get();
|
||||||
|
let filter_text = if filter == "all" { "all content" } else { &filter };
|
||||||
|
format!("Showing {} {} items", count, filter_text)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// HTML Content rendering demo
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">"HTML Content Rendering"</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
"The HtmlContent component safely renders HTML with built-in sanitization."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 gap-6">
|
||||||
|
<UnifiedContentCard title="Safe HTML Rendering" class="h-full">
|
||||||
|
<HtmlContent
|
||||||
|
html=r#"
|
||||||
|
<h3>Rich Text Content</h3>
|
||||||
|
<p>This HTML content is <strong>safely rendered</strong> with sanitization enabled.</p>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Safe HTML tags are allowed</li>
|
||||||
|
<li>🛡️ Dangerous scripts are removed</li>
|
||||||
|
<li>🎨 Styling is preserved</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>
|
||||||
|
<em>"Security and functionality working together."</em>
|
||||||
|
</blockquote>
|
||||||
|
"#
|
||||||
|
sanitize=true
|
||||||
|
class="prose prose-sm"
|
||||||
|
/>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="Markdown-style Content" class="h-full">
|
||||||
|
<HtmlContent
|
||||||
|
html=r#"
|
||||||
|
<div class="markdown-content">
|
||||||
|
<h3>📝 Code Example</h3>
|
||||||
|
<pre><code>use rustelo_components::content::HtmlContent;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<HtmlContent
|
||||||
|
html="<p>Safe HTML</p>"
|
||||||
|
sanitize=true
|
||||||
|
/>
|
||||||
|
}</code></pre>
|
||||||
|
<p>The component handles HTML rendering with configurable sanitization rules.</p>
|
||||||
|
</div>
|
||||||
|
"#
|
||||||
|
sanitize=true
|
||||||
|
class="prose prose-sm"
|
||||||
|
/>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Content Manager demo
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Dynamic Content Management"</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
"The ContentManager component handles dynamic content loading and display with various layout options."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
// List layout
|
||||||
|
<UnifiedContentCard title="List Layout">
|
||||||
|
<ContentManager
|
||||||
|
content_type="featured"
|
||||||
|
layout="list"
|
||||||
|
limit=3
|
||||||
|
class="space-y-4"
|
||||||
|
/>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Card layout
|
||||||
|
<UnifiedContentCard title="Card Layout">
|
||||||
|
<ContentManager
|
||||||
|
content_type="recent"
|
||||||
|
layout="cards"
|
||||||
|
limit=4
|
||||||
|
/>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Layout variations
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Grid Layout Variations"</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
"Different grid configurations for various content display needs."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
// 2-column grid
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">"Two Column Grid"</h3>
|
||||||
|
<SimpleContentGrid columns=2 gap="2rem" responsive=true>
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Performance Optimization"
|
||||||
|
image_url="/images/performance.jpg"
|
||||||
|
>
|
||||||
|
<p>"Learn techniques to optimize your Rustelo applications for better performance and user experience."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Testing Strategies"
|
||||||
|
image_url="/images/testing.jpg"
|
||||||
|
>
|
||||||
|
<p>"Comprehensive testing approaches for Rust web applications using modern testing frameworks."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</SimpleContentGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 4-column grid
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">"Four Column Grid"</h3>
|
||||||
|
<SimpleContentGrid columns=4 gap="1rem" responsive=true>
|
||||||
|
<UnifiedContentCard title="🚀 Fast" class="text-center">
|
||||||
|
<p class="text-sm">"Lightning-fast development with Rust's performance."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="🛡️ Safe" class="text-center">
|
||||||
|
<p class="text-sm">"Memory safety without garbage collection."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="🔧 Productive" class="text-center">
|
||||||
|
<p class="text-sm">"Rich type system and ownership model."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="🌐 Modern" class="text-center">
|
||||||
|
<p class="text-sm">"WebAssembly and modern web standards."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</SimpleContentGrid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mixed content sizes
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">"Mixed Content Sizes"</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Featured Article"
|
||||||
|
subtitle="In-depth tutorial on advanced Rustelo patterns"
|
||||||
|
image_url="/images/featured.jpg"
|
||||||
|
class="h-full"
|
||||||
|
>
|
||||||
|
<p>"This comprehensive guide covers advanced component patterns, state management, and performance optimization techniques for building scalable Rustelo applications."</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<SpaLink href="/featured" class="btn btn-primary">"Read Full Article"</SpaLink>
|
||||||
|
<SpaLink href="/tutorials" class="btn btn-secondary">"More Tutorials"</SpaLink>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="Quick Tips" class="h-full">
|
||||||
|
<ul class="text-sm space-y-2">
|
||||||
|
<li>"💡 Use memos for expensive computations"</li>
|
||||||
|
<li>"🔄 Batch state updates when possible"</li>
|
||||||
|
<li>"📦 Code-split large components"</li>
|
||||||
|
<li>"🎨 Use CSS-in-Rust for theming"</li>
|
||||||
|
</ul>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
<UnifiedContentCard title="Resources" class="h-full">
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<SpaLink href="/docs" class="block text-blue-600 hover:text-blue-800">
|
||||||
|
"📚 Documentation"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/examples" class="block text-blue-600 hover:text-blue-800">
|
||||||
|
"💻 Code Examples"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/community" class="block text-blue-600 hover:text-blue-800">
|
||||||
|
"👥 Community"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/github" class="block text-blue-600 hover:text-blue-800">
|
||||||
|
"🐙 GitHub Repo"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Loading states demo
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Loading States & Error Handling"</h2>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
"Content components with loading states and error boundaries for robust user experiences."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<SimpleContentGrid columns=3 gap="1.5rem" responsive=true>
|
||||||
|
// Loading state demo
|
||||||
|
<UnifiedContentCard title="Loading State">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-center text-gray-500 text-sm">
|
||||||
|
"Loading content..."
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Empty state demo
|
||||||
|
<UnifiedContentCard title="Empty State">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-6xl mb-4">"📭"</div>
|
||||||
|
<p class="text-gray-500 mb-4">"No content available"</p>
|
||||||
|
<SpaLink href="/create" class="btn btn-primary btn-sm">
|
||||||
|
"Create Content"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Error state demo
|
||||||
|
<UnifiedContentCard title="Error State">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-6xl mb-4">"⚠️"</div>
|
||||||
|
<p class="text-red-600 mb-4">"Failed to load content"</p>
|
||||||
|
<button class="btn btn-secondary btn-sm">
|
||||||
|
"Try Again"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
</SimpleContentGrid>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer copyright="© 2024 Content Showcase Demo" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper struct for content items
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ContentItem {
|
||||||
|
id: String,
|
||||||
|
category: String,
|
||||||
|
title: String,
|
||||||
|
excerpt: String,
|
||||||
|
url: String,
|
||||||
|
image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContentItem {
|
||||||
|
fn new(id: &str, category: &str, title: &str, excerpt: &str, url: &str, image_url: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.to_string(),
|
||||||
|
category: category.to_string(),
|
||||||
|
title: title.to_string(),
|
||||||
|
excerpt: excerpt.to_string(),
|
||||||
|
url: url.to_string(),
|
||||||
|
image_url: image_url.map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This comprehensive showcase demonstrates:
|
||||||
|
|
||||||
|
✅ **Content Display Patterns**
|
||||||
|
- Various card layouts and configurations
|
||||||
|
- Grid systems with responsive behavior
|
||||||
|
- Mixed content sizes and layouts
|
||||||
|
|
||||||
|
✅ **Dynamic Content Management**
|
||||||
|
- Content filtering and categorization
|
||||||
|
- Dynamic content loading with ContentManager
|
||||||
|
- Real-time content updates
|
||||||
|
|
||||||
|
✅ **HTML Rendering**
|
||||||
|
- Safe HTML content rendering with sanitization
|
||||||
|
- Support for rich text and markdown-style content
|
||||||
|
- Configurable sanitization rules
|
||||||
|
|
||||||
|
✅ **Layout Flexibility**
|
||||||
|
- Multiple grid column configurations
|
||||||
|
- Responsive grid behavior
|
||||||
|
- Mixed content sizing patterns
|
||||||
|
|
||||||
|
✅ **User Experience**
|
||||||
|
- Loading states and skeletons
|
||||||
|
- Empty state handling
|
||||||
|
- Error state management
|
||||||
|
- Interactive filtering
|
||||||
|
|
||||||
|
✅ **Content Organization**
|
||||||
|
- Category-based filtering
|
||||||
|
- Search and discovery patterns
|
||||||
|
- Content relationship management
|
||||||
|
|
||||||
|
To use these patterns in your application:
|
||||||
|
|
||||||
|
1. **Import required components:**
|
||||||
|
```rust
|
||||||
|
use rustelo_components::{
|
||||||
|
content::*,
|
||||||
|
filter::UnifiedCategoryFilter,
|
||||||
|
ui::SpaLink,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Customize content structures:**
|
||||||
|
- Replace ContentItem with your data models
|
||||||
|
- Adapt filtering logic to your categorization
|
||||||
|
- Customize card layouts for your content types
|
||||||
|
|
||||||
|
3. **Integrate with your data:**
|
||||||
|
- Connect ContentManager to your API
|
||||||
|
- Replace mock data with real content
|
||||||
|
- Add pagination and infinite scroll
|
||||||
|
|
||||||
|
4. **Style for your brand:**
|
||||||
|
- Customize CSS classes and colors
|
||||||
|
- Apply your design system
|
||||||
|
- Add animations and transitions
|
||||||
|
|
||||||
|
The showcase is fully responsive and accessible,
|
||||||
|
providing a solid foundation for content-rich applications.
|
||||||
|
*/
|
||||||
@ -0,0 +1,366 @@
|
|||||||
|
//! # Navigation Demo Example
|
||||||
|
//!
|
||||||
|
//! Comprehensive demonstration of navigation components including
|
||||||
|
//! responsive menus, language switching, and active state management.
|
||||||
|
|
||||||
|
use rustelo_components::{
|
||||||
|
navigation::{BrandHeader, Footer, NavMenu, LanguageSelector},
|
||||||
|
ui::{SpaLink, MobileMenu, MobileMenuToggle},
|
||||||
|
content::UnifiedContentCard,
|
||||||
|
};
|
||||||
|
use leptos::*;
|
||||||
|
|
||||||
|
/// Advanced navigation demo with responsive mobile menu and language switching
|
||||||
|
#[component]
|
||||||
|
pub fn NavigationDemo() -> impl IntoView {
|
||||||
|
// Mobile menu state
|
||||||
|
let (mobile_menu_open, set_mobile_menu_open) = create_signal(false);
|
||||||
|
|
||||||
|
// Language state
|
||||||
|
let (current_language, set_current_language) = create_signal("en".to_string());
|
||||||
|
let available_languages = vec!["en".to_string(), "es".to_string(), "fr".to_string()];
|
||||||
|
|
||||||
|
// Current page for active state demo
|
||||||
|
let (current_path, set_current_path) = create_signal("/".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
// Advanced header with responsive navigation
|
||||||
|
<BrandHeader
|
||||||
|
brand_name="Rustelo Navigation Demo"
|
||||||
|
logo_url="/logo.svg"
|
||||||
|
class="bg-white shadow-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between w-full">
|
||||||
|
// Desktop navigation menu
|
||||||
|
<div class="hidden lg:block">
|
||||||
|
<NavMenu
|
||||||
|
orientation="horizontal"
|
||||||
|
active_path=current_path
|
||||||
|
class="space-x-1"
|
||||||
|
>
|
||||||
|
<SpaLink
|
||||||
|
href="/"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| set_current_path.set("/".to_string())
|
||||||
|
>
|
||||||
|
"Home"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/products"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| set_current_path.set("/products".to_string())
|
||||||
|
>
|
||||||
|
"Products"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/services"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| set_current_path.set("/services".to_string())
|
||||||
|
>
|
||||||
|
"Services"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/blog"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| set_current_path.set("/blog".to_string())
|
||||||
|
>
|
||||||
|
"Blog"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/contact"
|
||||||
|
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| set_current_path.set("/contact".to_string())
|
||||||
|
>
|
||||||
|
"Contact"
|
||||||
|
</SpaLink>
|
||||||
|
</NavMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Header utilities (language selector + mobile menu toggle)
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
// Language selector
|
||||||
|
<LanguageSelector
|
||||||
|
current_lang=current_language
|
||||||
|
available_langs=available_languages
|
||||||
|
on_change=move |new_lang| {
|
||||||
|
set_current_language.set(new_lang.clone());
|
||||||
|
logging::log!("Language changed to: {}", new_lang);
|
||||||
|
}
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Mobile menu toggle (hidden on desktop)
|
||||||
|
<div class="lg:hidden">
|
||||||
|
<MobileMenuToggle
|
||||||
|
is_open=mobile_menu_open
|
||||||
|
on_toggle=move |_| {
|
||||||
|
set_mobile_menu_open.update(|open| *open = !*open);
|
||||||
|
}
|
||||||
|
class="text-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BrandHeader>
|
||||||
|
|
||||||
|
// Mobile slide-out menu
|
||||||
|
<MobileMenu
|
||||||
|
is_open=mobile_menu_open
|
||||||
|
on_close=move |_| set_mobile_menu_open.set(false)
|
||||||
|
position="right"
|
||||||
|
class="lg:hidden"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-6 space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">"Navigation"</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<SpaLink
|
||||||
|
href="/"
|
||||||
|
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_current_path.set("/".to_string());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"🏠 Home"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/products"
|
||||||
|
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_current_path.set("/products".to_string());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"📦 Products"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/services"
|
||||||
|
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_current_path.set("/services".to_string());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"⚙️ Services"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/blog"
|
||||||
|
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_current_path.set("/blog".to_string());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"📝 Blog"
|
||||||
|
</SpaLink>
|
||||||
|
|
||||||
|
<SpaLink
|
||||||
|
href="/contact"
|
||||||
|
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
active_class="bg-blue-100 text-blue-700"
|
||||||
|
on:click=move |_| {
|
||||||
|
set_current_path.set("/contact".to_string());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"📞 Contact"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile language selector
|
||||||
|
<div class="pt-4 mt-4 border-t border-gray-200">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">"Language"</h4>
|
||||||
|
<LanguageSelector
|
||||||
|
current_lang=current_language
|
||||||
|
available_langs=available_languages.clone()
|
||||||
|
on_change=move |new_lang| {
|
||||||
|
set_current_language.set(new_lang.clone());
|
||||||
|
set_mobile_menu_open.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileMenu>
|
||||||
|
|
||||||
|
// Main content showcasing current navigation state
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<UnifiedContentCard
|
||||||
|
title="Navigation Demo"
|
||||||
|
subtitle="Interactive navigation component showcase"
|
||||||
|
>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">"Current State"</h3>
|
||||||
|
<div class="bg-gray-100 p-4 rounded-lg">
|
||||||
|
<p><strong>"Active Page:"</strong> {move || current_path.get()}</p>
|
||||||
|
<p><strong>"Current Language:"</strong> {move || current_language.get()}</p>
|
||||||
|
<p><strong>"Mobile Menu:"</strong> {move || if mobile_menu_open.get() { "Open" } else { "Closed" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">"Features Demonstrated"</h3>
|
||||||
|
<ul class="list-disc list-inside space-y-2 text-gray-700">
|
||||||
|
<li>"Responsive navigation (desktop horizontal, mobile slide-out)"</li>
|
||||||
|
<li>"Active state management with visual indicators"</li>
|
||||||
|
<li>"Language switching with state persistence"</li>
|
||||||
|
<li>"Mobile menu toggle with overlay"</li>
|
||||||
|
<li>"SPA-aware navigation links"</li>
|
||||||
|
<li>"Keyboard navigation support"</li>
|
||||||
|
<li>"Accessible ARIA attributes"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">"Try It Out"</h3>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="font-medium">"Desktop Navigation:"</p>
|
||||||
|
<ul class="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>"• Click navigation items to see active states"</li>
|
||||||
|
<li>"• Switch languages to see state changes"</li>
|
||||||
|
<li>"• Resize window to see responsive behavior"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="font-medium">"Mobile Navigation:"</p>
|
||||||
|
<ul class="text-sm text-gray-600 space-y-1">
|
||||||
|
<li>"• Use hamburger menu on small screens"</li>
|
||||||
|
<li>"• Test slide-out navigation"</li>
|
||||||
|
<li>"• Try mobile language switching"</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
|
||||||
|
// Demonstration pages content
|
||||||
|
<div class="mt-8 grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{move || {
|
||||||
|
match current_path.get().as_str() {
|
||||||
|
"/" => view! {
|
||||||
|
<UnifiedContentCard title="Home Page">
|
||||||
|
<p>"Welcome to the navigation demo home page. This content changes based on the active navigation item."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
|
||||||
|
"/products" => view! {
|
||||||
|
<UnifiedContentCard title="Products">
|
||||||
|
<p>"Product catalog and listings would appear here."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
|
||||||
|
"/services" => view! {
|
||||||
|
<UnifiedContentCard title="Services">
|
||||||
|
<p>"Service offerings and descriptions would be shown here."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
|
||||||
|
"/blog" => view! {
|
||||||
|
<UnifiedContentCard title="Blog">
|
||||||
|
<p>"Blog posts and articles would be listed here."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
|
||||||
|
"/contact" => view! {
|
||||||
|
<UnifiedContentCard title="Contact">
|
||||||
|
<p>"Contact information and forms would be available here."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
|
||||||
|
_ => view! {
|
||||||
|
<UnifiedContentCard title="Page Not Found">
|
||||||
|
<p>"This page doesn't exist in the demo."</p>
|
||||||
|
</UnifiedContentCard>
|
||||||
|
}.into_view(),
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
// Footer with additional navigation
|
||||||
|
<Footer
|
||||||
|
copyright="© 2024 Rustelo Navigation Demo"
|
||||||
|
class="bg-white border-t"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap justify-center space-x-6 text-sm">
|
||||||
|
<SpaLink href="/privacy" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Privacy Policy"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/terms" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Terms of Service"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/accessibility" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Accessibility"
|
||||||
|
</SpaLink>
|
||||||
|
<SpaLink href="/sitemap" class="text-gray-600 hover:text-gray-800">
|
||||||
|
"Sitemap"
|
||||||
|
</SpaLink>
|
||||||
|
</div>
|
||||||
|
</Footer>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This example demonstrates:
|
||||||
|
|
||||||
|
✅ **Responsive Navigation**
|
||||||
|
- Desktop horizontal navigation menu
|
||||||
|
- Mobile slide-out menu with overlay
|
||||||
|
- Automatic responsive behavior
|
||||||
|
|
||||||
|
✅ **Active State Management**
|
||||||
|
- Visual indicators for current page
|
||||||
|
- Consistent styling across components
|
||||||
|
- State synchronization
|
||||||
|
|
||||||
|
✅ **Language Switching**
|
||||||
|
- Multi-language support
|
||||||
|
- State persistence across navigation
|
||||||
|
- Integration with i18n systems
|
||||||
|
|
||||||
|
✅ **Mobile UX**
|
||||||
|
- Touch-friendly mobile menu toggle
|
||||||
|
- Slide-out navigation with proper animations
|
||||||
|
- Mobile-optimized layout and spacing
|
||||||
|
|
||||||
|
✅ **Accessibility Features**
|
||||||
|
- ARIA attributes for screen readers
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Focus management and indicators
|
||||||
|
|
||||||
|
✅ **SPA Navigation**
|
||||||
|
- Client-side routing with SpaLink
|
||||||
|
- No page reloads for navigation
|
||||||
|
- Fast navigation between pages
|
||||||
|
|
||||||
|
To use in your application:
|
||||||
|
1. Import the NavigationDemo component
|
||||||
|
2. Customize the navigation items for your needs
|
||||||
|
3. Replace demo content with real pages
|
||||||
|
4. Add your brand colors and styling
|
||||||
|
5. Integrate with your routing system
|
||||||
|
|
||||||
|
The component is fully self-contained and can be used as-is
|
||||||
|
or customized for your specific navigation requirements.
|
||||||
|
*/
|
||||||
@ -0,0 +1,326 @@
|
|||||||
|
//! Client-side Admin Layout Components
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of admin layout components for client-side rendering.
|
||||||
|
|
||||||
|
use super::unified::AdminSection;
|
||||||
|
use crate::ui::spa_link::SpaLink;
|
||||||
|
use ::rustelo_core_lib::i18n::{use_unified_i18n, UnifiedI18n};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminLayout(
|
||||||
|
current_path: ReadSignal<String>,
|
||||||
|
#[prop(optional)] children: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
let current_section = Memo::new(move |_| {
|
||||||
|
let pathname = current_path.get();
|
||||||
|
match pathname.as_str() {
|
||||||
|
"/admin/users" => AdminSection::Users,
|
||||||
|
"/admin/roles" => AdminSection::Roles,
|
||||||
|
"/admin/content" => AdminSection::Content,
|
||||||
|
_ => AdminSection::Dashboard,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<div class="flex">
|
||||||
|
// Sidebar
|
||||||
|
<div class="fixed inset-y-0 left-0 z-50 w-64 ds-bg ds-shadow-lg border-r ds-border transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
|
||||||
|
<div class="flex items-center justify-center h-16 px-4 bg-indigo-600">
|
||||||
|
<h1 class="text-xl font-bold ds-text">
|
||||||
|
{i18n.t("admin-dashboard")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mt-8 px-4">
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Dashboard
|
||||||
|
current_section=current_section
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Users
|
||||||
|
current_section=current_section
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Roles
|
||||||
|
current_section=current_section
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Content
|
||||||
|
current_section=current_section
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
// User info at bottom
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 border-t ds-border">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-indigo-600 ds-rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 ds-text" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<p class="ds-caption font-medium ds-text truncate">
|
||||||
|
{i18n.t("admin-user")}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs ds-text-muted truncate">
|
||||||
|
"admin@example.com"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2">
|
||||||
|
<button class="ds-text-muted hover:ds-text-secondary">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
<div class="flex-1 lg:ml-64">
|
||||||
|
<main class="flex-1">
|
||||||
|
// Dynamic content based on current section - this would typically be handled by routing
|
||||||
|
{children.map(|c| c()).unwrap_or_else(|| {
|
||||||
|
view! {
|
||||||
|
<div class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold ds-text mb-4">
|
||||||
|
{current_section.get().title(&i18n)}
|
||||||
|
</h2>
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
{i18n.t("admin-content-placeholder")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn AdminNavItem(
|
||||||
|
section: AdminSection,
|
||||||
|
current_section: Memo<AdminSection>,
|
||||||
|
i18n: UnifiedI18n,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let section_route = section.route().to_string();
|
||||||
|
let section_icon = section.icon();
|
||||||
|
let section_title = section.title(&i18n);
|
||||||
|
let is_current = Memo::new(move |_| current_section.get() == section);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SpaLink
|
||||||
|
href=section_route
|
||||||
|
class={
|
||||||
|
let base_classes = "group flex items-center px-2 py-2 ds-caption font-medium ds-rounded-md transition-colors duration-150 ease-in-out mb-1";
|
||||||
|
if is_current.get() {
|
||||||
|
format!("{} bg-indigo-100 text-indigo-700", base_classes)
|
||||||
|
} else {
|
||||||
|
format!("{} ds-text-secondary hover:ds-text", base_classes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class={
|
||||||
|
let base_classes = "mr-3 flex-shrink-0 h-6 w-6";
|
||||||
|
if is_current.get() {
|
||||||
|
format!("{} text-indigo-500", base_classes)
|
||||||
|
} else {
|
||||||
|
format!("{} ds-text-muted group-hover:ds-text-muted", base_classes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d=section_icon
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{section_title}
|
||||||
|
</SpaLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminBreadcrumb(current_path: ReadSignal<String>) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
let breadcrumb_items = Memo::new(move |_| {
|
||||||
|
let pathname = current_path.get();
|
||||||
|
let mut items = vec![(i18n.t("admin").to_string(), "/admin".to_string())];
|
||||||
|
|
||||||
|
match pathname.as_str() {
|
||||||
|
"/admin/users" => items.push((i18n.t("admin-users-title"), "/admin/users".to_string())),
|
||||||
|
"/admin/roles" => items.push((i18n.t("admin-roles-title"), "/admin/roles".to_string())),
|
||||||
|
"/admin/content" => {
|
||||||
|
items.push((i18n.t("admin-content-title"), "/admin/content".to_string()))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="flex mb-4" aria-label="Breadcrumb">
|
||||||
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
|
<For
|
||||||
|
each=move || breadcrumb_items.get()
|
||||||
|
key=|(title, _)| title.clone()
|
||||||
|
children=move |(title, href)| {
|
||||||
|
let items = breadcrumb_items.get();
|
||||||
|
let is_last = items.last().map(|(t, _)| t.as_str()) == Some(&title);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
{if is_last {
|
||||||
|
view! {
|
||||||
|
<span class="ml-1 ds-caption font-medium ds-text-muted md:ml-2">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<SpaLink
|
||||||
|
href=href
|
||||||
|
class="inline-flex items-center ds-caption font-medium ds-text-secondary hover:text-blue-600".to_string()
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</SpaLink>
|
||||||
|
<svg class="w-6 h-6 ds-text-muted ml-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminHeader(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] subtitle: Option<String>,
|
||||||
|
#[prop(optional)] actions: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let title_text = title.unwrap_or_else(|| i18n.t("admin"));
|
||||||
|
let subtitle_text = subtitle.unwrap_or_default();
|
||||||
|
let has_subtitle = !subtitle_text.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="ds-bg shadow">
|
||||||
|
<div class="px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
|
||||||
|
<div class="py-6 md:flex md:items-center md:justify-between lg:border-t lg:ds-border">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="ml-3 text-2xl font-bold leading-7 ds-text sm:leading-9 sm:truncate">
|
||||||
|
{title_text}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Show when=move || has_subtitle>
|
||||||
|
<dl class="mt-6 flex flex-col sm:ml-3 sm:mt-1 sm:flex-row sm:flex-wrap">
|
||||||
|
<dd class="ds-caption ds-text-muted sm:mr-6">
|
||||||
|
{subtitle_text.clone()}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex space-x-3 md:mt-0 md:ml-4">
|
||||||
|
{actions.map(|a| a()).unwrap_or_else(|| view! {}.into_any())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminCard(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class_str = class.unwrap_or_default();
|
||||||
|
let title_str = title.unwrap_or_default();
|
||||||
|
let has_title = !title_str.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=format!(
|
||||||
|
"ds-bg overflow-hidden shadow ds-rounded-lg {}",
|
||||||
|
class_str
|
||||||
|
)>
|
||||||
|
<Show when=move || has_title>
|
||||||
|
<div class="px-4 py-5 sm:p-6 border-b ds-border">
|
||||||
|
<h3 class="ds-body leading-6 font-medium ds-text">
|
||||||
|
{title_str.clone()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminEmptyState(
|
||||||
|
#[prop(optional)] icon: Option<String>,
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] description: Option<String>,
|
||||||
|
#[prop(optional)] action: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let icon_str = icon.unwrap_or_default();
|
||||||
|
let title_str = title.unwrap_or_else(|| i18n.t("admin-no-items"));
|
||||||
|
let description_str = description.unwrap_or_default();
|
||||||
|
let has_icon = !icon_str.is_empty();
|
||||||
|
let has_description = !description_str.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<Show when=move || has_icon>
|
||||||
|
<svg class="mx-auto h-12 w-12 ds-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=icon_str.clone()></path>
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
<h3 class="mt-2 ds-caption font-medium ds-text">
|
||||||
|
{title_str}
|
||||||
|
</h3>
|
||||||
|
<Show when=move || has_description>
|
||||||
|
<p class="mt-1 ds-caption ds-text-muted">
|
||||||
|
{description_str.clone()}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<div class="mt-6">
|
||||||
|
{action.map(|a| a()).unwrap_or_else(|| view! {}.into_any())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
//! Admin Layout component module
|
||||||
|
//!
|
||||||
|
//! Provides unified admin layout components that work across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interfaces as the main API
|
||||||
|
pub use unified::{
|
||||||
|
AdminBreadcrumb, AdminCard, AdminEmptyState, AdminHeader, AdminLayout, AdminSection,
|
||||||
|
};
|
||||||
@ -0,0 +1,320 @@
|
|||||||
|
//! Server-side Admin Layout Components
|
||||||
|
//!
|
||||||
|
//! Static implementation of admin layout components for server-side rendering.
|
||||||
|
|
||||||
|
use super::unified::AdminSection;
|
||||||
|
use ::rustelo_core_lib::i18n::use_unified_i18n;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminLayout(
|
||||||
|
current_path: ReadSignal<String>,
|
||||||
|
#[prop(optional)] children: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
let current_section = move || {
|
||||||
|
let pathname = current_path.get();
|
||||||
|
match pathname.as_str() {
|
||||||
|
"/admin/users" => AdminSection::Users,
|
||||||
|
"/admin/roles" => AdminSection::Roles,
|
||||||
|
"/admin/content" => AdminSection::Content,
|
||||||
|
_ => AdminSection::Dashboard,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="min-h-screen">
|
||||||
|
<div class="flex">
|
||||||
|
// Sidebar
|
||||||
|
<div class="fixed inset-y-0 left-0 z-50 w-64 ds-bg ds-shadow-lg border-r ds-border transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
|
||||||
|
<div class="flex items-center justify-center h-16 px-4 bg-indigo-600">
|
||||||
|
<h1 class="text-xl font-bold ds-text">
|
||||||
|
{i18n.t("admin-dashboard")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mt-8 px-4">
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Dashboard
|
||||||
|
current_section=current_section()
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Users
|
||||||
|
current_section=current_section()
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Roles
|
||||||
|
current_section=current_section()
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
<AdminNavItem
|
||||||
|
section=AdminSection::Content
|
||||||
|
current_section=current_section()
|
||||||
|
i18n=i18n.clone()
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
// User info at bottom
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-4 border-t ds-border">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-8 h-8 bg-indigo-600 ds-rounded-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 ds-text" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 min-w-0">
|
||||||
|
<p class="ds-caption font-medium ds-text truncate">
|
||||||
|
{i18n.t("admin-user")}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs ds-text-muted truncate">
|
||||||
|
"admin@example.com"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-2">
|
||||||
|
<button class="ds-text-muted hover:ds-text-secondary">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
<div class="flex-1 lg:ml-64">
|
||||||
|
<main class="flex-1">
|
||||||
|
{children.map(|c| c()).unwrap_or_else(|| {
|
||||||
|
view! {
|
||||||
|
<div class="p-8">
|
||||||
|
<h2 class="text-2xl font-bold ds-text mb-4">
|
||||||
|
{current_section().title(&i18n)}
|
||||||
|
</h2>
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
{i18n.t("admin-content-placeholder")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn AdminNavItem(
|
||||||
|
section: AdminSection,
|
||||||
|
current_section: AdminSection,
|
||||||
|
i18n: rustelo_core_lib::i18n::UnifiedI18n,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let section_route = section.route();
|
||||||
|
let section_icon = section.icon();
|
||||||
|
let section_title = section.title(&i18n);
|
||||||
|
let is_current = current_section == section;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href=section_route
|
||||||
|
class={
|
||||||
|
let base_classes = "group flex items-center px-2 py-2 ds-caption font-medium ds-rounded-md transition-colors duration-150 ease-in-out mb-1";
|
||||||
|
if is_current {
|
||||||
|
format!("{} bg-indigo-100 text-indigo-700", base_classes)
|
||||||
|
} else {
|
||||||
|
format!("{} ds-text-secondary hover:ds-text", base_classes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class={
|
||||||
|
let base_classes = "mr-3 flex-shrink-0 h-6 w-6";
|
||||||
|
if is_current {
|
||||||
|
format!("{} text-indigo-500", base_classes)
|
||||||
|
} else {
|
||||||
|
format!("{} ds-text-muted group-hover:ds-text-muted", base_classes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d=section_icon
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{section_title}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminBreadcrumb(current_path: ReadSignal<String>) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
let breadcrumb_items = move || {
|
||||||
|
let pathname = current_path.get();
|
||||||
|
let mut items = vec![(i18n.t("admin").to_string(), "/admin".to_string())];
|
||||||
|
|
||||||
|
match pathname.as_str() {
|
||||||
|
"/admin/users" => items.push((i18n.t("admin-users-title"), "/admin/users".to_string())),
|
||||||
|
"/admin/roles" => items.push((i18n.t("admin-roles-title"), "/admin/roles".to_string())),
|
||||||
|
"/admin/content" => {
|
||||||
|
items.push((i18n.t("admin-content-title"), "/admin/content".to_string()))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="flex mb-4" aria-label="Breadcrumb">
|
||||||
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
|
{breadcrumb_items().into_iter().enumerate().map(|(index, (title, href))| {
|
||||||
|
let items = breadcrumb_items();
|
||||||
|
let is_last = index == items.len() - 1;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
{if is_last {
|
||||||
|
view! {
|
||||||
|
<span class="ml-1 ds-caption font-medium ds-text-muted md:ml-2">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href=href
|
||||||
|
class="inline-flex items-center ds-caption font-medium ds-text-secondary hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
<svg class="w-6 h-6 ds-text-muted ml-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminHeader(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] subtitle: Option<String>,
|
||||||
|
#[prop(optional)] actions: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let title_text = title.unwrap_or_else(|| i18n.t("admin"));
|
||||||
|
let subtitle_text = subtitle.unwrap_or_default();
|
||||||
|
let has_subtitle = !subtitle_text.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="ds-bg shadow">
|
||||||
|
<div class="px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
|
||||||
|
<div class="py-6 md:flex md:items-center md:justify-between lg:border-t lg:ds-border">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="ml-3 text-2xl font-bold leading-7 ds-text sm:leading-9 sm:truncate">
|
||||||
|
{title_text}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{has_subtitle.then(|| view! {
|
||||||
|
<dl class="mt-6 flex flex-col sm:ml-3 sm:mt-1 sm:flex-row sm:flex-wrap">
|
||||||
|
<dd class="ds-caption ds-text-muted sm:mr-6">
|
||||||
|
{subtitle_text.clone()}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex space-x-3 md:mt-0 md:ml-4">
|
||||||
|
{actions.map(|a| a())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminCard(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let class_str = class.unwrap_or_default();
|
||||||
|
let title_str = title.unwrap_or_default();
|
||||||
|
let has_title = !title_str.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class=format!(
|
||||||
|
"ds-bg overflow-hidden shadow ds-rounded-lg {}",
|
||||||
|
class_str
|
||||||
|
)>
|
||||||
|
{has_title.then(|| view! {
|
||||||
|
<div class="px-4 py-5 sm:p-6 border-b ds-border">
|
||||||
|
<h3 class="ds-body leading-6 font-medium ds-text">
|
||||||
|
{title_str.clone()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminEmptyState(
|
||||||
|
#[prop(optional)] icon: Option<String>,
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] description: Option<String>,
|
||||||
|
#[prop(optional)] action: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let icon_str = icon.unwrap_or_default();
|
||||||
|
let title_str = title.unwrap_or_else(|| i18n.t("admin-no-items"));
|
||||||
|
let description_str = description.unwrap_or_default();
|
||||||
|
let has_icon = !icon_str.is_empty();
|
||||||
|
let has_description = !description_str.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="text-center py-12">
|
||||||
|
{has_icon.then(|| view! {
|
||||||
|
<svg class="mx-auto h-12 w-12 ds-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=icon_str.clone()></path>
|
||||||
|
</svg>
|
||||||
|
})}
|
||||||
|
<h3 class="mt-2 ds-caption font-medium ds-text">
|
||||||
|
{title_str}
|
||||||
|
</h3>
|
||||||
|
{has_description.then(|| view! {
|
||||||
|
<p class="mt-1 ds-caption ds-text-muted">
|
||||||
|
{description_str.clone()}
|
||||||
|
</p>
|
||||||
|
})}
|
||||||
|
<div class="mt-6">
|
||||||
|
{action.map(|a| a())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
//! Unified admin layout component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Define AdminSection here to avoid conditional compilation issues
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum AdminSection {
|
||||||
|
Dashboard,
|
||||||
|
Users,
|
||||||
|
Roles,
|
||||||
|
Content,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use super::client::{
|
||||||
|
AdminBreadcrumb as AdminBreadcrumbClient, AdminCard as AdminCardClient,
|
||||||
|
AdminEmptyState as AdminEmptyStateClient, AdminHeader as AdminHeaderClient,
|
||||||
|
AdminLayout as AdminLayoutClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use super::ssr::{
|
||||||
|
AdminBreadcrumb as AdminBreadcrumbSSR, AdminCard as AdminCardSSR,
|
||||||
|
AdminEmptyState as AdminEmptyStateSSR, AdminHeader as AdminHeaderSSR,
|
||||||
|
AdminLayout as AdminLayoutSSR,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl AdminSection {
|
||||||
|
pub fn route(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AdminSection::Dashboard => "/admin",
|
||||||
|
AdminSection::Users => "/admin/users",
|
||||||
|
AdminSection::Roles => "/admin/roles",
|
||||||
|
AdminSection::Content => "/admin/content",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn title(&self, i18n: &::rustelo_core_lib::i18n::UnifiedI18n) -> String {
|
||||||
|
match self {
|
||||||
|
AdminSection::Dashboard => i18n.t("admin-dashboard-title"),
|
||||||
|
AdminSection::Users => i18n.t("admin-users-title"),
|
||||||
|
AdminSection::Roles => i18n.t("admin-roles-title"),
|
||||||
|
AdminSection::Content => i18n.t("admin-content-title"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AdminSection::Dashboard => {
|
||||||
|
"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586l-2 2V5H5v14h7v2H4a1 1 0 01-1-1V4z"
|
||||||
|
}
|
||||||
|
AdminSection::Users => {
|
||||||
|
"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
|
||||||
|
}
|
||||||
|
AdminSection::Roles => {
|
||||||
|
"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
}
|
||||||
|
AdminSection::Content => {
|
||||||
|
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified admin layout component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn AdminLayout(
|
||||||
|
current_path: ReadSignal<String>,
|
||||||
|
#[prop(optional)] children: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<AdminLayoutSSR
|
||||||
|
current_path=current_path
|
||||||
|
children=children.unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<AdminLayoutClient
|
||||||
|
current_path=current_path
|
||||||
|
children=children.map(|c| Box::new(move || c()) as Box<dyn FnOnce() -> AnyView + Send>).unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified admin breadcrumb component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn AdminBreadcrumb(current_path: ReadSignal<String>) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<AdminBreadcrumbSSR current_path=current_path />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<AdminBreadcrumbClient current_path=current_path />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified admin header component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn AdminHeader(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] subtitle: Option<String>,
|
||||||
|
#[prop(optional)] actions: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<AdminHeaderSSR
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
subtitle=subtitle.unwrap_or_default()
|
||||||
|
actions=actions.unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<AdminHeaderClient
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
subtitle=subtitle.unwrap_or_default()
|
||||||
|
actions=actions.map(|a| Box::new(move || a()) as Box<dyn FnOnce() -> AnyView + Send>).unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified admin card component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn AdminCard(
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
children: Children,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<AdminCardSSR
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</AdminCardSSR>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<AdminCardClient
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</AdminCardClient>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified admin empty state component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn AdminEmptyState(
|
||||||
|
#[prop(optional)] icon: Option<String>,
|
||||||
|
#[prop(optional)] title: Option<String>,
|
||||||
|
#[prop(optional)] description: Option<String>,
|
||||||
|
#[prop(optional)] action: Option<Children>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<AdminEmptyStateSSR
|
||||||
|
icon=icon.unwrap_or_default()
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
description=description.unwrap_or_default()
|
||||||
|
action=action.unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<AdminEmptyStateClient
|
||||||
|
icon=icon.unwrap_or_default()
|
||||||
|
title=title.unwrap_or_default()
|
||||||
|
description=description.unwrap_or_default()
|
||||||
|
action=action.map(|a| Box::new(move || a()) as Box<dyn FnOnce() -> AnyView + Send>).unwrap_or_else(|| Box::new(|| AnyView::from(Fragment::new(vec![]))))
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
//! Admin components module
|
||||||
|
//!
|
||||||
|
//! Provides unified admin layout and utility components.
|
||||||
|
|
||||||
|
pub mod admin_layout;
|
||||||
275
crates/foundation/crates/rustelo_components/src/content/card.rs
Normal file
275
crates/foundation/crates/rustelo_components/src/content/card.rs
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
//! Unified Content Card Component
|
||||||
|
//!
|
||||||
|
//! Generic content card that works with any content type using the unified trait system
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
categories::get_category_emoji,
|
||||||
|
content::{ContentItemTrait, UnifiedContentItem},
|
||||||
|
i18n::create_content_provider,
|
||||||
|
// Note: get_content_type_base_path import removed - function not available
|
||||||
|
};
|
||||||
|
// Removed leptos_router import - using custom routing system
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use ::rustelo_core_lib::utils::nav;
|
||||||
|
|
||||||
|
/// Unified Content Card component that works with any content type
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedContentCard(
|
||||||
|
#[prop(into)] content_item: UnifiedContentItem,
|
||||||
|
#[prop(into)] content_type: String,
|
||||||
|
#[prop(into)] language: String,
|
||||||
|
#[prop(into)] _content_config: rustelo_core_lib::ContentConfig,
|
||||||
|
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
#[prop(optional)] base_path: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let content = create_content_provider(lang_content.clone());
|
||||||
|
let title = content_item.title().to_string();
|
||||||
|
let excerpt = content_item.excerpt.as_deref().unwrap_or("");
|
||||||
|
let date = content_item.created_at().to_string();
|
||||||
|
// Get the appropriate time field based on content type
|
||||||
|
let time_display = if let Some(read_time_str) = content_item.read_time.as_ref() {
|
||||||
|
if !read_time_str.is_empty() {
|
||||||
|
read_time_str.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let categories = content_item.categories();
|
||||||
|
let tags = content_item.tags();
|
||||||
|
let featured = content_item.featured();
|
||||||
|
|
||||||
|
// Generate URL using built-in method
|
||||||
|
let url = content_item.url_path();
|
||||||
|
let title_url = content_item.url_path();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<article class={
|
||||||
|
format!("ds-card ds-shadow-xl h-full {}",
|
||||||
|
if featured {
|
||||||
|
"ds-bg-base-200/50 ds-ring-2 ds-ring-primary/30 border border-base-300/50"
|
||||||
|
} else {
|
||||||
|
"ds-bg-base-100"
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<div class="ds-card-body p-6 flex flex-col justify-between h-full">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<h3 class="ds-card-title mb-3 flex flex-col">
|
||||||
|
<a href={title_url.clone()} class="ds-link-hover no-underline hover:text-primary transition-colors"
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &title_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</a>
|
||||||
|
{if featured {
|
||||||
|
view! {
|
||||||
|
<div class="ds-card-featured ds-text-xs-500 mt-2 text-end w-min text-opacity-70">
|
||||||
|
{content.t_with_prefixes("featured-post", &[&content_type, "content"], Some("Featured"))}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{if !excerpt.is_empty() {
|
||||||
|
view! {
|
||||||
|
<p class="ds-card-text mb-4 line-clamp-3">{excerpt}</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<div class="flex flex-col gap-2 mb-4">
|
||||||
|
{if !categories.is_empty() {
|
||||||
|
let main_category = categories[0].clone();
|
||||||
|
let main_category_title = content.t_with_prefixes(&main_category, &[&content_type, "content"], Some(&main_category));
|
||||||
|
// let categories_str = categories.join(",");
|
||||||
|
// Use the TOML configuration to get the proper base path for this content type and language
|
||||||
|
let resolved_base_path = base_path.as_ref().cloned().unwrap_or_else(|| {
|
||||||
|
Some(format!("/{}/{}", language, content_type)) // Fallback path returns Option
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"⚠️ CARD ROUTING WARNING: No route found for content_type='{}' in language='{}', using fallback",
|
||||||
|
content_type, language
|
||||||
|
).into(),
|
||||||
|
);
|
||||||
|
format!("/{}", content_type)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug logging for both server and client
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
"🐛 CARD URL DEBUG: content_type='{}', language='{}', base_path='{}', category='{}'",
|
||||||
|
content_type, language, resolved_base_path, main_category
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(&format!(
|
||||||
|
"🐛 CARD URL DEBUG: content_type='{}', language='{}', base_path='{}', category='{}'",
|
||||||
|
content_type, language, resolved_base_path, main_category
|
||||||
|
).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let main_category_url = format!("{}/{}", resolved_base_path, main_category);
|
||||||
|
let main_category_display = {
|
||||||
|
let category_emoji = get_category_emoji(&content_type, &language, &main_category);
|
||||||
|
format!("{} {}", category_emoji, main_category)
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<>
|
||||||
|
// Main category - prominently displayed
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href={main_category_url.clone()}
|
||||||
|
class="ds-badge ds-badge-outline no-underline hover:ds-badge-primary-focus px-3 py-1"
|
||||||
|
title={format!("{} {} {}", content.t("content-view-all"), &main_category_title, &content_type)}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &main_category_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{main_category_display}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Tags displayed separately for better visibility
|
||||||
|
{if !tags.is_empty() {
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{tags.iter().map(|tag| {
|
||||||
|
let tag_title = content.t_with_prefixes(&tag, &[&content_type, "content"], Some(&tag));
|
||||||
|
// Use TOML configuration to get proper base path for tags
|
||||||
|
let tag_base_path = base_path.clone().unwrap_or_else(|| {
|
||||||
|
Some(format!("/{}/{}", language, content_type)) // Fallback path returns Option
|
||||||
|
.unwrap_or_else(|| format!("/{}", content_type))
|
||||||
|
});
|
||||||
|
let tag_url = format!("{}/{}", tag_base_path, tag);
|
||||||
|
let tag_title_for_attr = tag_title.clone();
|
||||||
|
view! {
|
||||||
|
<a href={tag_url.clone()}
|
||||||
|
class="ds-badge ds-badge-ghost ds-badge-sm text-base-content/70 px-2 no-underline hover:ds-badge-accent hover:text-accent-content transition-colors cursor-pointer"
|
||||||
|
title={format!("{} {} {}", content.t("content-view-all"), &tag_title_for_attr, &content_type)}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &tag_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tag_title}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</>
|
||||||
|
}.into_any()
|
||||||
|
} else if !tags.is_empty() {
|
||||||
|
// If no main category, show tags as primary display
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{tags.iter().map(|tag| {
|
||||||
|
let tag_title = content.t_with_prefixes(&tag, &[&content_type, "content"], Some(&tag));
|
||||||
|
// Use TOML configuration to get proper base path for tags
|
||||||
|
let tag_base_path_no_cat = base_path.as_ref().cloned().unwrap_or_else(|| {
|
||||||
|
Some(format!("/{}/{}", language, content_type)) // Fallback path returns Option
|
||||||
|
.unwrap_or_else(|| format!("/{}", content_type))
|
||||||
|
});
|
||||||
|
let tag_url = format!("{}/{}", tag_base_path_no_cat, tag);
|
||||||
|
let tag_title_for_attr = tag_title.clone();
|
||||||
|
view! {
|
||||||
|
<a href={tag_url.clone()}
|
||||||
|
class="ds-badge ds-badge-ghost ds-badge-sm text-base-content/70 px-2 no-underline hover:ds-badge-accent hover:text-accent-content transition-colors cursor-pointer"
|
||||||
|
title={format!("{} {} {}", content.t("content-view-all"), &tag_title_for_attr, &content_type)}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &tag_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tag_title}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-3 border-t border-base-300">
|
||||||
|
<div class="grid grid-cols-2 gap-3 items-end">
|
||||||
|
<div class="grid grid-rows-2 gap-1 text-sm text-base-content/60">
|
||||||
|
{if !date.is_empty() {
|
||||||
|
let date_display = date.clone();
|
||||||
|
view! {
|
||||||
|
<div>{date_display}</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}}
|
||||||
|
{if !time_display.is_empty() {
|
||||||
|
view! {
|
||||||
|
<div>{time_display.clone()}</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<a href={url.clone()} class="ds-btn ds-btn-primary ds-btn-sm px-4 py-1 whitespace-nowrap"
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{content.t_with_prefixes("read-more", &[&content_type, "content"], Some("Read More"))}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
}
|
||||||
543
crates/foundation/crates/rustelo_components/src/content/grid.rs
Normal file
543
crates/foundation/crates/rustelo_components/src/content/grid.rs
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
//! Unified Content Grid Component
|
||||||
|
//!
|
||||||
|
//! Generic content grid that works with any content type dynamically,
|
||||||
|
//! using content type keys to distinguish between different content types.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
content::card::UnifiedContentCard, content::pagination::PaginationControls,
|
||||||
|
filter::unified::UnifiedCategoryFilter,
|
||||||
|
};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
// content::traits::*,
|
||||||
|
create_content_kind_registry,
|
||||||
|
fluent::load_content_index,
|
||||||
|
i18n::create_content_provider, // fluent::models::ContentIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unified Content Grid component that works with any content type
|
||||||
|
/// Uses content type keys to distinguish between different content types
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedContentGrid(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
#[prop(default = true)] show_categories: bool,
|
||||||
|
#[prop(default = false)] _use_filter: bool,
|
||||||
|
#[prop(default = "".to_string())] category_filter: String,
|
||||||
|
#[prop(default = 12)] limit: usize,
|
||||||
|
#[prop(default = 1)] current_page: u32,
|
||||||
|
#[prop(default = true)] enable_pagination: bool,
|
||||||
|
) -> AnyView {
|
||||||
|
let content = create_content_provider(lang_content.clone());
|
||||||
|
let show_cats = show_categories;
|
||||||
|
|
||||||
|
// Pagination state - simple implementation for now
|
||||||
|
let page = RwSignal::new(current_page);
|
||||||
|
let page_size = RwSignal::new(0u32); // Will be set from config
|
||||||
|
let set_page = page.write_only();
|
||||||
|
let set_page_size = page_size.write_only();
|
||||||
|
|
||||||
|
let content_config = {
|
||||||
|
let registry = create_content_kind_registry();
|
||||||
|
registry
|
||||||
|
.kinds.get(&content_type)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
tracing::error!(
|
||||||
|
"❌ CONFIG_ERROR: Unknown content_type: '{}', available configs: {:?}, using default config - WASM FILESYSTEM ISSUE!",
|
||||||
|
content_type, registry.kinds.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
rustelo_core_lib::ContentConfig::from_env()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set pagination configuration from content config
|
||||||
|
let config_page_size = 12; // Default page size
|
||||||
|
let config_page_size_options = vec![6, 12, 24, 48]; // Default options
|
||||||
|
let config_show_page_info = true; // Default to showing page info
|
||||||
|
|
||||||
|
// Initialize page size from config
|
||||||
|
if page_size.get_untracked() == 0 {
|
||||||
|
set_page_size.set(config_page_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple navigation function using custom navigation system
|
||||||
|
let update_page_in_url = move |new_page: u32, _new_page_size: u32| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(_set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
// For now, just update the page state without URL changes
|
||||||
|
// TODO: Implement URL query parameter support in custom navigation system
|
||||||
|
tracing::debug!("Page changed to: {}", new_page);
|
||||||
|
// Future: nav::anchor_navigate(set_path, &url_with_params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
tracing::debug!("Page changed to: {} (SSR mode)", new_page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug: Log the style_mode for this content type
|
||||||
|
let css_classes = format!(
|
||||||
|
"{} content-type-{} layout-{}",
|
||||||
|
"default", // Default CSS style
|
||||||
|
&content_type,
|
||||||
|
"grid-layout" // Default layout class
|
||||||
|
);
|
||||||
|
// Use provided language directly
|
||||||
|
let lang = language.clone();
|
||||||
|
|
||||||
|
let get_css_mode = {
|
||||||
|
move |item_count: usize, style_mode: &str| -> String {
|
||||||
|
if style_mode == "grid" {
|
||||||
|
if item_count == 1 {
|
||||||
|
String::from("grid grid-cols-1 gap-8 max-w-2xl mx-auto")
|
||||||
|
} else if item_count == 2 {
|
||||||
|
String::from("grid md:grid-cols-2 gap-8")
|
||||||
|
} else {
|
||||||
|
String::from("grid md:grid-cols-2 lg:grid-cols-2 gap-8")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::from("flex flex-col gap-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WASM version with reactive loading
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Initialize with loaded content to match SSR initial state and prevent hydration mismatch
|
||||||
|
let initial_content = load_content_index(&content_type, &lang).unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to load initial content index: {}", e);
|
||||||
|
rustelo_core_lib::fluent::ContentIndex::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let content_index = RwSignal::new(Some(initial_content));
|
||||||
|
|
||||||
|
let content_type_for_effect = content_type.clone();
|
||||||
|
let lang_for_effect = lang.clone();
|
||||||
|
let content_index_for_effect = content_index.clone();
|
||||||
|
|
||||||
|
// Use Effect to reactively reload content when content_type or lang changes
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let result = load_content_index(&content_type_for_effect, &lang_for_effect);
|
||||||
|
content_index_for_effect.set(Some(result.unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to reload content index: {}", e);
|
||||||
|
rustelo_core_lib::fluent::ContentIndex::default()
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
{move || content_index.with(|index_opt| {
|
||||||
|
render_content_grid(
|
||||||
|
index_opt.as_ref(),
|
||||||
|
show_cats,
|
||||||
|
&category_filter,
|
||||||
|
&content_type,
|
||||||
|
&lang,
|
||||||
|
&css_classes,
|
||||||
|
&content_config,
|
||||||
|
get_css_mode,
|
||||||
|
page,
|
||||||
|
set_page,
|
||||||
|
page_size,
|
||||||
|
set_page_size,
|
||||||
|
enable_pagination,
|
||||||
|
config_page_size_options.clone(),
|
||||||
|
config_show_page_info,
|
||||||
|
update_page_in_url,
|
||||||
|
&content,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR version with direct loading
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let load_result = load_content_index(&content_type, &lang);
|
||||||
|
let index_option = match load_result {
|
||||||
|
Ok(index) => Some(index),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to load content index: {}", e);
|
||||||
|
Some(rustelo_core_lib::fluent::ContentIndex::default())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render_content_grid(
|
||||||
|
index_option.as_ref(),
|
||||||
|
show_cats,
|
||||||
|
&category_filter,
|
||||||
|
&content_type,
|
||||||
|
&lang,
|
||||||
|
&css_classes,
|
||||||
|
&content_config,
|
||||||
|
get_css_mode,
|
||||||
|
page,
|
||||||
|
set_page,
|
||||||
|
page_size,
|
||||||
|
set_page_size,
|
||||||
|
enable_pagination,
|
||||||
|
config_page_size_options,
|
||||||
|
config_show_page_info,
|
||||||
|
update_page_in_url,
|
||||||
|
&content,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_content_grid(
|
||||||
|
index_opt: Option<&rustelo_core_lib::fluent::ContentIndex>,
|
||||||
|
show_cats: bool,
|
||||||
|
category_filter: &str,
|
||||||
|
content_type: &str,
|
||||||
|
lang: &str,
|
||||||
|
css_classes: &str,
|
||||||
|
content_config: &rustelo_core_lib::ContentConfig,
|
||||||
|
get_css_mode: impl Fn(usize, &str) -> String,
|
||||||
|
page: RwSignal<u32>,
|
||||||
|
set_page: WriteSignal<u32>,
|
||||||
|
page_size: RwSignal<u32>,
|
||||||
|
set_page_size: WriteSignal<u32>,
|
||||||
|
enable_pagination: bool,
|
||||||
|
config_page_size_options: Vec<u32>,
|
||||||
|
config_show_page_info: bool,
|
||||||
|
update_page_in_url: impl Fn(u32, u32) + Clone + Send + Sync + 'static,
|
||||||
|
content: &rustelo_core_lib::i18n::ContentProvider,
|
||||||
|
limit: usize,
|
||||||
|
) -> impl IntoView {
|
||||||
|
match index_opt {
|
||||||
|
Some(index) => {
|
||||||
|
let mut index = index.clone();
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"✅ CLIENT GRID: Loaded {} items for type='{}', lang='{}'",
|
||||||
|
index.items.len(),
|
||||||
|
content_type,
|
||||||
|
lang
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for published items first
|
||||||
|
index.items.retain(|item| item.published);
|
||||||
|
|
||||||
|
// Now filter by category if specified (skip if empty or "all")
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🔍 CLIENT GRID: Category filter: '{}'", category_filter).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !category_filter.is_empty() && category_filter.to_lowercase() != "all" {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let original_count = index.items.len();
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT GRID: Applying category filter '{}' to {} items",
|
||||||
|
category_filter, original_count
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
index.items.retain(|item| {
|
||||||
|
item.categories
|
||||||
|
.iter()
|
||||||
|
.any(|cat| cat.to_lowercase() == category_filter.to_lowercase())
|
||||||
|
});
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT GRID: After category filter: {} items remaining",
|
||||||
|
index.items.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT GRID: No category filtering - showing all {} items",
|
||||||
|
index.items.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store total items before pagination for pagination controls
|
||||||
|
let total_filtered_items = index.items.len() as u32;
|
||||||
|
|
||||||
|
// Apply pagination if enabled
|
||||||
|
let items_to_display = if enable_pagination {
|
||||||
|
let current_page_num = page.get_untracked();
|
||||||
|
let items_per_page = page_size.get_untracked() as usize;
|
||||||
|
let start_idx = ((current_page_num - 1) * page_size.get_untracked()) as usize;
|
||||||
|
let end_idx = std::cmp::min(start_idx + items_per_page, index.items.len());
|
||||||
|
|
||||||
|
if start_idx < index.items.len() {
|
||||||
|
index.items.drain(start_idx..end_idx).collect()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Apply original limit if pagination is disabled
|
||||||
|
index.items.truncate(limit);
|
||||||
|
index.items
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split items into featured and regular groups to avoid iterator consumption issues
|
||||||
|
let (featured_items, regular_items): (Vec<_>, Vec<_>) =
|
||||||
|
items_to_display.into_iter().partition(|item| item.featured);
|
||||||
|
|
||||||
|
let featured_count = featured_items.len();
|
||||||
|
let regular_count = regular_items.len();
|
||||||
|
let _item_count = featured_count + regular_count;
|
||||||
|
|
||||||
|
// Check which sections we'll render to determine the logic flow
|
||||||
|
let has_featured = !featured_items.is_empty();
|
||||||
|
let has_regular = !regular_items.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="content-grid max-w-7xl mx-auto px-4">
|
||||||
|
{
|
||||||
|
// Debug logging for both server and client
|
||||||
|
tracing::debug!(
|
||||||
|
"GRID: Filter conditions - show_cats: {}, category_filter: '{}' (filter will load its own data)",
|
||||||
|
show_cats, category_filter
|
||||||
|
);
|
||||||
|
|
||||||
|
// Additional browser console logging
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔍 CLIENT GRID FILTER: show_cats={}, category_filter='{}' (filter will handle its own data loading)",
|
||||||
|
show_cats, category_filter
|
||||||
|
).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render filter when show_cats is true - always show the filter regardless of current category
|
||||||
|
if show_cats {
|
||||||
|
view! {
|
||||||
|
<div class="category-filter mb-8">
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=lang.to_string()
|
||||||
|
_all_emoji="🏷️".to_string()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"❌ CLIENT GRID FILTER: NOT rendering filter - show_cats={}",
|
||||||
|
show_cats
|
||||||
|
).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Render content based on what we have
|
||||||
|
{if has_featured && has_regular {
|
||||||
|
// Layout with featured posts on the right and regular posts on the left
|
||||||
|
view! {
|
||||||
|
<div class="content-layout grid lg:grid-cols-3 gap-8">
|
||||||
|
// Regular items on the left (2/3 width)
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class={
|
||||||
|
// 🔍 CRITICAL: Debug the exact values being passed to get_css_mode
|
||||||
|
let style_mode_value = "grid"; // Default style mode
|
||||||
|
format!("regular-grid {} items-stretch {}", css_classes,
|
||||||
|
get_css_mode(regular_count, style_mode_value)
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
{regular_items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=lang.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Featured items on the right (1/3 width)
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="featured-section">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 ds-text no-underline">
|
||||||
|
{content.t_with_prefixes("featured", &[content_type, "content"], Some("Featured"))}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{featured_items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=lang.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else if has_featured {
|
||||||
|
// Only featured items - display in grid
|
||||||
|
view! {
|
||||||
|
<div class="featured-grid">
|
||||||
|
<div class={
|
||||||
|
let style_mode_value = "grid"; // Default style mode
|
||||||
|
format!("featured-only {} {}", css_classes,
|
||||||
|
get_css_mode(featured_count, style_mode_value)
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
{featured_items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=lang.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else if has_regular {
|
||||||
|
// Only regular items - display in grid
|
||||||
|
view! {
|
||||||
|
<div class="regular-grid">
|
||||||
|
<div class={
|
||||||
|
let style_mode_value = "grid"; // Default style mode
|
||||||
|
format!("regular-only {} {}", css_classes,
|
||||||
|
get_css_mode(regular_count, style_mode_value)
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
{regular_items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=lang.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="regular-grid">
|
||||||
|
<div class={
|
||||||
|
let style_mode_value = "grid"; // Default style mode
|
||||||
|
format!("empty-only {} {}", css_classes,
|
||||||
|
get_css_mode(0, style_mode_value)
|
||||||
|
)
|
||||||
|
}>
|
||||||
|
{vec![view! {
|
||||||
|
<div class="empty-content">
|
||||||
|
<p class="text-gray-500 text-center">"No content available"</p>
|
||||||
|
</div>
|
||||||
|
}].into_iter().collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Pagination controls
|
||||||
|
{if enable_pagination && total_filtered_items > 0 {
|
||||||
|
let total_pages = Memo::new(move |_| ((total_filtered_items + page_size.get() - 1) / page_size.get()).max(1));
|
||||||
|
|
||||||
|
// Pagination callbacks
|
||||||
|
let page_change_callback = {
|
||||||
|
let update_func = update_page_in_url.clone();
|
||||||
|
Callback::new(move |new_page: u32| {
|
||||||
|
set_page.set(new_page);
|
||||||
|
update_func(new_page, page_size.get_untracked());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_size_change_callback = {
|
||||||
|
let update_func = update_page_in_url.clone();
|
||||||
|
Callback::new(move |new_page_size: u32| {
|
||||||
|
set_page_size.set(new_page_size);
|
||||||
|
set_page.set(1); // Reset to first page when changing page size
|
||||||
|
update_func(1, new_page_size);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PaginationControls
|
||||||
|
current_page=Signal::from(page)
|
||||||
|
total_pages=Signal::from(total_pages)
|
||||||
|
total_items=total_filtered_items
|
||||||
|
items_per_page=Signal::from(page_size)
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
on_page_change=page_change_callback
|
||||||
|
on_page_size_change=page_size_change_callback
|
||||||
|
page_size_options=config_page_size_options
|
||||||
|
show_page_info=config_show_page_info
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Loading state - content not yet loaded
|
||||||
|
view! {
|
||||||
|
<div class="content-grid">
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/2 mb-6"></div>
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{(0..6).map(|_| view! {
|
||||||
|
<div class="bg-gray-100 h-64 rounded-lg"></div>
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
//! Safe HTML content component that handles hydration properly
|
||||||
|
//!
|
||||||
|
//! This component safely renders HTML content from FTL files without
|
||||||
|
//! causing hydration mismatches between SSR and client.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Component that safely renders HTML content with line breaks
|
||||||
|
///
|
||||||
|
/// Uses `inner_html` for consistent SSR/client rendering to avoid hydration issues.
|
||||||
|
#[component]
|
||||||
|
pub fn HtmlContent(
|
||||||
|
/// The HTML content to render
|
||||||
|
content: String,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(default = String::new())]
|
||||||
|
class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Use inner_html for hydration safety - this ensures the same HTML
|
||||||
|
// is rendered on both server and client without dynamic generation
|
||||||
|
view! {
|
||||||
|
<div class={class} inner_html={content}></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,365 @@
|
|||||||
|
//! Content Management UI Components
|
||||||
|
//!
|
||||||
|
//! Provides Leptos components for managing content through the REST API,
|
||||||
|
//! including content listing, creation, editing, and publishing controls.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::content::ContentItemManager;
|
||||||
|
use ::rustelo_core_lib::get_all_content_types;
|
||||||
|
use ::rustelo_core_lib::i18n::discovery;
|
||||||
|
use ::rustelo_core_lib::i18n::helpers::{build_page_content_patterns, UnifiedLocalizationHelper};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Using unified content types from shared crate
|
||||||
|
|
||||||
|
/// Content management dashboard component - now fully language-agnostic and configuration-driven
|
||||||
|
///
|
||||||
|
/// This component follows the project's best practices:
|
||||||
|
/// - Language agnostic: accepts language parameter and uses i18n
|
||||||
|
/// - Configuration-driven: discovers content types from content-kinds.toml
|
||||||
|
/// - No hardcoded strings: all text from Fluent i18n files
|
||||||
|
#[component]
|
||||||
|
pub fn ContentManager(#[prop(optional)] language: Option<String>) -> impl IntoView {
|
||||||
|
// Use language-agnostic discovery system
|
||||||
|
let current_language =
|
||||||
|
language.unwrap_or_else(|| discovery::get_default_language().to_string());
|
||||||
|
|
||||||
|
// Create i18n helper for this language
|
||||||
|
let i18n = UnifiedLocalizationHelper::new(current_language.clone());
|
||||||
|
|
||||||
|
// Load all content management i18n keys dynamically (zero maintenance!)
|
||||||
|
let i18n_content = build_page_content_patterns(
|
||||||
|
&i18n,
|
||||||
|
&["content-manager-", "admin-", "content-", "common-"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get available content types from configuration instead of hardcoding
|
||||||
|
let available_content_types: Vec<String> = get_all_content_types()
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get available languages from configuration instead of hardcoding
|
||||||
|
let available_languages: Vec<String> = discovery::discover_available_languages().clone();
|
||||||
|
// Generate configuration-driven mock data based on available content types
|
||||||
|
let mock_content = generate_mock_content_from_config(
|
||||||
|
&available_content_types,
|
||||||
|
¤t_language,
|
||||||
|
&i18n_content,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clone i18n_content for use in closures
|
||||||
|
let i18n_for_table = i18n_content.clone();
|
||||||
|
let i18n_for_note = i18n_content.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="content-manager p-6 max-w-7xl mx-auto">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-title")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Content Management".to_string())}
|
||||||
|
</h1>
|
||||||
|
<button class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2">
|
||||||
|
<span class="text-lg">"+"</span>
|
||||||
|
{i18n_content.get("content-manager-new-content")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "New Content".to_string())}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div class="flex flex-wrap gap-4 items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="filter-type" class="text-sm font-medium text-gray-700">
|
||||||
|
{i18n_content.get("content-manager-filter-type")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Content Type:".to_string())}
|
||||||
|
</label>
|
||||||
|
<select id="filter-type" class="border border-gray-300 rounded-md px-3 py-1 text-sm">
|
||||||
|
<option value="all">
|
||||||
|
{i18n_content.get("content-manager-all-types")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "All Types".to_string())}
|
||||||
|
</option>
|
||||||
|
// Configuration-driven content type options
|
||||||
|
{
|
||||||
|
available_content_types.into_iter().map(|content_type| {
|
||||||
|
let display_name = i18n_content.get(&format!("content-kind-{}", content_type))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| content_type.clone());
|
||||||
|
view! {
|
||||||
|
<option value={content_type.clone()}>{display_name}</option>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="filter-language" class="text-sm font-medium text-gray-700">
|
||||||
|
{i18n_content.get("content-manager-filter-language")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Language:".to_string())}
|
||||||
|
</label>
|
||||||
|
<select id="filter-language" class="border border-gray-300 rounded-md px-3 py-1 text-sm">
|
||||||
|
<option value="all">
|
||||||
|
{i18n_content.get("content-manager-all-languages")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "All Languages".to_string())}
|
||||||
|
</option>
|
||||||
|
// Configuration-driven language options
|
||||||
|
{
|
||||||
|
available_languages.into_iter().map(|lang_code| {
|
||||||
|
let display_name = i18n_content.get(&format!("language-{}", lang_code))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| lang_code.clone());
|
||||||
|
view! {
|
||||||
|
<option value={lang_code.clone()}>{display_name}</option>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1 rounded-md text-sm">
|
||||||
|
{i18n_content.get("content-manager-refresh")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Refresh".to_string())}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Content List
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-title")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Title".to_string())}
|
||||||
|
</th>
|
||||||
|
<th class="text-left p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-type")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Type".to_string())}
|
||||||
|
</th>
|
||||||
|
<th class="text-left p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-language")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Language".to_string())}
|
||||||
|
</th>
|
||||||
|
<th class="text-left p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-status")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Status".to_string())}
|
||||||
|
</th>
|
||||||
|
<th class="text-left p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-updated")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Updated".to_string())}
|
||||||
|
</th>
|
||||||
|
<th class="text-center p-4 font-semibold text-gray-900">
|
||||||
|
{i18n_content.get("content-manager-table-actions")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Actions".to_string())}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For
|
||||||
|
each=move || mock_content.clone()
|
||||||
|
key=|content| content.id.clone()
|
||||||
|
let:content
|
||||||
|
>
|
||||||
|
<ContentRow content=content i18n_content=i18n_for_table.clone() />
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Status message
|
||||||
|
<div class="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p class="text-blue-800 text-sm">
|
||||||
|
<strong>
|
||||||
|
{i18n_for_note.get("content-manager-note-label")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Note:".to_string())}
|
||||||
|
</strong>
|
||||||
|
{" "}
|
||||||
|
{i18n_for_note.get("content-manager-note-text")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "This is a mockup of the content management interface. Full functionality with API integration will be implemented in future updates.".to_string())}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content row component - now i18n-aware
|
||||||
|
#[component]
|
||||||
|
fn ContentRow(content: ContentItemManager, i18n_content: HashMap<String, String>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<tr class="border-b border-gray-200 hover:bg-gray-50">
|
||||||
|
<td class="p-4">
|
||||||
|
<div class="font-medium text-gray-900">{content.title.clone()}</div>
|
||||||
|
<div class="text-sm text-gray-500">{content.slug.clone()}</div>
|
||||||
|
</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{content.content_type.clone()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{content.language.clone()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<span class={format!(
|
||||||
|
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {}",
|
||||||
|
if content.published {
|
||||||
|
"bg-green-100 text-green-800"
|
||||||
|
} else {
|
||||||
|
"bg-yellow-100 text-yellow-800"
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
{if content.published {
|
||||||
|
i18n_content.get("content-manager-status-published")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Published".to_string())
|
||||||
|
} else {
|
||||||
|
i18n_content.get("content-manager-status-draft")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Draft".to_string())
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-4 text-sm text-gray-500">
|
||||||
|
{content.updated_at.clone()}
|
||||||
|
</td>
|
||||||
|
<td class="p-4">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<button class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||||
|
{i18n_content.get("content-manager-action-edit")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Edit".to_string())}
|
||||||
|
</button>
|
||||||
|
<button class={format!(
|
||||||
|
"text-sm font-medium {}",
|
||||||
|
if content.published {
|
||||||
|
"text-yellow-600 hover:text-yellow-800"
|
||||||
|
} else {
|
||||||
|
"text-green-600 hover:text-green-800"
|
||||||
|
}
|
||||||
|
)}>
|
||||||
|
{if content.published {
|
||||||
|
i18n_content.get("content-manager-action-unpublish")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Unpublish".to_string())
|
||||||
|
} else {
|
||||||
|
i18n_content.get("content-manager-action-publish")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Publish".to_string())
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<button class="text-red-600 hover:text-red-800 text-sm font-medium">
|
||||||
|
{i18n_content.get("content-manager-action-delete")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Delete".to_string())}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content creation request is now handled by UnifiedContentItem in shared crate
|
||||||
|
|
||||||
|
/// Generate configuration-driven mock content data
|
||||||
|
///
|
||||||
|
/// Instead of hardcoded data, this function creates mock content based on:
|
||||||
|
/// - Available content types from content-kinds.toml
|
||||||
|
/// - Current language for localized sample text
|
||||||
|
/// - i18n keys for sample content
|
||||||
|
fn generate_mock_content_from_config(
|
||||||
|
content_types: &[String],
|
||||||
|
language: &str,
|
||||||
|
i18n_content: &HashMap<String, String>,
|
||||||
|
) -> Vec<ContentItemManager> {
|
||||||
|
let mut mock_items = Vec::new();
|
||||||
|
|
||||||
|
for (index, content_type) in content_types.iter().enumerate() {
|
||||||
|
// Generate sample titles using i18n or fallback patterns
|
||||||
|
let sample_title = i18n_content
|
||||||
|
.get(&format!("content-manager-sample-{}-title", content_type))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let first_char = content_type
|
||||||
|
.chars()
|
||||||
|
.nth(0)
|
||||||
|
.map(|c| c.to_uppercase().collect::<String>())
|
||||||
|
.unwrap_or_else(|| "S".to_string());
|
||||||
|
format!(
|
||||||
|
"Sample {}{}",
|
||||||
|
first_char,
|
||||||
|
&content_type.get(1..).unwrap_or("")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let sample_slug = format!("sample-{}-{}", content_type, index + 1);
|
||||||
|
|
||||||
|
let sample_content = i18n_content
|
||||||
|
.get(&format!("content-manager-sample-{}-content", content_type))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"# Sample {}\n\nThis is sample content for demonstration.",
|
||||||
|
content_type
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let sample_excerpt = i18n_content
|
||||||
|
.get(&format!("content-manager-sample-{}-excerpt", content_type))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Sample {} content for demonstration purposes.",
|
||||||
|
content_type
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let sample_category = i18n_content
|
||||||
|
.get(&format!("content-manager-sample-category"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "General".to_string());
|
||||||
|
|
||||||
|
mock_items.push(ContentItemManager {
|
||||||
|
id: (index + 1).to_string(),
|
||||||
|
title: sample_title,
|
||||||
|
slug: sample_slug,
|
||||||
|
content: sample_content,
|
||||||
|
content_type: content_type.clone(),
|
||||||
|
language: language.to_string(),
|
||||||
|
published: index % 2 == 0, // Alternate between published and draft
|
||||||
|
tags: vec!["sample".to_string(), content_type.clone()],
|
||||||
|
category: sample_category,
|
||||||
|
excerpt: sample_excerpt,
|
||||||
|
updated_at: format!("2024-01-{:02} 10:30:00", 15 - index), // Sample dates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no content types available, return empty vector
|
||||||
|
if mock_items.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"No content types found in configuration - content manager will show empty state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_items
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
//! Content-related components
|
||||||
|
//!
|
||||||
|
//! Components for displaying content types posts
|
||||||
|
|
||||||
|
pub mod card;
|
||||||
|
pub mod grid;
|
||||||
|
pub mod html;
|
||||||
|
pub mod manager;
|
||||||
|
pub mod pagination;
|
||||||
|
pub mod simple_grid;
|
||||||
|
|
||||||
|
// Re-export unified components
|
||||||
|
pub use card::UnifiedContentCard;
|
||||||
|
pub use grid::UnifiedContentGrid;
|
||||||
|
pub use html::HtmlContent;
|
||||||
|
pub use manager::ContentManager;
|
||||||
|
pub use pagination::PaginationControls;
|
||||||
|
pub use simple_grid::SimpleContentGrid;
|
||||||
|
// Re-export unified content types for convenience
|
||||||
|
pub use rustelo_core_lib::content::traits::ContentCardItem;
|
||||||
|
pub use rustelo_core_lib::content::{
|
||||||
|
ContentItemManager as ContentItem, UnifiedContentItem as CreateContentRequest,
|
||||||
|
};
|
||||||
@ -0,0 +1,419 @@
|
|||||||
|
//! Pagination Controls Component
|
||||||
|
//!
|
||||||
|
//! Provides configurable pagination controls for content grids
|
||||||
|
//! with separate implementations for WASM (client-side) and SSR (server-side)
|
||||||
|
|
||||||
|
#![allow(dead_code)] // Suppress false positive warnings for Leptos component props
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::create_content_provider;
|
||||||
|
|
||||||
|
/// Pagination controls component with conditional WASM/SSR implementations
|
||||||
|
#[component]
|
||||||
|
pub fn PaginationControls(
|
||||||
|
#[prop(into)] current_page: Signal<u32>,
|
||||||
|
#[prop(into)] total_pages: Signal<u32>,
|
||||||
|
#[prop(into)] total_items: u32,
|
||||||
|
#[prop(into)] items_per_page: Signal<u32>,
|
||||||
|
#[prop(into)] content_type: String,
|
||||||
|
#[prop(optional)] lang_content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
#[prop(optional)] on_page_change: Option<Callback<u32>>,
|
||||||
|
#[prop(optional)] on_page_size_change: Option<Callback<u32>>,
|
||||||
|
#[prop(default = vec![10, 20, 50])] page_size_options: Vec<u32>,
|
||||||
|
#[prop(default = true)] show_page_info: bool,
|
||||||
|
#[prop(default = true)] show_page_size_selector: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Conditional implementation based on environment
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
view! {
|
||||||
|
<PaginationControlsClient
|
||||||
|
current_page=current_page
|
||||||
|
total_pages=total_pages
|
||||||
|
total_items=total_items
|
||||||
|
items_per_page=items_per_page
|
||||||
|
content_type=content_type
|
||||||
|
lang_content=lang_content.clone().unwrap_or_default()
|
||||||
|
on_page_change=on_page_change.unwrap_or_else(|| Callback::new(|_| {}))
|
||||||
|
on_page_size_change=on_page_size_change.unwrap_or_else(|| Callback::new(|_| {}))
|
||||||
|
page_size_options=page_size_options
|
||||||
|
show_page_info=show_page_info
|
||||||
|
show_page_size_selector=show_page_size_selector
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
view! {
|
||||||
|
<PaginationControlsSSR
|
||||||
|
current_page=current_page
|
||||||
|
total_pages=total_pages
|
||||||
|
total_items=total_items
|
||||||
|
items_per_page=items_per_page
|
||||||
|
content_type=content_type
|
||||||
|
lang_content=lang_content.unwrap_or_default()
|
||||||
|
_on_page_change=on_page_change.unwrap_or_else(|| Callback::new(|_| {}))
|
||||||
|
_on_page_size_change=on_page_size_change.unwrap_or_else(|| Callback::new(|_| {}))
|
||||||
|
page_size_options=page_size_options
|
||||||
|
show_page_info=show_page_info
|
||||||
|
show_page_size_selector=show_page_size_selector
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side pagination implementation with full reactivity
|
||||||
|
#[component]
|
||||||
|
#[allow(dead_code)] // False positive: Leptos macro can't detect prop usage in view! macro
|
||||||
|
fn PaginationControlsClient(
|
||||||
|
#[prop(into)] current_page: Signal<u32>,
|
||||||
|
#[prop(into)] total_pages: Signal<u32>,
|
||||||
|
#[prop(into)] total_items: u32,
|
||||||
|
#[prop(into)] items_per_page: Signal<u32>,
|
||||||
|
#[prop(into)] content_type: String,
|
||||||
|
#[prop(into)] lang_content: std::collections::HashMap<String, String>,
|
||||||
|
#[prop(into)] on_page_change: Callback<u32>,
|
||||||
|
#[prop(into)] on_page_size_change: Callback<u32>,
|
||||||
|
#[prop(default = vec![10, 20, 50])] page_size_options: Vec<u32>,
|
||||||
|
#[prop(default = true)] show_page_info: bool,
|
||||||
|
#[prop(default = true)] show_page_size_selector: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Create static values for display (non-reactive in client mode for simplicity)
|
||||||
|
let showing_text = create_content_provider(Some(lang_content.clone())).t_with_prefixes(
|
||||||
|
"showing",
|
||||||
|
&[&content_type, "content"],
|
||||||
|
Some("Showing"),
|
||||||
|
);
|
||||||
|
let of_text = create_content_provider(Some(lang_content.clone())).t_with_prefixes(
|
||||||
|
"of",
|
||||||
|
&[&content_type, "content"],
|
||||||
|
Some("of"),
|
||||||
|
);
|
||||||
|
let items_text = create_content_provider(Some(lang_content.clone())).t_with_prefixes(
|
||||||
|
"items",
|
||||||
|
&[&content_type, "content"],
|
||||||
|
Some("items"),
|
||||||
|
);
|
||||||
|
let items_per_page_text = create_content_provider(Some(lang_content.clone())).t_with_prefixes(
|
||||||
|
"items-per-page",
|
||||||
|
&[&content_type, "content"],
|
||||||
|
Some("Items per page:"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reactive calculations for Client
|
||||||
|
let start_item = move || {
|
||||||
|
if total_items == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(current_page.get() - 1) * items_per_page.get() + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let end_item = move || std::cmp::min(current_page.get() * items_per_page.get(), total_items);
|
||||||
|
|
||||||
|
// Don't render if only one page
|
||||||
|
let should_render = move || total_pages.get() > 1;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=should_render fallback=|| ()>
|
||||||
|
<div class="pagination-container mt-8 pt-6 border-t border-base-300">
|
||||||
|
{if show_page_info {
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||||
|
<div class="text-sm text-base-content/70">
|
||||||
|
{
|
||||||
|
let showing_text = showing_text.clone();
|
||||||
|
let of_text = of_text.clone();
|
||||||
|
let items_text = items_text.clone();
|
||||||
|
move || format!("{} {}-{} {} {} {}",
|
||||||
|
showing_text,
|
||||||
|
start_item(),
|
||||||
|
end_item(),
|
||||||
|
of_text,
|
||||||
|
total_items,
|
||||||
|
items_text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if show_page_size_selector {
|
||||||
|
view! {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-base-content/70">
|
||||||
|
{items_per_page_text.clone()}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
class="ds-select ds-select-bordered ds-select-sm"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let value = event_target_value(&ev).parse::<u32>().unwrap_or(items_per_page.get());
|
||||||
|
on_page_size_change.run(value);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{page_size_options.iter().map(|&size| {
|
||||||
|
view! {
|
||||||
|
<option
|
||||||
|
value={size.to_string()}
|
||||||
|
selected={move || size == items_per_page.get()}
|
||||||
|
>
|
||||||
|
{size.to_string()}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="ds-join">
|
||||||
|
// Previous button
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-disabled={move || current_page.get() <= 1}
|
||||||
|
disabled={move || current_page.get() <= 1}
|
||||||
|
on:click=move |_| {
|
||||||
|
if current_page.get() > 1 {
|
||||||
|
on_page_change.run(current_page.get() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"‹"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Page numbers (reactive)
|
||||||
|
{move || {
|
||||||
|
let page_numbers = create_page_numbers(current_page.get(), total_pages.get());
|
||||||
|
page_numbers.into_iter().map(|page_item| {
|
||||||
|
match page_item {
|
||||||
|
PageItem::Number(page_num) => {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-active={move || page_num == current_page.get()}
|
||||||
|
on:click=move |_| {
|
||||||
|
on_page_change.run(page_num);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{page_num.to_string()}
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
PageItem::Ellipsis => {
|
||||||
|
view! {
|
||||||
|
<span class="ds-join-item ds-btn ds-btn-sm ds-btn-disabled">
|
||||||
|
"…"
|
||||||
|
</span>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-disabled={move || current_page.get() >= total_pages.get()}
|
||||||
|
disabled={move || current_page.get() >= total_pages.get()}
|
||||||
|
on:click=move |_| {
|
||||||
|
if current_page.get() < total_pages.get() {
|
||||||
|
on_page_change.run(current_page.get() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"›"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR (Server-side) pagination implementation with static rendering
|
||||||
|
#[component]
|
||||||
|
fn PaginationControlsSSR(
|
||||||
|
#[prop(into)] current_page: Signal<u32>,
|
||||||
|
#[prop(into)] total_pages: Signal<u32>,
|
||||||
|
#[prop(into)] total_items: u32,
|
||||||
|
#[prop(into)] items_per_page: Signal<u32>,
|
||||||
|
#[prop(into)] content_type: String,
|
||||||
|
#[prop(into)] lang_content: std::collections::HashMap<String, String>,
|
||||||
|
#[prop(into)] _on_page_change: Callback<u32>,
|
||||||
|
#[prop(into)] _on_page_size_change: Callback<u32>,
|
||||||
|
#[prop(default = vec![10, 20, 50])] page_size_options: Vec<u32>,
|
||||||
|
#[prop(default = true)] show_page_info: bool,
|
||||||
|
#[prop(default = true)] show_page_size_selector: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let content = create_content_provider(Some(lang_content.clone()));
|
||||||
|
|
||||||
|
// Static calculations for SSR (get values once)
|
||||||
|
let current_page_val = current_page.get_untracked();
|
||||||
|
let total_pages_val = total_pages.get_untracked();
|
||||||
|
let items_per_page_val = items_per_page.get_untracked();
|
||||||
|
|
||||||
|
let start_item = if total_items == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(current_page_val - 1) * items_per_page_val + 1
|
||||||
|
};
|
||||||
|
let end_item = std::cmp::min(current_page_val * items_per_page_val, total_items);
|
||||||
|
|
||||||
|
// Don't render if only one page
|
||||||
|
if total_pages_val <= 1 {
|
||||||
|
return view! { <></> }.into_any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_numbers = create_page_numbers(current_page_val, total_pages_val);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="pagination-container mt-8 pt-6 border-t border-base-300">
|
||||||
|
{if show_page_info {
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-4">
|
||||||
|
<div class="text-sm text-base-content/70">
|
||||||
|
{format!("{} {}-{} {} {} {}",
|
||||||
|
content.t_with_prefixes("showing", &[&content_type, "content"], Some("Showing")),
|
||||||
|
start_item,
|
||||||
|
end_item,
|
||||||
|
content.t_with_prefixes("of", &[&content_type, "content"], Some("of")),
|
||||||
|
total_items,
|
||||||
|
content.t_with_prefixes("items", &[&content_type, "content"], Some("items"))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if show_page_size_selector {
|
||||||
|
view! {
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-base-content/70">
|
||||||
|
{content.t_with_prefixes("items-per-page", &[&content_type, "content"], Some("Items per page:"))}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
class="ds-select ds-select-bordered ds-select-sm"
|
||||||
|
disabled=true
|
||||||
|
>
|
||||||
|
{page_size_options.iter().map(|&size| {
|
||||||
|
view! {
|
||||||
|
<option
|
||||||
|
value={size.to_string()}
|
||||||
|
selected={size == items_per_page_val}
|
||||||
|
>
|
||||||
|
{size.to_string()}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="ds-join">
|
||||||
|
// Previous button (static)
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-disabled={current_page_val <= 1}
|
||||||
|
disabled={current_page_val <= 1}
|
||||||
|
>
|
||||||
|
"‹"
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Page numbers (static)
|
||||||
|
{page_numbers.into_iter().map(|page_item| {
|
||||||
|
match page_item {
|
||||||
|
PageItem::Number(page_num) => {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-active={page_num == current_page_val}
|
||||||
|
disabled={page_num == current_page_val}
|
||||||
|
>
|
||||||
|
{page_num.to_string()}
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
PageItem::Ellipsis => {
|
||||||
|
view! {
|
||||||
|
<span class="ds-join-item ds-btn ds-btn-sm ds-btn-disabled">
|
||||||
|
"…"
|
||||||
|
</span>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
|
||||||
|
// Next button (static)
|
||||||
|
<button
|
||||||
|
class="ds-join-item ds-btn ds-btn-sm"
|
||||||
|
class:ds-btn-disabled={current_page_val >= total_pages_val}
|
||||||
|
disabled={current_page_val >= total_pages_val}
|
||||||
|
>
|
||||||
|
"›"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum PageItem {
|
||||||
|
Number(u32),
|
||||||
|
Ellipsis,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create page numbers with ellipsis logic
|
||||||
|
fn create_page_numbers(current_page: u32, total_pages: u32) -> Vec<PageItem> {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
|
||||||
|
if total_pages <= 7 {
|
||||||
|
// Show all pages if 7 or fewer
|
||||||
|
for page in 1..=total_pages {
|
||||||
|
pages.push(PageItem::Number(page));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always show first page
|
||||||
|
pages.push(PageItem::Number(1));
|
||||||
|
|
||||||
|
if current_page <= 4 {
|
||||||
|
// Current page is near the beginning
|
||||||
|
for page in 2..=5 {
|
||||||
|
pages.push(PageItem::Number(page));
|
||||||
|
}
|
||||||
|
pages.push(PageItem::Ellipsis);
|
||||||
|
pages.push(PageItem::Number(total_pages));
|
||||||
|
} else if current_page >= total_pages - 3 {
|
||||||
|
// Current page is near the end
|
||||||
|
pages.push(PageItem::Ellipsis);
|
||||||
|
for page in (total_pages - 4)..=total_pages {
|
||||||
|
pages.push(PageItem::Number(page));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Current page is in the middle
|
||||||
|
pages.push(PageItem::Ellipsis);
|
||||||
|
for page in (current_page - 1)..=(current_page + 1) {
|
||||||
|
pages.push(PageItem::Number(page));
|
||||||
|
}
|
||||||
|
pages.push(PageItem::Ellipsis);
|
||||||
|
pages.push(PageItem::Number(total_pages));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pages
|
||||||
|
}
|
||||||
@ -0,0 +1,312 @@
|
|||||||
|
//! Enhanced Simple Content Grid Component
|
||||||
|
//!
|
||||||
|
//! Pure functional content grid with content-kinds.toml configuration support.
|
||||||
|
//! Includes essential features while avoiding complex reactive patterns and hydration issues.
|
||||||
|
|
||||||
|
use crate::content::card::UnifiedContentCard;
|
||||||
|
use crate::content::pagination::PaginationControls;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::{
|
||||||
|
content::UnifiedContentItem, create_content_kind_registry, fluent::load_content_index,
|
||||||
|
i18n::create_content_provider,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Enhanced Simple Content Grid with content-kinds.toml configuration support
|
||||||
|
#[component]
|
||||||
|
pub fn SimpleContentGrid(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(default = HashMap::new())] lang_content: HashMap<String, String>,
|
||||||
|
#[prop(default = "".to_string())] category_filter: String,
|
||||||
|
#[prop(default = 12)] limit: usize,
|
||||||
|
#[prop(default = 1)] current_page: u32,
|
||||||
|
#[prop(default = true)] enable_pagination: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let content_provider = create_content_provider(Some(lang_content.clone()));
|
||||||
|
|
||||||
|
let content_config = {
|
||||||
|
let registry = create_content_kind_registry();
|
||||||
|
registry
|
||||||
|
.kinds.get(&content_type)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
tracing::error!(
|
||||||
|
"❌ CONFIG_ERROR: Unknown content_type: '{}', available configs: {:?}, using default config - WASM FILESYSTEM ISSUE!",
|
||||||
|
content_type, registry.kinds.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
rustelo_core_lib::ContentConfig::from_env()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = RwSignal::new(current_page);
|
||||||
|
let page_size = RwSignal::new(0u32); // Will be set from config
|
||||||
|
let set_page = page.write_only();
|
||||||
|
let set_page_size = page_size.write_only();
|
||||||
|
|
||||||
|
// Set pagination configuration from content config
|
||||||
|
let config_page_size = 12; // Default page size since ContentFeatures doesn't have this field
|
||||||
|
let config_page_size_options = vec![6, 12, 24, 48]; // Default options
|
||||||
|
let config_show_page_info = true; // Default to showing page info
|
||||||
|
|
||||||
|
// Initialize page size from config
|
||||||
|
if page_size.get_untracked() == 0 {
|
||||||
|
set_page_size.set(config_page_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone strings for use in multiple closures
|
||||||
|
let content_type_clone = content_type.clone();
|
||||||
|
let content_type_for_empty = content_type.clone();
|
||||||
|
let language_clone = language.clone();
|
||||||
|
let category_filter_clone = category_filter.clone();
|
||||||
|
|
||||||
|
// Calculate effective page size and offset for pagination
|
||||||
|
let effective_page_size = if enable_pagination {
|
||||||
|
12 // Default page size
|
||||||
|
} else {
|
||||||
|
limit
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_offset = if enable_pagination {
|
||||||
|
((current_page.saturating_sub(1)) as usize) * (effective_page_size as usize)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone config for use in memo and later in view
|
||||||
|
let config_for_memo = content_config.clone();
|
||||||
|
let _use_feature = true; // Default feature usage
|
||||||
|
let _use_emojis = true; // Default emoji usage
|
||||||
|
let style_css = Some("default".to_string()); // Default CSS style
|
||||||
|
let style_mode = "grid".to_string(); // Default style mode
|
||||||
|
|
||||||
|
// Static content loading with configuration support - no reactive signals during render
|
||||||
|
let (content_items, total_items) = Memo::new(move |_| {
|
||||||
|
load_content_for_type_enhanced(
|
||||||
|
&content_type_clone,
|
||||||
|
&language_clone,
|
||||||
|
&category_filter_clone,
|
||||||
|
&config_for_memo,
|
||||||
|
effective_page_size as usize,
|
||||||
|
page_offset,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.get_untracked();
|
||||||
|
|
||||||
|
let update_page_in_url = move |new_page: u32, _new_page_size: u32| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(_set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
// For now, just update the page state without URL changes
|
||||||
|
// TODO: Implement URL query parameter support in custom navigation system
|
||||||
|
tracing::debug!("Page changed to: {}", new_page);
|
||||||
|
// Future: nav::anchor_navigate(set_path, &url_with_params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
tracing::debug!("Page changed to: {} (SSR mode)", new_page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate CSS classes from content configuration
|
||||||
|
let css_classes = format!(
|
||||||
|
"enhanced-content-grid {} content-type-{} layout-{}-layout",
|
||||||
|
style_css.unwrap_or("default".to_string()),
|
||||||
|
&content_type,
|
||||||
|
style_mode
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine grid layout based on style_mode
|
||||||
|
let grid_classes = match style_mode.as_str() {
|
||||||
|
"grid" => match content_items.len() {
|
||||||
|
0 => "grid grid-cols-1 gap-6",
|
||||||
|
1 => "grid grid-cols-1 gap-8 max-w-2xl mx-auto",
|
||||||
|
2 => "grid md:grid-cols-2 gap-8",
|
||||||
|
_ => "grid md:grid-cols-2 lg:grid-cols-3 gap-6",
|
||||||
|
},
|
||||||
|
_ => "flex flex-col gap-6",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if content is empty before consuming the vector
|
||||||
|
let is_empty = content_items.is_empty();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class={css_classes}>
|
||||||
|
<div class={grid_classes}>
|
||||||
|
{content_items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
language=language.clone()
|
||||||
|
lang_content=lang_content.clone()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Pagination controls (if enabled)
|
||||||
|
{if enable_pagination && total_items > effective_page_size {
|
||||||
|
let total_pages = (total_items + effective_page_size - 1) / effective_page_size;
|
||||||
|
|
||||||
|
// Create callbacks outside the view
|
||||||
|
let page_change_callback = {
|
||||||
|
Callback::new(move |new_page: u32| {
|
||||||
|
set_page.set(new_page);
|
||||||
|
update_page_in_url(new_page, page_size.get_untracked());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_size_change_callback = {
|
||||||
|
Callback::new(move |new_page_size: u32| {
|
||||||
|
set_page_size.set(new_page_size);
|
||||||
|
set_page.set(1); // Reset to first page when changing page size
|
||||||
|
update_page_in_url(1, new_page_size);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<PaginationControls
|
||||||
|
current_page=Signal::from(page)
|
||||||
|
total_pages=Signal::from(total_pages as u32)
|
||||||
|
total_items=total_items as u32
|
||||||
|
items_per_page=Signal::from(page_size)
|
||||||
|
content_type=content_type.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
on_page_change=page_change_callback
|
||||||
|
on_page_size_change=page_size_change_callback
|
||||||
|
page_size_options=config_page_size_options
|
||||||
|
show_page_info=config_show_page_info
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Empty state when no content
|
||||||
|
{if is_empty {
|
||||||
|
view! {
|
||||||
|
<div class="empty-state text-center py-12">
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="text-4xl mb-4 opacity-50">{"📄"}</div>
|
||||||
|
<h3 class="text-lg font-medium ds-text mb-2">
|
||||||
|
{content_provider.t_with_prefixes("no-content", &[&content_type_for_empty, "content"], Some("No content available"))}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm ds-text-secondary">
|
||||||
|
{if !category_filter.is_empty() {
|
||||||
|
format!("No {} content found for category: {}", content_type_for_empty, category_filter)
|
||||||
|
} else {
|
||||||
|
format!("No {} content is currently available", content_type_for_empty)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Using UnifiedContentItem directly instead of custom ContentItem struct
|
||||||
|
|
||||||
|
// Enhanced content loading function with configuration support
|
||||||
|
fn load_content_for_type_enhanced(
|
||||||
|
content_type: &str,
|
||||||
|
language: &str,
|
||||||
|
category_filter: &str,
|
||||||
|
_content_config: &rustelo_core_lib::ContentConfig,
|
||||||
|
page_size: usize,
|
||||||
|
page_offset: usize,
|
||||||
|
) -> (Vec<UnifiedContentItem>, usize) {
|
||||||
|
// Load content index
|
||||||
|
let content_index = match load_content_index(content_type, language) {
|
||||||
|
Ok(index) => index,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to load content index for {}/{}: {}",
|
||||||
|
content_type,
|
||||||
|
language,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return (vec![], 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter content based on configuration and parameters
|
||||||
|
let mut all_items: Vec<UnifiedContentItem> = content_index
|
||||||
|
.items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.published) // Only published items
|
||||||
|
.filter_map(|item| {
|
||||||
|
// Category filtering
|
||||||
|
if !category_filter.is_empty()
|
||||||
|
&& category_filter != "all"
|
||||||
|
&& !item.categories.contains(&category_filter.to_string())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(UnifiedContentItem {
|
||||||
|
id: item.slug.clone(),
|
||||||
|
title: item.title,
|
||||||
|
slug: item.slug,
|
||||||
|
language: language.to_string(),
|
||||||
|
content_type: content_type.to_string(),
|
||||||
|
content: String::new(), // Not loaded in grid view
|
||||||
|
excerpt: item.excerpt,
|
||||||
|
subtitle: None,
|
||||||
|
categories: item.categories,
|
||||||
|
tags: item.tags,
|
||||||
|
emoji: None,
|
||||||
|
featured: item.featured,
|
||||||
|
published: true, // Already filtered
|
||||||
|
draft: Some(false),
|
||||||
|
author: None,
|
||||||
|
read_time: item.read_time,
|
||||||
|
created_at: item.created_at,
|
||||||
|
updated_at: Some(String::new()),
|
||||||
|
translations: vec![],
|
||||||
|
localized_slug: None,
|
||||||
|
source_file: String::new(),
|
||||||
|
metadata: serde_json::json!({}),
|
||||||
|
difficulty: None,
|
||||||
|
prep_time: None,
|
||||||
|
duration: None,
|
||||||
|
prerequisites: None,
|
||||||
|
view_count: None,
|
||||||
|
image_url: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_count = all_items.len();
|
||||||
|
|
||||||
|
// Sort content based on configuration
|
||||||
|
all_items.sort_by(|a, b| {
|
||||||
|
// Featured items first if use_feature is enabled
|
||||||
|
if true {
|
||||||
|
// Default feature usage
|
||||||
|
match (a.featured, b.featured) {
|
||||||
|
(true, false) => return std::cmp::Ordering::Less,
|
||||||
|
(false, true) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then sort by date (newest first)
|
||||||
|
b.created_at.cmp(&a.created_at)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
let items = all_items
|
||||||
|
.into_iter()
|
||||||
|
.skip(page_offset)
|
||||||
|
.take(page_size)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(items, total_count)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
//! Client-side (WASM) implementations for filter components
|
||||||
|
//!
|
||||||
|
//! These components provide interactive filtering functionality that runs
|
||||||
|
//! in the browser after hydration, including DOM manipulation and navigation.
|
||||||
|
|
||||||
|
// No longer need these imports as they were moved to unified.rs
|
||||||
|
|
||||||
|
use super::unified::UnifiedCategoryFilter;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Client-side Content Category Filter Component
|
||||||
|
///
|
||||||
|
/// Provides interactive filtering functionality including:
|
||||||
|
/// - Category button interactions
|
||||||
|
/// - URL navigation to filtered views
|
||||||
|
/// - Visual state updates
|
||||||
|
#[component]
|
||||||
|
pub fn CategoryFilterClient(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(optional)] all_emoji: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let all_emoji = all_emoji.unwrap_or_else(|| "📚".to_string());
|
||||||
|
|
||||||
|
// Load categories dynamically from filter-index.json
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="mb-8">
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
content_type=content_type
|
||||||
|
language=language
|
||||||
|
_all_emoji=all_emoji
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
//! Filter Components Module
|
||||||
|
//!
|
||||||
|
//! Provides modular filter components that work seamlessly in both
|
||||||
|
//! server-side rendering (SSR) and client-side (WASM) environments.
|
||||||
|
//!
|
||||||
|
//! ## Architecture
|
||||||
|
//!
|
||||||
|
//! This module follows the project's modular architecture pattern:
|
||||||
|
//! - `shared.rs` - Common types and utilities used by both client and server
|
||||||
|
//! - `ssr.rs` - Server-side rendering implementations
|
||||||
|
//! - `client.rs` - Client-side interactive implementations
|
||||||
|
//! - `unified.rs` - Unified components that automatically choose the right implementation
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//!
|
||||||
|
//! Import the unified components for seamless SSR/client compatibility:
|
||||||
|
//!
|
||||||
|
//! ```rust
|
||||||
|
//! use rustelo_components::filter::ContentCategoryFilter};
|
||||||
|
//! use leptos::prelude::*;
|
||||||
|
//!
|
||||||
|
//! fn example_usage() -> impl IntoView {
|
||||||
|
//! view! {
|
||||||
|
//! <div>
|
||||||
|
//! // Category-specific filter
|
||||||
|
//! <ContentCategoryFilter
|
||||||
|
//! content_type="recipes".to_string()
|
||||||
|
//! language="es".to_string()
|
||||||
|
//! all_emoji="📚".to_string()
|
||||||
|
//! />
|
||||||
|
//! </div>
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Features
|
||||||
|
//!
|
||||||
|
//! - **Language Agnostic**: Automatically adapts to any configured language
|
||||||
|
//! - **Content Type Agnostic**: Works with any content type defined in content-kinds.toml
|
||||||
|
//! - **SSR Compatible**: Renders proper initial HTML structure on the server
|
||||||
|
//! - **Interactive**: Provides rich client-side filtering and navigation
|
||||||
|
//! - **Cached**: Intelligent caching to avoid repeated data loading
|
||||||
|
//! - **Responsive**: Mobile-friendly responsive design
|
||||||
|
|
||||||
|
// Shared types and utilities
|
||||||
|
pub mod shared;
|
||||||
|
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Unified components that work in both environments
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
// Temporarily disabled due to Leptos macro compilation errors
|
||||||
|
// pub use client::CategoryFilterClient;
|
||||||
|
|
||||||
|
// pub use ssr::CategoryFilterSSR;
|
||||||
|
|
||||||
|
// Re-export the unified components as the main public API
|
||||||
|
pub use unified::{CategoryFilter, UnifiedCategoryFilter};
|
||||||
|
|
||||||
|
// Re-export shared types for external use
|
||||||
|
pub use shared::{
|
||||||
|
clear_filter_cache, get_active_button_classes, get_inactive_button_classes,
|
||||||
|
load_filter_items_from_json, FilterItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export the filter loading functionality from core-lib
|
||||||
|
pub use ::rustelo_core_lib::fluent::{load_filter_index, FilterIndex};
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
//! Shared types and utilities for filter components
|
||||||
|
//!
|
||||||
|
//! This module contains types and utilities that are shared between
|
||||||
|
//! client and server filter implementations.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, RwLock};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// Filter item with name, count, and emoji
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FilterItem {
|
||||||
|
pub name: String,
|
||||||
|
pub count: u32,
|
||||||
|
#[serde(default = "default_emoji")]
|
||||||
|
pub emoji: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_emoji() -> String {
|
||||||
|
"🏷️".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache entry for filter items with timestamp
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CachedFilterItems {
|
||||||
|
pub items: Vec<FilterItem>,
|
||||||
|
pub timestamp: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global cache for filter items to avoid repeated file reads
|
||||||
|
pub static FILTER_CACHE: LazyLock<RwLock<HashMap<String, CachedFilterItems>>> =
|
||||||
|
LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Cache duration before checking for file updates (5 minutes in development, 1 hour in production)
|
||||||
|
pub const CACHE_DURATION_SECS: u64 = if cfg!(debug_assertions) { 300 } else { 3600 };
|
||||||
|
|
||||||
|
/// Load filter items from JSON content
|
||||||
|
pub fn load_filter_items_from_json(
|
||||||
|
content: &str,
|
||||||
|
array_key: &str,
|
||||||
|
) -> Result<Vec<FilterItem>, Box<dyn std::error::Error>> {
|
||||||
|
let index: serde_json::Value = serde_json::from_str(content)?;
|
||||||
|
|
||||||
|
if let Some(items_array) = index.get(array_key).and_then(|v| v.as_array()) {
|
||||||
|
let items: Vec<FilterItem> = items_array
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name = item.get("name")?.as_str()?.to_string();
|
||||||
|
let count = item.get("count")?.as_u64()? as u32;
|
||||||
|
let emoji = item
|
||||||
|
.get("emoji")
|
||||||
|
.and_then(|e| e.as_str())
|
||||||
|
.unwrap_or("🏷️")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Some(FilterItem { name, count, emoji })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all cached filter items (useful for development/testing)
|
||||||
|
pub fn clear_filter_cache() {
|
||||||
|
if let Ok(mut cache) = FILTER_CACHE.write() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate CSS classes for filter buttons
|
||||||
|
pub fn get_active_button_classes() -> &'static str {
|
||||||
|
"ds-btn-content-category-filter px-4 py-2 border rounded-full transition-colors duration-200 border-blue-500 text-blue-400 hover:text-white active"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_inactive_button_classes() -> &'static str {
|
||||||
|
"ds-btn-content-category-filter px-4 py-2 border rounded-full transition-colors duration-200 border-gray-400 text-gray-400 hover:border-blue-400"
|
||||||
|
}
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
//! Server-side rendering (SSR) implementations for filter components
|
||||||
|
//!
|
||||||
|
//! These components render filter UI during server-side rendering,
|
||||||
|
//! providing initial HTML structure before client-side hydration.
|
||||||
|
|
||||||
|
// use crate::{get_active_button_classes, get_inactive_button_classes};
|
||||||
|
|
||||||
|
use super::unified::UnifiedCategoryFilter;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// SSR-only Content Category Filter Component
|
||||||
|
///
|
||||||
|
/// Renders the initial HTML structure for category filtering during SSR.
|
||||||
|
/// Client-side functionality is handled by the client-side component after hydration.
|
||||||
|
#[component]
|
||||||
|
pub fn CategoryFilterSSR(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(optional)] all_emoji: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="mb-8">
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
content_type=content_type.clone()
|
||||||
|
language=language.clone()
|
||||||
|
_all_emoji=all_emoji.unwrap_or_else(|| "".to_string())
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR-compatible helper functions that also work in WASM for hydration consistency
|
||||||
|
|
||||||
|
/// Generic content filtering by category - SSR compatible version
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn filter_content_by_category(content_type: &str, category: &str) {
|
||||||
|
// Navigate to category URL when not "All"
|
||||||
|
if category != "All" {
|
||||||
|
navigate_to_category_url(content_type, category);
|
||||||
|
return; // Let the URL change handle the content filtering
|
||||||
|
}
|
||||||
|
|
||||||
|
// For "All" category, navigate back to main content page
|
||||||
|
navigate_to_main_content_url(content_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to the appropriate category URL based on content type and language
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn navigate_to_category_url(content_type: &str, category: &str) {
|
||||||
|
// Use the current language context to determine the language
|
||||||
|
let language = rustelo_core_lib::state::language::use_current_language().get();
|
||||||
|
|
||||||
|
// Build the correct category URL using the routing system dynamically
|
||||||
|
let base_path = format!("/{}", content_type);
|
||||||
|
// First get the localized base path, then append the category
|
||||||
|
let localized_base = rustelo_core_lib::routing::rustelo_utils::get_localized_route(&base_path, &language);
|
||||||
|
let category_url = format!("{}/{}", localized_base, category);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"SPA Navigation: Navigating to category URL: {}",
|
||||||
|
category_url
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the proper navigation system instead of location.set_href() to maintain SPA behavior
|
||||||
|
if let Some(navigate) = try_get_navigate() {
|
||||||
|
let success = navigate(&category_url);
|
||||||
|
if !success {
|
||||||
|
tracing::warn!(
|
||||||
|
"SPA navigation failed for: {}, falling back to page reload",
|
||||||
|
category_url
|
||||||
|
);
|
||||||
|
// Fallback to page reload if SPA navigation fails
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&category_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Navigation context not available, using page reload for: {}",
|
||||||
|
category_url
|
||||||
|
);
|
||||||
|
// Fallback to page reload if navigation context is not available
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&category_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to main content page (when "All" is selected)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn navigate_to_main_content_url(content_type: &str) {
|
||||||
|
// Use the current language context to determine the language
|
||||||
|
let language = rustelo_core_lib::state::language::use_current_language().get();
|
||||||
|
|
||||||
|
// Build the correct main URL using the routing system dynamically
|
||||||
|
let base_path = format!("/{}", content_type);
|
||||||
|
let main_url = rustelo_core_lib::routing::rustelo_utils::get_localized_route(&base_path, &language);
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"SPA Navigation: Navigating to main content URL: {}",
|
||||||
|
main_url
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use the proper navigation system instead of location.set_href() to maintain SPA behavior
|
||||||
|
if let Some(navigate) = try_get_navigate() {
|
||||||
|
let success = navigate(&main_url);
|
||||||
|
if !success {
|
||||||
|
tracing::warn!(
|
||||||
|
"SPA navigation failed for: {}, falling back to page reload",
|
||||||
|
main_url
|
||||||
|
);
|
||||||
|
// Fallback to page reload if SPA navigation fails
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&main_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"Navigation context not available, using page reload for: {}",
|
||||||
|
main_url
|
||||||
|
);
|
||||||
|
// Fallback to page reload if navigation context is not available
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&main_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update content filter button visual states
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn update_content_filter_button_states(content_type: &str, selected_category: &str) {
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use web_sys::{window, HtmlElement};
|
||||||
|
|
||||||
|
let window = match window() {
|
||||||
|
Some(w) => w,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let document = match window.document() {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update all content filter buttons
|
||||||
|
let buttons = document.get_elements_by_class_name("content-category-filter-btn");
|
||||||
|
|
||||||
|
for i in 0..buttons.length() {
|
||||||
|
if let Some(element) = buttons.item(i) {
|
||||||
|
if let Ok(button) = element.dyn_into::<HtmlElement>() {
|
||||||
|
let button_content_type = button
|
||||||
|
.get_attribute("data-content-type")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let category = button.get_attribute("data-category").unwrap_or_default();
|
||||||
|
|
||||||
|
// Only update buttons for the matching content type
|
||||||
|
if button_content_type == content_type {
|
||||||
|
if category == selected_category {
|
||||||
|
// Active button style
|
||||||
|
let _ = button.set_class_name(crate::get_active_button_classes());
|
||||||
|
} else {
|
||||||
|
// Inactive button style
|
||||||
|
let _ = button.set_class_name(crate::get_inactive_button_classes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status text
|
||||||
|
let status_id = format!("{}-filter-status", content_type);
|
||||||
|
if let Some(status_element) = document.get_element_by_id(&status_id) {
|
||||||
|
if let Ok(status) = status_element.dyn_into::<HtmlElement>() {
|
||||||
|
let status_text = if selected_category == "All" {
|
||||||
|
format!("Showing all {} content", content_type)
|
||||||
|
} else {
|
||||||
|
format!("Showing {}: {}", content_type, selected_category)
|
||||||
|
};
|
||||||
|
status.set_inner_text(&status_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to get the navigation function, handling cases where the context is not available
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn try_get_navigate() -> Option<impl Fn(&str) -> bool> {
|
||||||
|
use std::panic;
|
||||||
|
|
||||||
|
// Try to get the navigation context safely
|
||||||
|
let result = panic::catch_unwind(|| rustelo_core_lib::state::navigation::use_navigate());
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(navigate_fn) => Some(navigate_fn),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("Navigation context not available, will use page reload fallback");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
//! Server-side rendering (SSR) implementations for filter components
|
||||||
|
//!
|
||||||
|
//! These components render filter UI during server-side rendering,
|
||||||
|
//! providing initial HTML structure before client-side hydration.
|
||||||
|
|
||||||
|
// use crate::{get_active_button_classes, get_inactive_button_classes};
|
||||||
|
|
||||||
|
use super::unified::UnifiedCategoryFilter;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// SSR-only Content Category Filter Component
|
||||||
|
///
|
||||||
|
/// Renders the initial HTML structure for category filtering during SSR.
|
||||||
|
/// Client-side functionality is handled by the client-side component after hydration.
|
||||||
|
#[component]
|
||||||
|
pub fn CategoryFilterSSR(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(optional)] all_emoji: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class="mb-8">
|
||||||
|
<UnifiedCategoryFilter
|
||||||
|
content_type=content_type.clone()
|
||||||
|
language=language.clone()
|
||||||
|
_all_emoji=all_emoji.unwrap_or_default()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,565 @@
|
|||||||
|
//! Unified filter components that work seamlessly in both SSR and client environments
|
||||||
|
//!
|
||||||
|
//! These components automatically use the appropriate implementation (client/server)
|
||||||
|
//! based on the compilation target, providing a unified API for filter functionality.
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use rustelo_core_lib::routing::utils::get_content_type_base_path;
|
||||||
|
// Removed leptos_router import - using custom routing system
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Re-export FilterIndex from core-lib for consistency
|
||||||
|
pub use ::rustelo_core_lib::fluent::FilterIndex;
|
||||||
|
|
||||||
|
// Import the CSS class helper functions
|
||||||
|
use crate::{get_active_button_classes, get_inactive_button_classes};
|
||||||
|
|
||||||
|
/// Async function to fetch filter data from URL (WASM only)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
async fn fetch_filter_data(url: &str) -> Result<FilterIndex, Box<dyn std::error::Error>> {
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::SameOrigin);
|
||||||
|
|
||||||
|
let request = Request::new_with_str_and_init(url, &opts)
|
||||||
|
.map_err(|e| format!("Failed to create request: {:?}", e))?;
|
||||||
|
|
||||||
|
let _window = web_sys::window().ok_or("no global `window` exists")?;
|
||||||
|
let resp_value = JsFuture::from(_window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Fetch failed: {:?}", e))?;
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|e| format!("Response conversion failed: {:?}", e))?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP error: {}", resp.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = JsFuture::from(
|
||||||
|
resp.json()
|
||||||
|
.map_err(|e| format!("JSON parsing failed: {:?}", e))?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("JSON future failed: {:?}", e))?;
|
||||||
|
let text = js_sys::JSON::stringify(&json)
|
||||||
|
.map_err(|e| format!("JSON stringify failed: {:?}", e))?
|
||||||
|
.as_string()
|
||||||
|
.ok_or("failed to stringify JSON")?;
|
||||||
|
|
||||||
|
let filter_index: FilterIndex = serde_json::from_str(&text)?;
|
||||||
|
Ok(filter_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified Content Category Filter Component
|
||||||
|
///
|
||||||
|
/// Automatically uses the appropriate implementation based on compilation target:
|
||||||
|
/// - Server-side: Renders initial HTML structure during SSR
|
||||||
|
/// - Client-side: Provides interactive functionality after hydration
|
||||||
|
///
|
||||||
|
/// Usage: `<CategoryFilter content_type="content_type_key" language="en" />`
|
||||||
|
#[component]
|
||||||
|
pub fn CategoryFilter(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(optional)] all_emoji: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Use conditional compilation to choose the appropriate implementation
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
view! {
|
||||||
|
<super::ssr::CategoryFilterSSR
|
||||||
|
content_type=content_type
|
||||||
|
language=language
|
||||||
|
all_emoji=all_emoji.unwrap_or_default()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
view! {
|
||||||
|
<super::client::CategoryFilterClient
|
||||||
|
content_type=content_type
|
||||||
|
language=language
|
||||||
|
all_emoji=all_emoji.unwrap_or_default()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enhanced Category and Tag Filter Component that loads from filter-index.json
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedCategoryFilter(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
_all_emoji: String,
|
||||||
|
) -> leptos::prelude::AnyView {
|
||||||
|
// Debug: Component instantiation
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🎯 UnifiedCategoryFilter: Component instantiated with content_type='{}', language='{}'", content_type, language).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_filter_data, set_filter_data) = signal(None::<FilterIndex>);
|
||||||
|
let current_filter = "All".to_string(); // TODO: Parse from URL when needed
|
||||||
|
|
||||||
|
// Both SSR and WASM: Start with empty data to prevent hydration mismatch
|
||||||
|
// Load real data after hydration completes
|
||||||
|
set_filter_data.set(Some(FilterIndex {
|
||||||
|
generated_at: String::new(),
|
||||||
|
content_type: content_type.clone(),
|
||||||
|
language: language.clone(),
|
||||||
|
total_posts: 0,
|
||||||
|
categories: std::collections::HashMap::new(),
|
||||||
|
tags: std::collections::HashMap::new(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Load real data after component is hydrated (client-side only)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let content_type_for_effect = content_type.clone();
|
||||||
|
let language_for_effect = language.clone();
|
||||||
|
let set_filter_data_for_effect = set_filter_data.clone();
|
||||||
|
|
||||||
|
// Use Effect::new to safely load data after hydration - only on client
|
||||||
|
Effect::new(move |_| {
|
||||||
|
// Double-check we're actually on the client side
|
||||||
|
if !cfg!(target_arch = "wasm32") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone values for the async block
|
||||||
|
let content_type_cloned = content_type_for_effect.clone();
|
||||||
|
let language_cloned = language_for_effect.clone();
|
||||||
|
let set_data_cloned = set_filter_data_for_effect.clone();
|
||||||
|
|
||||||
|
// Only load data on client side after hydration
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
// CRITICAL: Use target route evaluation to get the correct API path
|
||||||
|
// This ensures the API URL matches the target language route
|
||||||
|
// For filter API URLs, we always use the content_type directly
|
||||||
|
// This ensures consistent API endpoints regardless of language routing
|
||||||
|
let filter_url = format!(
|
||||||
|
"/r/{}/{}/filter-index.json",
|
||||||
|
content_type_cloned, language_cloned
|
||||||
|
);
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🔄 CLIENT FILTER: Fetching filter data from {}", filter_url).into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match fetch_filter_data(&filter_url).await {
|
||||||
|
Ok(data) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"CLIENT: Async filter index loaded: {} categories, {} tags ",
|
||||||
|
data.categories.len(),
|
||||||
|
data.tags.len()
|
||||||
|
);
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"✅ CLIENT FILTER: Async loaded {} categories, {} tags ",
|
||||||
|
data.categories.len(),
|
||||||
|
data.tags.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
set_data_cloned.set(Some(data));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("CLIENT: Async filter index loading failed: {}", e);
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("❌ CLIENT FILTER: Async loading failed: {}", e).into(),
|
||||||
|
);
|
||||||
|
// Keep the empty data that was already set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create clones for closures
|
||||||
|
let content_type_for_categories = content_type.clone();
|
||||||
|
let content_type_for_tags = content_type.clone();
|
||||||
|
let current_filter_for_categories = current_filter.clone();
|
||||||
|
let current_filter_for_tags = current_filter.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="filter-container space-y-4">
|
||||||
|
{
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Clone variables before moving into Memo closure
|
||||||
|
let content_type_for_memo = content_type.clone();
|
||||||
|
let language_for_memo = language.clone();
|
||||||
|
let filter_memo = Memo::new(move |_| {
|
||||||
|
// Use .with() instead of .with_untracked() to enable reactive tracking
|
||||||
|
_filter_data.with(|data| {
|
||||||
|
if let Some(data) = data {
|
||||||
|
tracing::debug!(
|
||||||
|
"RENDER: Filter component rendering with {} categories, {} tags. Categories: {:?}, Tags: {:?}",
|
||||||
|
data.categories.len(),
|
||||||
|
data.tags.len(),
|
||||||
|
data.categories,
|
||||||
|
data.tags
|
||||||
|
);
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔄 MEMO UPDATE: Rendering with {} categories, {} items ",
|
||||||
|
data.categories.len(),
|
||||||
|
data.tags.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
data.clone()
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"🔄 MEMO UPDATE: Rendering with empty data ".into(),
|
||||||
|
);
|
||||||
|
FilterIndex {
|
||||||
|
generated_at: String::new(),
|
||||||
|
content_type: content_type_for_memo.clone(),
|
||||||
|
language: language_for_memo.clone(),
|
||||||
|
total_posts: 0,
|
||||||
|
categories: std::collections::HashMap::new(),
|
||||||
|
tags: std::collections::HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
move || {
|
||||||
|
let data = filter_memo.get();
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
// Categories section - always show "All", then additional categories if they exist
|
||||||
|
<div class="category-filters ">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">"Categories"</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
// Always show "All" category
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={if current_filter_for_categories == "All" {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
}}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value="All"
|
||||||
|
data-content-type={content_type_for_categories.clone()}
|
||||||
|
on:click={
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">{"🏷️ All "}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Additional categories from data
|
||||||
|
{
|
||||||
|
data.categories.iter().map(|(category, category_data)| {
|
||||||
|
let category_for_click = category.clone();
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
let is_active = current_filter_for_categories == *category;
|
||||||
|
let cat_class = if is_active {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cat_class}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value={category_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
// Get the proper base path using configuration-driven lookup
|
||||||
|
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
||||||
|
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", category_data.emoji, category_for_click)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Tags section - always render container
|
||||||
|
<div class="tag-filters ">
|
||||||
|
{if !data.tags.is_empty() {
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">"Tags"</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
// Show All button for tags
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={format!("{} show-all-filter ", get_active_button_classes())}
|
||||||
|
data-filter-type="show-all "
|
||||||
|
data-filter-value="all"
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
tracing::debug!("Show All: clearing all tag filters ");
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"🏷️ Show All: clearing all tag filters ".into()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear all tag filters using JavaScript
|
||||||
|
let js_code = "window.clearAllTagFilters && window.clearAllTagFilters();";
|
||||||
|
let _ = js_sys::eval(&js_code);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">{"🔍 Show All "}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{data.tags.iter().map(|(tag, tag_data)| {
|
||||||
|
let tag_for_click = tag.clone();
|
||||||
|
let content_type_for_click = content_type_for_tags.clone();
|
||||||
|
let is_active = current_filter_for_tags == *tag;
|
||||||
|
let tag_class = if is_active {
|
||||||
|
format!("{} tag-filter ", get_active_button_classes())
|
||||||
|
} else {
|
||||||
|
format!("{} tag-filter ", get_inactive_button_classes())
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={tag_class}
|
||||||
|
data-filter-type="tag"
|
||||||
|
data-filter-value={tag_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
// Tags filter content on current page, not navigate
|
||||||
|
tracing::debug!("Tag filter: filtering by tag {}", tag_for_click);
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🏷️ Tag filter: filtering posts by tag {}", tag_for_click).into()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter posts by tag using JavaScript
|
||||||
|
let js_code = format!(
|
||||||
|
r#"window.filterPostsByTag && window.filterPostsByTag("{}");"#,
|
||||||
|
tag_for_click
|
||||||
|
);
|
||||||
|
let _ = js_sys::eval(&js_code);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", tag_data.emoji, tag_for_click)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="hidden">
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR: Use empty initial data since we can't access signals in SSR context
|
||||||
|
// Create empty FilterIndex for SSR rendering (will be hydrated on client)
|
||||||
|
let empty_data = FilterIndex {
|
||||||
|
generated_at: String::new(),
|
||||||
|
content_type: content_type.clone(),
|
||||||
|
language: language.clone(),
|
||||||
|
total_posts: 0,
|
||||||
|
categories: std::collections::HashMap::new(),
|
||||||
|
tags: std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!(
|
||||||
|
"RENDER: SSR Filter component rendering with empty data for hydration, content_type={}, language={}",
|
||||||
|
empty_data.content_type,
|
||||||
|
empty_data.language
|
||||||
|
);
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
// Categories section - always show "All", then additional categories if they exist
|
||||||
|
<div class="category-filters ">
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">"Categories"</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
// Always show "All" category
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={if current_filter_for_categories == "All" {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
}}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value="All"
|
||||||
|
data-content-type={content_type_for_categories.clone()}
|
||||||
|
on:click={
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">{"🏷️ All "}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Additional categories from data (empty for SSR, will be hydrated)
|
||||||
|
{
|
||||||
|
empty_data.categories.iter().map(|(category, category_data)| {
|
||||||
|
let category_for_click = category.clone();
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
let is_active = current_filter_for_categories == *category;
|
||||||
|
let cat_class = if is_active {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cat_class}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value={category_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Get the proper base path using configuration-driven lookup
|
||||||
|
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
||||||
|
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", category_data.emoji, category_for_click)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Tags section - always render container (empty for SSR, will be hydrated)
|
||||||
|
<div class="tag-filters ">
|
||||||
|
{if !empty_data.tags.is_empty() {
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-2">"Tags"</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{empty_data.tags.iter().map(|(tag, tag_data)| {
|
||||||
|
let tag_for_click = tag.clone();
|
||||||
|
let content_type_for_click = content_type_for_tags.clone();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
let is_active = current_filter_for_tags == *tag;
|
||||||
|
let tag_class = if is_active {
|
||||||
|
format!("{} tag-filter ", get_active_button_classes())
|
||||||
|
} else {
|
||||||
|
format!("{} tag-filter ", get_inactive_button_classes())
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={tag_class}
|
||||||
|
data-filter-type="tag"
|
||||||
|
data-filter-value={tag_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Tags filter content on current page, not navigate
|
||||||
|
tracing::debug!("Tag filter: filtering by tag {}", tag_for_click);
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🏷️ Tag filter: filtering posts by tag {}", tag_for_click).into()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter posts by tag using JavaScript
|
||||||
|
let js_code = format!(
|
||||||
|
r#"window.filterPostsByTag && window.filterPostsByTag("{}");"#,
|
||||||
|
tag_for_click
|
||||||
|
);
|
||||||
|
let _ = js_sys::eval(&js_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", tag_data.emoji, tag_for_click)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<div class="hidden"></div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
68
crates/foundation/crates/rustelo_components/src/lib.rs
Normal file
68
crates/foundation/crates/rustelo_components/src/lib.rs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
//! # RUSTELO Components
|
||||||
|
//!
|
||||||
|
//! <div align="center">
|
||||||
|
//! <img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
|
||||||
|
//! </div>
|
||||||
|
//!
|
||||||
|
//! Reusable UI components for the RUSTELO web application framework.
|
||||||
|
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
#![allow(non_snake_case)]
|
||||||
|
// Allow Leptos macro patterns that trigger clippy warnings
|
||||||
|
#![allow(clippy::unit_arg)] // view! { <></> }.into_any() is idiomatic Leptos
|
||||||
|
#![allow(clippy::too_many_arguments)] // Component render functions often have many props
|
||||||
|
#![allow(clippy::unnecessary_literal_unwrap)] // Leptos pattern for Option handling
|
||||||
|
#![allow(clippy::needless_borrow)] // Leptos macros sometimes need explicit borrows
|
||||||
|
#![allow(clippy::manual_div_ceil)] // Used in pagination logic
|
||||||
|
#![allow(clippy::iter_nth_zero)] // Sometimes clearer than .next() in iteration
|
||||||
|
#![allow(clippy::useless_format)] // Format strings for consistency
|
||||||
|
#![allow(clippy::unnecessary_cast)] // Casts for clarity in numeric operations
|
||||||
|
#![allow(clippy::manual_unwrap_or_default)] // Match patterns can be clearer
|
||||||
|
#![allow(clippy::redundant_closure)] // Closures in Leptos patterns
|
||||||
|
|
||||||
|
// Fix Leptos macro conflicts by shadowing our 'core' crate with std::core
|
||||||
|
|
||||||
|
pub mod admin;
|
||||||
|
pub mod content;
|
||||||
|
pub mod filter;
|
||||||
|
pub mod logo;
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod ui;
|
||||||
|
|
||||||
|
// Re-enable filter exports (partially - components with macro errors disabled)
|
||||||
|
pub use filter::{
|
||||||
|
clear_filter_cache,
|
||||||
|
get_active_button_classes,
|
||||||
|
get_inactive_button_classes,
|
||||||
|
load_filter_items_from_json,
|
||||||
|
FilterItem,
|
||||||
|
// Re-enabled after fixing spawn_local compilation errors
|
||||||
|
UnifiedCategoryFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export navigation components (commented out until fixed)
|
||||||
|
// pub use navigation::{BrandHeader, Footer, LanguageSelector, NavMenu};
|
||||||
|
|
||||||
|
// Re-export logo components (commented out until fixed)
|
||||||
|
// pub use logo::{Logo, LogoLink, UnifiedProjectLogo};
|
||||||
|
|
||||||
|
// Re-export theme components (commented out until fixed)
|
||||||
|
// pub use theme::{DarkModeToggle, EffectiveTheme, ThemeMode, ThemeProvider, ThemeUtils};
|
||||||
|
|
||||||
|
// Re-export UI components
|
||||||
|
pub use ui::spa_link::SpaLink;
|
||||||
|
// pub use ui::mobile_menu::{MobileMenu, MobileMenuToggle};
|
||||||
|
// pub use ui::page_transition::{PageTransition, SimplePageTransition, TransitionStyle};
|
||||||
|
|
||||||
|
// Re-export content components
|
||||||
|
pub use content::{ContentManager, HtmlContent, SimpleContentGrid, UnifiedContentCard};
|
||||||
|
|
||||||
|
// Re-export admin components
|
||||||
|
pub use admin::admin_layout::{
|
||||||
|
AdminBreadcrumb, AdminCard, AdminEmptyState, AdminHeader, AdminLayout, AdminSection,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export client-specific theme types when available
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use theme::{use_theme, ThemeContext};
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
//! Client-side logo components
|
||||||
|
//!
|
||||||
|
//! Reactive implementations of logo components for client-side rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::LogoConfig;
|
||||||
|
|
||||||
|
/// Client-side reactive logo component
|
||||||
|
#[component]
|
||||||
|
pub fn LogoClient(
|
||||||
|
#[prop(optional)] config: Option<LogoConfig>,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
#[prop(default = false)] as_link: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
||||||
|
// Get reactive theme/language if needed
|
||||||
|
let _current_lang = move || {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Get current path from browser location
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(pathname) = window.location().pathname() {
|
||||||
|
return rustelo_core_lib::routing::utils::detect_language_from_path(&pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"en".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delegate to unified implementation
|
||||||
|
view! {
|
||||||
|
<crate::logo::unified::Logo
|
||||||
|
config=logo_config
|
||||||
|
class=class
|
||||||
|
as_link=as_link
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side reactive logo link component
|
||||||
|
#[component]
|
||||||
|
pub fn LogoLinkClient(
|
||||||
|
#[prop(optional)] config: Option<LogoConfig>,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
||||||
|
view! {
|
||||||
|
<crate::logo::unified::LogoLink config=logo_config class=class />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as Logo and LogoLink for compatibility
|
||||||
|
pub use LogoClient as Logo;
|
||||||
|
pub use LogoLinkClient as LogoLink;
|
||||||
14
crates/foundation/crates/rustelo_components/src/logo/mod.rs
Normal file
14
crates/foundation/crates/rustelo_components/src/logo/mod.rs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//! Logo component module
|
||||||
|
//!
|
||||||
|
//! Provides unified logo components that work across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::{Logo, LogoLink, UnifiedProjectLogo};
|
||||||
42
crates/foundation/crates/rustelo_components/src/logo/ssr.rs
Normal file
42
crates/foundation/crates/rustelo_components/src/logo/ssr.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
//! Server-side logo components
|
||||||
|
//!
|
||||||
|
//! Static implementations of logo components optimized for SSR rendering.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::LogoConfig;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side static logo component
|
||||||
|
#[component]
|
||||||
|
pub fn LogoSSR(
|
||||||
|
#[prop(optional)] config: Option<LogoConfig>,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
#[prop(default = false)] as_link: bool,
|
||||||
|
#[prop(default = "en".to_string())] _lang: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
||||||
|
// Delegate to unified implementation
|
||||||
|
view! {
|
||||||
|
<crate::logo::unified::Logo
|
||||||
|
config=logo_config
|
||||||
|
class=class
|
||||||
|
as_link=as_link
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side static logo link component
|
||||||
|
#[component]
|
||||||
|
pub fn LogoLinkSSR(
|
||||||
|
#[prop(optional)] config: Option<LogoConfig>,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
#[prop(default = "en".to_string())] _lang: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
||||||
|
view! {
|
||||||
|
<crate::logo::unified::LogoLink config=logo_config class=class />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as Logo and LogoLink for compatibility
|
||||||
|
pub use LogoLinkSSR as LogoLink;
|
||||||
|
pub use LogoSSR as Logo;
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
//! Unified logo component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::LogoConfig;
|
||||||
|
|
||||||
|
/// Simple Logo component that displays just the image
|
||||||
|
#[component]
|
||||||
|
pub fn Logo(
|
||||||
|
config: LogoConfig,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
#[prop(default = false)] as_link: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let logo_config = config;
|
||||||
|
|
||||||
|
// Build size classes from config
|
||||||
|
let mut size_classes = String::new();
|
||||||
|
if let (Some(width), Some(height)) = (&logo_config.width, &logo_config.height) {
|
||||||
|
size_classes = format!("w-[{}px] h-[{}px]", width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
let img_classes = if size_classes.is_empty() {
|
||||||
|
class.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", size_classes, class)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always render the same structure to avoid hydration mismatch
|
||||||
|
let logo_image = view! {
|
||||||
|
<img src={logo_config.light_src.clone()}
|
||||||
|
alt={logo_config.alt.clone()}
|
||||||
|
class={
|
||||||
|
if logo_config.has_dark_variant() {
|
||||||
|
format!("{} dark:hidden", img_classes)
|
||||||
|
} else {
|
||||||
|
img_classes.clone()
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
<img src={logo_config.dark_src.clone()}
|
||||||
|
alt={logo_config.alt.clone()}
|
||||||
|
class={
|
||||||
|
if logo_config.has_dark_variant() {
|
||||||
|
format!("{} hidden dark:block", img_classes)
|
||||||
|
} else {
|
||||||
|
"hidden".to_string()
|
||||||
|
}
|
||||||
|
} />
|
||||||
|
}
|
||||||
|
.into_any();
|
||||||
|
|
||||||
|
if as_link {
|
||||||
|
view! {
|
||||||
|
<a href={logo_config.href} class="block">
|
||||||
|
{logo_image}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
logo_image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logo Link component - Logo wrapped in a link
|
||||||
|
#[component]
|
||||||
|
pub fn LogoLink(
|
||||||
|
config: LogoConfig,
|
||||||
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<Logo config=config class=class as_link=true />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy UnifiedProjectLogo component for backward compatibility
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedProjectLogo(
|
||||||
|
light_src: String,
|
||||||
|
dark_src: String,
|
||||||
|
alt: String,
|
||||||
|
href: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let config = LogoConfig {
|
||||||
|
light_src,
|
||||||
|
dark_src,
|
||||||
|
alt,
|
||||||
|
href,
|
||||||
|
show_in_nav: true,
|
||||||
|
show_in_footer: true,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<LogoLink config=config class="h-28 w-28 hover:scale-105 transition-transform".to_string() />
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,134 @@
|
|||||||
|
//! Unified brand header component
|
||||||
|
//!
|
||||||
|
//! Provides consistent branding (logo or title) with identical behavior
|
||||||
|
//! across navigation menu and footer components.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::{get_theme, load_reactive_footer_for_language};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Extract logo configuration from registered theme
|
||||||
|
///
|
||||||
|
/// This function reads from the registered theme resources (loaded at server startup
|
||||||
|
/// from config/themes/default.toml) to get configuration-driven logo paths.
|
||||||
|
/// PAP-compliant: Uses registered resources, no hardcoding.
|
||||||
|
fn get_logo_config() -> (String, String, String) {
|
||||||
|
// Get theme - available in both SSR and client contexts
|
||||||
|
// On WASM: registry will be populated from embedded constants
|
||||||
|
// On server: registry is populated at startup from ResourceContributor
|
||||||
|
let theme_result = get_theme("default");
|
||||||
|
|
||||||
|
if let Some(theme_toml) = theme_result {
|
||||||
|
let logo_light = extract_toml_value(&theme_toml, "logo_light");
|
||||||
|
let logo_dark = extract_toml_value(&theme_toml, "logo_dark");
|
||||||
|
let mut logo_alt = extract_toml_value(&theme_toml, "logo_alt");
|
||||||
|
|
||||||
|
if !logo_light.is_empty() {
|
||||||
|
// Use extracted alt text, or default to "Logo"
|
||||||
|
if logo_alt.is_empty() {
|
||||||
|
logo_alt = "Logo".to_string();
|
||||||
|
}
|
||||||
|
return (logo_light, logo_dark, logo_alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback when theme unavailable
|
||||||
|
(String::new(), String::new(), "Logo".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to extract TOML values from theme content using simple string parsing
|
||||||
|
/// This avoids adding toml dependency to rustelo_components
|
||||||
|
fn extract_toml_value(content: &str, key: &str) -> String {
|
||||||
|
// Look for key = "value" pattern in [assets] section
|
||||||
|
let pattern = format!("{} = \"", key);
|
||||||
|
if let Some(start) = content.find(&pattern) {
|
||||||
|
let start_pos = start + pattern.len();
|
||||||
|
if let Some(end) = content[start_pos..].find('"') {
|
||||||
|
return content[start_pos..start_pos + end].to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified brand header that works consistently in nav and footer
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn BrandHeader(
|
||||||
|
/// Language for loading configuration
|
||||||
|
#[prop(optional)]
|
||||||
|
language: Option<String>,
|
||||||
|
/// CSS classes for the brand element
|
||||||
|
#[prop(default = "text-xl font-bold".to_string())]
|
||||||
|
class: String,
|
||||||
|
/// Force show/hide logo (overrides config)
|
||||||
|
#[prop(optional)]
|
||||||
|
force_logo: Option<bool>,
|
||||||
|
/// Use footer logo settings instead of nav settings
|
||||||
|
#[prop(default = false)]
|
||||||
|
is_footer: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Get logo configuration
|
||||||
|
let (logo_light, logo_dark, logo_alt) = get_logo_config();
|
||||||
|
|
||||||
|
// Get site title from footer config or environment
|
||||||
|
let site_title = {
|
||||||
|
// Try to load from footer config first
|
||||||
|
let lang = language.as_deref().unwrap_or("en");
|
||||||
|
let config_title = load_reactive_footer_for_language(lang)
|
||||||
|
.ok()
|
||||||
|
.map(|footer| footer.title)
|
||||||
|
.filter(|title| !title.is_empty());
|
||||||
|
|
||||||
|
// Fallback to environment variables
|
||||||
|
config_title.unwrap_or_else(|| {
|
||||||
|
std::env::var("SITE_TITLE")
|
||||||
|
.or_else(|_| std::env::var("SITE_LOGO_ALT"))
|
||||||
|
.unwrap_or_else(|_| "Site".to_string())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if we should show logo (from config or override)
|
||||||
|
let show_logo = force_logo.unwrap_or(true);
|
||||||
|
let render_logo = show_logo && !logo_light.is_empty();
|
||||||
|
let has_dark_logo = !logo_dark.is_empty();
|
||||||
|
view! {
|
||||||
|
<a href="/" class=format!("{} transition-colors flex items-center gap-2", class)>
|
||||||
|
{
|
||||||
|
if render_logo {
|
||||||
|
// Render logo images if available
|
||||||
|
if has_dark_logo {
|
||||||
|
view! {
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src=logo_light.clone()
|
||||||
|
alt=logo_alt.clone()
|
||||||
|
class="h-10 object-contain dark:hidden"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src=logo_dark.clone()
|
||||||
|
alt=logo_alt.clone()
|
||||||
|
class="h-10 object-contain hidden dark:block"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<img
|
||||||
|
src=logo_light.clone()
|
||||||
|
alt=logo_alt.clone()
|
||||||
|
class="h-10 object-contain"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Render text fallback
|
||||||
|
view! {
|
||||||
|
<span class="ds-text">{site_title.clone()}</span>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
//! Client-side footer wrapper
|
||||||
|
//!
|
||||||
|
//! This is a thin wrapper that prepares client-specific data (reactive language)
|
||||||
|
//! and delegates to the unified implementation.
|
||||||
|
|
||||||
|
use crate::navigation::footer::unified::UnifiedFooter;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
config::get_default_language,
|
||||||
|
i18n::{build_page_content_patterns, UnifiedI18n},
|
||||||
|
state::use_language,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Client-side footer wrapper
|
||||||
|
#[component]
|
||||||
|
pub fn FooterClient(
|
||||||
|
set_path: WriteSignal<String>,
|
||||||
|
path: ReadSignal<String>,
|
||||||
|
#[prop(default = get_default_language().to_string())] _language: String,
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// All functionality is handled by the unified footer component
|
||||||
|
// Get language context to track reactive language changes
|
||||||
|
let language_context = use_language();
|
||||||
|
let lang_content = Memo::new(move |_| {
|
||||||
|
let current_language = language_context.current.get();
|
||||||
|
let i18n = UnifiedI18n::new(¤t_language, "/");
|
||||||
|
build_page_content_patterns(&i18n, &["footer-", "lang-"])
|
||||||
|
});
|
||||||
|
move || {
|
||||||
|
view! {
|
||||||
|
<UnifiedFooter
|
||||||
|
navigation_signals=(path.clone(), set_path.clone())
|
||||||
|
language=language_context.current.get()
|
||||||
|
lang_content=lang_content.get()
|
||||||
|
class=class.clone().unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as Footer for compatibility
|
||||||
|
// pub use FooterClient as Footer;
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
//! Footer components module
|
||||||
|
//!
|
||||||
|
//! This module provides unified footer components that work seamlessly
|
||||||
|
//! across both client-side (reactive) and server-side rendering (static) contexts.
|
||||||
|
|
||||||
|
// Client-side implementation
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Server-side implementation
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
// Unified interface (always available)
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
// Re-export unified component as the main interface
|
||||||
|
pub use unified::Footer;
|
||||||
|
|
||||||
|
// Re-export individual implementations for direct use if needed
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use client::FooterClient;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use ssr::FooterSSR;
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
//! Server-side footer wrapper
|
||||||
|
//!
|
||||||
|
//! This is a thin wrapper that prepares SSR-specific data (static language)
|
||||||
|
//! and delegates to the unified implementation.
|
||||||
|
|
||||||
|
use crate::navigation::footer::unified::UnifiedFooter;
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
config::get_default_language,
|
||||||
|
i18n::{build_page_content_patterns, SsrTranslator},
|
||||||
|
};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side footer wrapper
|
||||||
|
#[component]
|
||||||
|
pub fn FooterSSR(
|
||||||
|
#[prop(default = get_default_language().to_string())] language: String,
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let ssr_i18n = SsrTranslator::new(language.clone());
|
||||||
|
let patterns = ["footer-", "lang-"];
|
||||||
|
let lang_content = build_page_content_patterns(&ssr_i18n, &patterns);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedFooter
|
||||||
|
language=language
|
||||||
|
lang_content=lang_content
|
||||||
|
class=class.clone().unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as Footer for compatibility
|
||||||
|
// pub use FooterSSR as Footer;
|
||||||
@ -0,0 +1,265 @@
|
|||||||
|
//! Unified footer with actual implementation
|
||||||
|
//!
|
||||||
|
//! This module contains the main footer implementation that works
|
||||||
|
//! in both client-side and server-side contexts.
|
||||||
|
|
||||||
|
use crate::navigation::brand_header::BrandHeader;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::navigation::footer::client::FooterClient;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::navigation::footer::ssr::FooterSSR;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::load_reactive_footer_for_language;
|
||||||
|
|
||||||
|
/// Main Footer component that delegates to appropriate wrapper
|
||||||
|
#[component]
|
||||||
|
pub fn Footer(
|
||||||
|
/// Path setter for SPA navigation (client-side only)
|
||||||
|
#[prop(optional)]
|
||||||
|
_set_path: Option<WriteSignal<String>>,
|
||||||
|
/// Current path signal (client-side only)
|
||||||
|
#[prop(optional)]
|
||||||
|
_path: Option<ReadSignal<String>>,
|
||||||
|
/// Language for SSR context
|
||||||
|
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
|
||||||
|
language: String,
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use SSR wrapper
|
||||||
|
view! {
|
||||||
|
<FooterSSR
|
||||||
|
language=language
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let (Some(set_path), Some(path)) = (_set_path, _path) {
|
||||||
|
// Client context: use unified footer directly to avoid reactive stack overflow
|
||||||
|
view! {
|
||||||
|
<FooterClient
|
||||||
|
set_path=set_path
|
||||||
|
path=path
|
||||||
|
_language=language
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div class="nav-error">"Navigation unavailable"</div> }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified footer component with the actual implementation
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedFooter(
|
||||||
|
language: String,
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
/// Footer content from i18n (optional)
|
||||||
|
#[prop(optional)]
|
||||||
|
lang_content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
/// Navigation signals for SPA routing (optional)
|
||||||
|
#[prop(optional)]
|
||||||
|
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Use static language from props - no reactive closure needed
|
||||||
|
let current_lang = language.clone();
|
||||||
|
|
||||||
|
// Load footer configuration from TOML files using static language
|
||||||
|
let footer_config = match load_reactive_footer_for_language(¤t_lang) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to load footer config for language: {}",
|
||||||
|
current_lang
|
||||||
|
);
|
||||||
|
// Create fallback config
|
||||||
|
rustelo_core_lib::defs::FooterConfig::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Setup footer content from loaded config and optional override
|
||||||
|
let footer_content = lang_content.unwrap_or_default();
|
||||||
|
|
||||||
|
// Simple content lookup function
|
||||||
|
let get_content = move |key: &str| -> String {
|
||||||
|
footer_content.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
tracing::warn!("i18n key not found: '{}'", key);
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll to top handler
|
||||||
|
let scroll_to_top = move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Try multiple scroll approaches
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
// Method 1: Use window.scrollTo (most reliable)
|
||||||
|
let _ = window.scroll_to_with_x_and_y(0.0, 0.0);
|
||||||
|
|
||||||
|
// Method 2: Use document.documentElement.scrollTop (fallback)
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
if let Some(element) = document.document_element() {
|
||||||
|
element.set_scroll_top(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_class = "ds-bg-surface border-t border-gray-200 mt-auto";
|
||||||
|
let footer_class = format!("{} {}", base_class, class.unwrap_or_default());
|
||||||
|
|
||||||
|
let render_footer_link = |link: rustelo_core_lib::FooterLink| {
|
||||||
|
let link_label = link.label.get_with_fallback(¤t_lang, "en");
|
||||||
|
let is_external = link.is_external.unwrap_or(false);
|
||||||
|
let href = link.route.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<li>
|
||||||
|
<a href={href.clone()}
|
||||||
|
class="ds-text-secondary hover:ds-text transition-colors no-underline"
|
||||||
|
target={if is_external { "_blank" } else { "_self" }}
|
||||||
|
rel={if is_external { "noopener noreferrer" } else { "" }}
|
||||||
|
on:click=move |ev| {
|
||||||
|
ev.prevent_default();
|
||||||
|
// Only use SPA routing for internal links
|
||||||
|
if !is_external {
|
||||||
|
// Only handle click if navigation_signals are available (client-side)
|
||||||
|
if let Some((_, set_path)) = navigation_signals {
|
||||||
|
ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &href);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
set_path.set(href.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("⚠️ FooterLink: No navigation signals available for href: '{}', allowing default browser footer", href).into());
|
||||||
|
}
|
||||||
|
// Otherwise, let the normal link behavior happen (SSR)
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🌐 FooterLink: External link detected for href: '{}', allowing default browser footer", href).into());
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{link_label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<footer class={footer_class}>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
// Company info from footer config - consistent with navigation
|
||||||
|
<div class="col-span-1">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<BrandHeader
|
||||||
|
language={current_lang.clone()}
|
||||||
|
class="h-8 w-auto text-xl font-bold".to_string()
|
||||||
|
is_footer=true
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ds-text-secondary text-sm mb-4">
|
||||||
|
{footer_config.company_desc}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm ds-text-secondary">
|
||||||
|
{footer_config.copyright_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Dynamic footer sections from TOML
|
||||||
|
{footer_config.sections.clone().into_iter().map(|section| {
|
||||||
|
let section_title = section.title.get_with_fallback(¤t_lang, "en");
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold ds-text mb-3">{section_title}</h4>
|
||||||
|
<ul class="space-y-2 text-sm">
|
||||||
|
{section.links.into_iter().map(render_footer_link).collect::<Vec<_>>()}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
|
||||||
|
// Dynamic social links (if enabled and defined)
|
||||||
|
{(show_social && !footer_config.social_links.is_empty()).then(|| view! {
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold ds-text mb-3">{get_content("footer-social-links")}</h4>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
{footer_config.social_links.into_iter().map(|social| {
|
||||||
|
let name = social.name.clone();
|
||||||
|
// Extract icon class for proper rendering
|
||||||
|
let icon_html = match social.icon_class.as_str() {
|
||||||
|
icon if icon.contains("github") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>"#.to_string(),
|
||||||
|
icon if icon.contains("linkedin") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>"#.to_string(),
|
||||||
|
_ => format!(r#"<i class="{}" aria-hidden="true"></i>"#, social.icon_class),
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a href={social.url.clone()}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="ds-text-secondary hover:ds-text transition-colors"
|
||||||
|
aria-label={name.clone()}>
|
||||||
|
<span inner_html={icon_html}></span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
// Scroll to top button (if enabled) - small fixed position button
|
||||||
|
{show_scroll_to_top.then(|| view! {
|
||||||
|
<button
|
||||||
|
class="fixed bottom-4 right-4 w-10 h-10 ds-bg-primary text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center z-50"
|
||||||
|
on:click=scroll_to_top
|
||||||
|
aria-label="Scroll to top"
|
||||||
|
title="Scroll to top"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
//! Client-side language selector component
|
||||||
|
//!
|
||||||
|
//! Reactive implementation with i18n content loading
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::{build_page_content_patterns, SsrTranslator};
|
||||||
|
|
||||||
|
/// Client-side reactive language button
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageButton(
|
||||||
|
code: String,
|
||||||
|
native_name: String,
|
||||||
|
is_current: bool,
|
||||||
|
on_select: impl Fn(String) + 'static + Copy,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let code_clone = code.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm ds-bg hover:ds-bg-hover ds-text transition-colors duration-200"
|
||||||
|
class:opacity-50=is_current
|
||||||
|
class:cursor-not-allowed=is_current
|
||||||
|
class:pointer-events-none=is_current
|
||||||
|
disabled=is_current
|
||||||
|
on:click=move |_| {
|
||||||
|
if !is_current {
|
||||||
|
on_select(code_clone.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-4 h-4 mr-2">
|
||||||
|
{if is_current { "✓" } else { "" }}
|
||||||
|
</div>
|
||||||
|
{native_name.clone()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side reactive language selector component
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageSelectorClient(
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
#[prop(optional)] current_language: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Get current language from URL or browser detection
|
||||||
|
let current_lang = current_language.unwrap_or_else(|| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(pathname) = window.location().pathname() {
|
||||||
|
return rustelo_core_lib::routing::utils::detect_language_from_path(&pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rustelo_core_lib::config::get_default_language().to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load i18n content for language selector
|
||||||
|
let translator = SsrTranslator::new(current_lang.clone());
|
||||||
|
let lang_content = build_page_content_patterns(&translator, &["lang-"]);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
current_language=Some(current_lang)
|
||||||
|
content=lang_content
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
//! Language selector component module
|
||||||
|
//!
|
||||||
|
//! Provides unified language selector components that work across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::LanguageSelector;
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
//! Server-side language selector component
|
||||||
|
//!
|
||||||
|
//! Static implementation of the language selector optimized for SSR rendering.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::{build_page_content_patterns, SsrTranslator};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side static language selector component
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageSelectorSSR(
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
#[prop(optional)] current_language: Option<String>,
|
||||||
|
#[prop(optional)] current_path: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = current_language
|
||||||
|
.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
let _path = current_path.clone().unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
// Use pattern-based key discovery - same as client for perfect hydration sync
|
||||||
|
let ssr_i18n = SsrTranslator::new(lang.clone());
|
||||||
|
let lang_content = build_page_content_patterns(&ssr_i18n, &["lang-"]);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
current_language=Some(lang)
|
||||||
|
_current_path=current_path.unwrap_or_default()
|
||||||
|
content=lang_content
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side static language toggle component
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageToggleSSR(
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
#[prop(optional)] current_language: Option<String>,
|
||||||
|
#[prop(optional)] current_path: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = current_language
|
||||||
|
.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
let _path = current_path.clone().unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
|
// Use pattern-based key discovery - same as client for perfect hydration sync
|
||||||
|
let ssr_i18n = SsrTranslator::new(lang.clone());
|
||||||
|
let lang_content = build_page_content_patterns(&ssr_i18n, &["lang-"]);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
current_language=Some(lang)
|
||||||
|
_current_path=current_path.unwrap_or_default()
|
||||||
|
content=lang_content
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,259 @@
|
|||||||
|
//! Simplified language selector component with no reactive complexity
|
||||||
|
//!
|
||||||
|
//! This module provides a language-agnostic implementation that uses the LanguageRegistry
|
||||||
|
//! to automatically discover available languages without reactive state management.
|
||||||
|
|
||||||
|
use leptos::ev::MouseEvent;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::language_config::get_language_registry;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
/// Simple language selector component - SVG icon only, no reactive state
|
||||||
|
#[component]
|
||||||
|
pub fn LanguageSelector(
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
#[prop(default = Some(rustelo_core_lib::config::get_default_language().to_string()))]
|
||||||
|
current_language: Option<String>,
|
||||||
|
#[prop(optional)] _current_path: Option<String>,
|
||||||
|
#[prop(optional)] content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
/// Navigation signals for client-side routing (optional)
|
||||||
|
#[prop(optional)]
|
||||||
|
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let selector_class = class.unwrap_or_default();
|
||||||
|
|
||||||
|
// Get language registry for auto-detection
|
||||||
|
let registry = get_language_registry();
|
||||||
|
let available_languages = registry.get_available_languages();
|
||||||
|
|
||||||
|
// State for dropdown visibility
|
||||||
|
let (_is_open, set_is_open) = signal(false);
|
||||||
|
|
||||||
|
// Get language context for switching
|
||||||
|
let language_context = rustelo_core_lib::state::use_language();
|
||||||
|
|
||||||
|
// Setup i18n content with fallbacks
|
||||||
|
let lang_content = content.unwrap_or_else(|| {
|
||||||
|
tracing::warn!("No content provided to LanguageSelector, using empty HashMap");
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple content lookup function - clone to create multiple copies
|
||||||
|
let content_lookup = |key: &str| -> String {
|
||||||
|
lang_content.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Language selector i18n key not found: '{}', using fallback",
|
||||||
|
key
|
||||||
|
);
|
||||||
|
match key {
|
||||||
|
"lang-selector-button" => "Language selector".to_string(),
|
||||||
|
_ => {
|
||||||
|
// For unknown language codes, return uppercase code
|
||||||
|
if key.starts_with("lang-") && key.ends_with("-display") {
|
||||||
|
let lang_code =
|
||||||
|
key.trim_start_matches("lang-").trim_end_matches("-display");
|
||||||
|
lang_code.to_uppercase()
|
||||||
|
} else {
|
||||||
|
key.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the button aria-label value early to avoid closure conflicts
|
||||||
|
let aria_label = content_lookup("lang-selector-button");
|
||||||
|
|
||||||
|
// Handle escape key to close dropdown
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let set_is_open_clone = set_is_open;
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let closure =
|
||||||
|
wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
|
||||||
|
if e.key() == "Escape" {
|
||||||
|
set_is_open_clone.set(false);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract set_path from navigation_signals prop
|
||||||
|
let set_path = navigation_signals.map(|(_, set_path)| set_path);
|
||||||
|
|
||||||
|
// Generate dropdown items reactively from available languages
|
||||||
|
let language_options = move || {
|
||||||
|
available_languages
|
||||||
|
.iter()
|
||||||
|
.map(|lang_code| {
|
||||||
|
// Use static current_language prop to ensure SSR/client consistency
|
||||||
|
// This avoids hydration mismatches by using the same value on both sides
|
||||||
|
let is_current = lang_code == current_language.as_ref().unwrap_or(&"en".to_string());
|
||||||
|
|
||||||
|
let button_class = if is_current {
|
||||||
|
"w-full text-left px-3 py-2 text-sm text-gray-400 cursor-not-allowed flex items-center"
|
||||||
|
} else {
|
||||||
|
"w-full text-left px-3 py-2 text-sm ds-text hover:ds-bg-hover flex items-center"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get language-agnostic display name using registry method
|
||||||
|
let display_check = if is_current {
|
||||||
|
"✓"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let display_name = registry.get_display_name(lang_code, false);
|
||||||
|
|
||||||
|
// Create the language option button
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class={button_class}
|
||||||
|
disabled={is_current}
|
||||||
|
data-language={lang_code.clone()}
|
||||||
|
on:click={
|
||||||
|
let _btn_lang_code = lang_code.clone();
|
||||||
|
let lang_code_clone = lang_code.clone();
|
||||||
|
let _set_path_signal = set_path;
|
||||||
|
let _current_path_clone = _current_path.clone();
|
||||||
|
let _lang_ctx = language_context.clone();
|
||||||
|
move |e: MouseEvent| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: Language button clicked - {}, is_current: {}", lang_code_clone, is_current).into());
|
||||||
|
|
||||||
|
if !is_current {
|
||||||
|
e.prevent_default();
|
||||||
|
set_is_open.set(false); // Close dropdown after selection
|
||||||
|
|
||||||
|
// Save language preference in cookie before navigation
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
_lang_ctx.switch_language(&_btn_lang_code);
|
||||||
|
// Store the language preference in cookie for persistence
|
||||||
|
if let Some(_window) = web_sys::window() {
|
||||||
|
let cookie_script = format!(
|
||||||
|
"document.cookie = 'preferred_language={}; path=/; max-age={}; SameSite=Lax'",
|
||||||
|
lang_code_clone,
|
||||||
|
365 * 24 * 60 * 60 // 1 year in seconds
|
||||||
|
);
|
||||||
|
let _ = js_sys::eval(&cookie_script);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current path and navigate to localized version
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Ok(pathname) = window.location().pathname() {
|
||||||
|
// Debug logging in development builds only
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: Current path: '{}'", pathname).into());
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: Target language: '{}'", lang_code_clone).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new localized path
|
||||||
|
let new_path = rustelo_core_lib::routing::utils::get_localized_route(&pathname, &lang_code_clone);
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: New path: '{}' -> '{}'", pathname, new_path).into());
|
||||||
|
|
||||||
|
if let Some(set_path) = _set_path_signal {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: Using anchor_navigate to: '{}'", new_path).into());
|
||||||
|
|
||||||
|
// Use anchor_navigate to properly update URL and apply transitions
|
||||||
|
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &new_path);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: No set_path signal, using direct navigation to: '{}'", new_path).into());
|
||||||
|
|
||||||
|
// Fallback to direct navigation
|
||||||
|
let _ = window.location().set_pathname(&new_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
tracing::debug!("SSR: would navigate to language {}", lang_code_clone);
|
||||||
|
if let Some(set_path) = _set_path_signal {
|
||||||
|
let the_current_path = _current_path_clone.clone().unwrap_or_default();
|
||||||
|
set_path.set(the_current_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="mx-2 text-xs">{display_check} </span>
|
||||||
|
<span>{display_name}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let aria_expanded = move || _is_open.get();
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let aria_expanded = false;
|
||||||
|
|
||||||
|
// Interactive dropdown structure
|
||||||
|
view! {
|
||||||
|
<div class={format!("language-selector relative {}", selector_class)}>
|
||||||
|
// Button with SVG icon only
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm ds-text hover:ds-text-secondary flex items-center justify-center w-8 h-8 text-sm"
|
||||||
|
aria-label={aria_label.clone()}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={aria_expanded}
|
||||||
|
on:click=move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&"🌍 Language Selector: Main button clicked, toggling dropdown".into());
|
||||||
|
set_is_open.update(|open| *open = !*open)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Dropdown menu - shows when clicked
|
||||||
|
<div
|
||||||
|
class=move || {
|
||||||
|
let visibility = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{ _is_open.get() }
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ false } // SSR default: dropdown closed
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"absolute right-0 mt-1 w-48 border border-gray-500 rounded-md shadow-lg z-10 transition-all duration-200 {}",
|
||||||
|
if visibility { "opacity-100 visible" } else { "opacity-0 invisible" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
role="menu"
|
||||||
|
aria-label="Language selection"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
{language_options()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LanguageToggle removed - only LanguageSelector dropdown is used
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
//! Menu Registry for Context-Based Menu Management
|
||||||
|
//!
|
||||||
|
//! This module provides the MenuRegistry type that stores all language menus
|
||||||
|
//! for instant access without HTTP requests. Used in conjunction with Leptos
|
||||||
|
//! context system for SSR-first architecture.
|
||||||
|
|
||||||
|
use rustelo_core_lib::defs::MenuItem;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Registry containing menus for all available languages
|
||||||
|
///
|
||||||
|
/// This is provided via Leptos context during SSR and hydration,
|
||||||
|
/// allowing instant menu access on language changes without HTTP calls.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MenuRegistry {
|
||||||
|
/// HashMap mapping language codes to their menu items
|
||||||
|
/// e.g. "en" -> vec![MenuItem {...}, ...], "es" -> vec![MenuItem {...}, ...]
|
||||||
|
menus: HashMap<String, Vec<MenuItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuRegistry {
|
||||||
|
/// Create a new empty MenuRegistry
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
menus: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert menu items for a language
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `lang` - Language code (e.g., "en", "es", "fr")
|
||||||
|
/// * `items` - Vector of menu items for that language
|
||||||
|
pub fn insert(&mut self, lang: impl Into<String>, items: Vec<MenuItem>) {
|
||||||
|
self.menus.insert(lang.into(), items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get menu items for a language
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `lang` - Language code to retrieve
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Some(&Vec<MenuItem>)` if language exists
|
||||||
|
/// * `None` if language not found
|
||||||
|
pub fn get(&self, lang: &str) -> Option<&Vec<MenuItem>> {
|
||||||
|
self.menus.get(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get menu items for a language (cloned)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `lang` - Language code to retrieve
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `Some(Vec<MenuItem>)` if language exists
|
||||||
|
/// * `None` if language not found
|
||||||
|
pub fn get_cloned(&self, lang: &str) -> Option<Vec<MenuItem>> {
|
||||||
|
self.menus.get(lang).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a language exists in the registry
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `lang` - Language code to check
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `true` if language exists
|
||||||
|
/// * `false` if language not found
|
||||||
|
pub fn contains_language(&self, lang: &str) -> bool {
|
||||||
|
self.menus.contains_key(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get list of available languages
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// Vector of language codes
|
||||||
|
pub fn available_languages(&self) -> Vec<String> {
|
||||||
|
self.menus.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get number of languages in registry
|
||||||
|
pub fn language_count(&self) -> usize {
|
||||||
|
self.menus.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from HashMap (useful for batch loading)
|
||||||
|
pub fn from_map(menus: HashMap<String, Vec<MenuItem>>) -> Self {
|
||||||
|
Self { menus }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get internal HashMap reference
|
||||||
|
pub fn menus(&self) -> &HashMap<String, Vec<MenuItem>> {
|
||||||
|
&self.menus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to HashMap
|
||||||
|
pub fn into_map(self) -> HashMap<String, Vec<MenuItem>> {
|
||||||
|
self.menus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MenuRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error type for menu registry operations
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum MenuRegistryError {
|
||||||
|
/// Language not found in registry
|
||||||
|
LanguageNotFound(String),
|
||||||
|
/// Failed to load menus
|
||||||
|
LoadError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MenuRegistryError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::LanguageNotFound(lang) => {
|
||||||
|
write!(f, "Language '{}' not found in menu registry", lang)
|
||||||
|
}
|
||||||
|
Self::LoadError(msg) => write!(f, "Failed to load menus: {}", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MenuRegistryError {}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
//! Navigation components module
|
||||||
|
//!
|
||||||
|
//! This module provides unified navigation components that work seamlessly
|
||||||
|
//! across both client-side (reactive) and server-side rendering (static) contexts.
|
||||||
|
|
||||||
|
// New unified navigation components
|
||||||
|
pub mod brand_header;
|
||||||
|
pub mod footer;
|
||||||
|
pub mod language_selector;
|
||||||
|
pub mod menu_registry;
|
||||||
|
pub mod navmenu;
|
||||||
|
|
||||||
|
// Re-export main components
|
||||||
|
pub use brand_header::BrandHeader;
|
||||||
|
pub use footer::Footer;
|
||||||
|
pub use language_selector::LanguageSelector;
|
||||||
|
pub use menu_registry::{MenuRegistry, MenuRegistryError};
|
||||||
|
pub use navmenu::NavMenu;
|
||||||
|
|
||||||
|
// Re-export individual implementations for direct use if needed
|
||||||
|
// Note: These are conditionally compiled, so they may not always be available
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub use navmenu::NavMenuClient;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use navmenu::NavMenuSSR;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use footer::FooterClient;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use footer::FooterSSR;
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
//! Client-side navigation menu wrapper with context-based menu loading
|
||||||
|
//!
|
||||||
|
//! This component loads menu items from the MenuRegistry context (SSR-pre-loaded)
|
||||||
|
//! and provides them to the unified menu component for instant language switching.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::state::use_language;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use crate::navigation::MenuRegistry;
|
||||||
|
|
||||||
|
/// Client-side navigation menu wrapper with context-based loading
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenuClient(set_path: WriteSignal<String>, path: ReadSignal<String>) -> impl IntoView {
|
||||||
|
// Get reactive language context
|
||||||
|
let language_context = use_language();
|
||||||
|
|
||||||
|
// Get menu registry from context (SSR-pre-loaded)
|
||||||
|
let menu_registry = use_context::<MenuRegistry>().unwrap_or_else(|| {
|
||||||
|
tracing::warn!("🔧 NavMenuClient: MenuRegistry context not found, using empty registry");
|
||||||
|
MenuRegistry::new()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render function that reactively responds to language changes
|
||||||
|
move || {
|
||||||
|
let current_lang = language_context.current.with(|lang| lang.clone());
|
||||||
|
|
||||||
|
// Get menu items for current language from registry
|
||||||
|
let menu_items = menu_registry.get_cloned(¤t_lang).unwrap_or_default();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"🔧 NavMenuClient: Rendering menu for language '{}' ({} items)",
|
||||||
|
current_lang,
|
||||||
|
menu_items.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: i18n content is now loaded automatically by UnifiedNavMenu from FTL registry
|
||||||
|
// if not provided as a prop, so we don't need to extract it here
|
||||||
|
view! {
|
||||||
|
<crate::navigation::navmenu::unified::UnifiedNavMenu
|
||||||
|
navigation_signals=(path, set_path)
|
||||||
|
language=current_lang
|
||||||
|
menu_items=menu_items
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as NavMenu for compatibility
|
||||||
|
pub use NavMenuClient as NavMenu;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_menu_registry_empty() {
|
||||||
|
let registry = MenuRegistry::new();
|
||||||
|
assert_eq!(registry.language_count(), 0);
|
||||||
|
assert!(registry.available_languages().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_menu_registry_insert_and_get() {
|
||||||
|
let mut registry = MenuRegistry::new();
|
||||||
|
let menu_items = vec![];
|
||||||
|
|
||||||
|
registry.insert("en", menu_items.clone());
|
||||||
|
|
||||||
|
assert_eq!(registry.language_count(), 1);
|
||||||
|
assert!(registry.contains_language("en"));
|
||||||
|
assert_eq!(registry.get("en"), Some(&menu_items));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_menu_registry_multiple_languages() {
|
||||||
|
let mut registry = MenuRegistry::new();
|
||||||
|
registry.insert("en", vec![]);
|
||||||
|
registry.insert("es", vec![]);
|
||||||
|
registry.insert("fr", vec![]);
|
||||||
|
|
||||||
|
assert_eq!(registry.language_count(), 3);
|
||||||
|
|
||||||
|
let languages = registry.available_languages();
|
||||||
|
assert!(languages.contains(&"en".to_string()));
|
||||||
|
assert!(languages.contains(&"es".to_string()));
|
||||||
|
assert!(languages.contains(&"fr".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_menu_registry_get_cloned() {
|
||||||
|
let mut registry = MenuRegistry::new();
|
||||||
|
registry.insert("en", vec![]);
|
||||||
|
|
||||||
|
let cloned = registry.get_cloned("en");
|
||||||
|
assert!(cloned.is_some());
|
||||||
|
assert_eq!(cloned.unwrap(), vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_menu_registry_get_missing_language() {
|
||||||
|
let registry = MenuRegistry::new();
|
||||||
|
assert!(registry.get("en").is_none());
|
||||||
|
assert!(registry.get_cloned("en").is_none());
|
||||||
|
assert!(!registry.contains_language("en"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
//! Navigation menu components module
|
||||||
|
//!
|
||||||
|
//! This module provides unified navigation menu components that work seamlessly
|
||||||
|
//! across both client-side (reactive) and server-side rendering (static) contexts.
|
||||||
|
|
||||||
|
// Client-side implementation
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Server-side implementation
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
// Unified interface (always available)
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
// Re-export unified component as the main interface
|
||||||
|
pub use unified::NavMenu;
|
||||||
|
|
||||||
|
// Re-export individual implementations for direct use if needed
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
pub use client::NavMenu as NavMenuClient;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use ssr::NavMenu as NavMenuSSR;
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
//! Server-side navigation menu wrapper
|
||||||
|
//!
|
||||||
|
//! This is a thin wrapper that prepares SSR-specific data (static menu items)
|
||||||
|
//! and delegates to the unified implementation.
|
||||||
|
|
||||||
|
use crate::navigation::navmenu::unified::UnifiedNavMenu;
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
config::get_default_language,
|
||||||
|
i18n::{build_page_content_patterns, SsrTranslator},
|
||||||
|
load_reactive_menu_for_language,
|
||||||
|
};
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side navigation menu wrapper
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenuSSR(
|
||||||
|
#[prop(default = get_default_language().to_string())] language: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Load menu items for the specified language
|
||||||
|
let menu_items = load_reactive_menu_for_language(&language).unwrap_or_default();
|
||||||
|
|
||||||
|
// Prepare i18n content from FTL files
|
||||||
|
let ssr_i18n = SsrTranslator::new(language.clone());
|
||||||
|
let nav_content = build_page_content_patterns(&ssr_i18n, &["nav-", "lang-"]);
|
||||||
|
|
||||||
|
// Delegate to unified implementation
|
||||||
|
view! {
|
||||||
|
<UnifiedNavMenu
|
||||||
|
language=language
|
||||||
|
menu_items=menu_items
|
||||||
|
content=nav_content
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export as NavMenu for compatibility
|
||||||
|
pub use NavMenuSSR as NavMenu;
|
||||||
@ -0,0 +1,403 @@
|
|||||||
|
//! Unified navigation menu with actual implementation
|
||||||
|
//!
|
||||||
|
//! This module contains the main navigation menu implementation that works
|
||||||
|
//! in both client-side and server-side contexts.
|
||||||
|
|
||||||
|
use crate::navigation::brand_header::BrandHeader;
|
||||||
|
use crate::theme::DarkModeToggle;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::state::use_current_language;
|
||||||
|
|
||||||
|
/// Main NavMenu component that delegates to appropriate wrapper
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenu(
|
||||||
|
/// Path setter for SPA navigation (client-side only)
|
||||||
|
#[prop(optional)]
|
||||||
|
_set_path: Option<WriteSignal<String>>,
|
||||||
|
/// Current path signal (client-side only)
|
||||||
|
#[prop(optional)]
|
||||||
|
_path: Option<ReadSignal<String>>,
|
||||||
|
/// Language for SSR context
|
||||||
|
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
|
||||||
|
language: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Use target_arch to determine execution context
|
||||||
|
// Server-side (not WASM): delegate to SSR implementation
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
tracing::info!("🔧 NavMenu: Server-side context, using SSR branch");
|
||||||
|
view! { <crate::navigation::navmenu::ssr::NavMenuSSR language=language /> }.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side (WASM): delegate to client implementation if signals provided
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🔧 NavMenu: WASM context - signals available: {}",
|
||||||
|
_set_path.is_some() && _path.is_some()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client context: use client wrapper
|
||||||
|
if let (Some(set_path), Some(path)) = (_set_path, _path) {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"🔧 NavMenu: Delegating to NavMenuClient with navigation signals".into(),
|
||||||
|
);
|
||||||
|
view! { <crate::navigation::navmenu::client::NavMenuClient set_path=set_path path=path /> }.into_any()
|
||||||
|
} else {
|
||||||
|
// Fallback if navigation signals not provided - render unified without signals
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&"⚠️ NavMenu: Navigation signals not provided, showing empty menu for hydration"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
view! { <UnifiedNavMenu language=language /> }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified navigation menu component with the actual implementation
|
||||||
|
#[component]
|
||||||
|
pub fn UnifiedNavMenu(
|
||||||
|
/// Navigation signals for client-side routing (optional)
|
||||||
|
#[prop(optional)]
|
||||||
|
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
||||||
|
/// Language for menu items
|
||||||
|
#[prop(default = use_current_language().with(|lang| lang.clone()))]
|
||||||
|
language: String,
|
||||||
|
/// Menu items (if pre-loaded)
|
||||||
|
#[prop(optional)]
|
||||||
|
menu_items: Option<Vec<rustelo_core_lib::MenuItem>>,
|
||||||
|
/// i18n content map
|
||||||
|
#[prop(optional)]
|
||||||
|
content: Option<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Get current language - use static language prop only
|
||||||
|
let current_language = language.clone();
|
||||||
|
|
||||||
|
// Get menu items - use prop if provided (from server SSR)
|
||||||
|
// During hydration, context is NOT available, so we use empty fallback
|
||||||
|
// After hydration, effects can populate menus from context
|
||||||
|
let items = menu_items.unwrap_or_default();
|
||||||
|
|
||||||
|
// Setup i18n content from provided prop or load from FTL registry
|
||||||
|
let nav_content = content.unwrap_or_else(|| {
|
||||||
|
let mut content = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Try to load from registered FTL resources
|
||||||
|
// FTL files are registered with language prefix: {language}_{filename}
|
||||||
|
let ftl_key = format!("{}_{}", current_language, "navigation");
|
||||||
|
if let Some(ftl_content) = ::rustelo_core_lib::registration::get_ftl(&ftl_key) {
|
||||||
|
// Extract navigation keys from FTL content
|
||||||
|
// FTL format: key = value
|
||||||
|
for line in ftl_content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Parse key = value format
|
||||||
|
if let Some(eq_pos) = line.find('=') {
|
||||||
|
let key = line[..eq_pos].trim();
|
||||||
|
let value = line[eq_pos + 1..].trim();
|
||||||
|
|
||||||
|
// Include navigation and language selector keys
|
||||||
|
if key.starts_with("nav-") || key.starts_with("lang-") {
|
||||||
|
content.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback defaults if FTL not found or keys missing
|
||||||
|
content
|
||||||
|
.entry("nav-menu-aria-controls".to_string())
|
||||||
|
.or_insert_with(|| "navbar-collapse".to_string());
|
||||||
|
content
|
||||||
|
.entry("nav-menu-aria-label".to_string())
|
||||||
|
.or_insert_with(|| "Open navigation menu".to_string());
|
||||||
|
content
|
||||||
|
.entry("nav-mobile-controls".to_string())
|
||||||
|
.or_insert_with(|| "Controls".to_string());
|
||||||
|
content
|
||||||
|
.entry("lang-selector-button".to_string())
|
||||||
|
.or_insert_with(|| "Language Selector".to_string());
|
||||||
|
content
|
||||||
|
.entry("lang-en-display".to_string())
|
||||||
|
.or_insert_with(|| "English".to_string());
|
||||||
|
content
|
||||||
|
.entry("lang-es-display".to_string())
|
||||||
|
.or_insert_with(|| "Español".to_string());
|
||||||
|
|
||||||
|
content
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple content lookup function
|
||||||
|
let get_content = |key: &str| -> String {
|
||||||
|
nav_content.get(key).cloned().unwrap_or_else(|| {
|
||||||
|
tracing::warn!("i18n key not found: '{}'", key);
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mobile menu state - create signal with platform-specific initialization
|
||||||
|
// On WASM/client: create reactive signal for interactivity
|
||||||
|
// On SSR/server: create dummy signals that won't be used
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let (mobile_open, set_mobile_open) = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
signal(false)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// For SSR, create a dummy signal that will be optimized out
|
||||||
|
// The signal is created but the view code won't actually use it
|
||||||
|
// This ensures consistent compilation across both targets
|
||||||
|
signal(false)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clone current_language for use in menu rendering closures
|
||||||
|
let current_language_clone = current_language.clone();
|
||||||
|
|
||||||
|
// These are only used on WASM/client, but we need to get them for consistency
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let nav_menu_aria_controls = get_content("nav-menu-aria-controls");
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let nav_menu_aria_label = get_content("nav-menu-aria-label");
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let nav_mobile_controls = get_content("nav-mobile-controls");
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="border-b border-gray-200 shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
// Brand header - consistent with footer
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<BrandHeader
|
||||||
|
language={current_language.clone()}
|
||||||
|
class="h-8 w-auto text-xl font-bold".to_string()
|
||||||
|
is_footer=false
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Desktop menu
|
||||||
|
<div class="hidden lg:block md:ml-6 md:flex md:space-x-8">
|
||||||
|
{items.clone().into_iter().map(|item| {
|
||||||
|
let href = item.route.clone();
|
||||||
|
let is_external = item.is_external.unwrap_or(false);
|
||||||
|
let label_text = item
|
||||||
|
.label
|
||||||
|
.get_for_language(¤t_language_clone)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Menu Item".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href={href.clone()}
|
||||||
|
class="ds-text hover:ds-text-secondary px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline"
|
||||||
|
target={if is_external { "_blank" } else { "_self" }}
|
||||||
|
rel={if is_external { "noopener noreferrer" } else { "" }}
|
||||||
|
on:click={
|
||||||
|
let href = href.clone();
|
||||||
|
move |ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🔧 NavMenu: Link clicked! href='{}', is_external={}, has_nav_signals={}", href, is_external, navigation_signals.is_some()).into());
|
||||||
|
|
||||||
|
// Only use SPA routing for internal links
|
||||||
|
if !is_external {
|
||||||
|
// Only handle click if navigation_signals are available (client-side)
|
||||||
|
if let Some((_, set_path)) = navigation_signals {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🔧 NavMenu: Click intercepted for href: '{}', preventing default and using SPA navigation", href).into());
|
||||||
|
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
// Use anchor_navigate to properly update URL and apply transitions
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &href);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
set_path.set(href.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("⚠️ NavMenu: No navigation signals available for href: '{}', allowing default browser navigation", href).into());
|
||||||
|
}
|
||||||
|
// Otherwise, let the normal link behavior happen (SSR)
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🌐 NavMenu: External link detected for href: '{}', allowing default browser navigation", href).into());
|
||||||
|
}
|
||||||
|
// External links: let the browser handle navigation normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label_text}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Right side: Controls and Mobile menu button
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
// Desktop controls (hidden on mobile)
|
||||||
|
<div class="hidden md:flex md:items-center md:space-x-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
||||||
|
{
|
||||||
|
if let Some(nav_signals) = navigation_signals {
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
current_language={Some(current_language.clone())}
|
||||||
|
content=nav_content.clone()
|
||||||
|
navigation_signals=nav_signals
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
current_language={Some(current_language.clone())}
|
||||||
|
content=nav_content.clone()
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile menu button - rendered on both SSR and client for hydration consistency
|
||||||
|
// On SSR, this is just static HTML that won't be interactive
|
||||||
|
// On client (WASM), this becomes interactive with proper click handlers
|
||||||
|
<div class="md:hidden flex items-center">
|
||||||
|
<button
|
||||||
|
aria-controls={nav_menu_aria_controls.clone()}
|
||||||
|
class="ds-mobile-menu-btn"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label={nav_menu_aria_label.clone()}
|
||||||
|
on:click=move |_| {
|
||||||
|
set_mobile_open.set(!mobile_open.get_untracked())
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile menu - rendered on both SSR and client for hydration consistency
|
||||||
|
// On SSR: just renders as static HTML, hidden via CSS
|
||||||
|
// On client (WASM): becomes reactive with style:display controlled by signal
|
||||||
|
<div
|
||||||
|
class="md:hidden border-t border-gray-200 bg-white"
|
||||||
|
id="navbar-collapse"
|
||||||
|
// style:display=move || {
|
||||||
|
// if mobile_open.get() { "block" } else { "none" }
|
||||||
|
// }
|
||||||
|
>
|
||||||
|
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||||
|
// Mobile menu items
|
||||||
|
<div class="text-gray-500 text-sm p-3">
|
||||||
|
{items.clone().into_iter().map(|item| {
|
||||||
|
let href = item.route.clone();
|
||||||
|
let is_external = item.is_external.unwrap_or(false);
|
||||||
|
let label_text = item
|
||||||
|
.label
|
||||||
|
.get_for_language(¤t_language_clone)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Menu Item".to_string());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href={href.clone()}
|
||||||
|
class="ds-text hover:ds-text-secondary px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline"
|
||||||
|
target={if is_external { "_blank" } else { "_self" }}
|
||||||
|
rel={if is_external { "noopener noreferrer" } else { "" }}
|
||||||
|
on:click={
|
||||||
|
let href = href.clone();
|
||||||
|
move |ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🔧 NavMenu: Link clicked! href='{}', is_external={}, has_nav_signals={}", href, is_external, navigation_signals.is_some()).into());
|
||||||
|
|
||||||
|
// Only use SPA routing for internal links
|
||||||
|
if !is_external {
|
||||||
|
// Only handle click if navigation_signals are available (client-side)
|
||||||
|
if let Some((_, set_path)) = navigation_signals {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🔧 NavMenu: Click intercepted for href: '{}', preventing default and using SPA navigation", href).into());
|
||||||
|
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
// Use anchor_navigate to properly update URL and apply transitions
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &href);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
set_path.set(href.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("⚠️ NavMenu: No navigation signals available for href: '{}', allowing default browser navigation", href).into());
|
||||||
|
}
|
||||||
|
// Otherwise, let the normal link behavior happen (SSR)
|
||||||
|
} else {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🌐 NavMenu: External link detected for href: '{}', allowing default browser navigation", href).into());
|
||||||
|
}
|
||||||
|
// External links: let the browser handle navigation normally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label_text}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile controls
|
||||||
|
<div class="px-3 py-2 border-t border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">{nav_mobile_controls}</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
||||||
|
{
|
||||||
|
if let Some(nav_signals) = navigation_signals {
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
current_language={Some(current_language.clone())}
|
||||||
|
content=nav_content.clone()
|
||||||
|
navigation_signals=nav_signals
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<crate::navigation::language_selector::unified::LanguageSelector
|
||||||
|
current_language={Some(current_language)}
|
||||||
|
content=nav_content
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
//! Client-side footer component
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of the footer with SPA navigation capabilities.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
use ::rustelo_core_lib::{rustelo_components::UnifiedFooter, config::LogoConfig};
|
||||||
|
|
||||||
|
// Import components that this depends on
|
||||||
|
use crate::logo::Logo;
|
||||||
|
|
||||||
|
/// Client-side reactive footer component
|
||||||
|
#[component]
|
||||||
|
pub fn FooterClient(
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
// Create reactive memo for current language using i18n context
|
||||||
|
let current_lang_reactive = i18n.lang_code_reactive();
|
||||||
|
let current_lang = move || current_lang_reactive.get();
|
||||||
|
|
||||||
|
// Get logo configuration from environment variables
|
||||||
|
let logo_config = LogoConfig::default();
|
||||||
|
|
||||||
|
move || {
|
||||||
|
let lang = current_lang();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<UnifiedFooter
|
||||||
|
language=lang.clone()
|
||||||
|
class=class.clone().unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
logo_view={view! {
|
||||||
|
<Logo
|
||||||
|
size="small".to_string()
|
||||||
|
class="".to_string()
|
||||||
|
logo_src_light={logo_config.light_path.clone()}
|
||||||
|
logo_src_dark={logo_config.dark_path.clone()}
|
||||||
|
/>
|
||||||
|
}.into_any()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Import use_i18n from the actual client crate location
|
||||||
|
// For now we'll use a placeholder that compiles
|
||||||
|
fn use_i18n() -> DummyI18n {
|
||||||
|
DummyI18n
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DummyI18n;
|
||||||
|
impl DummyI18n {
|
||||||
|
fn lang_code_reactive(&self) -> leptos::prelude::ReadSignal<String> {
|
||||||
|
let (lang, _) = signal("en".to_string());
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
//! Footer component module
|
||||||
|
//!
|
||||||
|
//! Provides unified footer component that works across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::Footer;
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
//! Server-side footer component
|
||||||
|
//!
|
||||||
|
//! Static implementation of the footer optimized for SSR rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
use ::rustelo_core_lib::config::LogoConfig;
|
||||||
|
|
||||||
|
// Import logo component that this depends on
|
||||||
|
// use crate::logo::LogoLink;
|
||||||
|
|
||||||
|
/// Server-side static footer component
|
||||||
|
#[component]
|
||||||
|
pub fn FooterSSR(
|
||||||
|
/// Language for localization
|
||||||
|
#[prop(optional)]
|
||||||
|
language: Option<String>,
|
||||||
|
/// Custom CSS class
|
||||||
|
#[prop(optional)]
|
||||||
|
class: Option<String>,
|
||||||
|
/// Show social links
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_social: bool,
|
||||||
|
/// Show scroll to top button
|
||||||
|
#[prop(default = true)]
|
||||||
|
show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = language.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
|
||||||
|
// Get logo configuration from environment variables (SSR safe)
|
||||||
|
let logo_config = LogoConfig::from_env();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<footer class="ds-bg border-t ds-border mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<img src="/logos/jesusperez-logo-b.png" alt="Logo" class="h-6 w-auto" />
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
"© 2024 Jesús Pérez Lorenzo. All rights reserved."
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{if show_social {
|
||||||
|
view! {
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<a href="https://github.com/jesusperezlorenzo" target="_blank"
|
||||||
|
class="text-gray-400 hover:text-gray-500">
|
||||||
|
"GitHub"
|
||||||
|
</a>
|
||||||
|
<a href="https://linkedin.com/in/jesusperezlorenzo" target="_blank"
|
||||||
|
class="text-gray-400 hover:text-gray-500">
|
||||||
|
"LinkedIn"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <div></div> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
//! Unified footer component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::navigation::footer::ssr::FooterSSR;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::navigation::footer::client::FooterClient;
|
||||||
|
|
||||||
|
/// Unified footer component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn Footer(
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
#[prop(optional)] class: Option<String>,
|
||||||
|
#[prop(default = true)] show_social: bool,
|
||||||
|
#[prop(default = true)] show_scroll_to_top: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<FooterSSR
|
||||||
|
language=language.unwrap_or_default()
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<FooterClient
|
||||||
|
class=class.unwrap_or_default()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
//! Client-side navigation menu component
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of the navigation menu with SPA navigation capabilities.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
use ::rustelo_core_lib::load_reactive_menu_for_language;
|
||||||
|
|
||||||
|
// Import unified components that work in both client and SSR contexts
|
||||||
|
use crate::logo::Logo;
|
||||||
|
use crate::theme::DarkModeToggle; // This should use the unified version
|
||||||
|
use crate::navigation::LanguageSelector;
|
||||||
|
|
||||||
|
/// Client-side reactive navigation menu component
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenuClient(_set_path: WriteSignal<String>, _path: ReadSignal<String>) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
|
||||||
|
// Create reactive memo using pattern-based key discovery
|
||||||
|
let nav_content = Memo::new(move |_| i18n.clone().build_reactive_page_content_patterns(&["nav-"]));
|
||||||
|
|
||||||
|
// Create reactive memo for current language using i18n context
|
||||||
|
let current_lang_reactive = i18n.lang_code_reactive();
|
||||||
|
let current_lang = move || current_lang_reactive.get();
|
||||||
|
|
||||||
|
// Create reactive memo for menu items
|
||||||
|
let menu_items = move || {
|
||||||
|
let lang = current_lang();
|
||||||
|
match load_reactive_menu_for_language(&lang) {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(_) => vec![], // Fallback to empty menu on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple content lookup function
|
||||||
|
let get_content = move |key: &str| -> String {
|
||||||
|
nav_content.get().get(key).cloned().unwrap_or_else(|| {
|
||||||
|
tracing::warn!("i18n key not found: '{}'", key);
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// SPA-aware menu item renderer
|
||||||
|
move || {
|
||||||
|
let items = menu_items();
|
||||||
|
let items_mobile = items.clone();
|
||||||
|
let mobile_open = false; // Simplified for now
|
||||||
|
|
||||||
|
// Fetch content inside the closure to avoid ownership issues
|
||||||
|
let nav_menu_aria_controls = get_lang_content("nav-menu-aria-controls");
|
||||||
|
let nav_menu_aria_label = get_lang_content("nav-menu-aria-label");
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="border-b border-gray-200 shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
// Logo (theme-aware and hydration-safe)
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<Logo size="normal".to_string() />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Desktop menu
|
||||||
|
<div class="hidden ds-text lg:block md:ml-6 md:flex md:space-x-8">
|
||||||
|
{items.into_iter().map(|item| {
|
||||||
|
{menu_item_view(item)}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Right side: Controls and Mobile menu button
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
// Desktop controls (hidden on mobile)
|
||||||
|
<div class="hidden md:flex md:items-center md:space-x-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
||||||
|
<LanguageSelector
|
||||||
|
class="".to_string()
|
||||||
|
current_language=current_lang()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile menu button (simplified)
|
||||||
|
<div class="md:hidden flex items-center">
|
||||||
|
<button
|
||||||
|
aria-controls={nav_menu_aria_controls}
|
||||||
|
class="lg:hidden grid place-items-center ds-border align-middle select-none font-sans font-medium text-center transition-all duration-300 ease-in disabled:opacity-50 disabled:shadow-none disabled:pointer-events-none ds-caption min-w-[34px] min-h-[34px] ds-rounded-md ds-bg ds-text hover:ds-bg hover:ds-border shadow-none hover:shadow-none ml-1"
|
||||||
|
aria-expanded={if mobile_open { "true" } else { "false" }}
|
||||||
|
aria-label={nav_menu_aria_label}
|
||||||
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile menu (simplified)
|
||||||
|
<div
|
||||||
|
class="md:hidden border-t border-gray-200 bg-white"
|
||||||
|
style={if mobile_open { "display: block;" } else { "display: none;" }}
|
||||||
|
id="navbar-collapse"
|
||||||
|
>
|
||||||
|
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||||
|
// Mobile menu items
|
||||||
|
<div class="text-gray-500 text-sm p-3">
|
||||||
|
{items_mobile.into_iter().map(|item| {
|
||||||
|
{menu_item_view(item)}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Mobile controls
|
||||||
|
<div class="px-3 py-2 border-t border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">{get_lang_content("nav-mobile-controls")}</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
||||||
|
<LanguageSelector
|
||||||
|
class="text-xs".to_string()
|
||||||
|
current_language=current_lang()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to render menu items with SPA navigation
|
||||||
|
// TODO: Define MenuItem type locally for now
|
||||||
|
// Using rustelo_core_lib::MenuItem instead of local definition
|
||||||
|
|
||||||
|
fn menu_item_view(item: rustelo_core_lib::MenuItem) -> impl IntoView {
|
||||||
|
// TODO: Implement SPA-aware menu item rendering
|
||||||
|
// This would use the navigation context to handle clicks
|
||||||
|
let label_text = item.label.translations.get("en").unwrap_or(&item.route).clone();
|
||||||
|
view! {
|
||||||
|
<a href={item.route} class="nav-link">
|
||||||
|
{label_text}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Import use_i18n from the actual client crate location
|
||||||
|
// For now we'll use a placeholder that compiles
|
||||||
|
fn use_i18n() -> DummyI18n { DummyI18n }
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct DummyI18n;
|
||||||
|
impl DummyI18n {
|
||||||
|
fn build_reactive_page_content_patterns(&self, _patterns: &[&str]) -> std::collections::HashMap<String, String> {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
}
|
||||||
|
fn lang_code_reactive(&self) -> leptos::prelude::ReadSignal<String> {
|
||||||
|
let (lang, _) = signal("en".to_string());
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
//! Navigation menu component module
|
||||||
|
//!
|
||||||
|
//! Provides unified navigation menu component that works across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::NavMenu;
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
//! Server-side navigation menu component
|
||||||
|
//!
|
||||||
|
//! Static implementation of the navigation menu optimized for SSR rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
use ::rustelo_core_lib::{
|
||||||
|
config::LogoConfig, i18n::build_page_content_patterns,
|
||||||
|
load_reactive_menu_for_language, SsrTranslator,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import other SSR components that this depends on
|
||||||
|
// use crate::logo::LogoLink;
|
||||||
|
use crate::navigation::LanguageSelector;
|
||||||
|
use crate::theme::DarkModeToggle;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenuSSR(#[prop(optional)] language: Option<String>) -> impl IntoView {
|
||||||
|
let lang = language.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
|
||||||
|
// Use pattern-based key discovery - same as client for perfect hydration sync
|
||||||
|
let ssr_i18n = SsrTranslator::new(lang.clone());
|
||||||
|
let nav_content = build_page_content_patterns(&ssr_i18n, &["nav-"]);
|
||||||
|
|
||||||
|
// Get logo configuration from environment variables (SSR safe)
|
||||||
|
let logo_config = LogoConfig::from_env();
|
||||||
|
|
||||||
|
// Load menu items for the current language
|
||||||
|
let menu_items = match load_reactive_menu_for_language(&lang) {
|
||||||
|
Ok(items) => items,
|
||||||
|
Err(_) => vec![], // Fallback to empty menu on error
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<nav class="border-b border-gray-200 shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a href="/" class="flex items-center space-x-2">
|
||||||
|
<img src="/logos/jesusperez-logo-b.png" alt="Logo" class="h-8 w-auto" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
{menu_items.into_iter().map(|item| {
|
||||||
|
let label = item.label.translations.get(&lang).unwrap_or(&item.route).clone();
|
||||||
|
view! {
|
||||||
|
<a href={item.route.clone()} class="text-gray-700 hover:text-gray-900 px-3 py-2 text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
||||||
|
<LanguageSelector
|
||||||
|
current_language=lang.clone()
|
||||||
|
class="".to_string()
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
//! Unified navigation menu component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
// Fix Leptos macro conflicts with our 'core' crate
|
||||||
|
use std::{default::Default, marker::PhantomData};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::navigation::navmenu::ssr::NavMenuSSR;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::navigation::navmenu::client::NavMenuClient;
|
||||||
|
|
||||||
|
/// Unified navigation menu component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenu(
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
#[prop(optional)]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
set_path: Option<WriteSignal<String>>,
|
||||||
|
#[prop(optional)]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
path: Option<ReadSignal<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! { <NavMenuSSR language=language.unwrap_or_default() /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
let set_path = set_path.unwrap_or_else(|| {
|
||||||
|
// Provide default signal if not provided (client-side only)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let (_, set_path) = signal(String::new());
|
||||||
|
set_path
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
|_: String| {} // No-op for SSR
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let path = path.unwrap_or_else(|| {
|
||||||
|
// Provide default signal if not provided (client-side only)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let (path, _) = signal(String::new());
|
||||||
|
path
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
|| String::new() // Static value for SSR
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { <NavMenuClient _set_path=set_path _path=path /> }
|
||||||
|
}
|
||||||
|
}
|
||||||
204
crates/foundation/crates/rustelo_components/src/theme/client.rs
Normal file
204
crates/foundation/crates/rustelo_components/src/theme/client.rs
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
//! Client-side theme system components
|
||||||
|
//!
|
||||||
|
//! Reactive implementations of theme components for client-side rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Re-export shared theme types
|
||||||
|
pub use ::rustelo_core_lib::{EffectiveTheme, ThemeMode, ThemeUtils};
|
||||||
|
|
||||||
|
/// Theme system context for managing application-wide theme state
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ThemeContext {
|
||||||
|
pub theme_mode: ReadSignal<ThemeMode>,
|
||||||
|
pub set_theme_mode: WriteSignal<ThemeMode>,
|
||||||
|
pub is_dark: Memo<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeContext {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeContext {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Initialize with theme from cookie/localStorage or fallback to System
|
||||||
|
let initial_theme = Self::detect_initial_theme();
|
||||||
|
let (theme_mode, set_theme_mode) = signal(initial_theme);
|
||||||
|
let is_dark = Memo::new(move |_| theme_mode.get() == ThemeMode::Dark);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
theme_mode,
|
||||||
|
set_theme_mode,
|
||||||
|
is_dark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect initial theme from browser storage
|
||||||
|
fn detect_initial_theme() -> ThemeMode {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
// Check localStorage first
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
if let Ok(Some(theme_value)) = storage.get_item("theme") {
|
||||||
|
match theme_value.as_str() {
|
||||||
|
"light" => return ThemeMode::Light,
|
||||||
|
"dark" => return ThemeMode::Dark,
|
||||||
|
"system" => return ThemeMode::System,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check current document class (what theme-init.js set)
|
||||||
|
if let Some(html_element) = document.document_element() {
|
||||||
|
let class_list = html_element.class_list();
|
||||||
|
if class_list.contains("dark") {
|
||||||
|
return ThemeMode::Dark;
|
||||||
|
} else if class_list.contains("light") {
|
||||||
|
return ThemeMode::Light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ultimate fallback for SSR or when detection fails - use Light instead of System to avoid automatic dark
|
||||||
|
ThemeMode::Light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side reactive theme provider
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeProvider(
|
||||||
|
#[prop(optional)] _default_theme: Option<ThemeMode>,
|
||||||
|
#[prop(optional)] _language: Option<String>,
|
||||||
|
children: leptos::children::ChildrenFn,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let theme_context = ThemeContext::new();
|
||||||
|
provide_context(theme_context.clone());
|
||||||
|
|
||||||
|
// Effect to apply theme changes to the DOM
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let theme = theme_context.theme_mode.get();
|
||||||
|
apply_theme_to_dom(theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
children()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side reactive dark mode toggle
|
||||||
|
#[component]
|
||||||
|
pub fn DarkModeToggle(#[prop(optional)] class: Option<String>) -> impl IntoView {
|
||||||
|
let theme_context = use_context::<ThemeContext>().expect("ThemeContext must be provided");
|
||||||
|
|
||||||
|
let handle_click = move |_| {
|
||||||
|
let current = theme_context.theme_mode.get();
|
||||||
|
let new_theme = match current {
|
||||||
|
ThemeMode::Dark => ThemeMode::Light,
|
||||||
|
ThemeMode::Light => ThemeMode::Dark,
|
||||||
|
_ => ThemeMode::Dark,
|
||||||
|
};
|
||||||
|
theme_context.set_theme_mode.set(new_theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=format!("ds-theme-toggle {}", class.unwrap_or_default())
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
title="Toggle theme"
|
||||||
|
on:click=handle_click
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 theme-light-icon hidden dark:block"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 theme-dark-icon dark:hidden"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook to access theme context
|
||||||
|
pub fn use_theme() -> ThemeContext {
|
||||||
|
use_context::<ThemeContext>().expect("ThemeContext must be provided by ThemeProvider")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply theme to DOM by setting data attributes and classes
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn apply_theme_to_dom(theme: ThemeMode) {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
if let Some(html_element) = document.document_element() {
|
||||||
|
// Remove existing theme classes
|
||||||
|
let _ = html_element.class_list().remove_1("dark");
|
||||||
|
let _ = html_element.class_list().remove_1("light");
|
||||||
|
|
||||||
|
// Prepare theme value for persistence
|
||||||
|
let theme_value = match theme {
|
||||||
|
ThemeMode::Dark => "dark",
|
||||||
|
ThemeMode::Light => "light",
|
||||||
|
ThemeMode::System => "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in localStorage (theme-init.js will read this on next page load)
|
||||||
|
if let Ok(Some(storage)) = window.local_storage() {
|
||||||
|
let _ = storage.set_item("theme", theme_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new theme class and data attribute
|
||||||
|
match theme {
|
||||||
|
ThemeMode::Dark => {
|
||||||
|
let _ = html_element.class_list().add_1("dark");
|
||||||
|
let _ = html_element.set_attribute("data-theme", "dark");
|
||||||
|
}
|
||||||
|
ThemeMode::Light => {
|
||||||
|
let _ = html_element.class_list().add_1("light");
|
||||||
|
let _ = html_element.set_attribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
ThemeMode::System => {
|
||||||
|
// Use system preference detection
|
||||||
|
if let Ok(media_query) = window.match_media("(prefers-color-scheme: dark)")
|
||||||
|
{
|
||||||
|
if let Some(media_query_list) = media_query {
|
||||||
|
if media_query_list.matches() {
|
||||||
|
let _ = html_element.class_list().add_1("dark");
|
||||||
|
let _ = html_element.set_attribute("data-theme", "dark");
|
||||||
|
} else {
|
||||||
|
let _ = html_element.class_list().add_1("light");
|
||||||
|
let _ = html_element.set_attribute("data-theme", "light");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/foundation/crates/rustelo_components/src/theme/mod.rs
Normal file
25
crates/foundation/crates/rustelo_components/src/theme/mod.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//! Theme system component module
|
||||||
|
//!
|
||||||
|
//! Provides unified theme system components that work across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::{DarkModeToggle, EffectiveTheme, ThemeMode, ThemeProvider, ThemeUtils};
|
||||||
|
|
||||||
|
// Re-export SSR-specific types when available
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use crate::theme::ssr::{
|
||||||
|
use_theme_ssr, DarkModeToggleSSR, ThemeContextSSR, ThemeProviderSSR, ThemeSelectorSSR,
|
||||||
|
ThemeStatusSSR,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export client-specific types when available
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use client::{use_theme, ThemeContext};
|
||||||
194
crates/foundation/crates/rustelo_components/src/theme/ssr.rs
Normal file
194
crates/foundation/crates/rustelo_components/src/theme/ssr.rs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
//! Server-side theme system components
|
||||||
|
//!
|
||||||
|
//! Static implementations of theme components optimized for SSR rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Re-export shared theme types
|
||||||
|
pub use rustelo_core_lib::ThemeMode;
|
||||||
|
|
||||||
|
/// Server-side static theme provider
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeProviderSSR(
|
||||||
|
#[prop(optional)] default_theme: Option<ThemeMode>,
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
children: leptos::children::ChildrenFn,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let _theme = default_theme.unwrap_or(ThemeMode::Dark);
|
||||||
|
let _lang =
|
||||||
|
language.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
|
||||||
|
// For SSR, we provide a static theme context without reactivity
|
||||||
|
children()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side static dark mode toggle
|
||||||
|
#[component]
|
||||||
|
pub fn DarkModeToggleSSR(#[prop(optional)] class: Option<String>) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=format!("ds-theme-toggle {}", class.unwrap_or_default())
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
title="Toggle theme"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 theme-light-icon hidden dark:block"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 theme-dark-icon dark:hidden"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR Theme Selector - static theme selector
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeSelectorSSR(
|
||||||
|
#[prop(optional)] current_theme: Option<ThemeMode>,
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let theme = current_theme.unwrap_or(ThemeMode::Dark);
|
||||||
|
let lang =
|
||||||
|
language.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
// TODO: Replace with proper i18n system when available
|
||||||
|
let t = |key: &str| format!("{}[{}]", key, lang);
|
||||||
|
|
||||||
|
let themes = vec![
|
||||||
|
(ThemeMode::Light, "☀️", t("theme-light")),
|
||||||
|
(ThemeMode::Dark, "🌙", t("theme-dark")),
|
||||||
|
(ThemeMode::System, "💻", t("theme-system")),
|
||||||
|
];
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="theme-selector-ssr">
|
||||||
|
<label class="block text-sm font-medium ds-text mb-2">
|
||||||
|
{t("theme-preference")}
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{themes.into_iter().map(|(theme_mode, icon, label)| {
|
||||||
|
let is_selected = theme_mode == theme;
|
||||||
|
view! {
|
||||||
|
<div class={format!(
|
||||||
|
"flex items-center p-3 ds-rounded ds-bg-secondary {}",
|
||||||
|
if is_selected { "ring-2 ring-blue-500" } else { "" }
|
||||||
|
)}>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="mr-3 text-lg">{icon}</span>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium ds-text">{label}</div>
|
||||||
|
<div class="text-sm ds-text-muted">
|
||||||
|
{match theme_mode {
|
||||||
|
ThemeMode::Light => t("theme-light-desc"),
|
||||||
|
ThemeMode::Dark => t("theme-dark-desc"),
|
||||||
|
ThemeMode::System => t("theme-system-desc"),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{is_selected.then(|| view! {
|
||||||
|
<div class="ml-auto text-blue-500">
|
||||||
|
"✓"
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs ds-text-muted mt-4">
|
||||||
|
{t("theme-selector-requires-js")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR Theme Status Display - shows current theme without interactivity
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeStatusSSR(
|
||||||
|
#[prop(optional)] current_theme: Option<ThemeMode>,
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let theme = current_theme.unwrap_or(ThemeMode::Dark);
|
||||||
|
let lang =
|
||||||
|
language.unwrap_or_else(|| rustelo_core_lib::config::get_default_language().to_string());
|
||||||
|
// TODO: Replace with proper i18n system when available
|
||||||
|
let t = |key: &str| format!("{}[{}]", key, lang);
|
||||||
|
|
||||||
|
let theme_name = match theme {
|
||||||
|
ThemeMode::Light => t("theme-light"),
|
||||||
|
ThemeMode::Dark => t("theme-dark"),
|
||||||
|
ThemeMode::System => t("theme-system"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme_icon = match theme {
|
||||||
|
ThemeMode::Light => "☀️",
|
||||||
|
ThemeMode::Dark => "🌙",
|
||||||
|
ThemeMode::System => "💻",
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="flex items-center space-x-2 text-sm ds-text-secondary">
|
||||||
|
<span>{theme_icon}</span>
|
||||||
|
<span>{t("current-theme")}: {theme_name}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hook-like function for SSR theme context (returns static values)
|
||||||
|
pub fn use_theme_ssr(default: Option<ThemeMode>) -> ThemeContextSSR {
|
||||||
|
ThemeContextSSR {
|
||||||
|
theme_mode: default.unwrap_or(ThemeMode::Dark),
|
||||||
|
is_dark: matches!(default, Some(ThemeMode::Dark) | None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SSR Theme Context (static, non-reactive)
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ThemeContextSSR {
|
||||||
|
pub theme_mode: ThemeMode,
|
||||||
|
pub is_dark: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeContextSSR {
|
||||||
|
pub fn new(theme_mode: ThemeMode) -> Self {
|
||||||
|
Self {
|
||||||
|
theme_mode,
|
||||||
|
is_dark: matches!(theme_mode, ThemeMode::Dark),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_css_classes(&self) -> Vec<&'static str> {
|
||||||
|
match self.theme_mode {
|
||||||
|
ThemeMode::Light => vec!["theme-light"],
|
||||||
|
ThemeMode::Dark => vec!["theme-dark", "dark"],
|
||||||
|
ThemeMode::System => vec!["theme-system"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_meta_theme_color(&self) -> &'static str {
|
||||||
|
match self.theme_mode {
|
||||||
|
ThemeMode::Light => "#ffffff",
|
||||||
|
ThemeMode::Dark => "#0f172a",
|
||||||
|
ThemeMode::System => "#0f172a", // Default to dark for SSR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
//! Unified theme system component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Re-export shared theme types for easy access
|
||||||
|
pub use ::rustelo_core_lib::{EffectiveTheme, ThemeMode, ThemeUtils};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::theme::ssr::{DarkModeToggleSSR, ThemeProviderSSR};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::theme::client::{
|
||||||
|
use_theme as use_theme_client, DarkModeToggle as DarkModeToggleClient, ThemeContext,
|
||||||
|
ThemeProvider as ClientThemeProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unified theme provider component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn ThemeProvider(
|
||||||
|
#[prop(optional)] default_theme: Option<ThemeMode>,
|
||||||
|
#[prop(optional)] language: Option<String>,
|
||||||
|
children: leptos::children::ChildrenFn,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<ThemeProviderSSR default_theme=default_theme.unwrap_or_default() language=language.unwrap_or_default()>
|
||||||
|
{children()}
|
||||||
|
</ThemeProviderSSR>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
// Note: Pass optional props directly - both unified and client have same signature
|
||||||
|
view! {
|
||||||
|
<ClientThemeProvider
|
||||||
|
_default_theme=default_theme.unwrap_or(rustelo_core_lib::ThemeMode::System)
|
||||||
|
_language=language.unwrap_or_default()
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</ClientThemeProvider>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified dark mode toggle component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn DarkModeToggle(#[prop(optional)] class: Option<String>) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! { <DarkModeToggleSSR class=class.unwrap_or_default() /> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! { <DarkModeToggleClient class=class.unwrap_or_default() /> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified theme hook (client-only)
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn use_theme() -> ThemeContext {
|
||||||
|
use_theme_client()
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
//! Client-side menu item component
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of menu items with SPA navigation capabilities.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::MenuItem;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// TODO: Import these from actual locations
|
||||||
|
use crate::ui::spa_link::SpaLink;
|
||||||
|
|
||||||
|
// Placeholder i18n
|
||||||
|
fn use_i18n() -> DummyI18n {
|
||||||
|
DummyI18n
|
||||||
|
}
|
||||||
|
struct DummyI18n;
|
||||||
|
impl DummyI18n {
|
||||||
|
fn lang_code_reactive(&self) -> leptos::prelude::ReadSignal<String> {
|
||||||
|
let (lang, _) = signal("en".to_string());
|
||||||
|
lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side reactive menu item renderer
|
||||||
|
pub fn menu_item_view(item: MenuItem) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let current_lang_reactive = i18n.lang_code_reactive();
|
||||||
|
let current_lang = move || {
|
||||||
|
// Use reactive i18n context for proper language switching
|
||||||
|
current_lang_reactive.get()
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = item
|
||||||
|
.label
|
||||||
|
.get_with_primary_fallback(current_lang().as_str());
|
||||||
|
let route = item
|
||||||
|
.get_route_for_language(current_lang().as_str())
|
||||||
|
.to_string();
|
||||||
|
let is_external = item.is_external.unwrap_or(false);
|
||||||
|
|
||||||
|
let target_attr = if is_external { "_blank" } else { "" };
|
||||||
|
let icon_class = item.icon.unwrap_or_default();
|
||||||
|
let rel_attr = if is_external {
|
||||||
|
"noopener noreferrer"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let class_attr = if is_external {
|
||||||
|
"no-underline text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium inline-flex items-center"
|
||||||
|
} else {
|
||||||
|
"no-underline ds-text hover:text-blue-500 px-3 py-2 text-sm font-medium inline-flex items-center"
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<SpaLink
|
||||||
|
href=route
|
||||||
|
target=target_attr.to_string()
|
||||||
|
rel=rel_attr.to_string()
|
||||||
|
external=is_external
|
||||||
|
class=class_attr.to_string()
|
||||||
|
>
|
||||||
|
<span class="menu-label"><i class={icon_class} aria-hidden="true"></i> {label}</span>
|
||||||
|
<span class="external-icon" style={if is_external { "display: inline;" } else { "display: none;" }}>
|
||||||
|
<svg class="ml-1 w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</SpaLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
//! Menu item component module
|
||||||
|
//!
|
||||||
|
//! Provides unified menu item rendering that works across client/SSR contexts.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interface as the main API
|
||||||
|
pub use unified::render_menu_item;
|
||||||
|
|
||||||
|
// Also re-export context-specific functions for direct use
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use ssr::menu_item_view_ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use client::menu_item_view;
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
//! Server-side menu item component
|
||||||
|
//!
|
||||||
|
//! Static implementation of menu items for SSR rendering.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::MenuItem;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side static menu item renderer
|
||||||
|
pub fn menu_item_view_ssr(item: MenuItem, language: &str) -> impl IntoView {
|
||||||
|
let label = item.label.get_with_primary_fallback(language);
|
||||||
|
let route = item.get_route_for_language(language).to_string();
|
||||||
|
let is_external = item.is_external.unwrap_or(false);
|
||||||
|
|
||||||
|
let target_attr = if is_external { "_blank" } else { "" };
|
||||||
|
let icon_class = item.icon.unwrap_or_default();
|
||||||
|
let rel_attr = if is_external {
|
||||||
|
"noopener noreferrer"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let class_attr = if is_external {
|
||||||
|
"no-underline text-gray-900 hover:text-blue-600 px-3 py-2 text-sm font-medium inline-flex items-center"
|
||||||
|
} else {
|
||||||
|
"no-underline ds-text hover:text-blue-500 px-3 py-2 text-sm font-medium inline-flex items-center"
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<a
|
||||||
|
href=route
|
||||||
|
target=target_attr
|
||||||
|
rel=rel_attr
|
||||||
|
class=class_attr
|
||||||
|
>
|
||||||
|
<span class="menu-label"><i class={icon_class} aria-hidden="true"></i> {label}</span>
|
||||||
|
<span class="external-icon" style={if is_external { "display: inline;" } else { "display: none;" }}>
|
||||||
|
<svg class="ml-1 w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
//! Unified menu item component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::defs::MenuItem;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::ui::menu_item::ssr::menu_item_view_ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::ui::menu_item::client::menu_item_view;
|
||||||
|
|
||||||
|
/// Unified menu item renderer that delegates to appropriate implementation
|
||||||
|
pub fn render_menu_item(item: MenuItem, language: Option<&str>) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation with language parameter
|
||||||
|
let lang = language.unwrap_or("en");
|
||||||
|
menu_item_view_ssr(item, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
let _ = language; // Ignore language parameter in client context
|
||||||
|
menu_item_view(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
//! Client-side Mobile Menu Components
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of mobile menu components for client-side rendering.
|
||||||
|
|
||||||
|
use leptos::ev::MouseEvent;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::use_unified_i18n;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
/// Client-side mobile menu toggle component
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenuToggle() -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
// Signal to control mobile menu visibility
|
||||||
|
let (is_open, set_is_open) = signal(false);
|
||||||
|
|
||||||
|
// Handle escape key to close menu
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let set_is_open_clone = set_is_open;
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let closure =
|
||||||
|
wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
|
||||||
|
if e.key() == "Escape" && is_open.get() {
|
||||||
|
set_is_open_clone.set(false);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 rounded-md ds-text hover:ds-bg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded=move || is_open.get().to_string()
|
||||||
|
on:click=move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
set_is_open.update(|open| *open = !*open);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("mobile-menu-open")}</span>
|
||||||
|
<Show
|
||||||
|
when=move || !is_open.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
// X icon when menu is open
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
// Hamburger icon when menu is closed
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Mobile menu panel
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
class=move || {
|
||||||
|
format!(
|
||||||
|
"md:hidden border-t ds-border {}",
|
||||||
|
if is_open.get() { "block" } else { "hidden" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<slot name="menu-content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side mobile menu with integrated content
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenu(children: Children) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
// Signal to control mobile menu visibility
|
||||||
|
let (is_open, set_is_open) = signal(false);
|
||||||
|
|
||||||
|
// Handle escape key to close menu
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let set_is_open_clone = set_is_open;
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let closure =
|
||||||
|
wasm_bindgen::closure::Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
|
||||||
|
if e.key() == "Escape" && is_open.get() {
|
||||||
|
set_is_open_clone.set(false);
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="md:hidden p-2 rounded-md ds-text hover:ds-bg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded=move || is_open.get().to_string()
|
||||||
|
on:click=move |e: MouseEvent| {
|
||||||
|
e.prevent_default();
|
||||||
|
set_is_open.update(|open| *open = !*open);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("mobile-menu-open")}</span>
|
||||||
|
<Show
|
||||||
|
when=move || !is_open.get()
|
||||||
|
fallback=move || view! {
|
||||||
|
// X icon when menu is open
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
// Hamburger icon when menu is closed
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Mobile menu panel
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
style:display=move || if is_open.get() { "block" } else { "none" }
|
||||||
|
class="md:hidden border-t ds-border"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
//! Mobile Menu component module
|
||||||
|
//!
|
||||||
|
//! Provides unified mobile navigation menu components.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interfaces as the main API
|
||||||
|
pub use unified::{MobileMenu, MobileMenuToggle};
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
//! Server-side Mobile Menu Components
|
||||||
|
//!
|
||||||
|
//! Static implementation of mobile menu components for server-side rendering.
|
||||||
|
|
||||||
|
use ::rustelo_core_lib::i18n::use_unified_i18n;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Server-side mobile menu toggle component (renders as static button)
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenuToggle() -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 rounded-md ds-text hover:ds-bg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("mobile-menu-open")}</span>
|
||||||
|
// Always show hamburger icon for SSR
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Mobile menu panel (hidden by default for SSR)
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
class="md:hidden border-t ds-border hidden"
|
||||||
|
>
|
||||||
|
<slot name="menu-content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side mobile menu with integrated content (renders as static hidden content)
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenu(children: Children) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="md:hidden p-2 rounded-md ds-text hover:ds-bg focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||||
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{i18n.t("mobile-menu-open")}</span>
|
||||||
|
// Always show hamburger icon for SSR
|
||||||
|
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Mobile menu panel (hidden by default for SSR)
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
style="display: none"
|
||||||
|
class="md:hidden border-t ds-border"
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
//! Unified mobile menu component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::ui::mobile_menu::ssr::{
|
||||||
|
MobileMenu as MobileMenuSSR, MobileMenuToggle as MobileMenuToggleSSR,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::ui::mobile_menu::client::{
|
||||||
|
MobileMenu as MobileMenuClient, MobileMenuToggle as MobileMenuToggleClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unified mobile menu toggle component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenuToggle() -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<MobileMenuToggleSSR />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<MobileMenuToggleClient />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified mobile menu component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn MobileMenu(children: Children) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<MobileMenuSSR>
|
||||||
|
{children()}
|
||||||
|
</MobileMenuSSR>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<MobileMenuClient>
|
||||||
|
{children()}
|
||||||
|
</MobileMenuClient>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
//! UI components module
|
||||||
|
//!
|
||||||
|
//! Provides unified UI components for common interface elements.
|
||||||
|
|
||||||
|
pub mod menu_item;
|
||||||
|
pub mod mobile_menu;
|
||||||
|
pub mod page_transition;
|
||||||
|
pub mod spa_link;
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
//! Client-side Page Transition Components
|
||||||
|
//!
|
||||||
|
//! Reactive implementation of page transition components for client-side rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// Import the unified TransitionStyle instead of defining a duplicate
|
||||||
|
use super::unified::TransitionStyle;
|
||||||
|
|
||||||
|
impl TransitionStyle {
|
||||||
|
fn out_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Fade => "fade-out",
|
||||||
|
Self::Slide => "slide-out",
|
||||||
|
Self::Scale => "scale-out",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_class(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Fade => "fade-in",
|
||||||
|
Self::Slide => "slide-in",
|
||||||
|
Self::Scale => "scale-in",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side hydration-safe page transition wrapper component
|
||||||
|
#[component]
|
||||||
|
pub fn PageTransition(
|
||||||
|
/// Signal that triggers the transition (usually the route path)
|
||||||
|
trigger: ReadSignal<String>,
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Transition style to use
|
||||||
|
#[prop(default = TransitionStyle::Fade)]
|
||||||
|
style: TransitionStyle,
|
||||||
|
/// Duration of the transition in milliseconds
|
||||||
|
#[prop(default = 300)]
|
||||||
|
duration_ms: u64,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Start with base class for hydration safety
|
||||||
|
let (transition_class, set_transition_class) = signal(String::from("page-content"));
|
||||||
|
let (is_hydrated, _set_is_hydrated) = signal(false);
|
||||||
|
|
||||||
|
// Mark as hydrated after initial render to avoid hydration issues
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !is_hydrated.get() {
|
||||||
|
// Small delay to ensure hydration is complete
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
_set_is_hydrated.set(true);
|
||||||
|
},
|
||||||
|
Duration::from_millis(10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create effect to handle transitions on path change - only after hydration
|
||||||
|
Effect::new(move |prev_path: Option<String>| {
|
||||||
|
let current_path = trigger.get();
|
||||||
|
|
||||||
|
// Only transition if hydrated and path actually changed
|
||||||
|
if is_hydrated.get() {
|
||||||
|
if let Some(prev) = prev_path {
|
||||||
|
if prev != current_path {
|
||||||
|
// Start fade out
|
||||||
|
set_transition_class.set(format!("page-content {}", style.out_class()));
|
||||||
|
|
||||||
|
// After a short delay, start fade in
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
set_transition_class.set(format!("page-content {}", style.in_class()));
|
||||||
|
},
|
||||||
|
Duration::from_millis(150),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear transition state after animation completes
|
||||||
|
set_timeout(
|
||||||
|
move || {
|
||||||
|
set_transition_class.set(String::from("page-content"));
|
||||||
|
},
|
||||||
|
Duration::from_millis(duration_ms),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current_path
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="page-transition-wrapper">
|
||||||
|
<div class=move || transition_class.get()>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side simpler page transition that just uses CSS classes
|
||||||
|
#[component]
|
||||||
|
pub fn SimplePageTransition(
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(default = String::new())]
|
||||||
|
class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class=format!("page-content fade-in {}", class)>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
//! Page Transition component module
|
||||||
|
//!
|
||||||
|
//! Provides unified page transition components for smooth navigation.
|
||||||
|
|
||||||
|
pub mod unified;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod ssr;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod client;
|
||||||
|
|
||||||
|
// Re-export the unified interfaces as the main API
|
||||||
|
pub use unified::{PageTransition, SimplePageTransition, TransitionStyle};
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
//! Server-side Page Transition Components
|
||||||
|
//!
|
||||||
|
//! Static implementation of page transition components for server-side rendering.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// Import the unified TransitionStyle instead of defining a duplicate
|
||||||
|
use super::unified::TransitionStyle;
|
||||||
|
|
||||||
|
/// Server-side page transition wrapper component (renders without transitions for SSR)
|
||||||
|
#[component]
|
||||||
|
pub fn PageTransition(
|
||||||
|
/// Signal that triggers the transition (usually the route path)
|
||||||
|
_trigger: ReadSignal<String>,
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Transition style to use (ignored in SSR)
|
||||||
|
#[prop(default = TransitionStyle::Fade)]
|
||||||
|
_style: TransitionStyle,
|
||||||
|
/// Duration of the transition in milliseconds (ignored in SSR)
|
||||||
|
#[prop(default = 300)]
|
||||||
|
_duration_ms: u64,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// For SSR, just render content without transitions
|
||||||
|
view! {
|
||||||
|
<div class="page-transition-wrapper">
|
||||||
|
<div class="page-content">
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side simple page transition component (renders without transitions)
|
||||||
|
#[component]
|
||||||
|
pub fn SimplePageTransition(
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(default = String::new())]
|
||||||
|
class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<div class=format!("page-content {}", class)>
|
||||||
|
{children()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
//! Unified page transition component interface using shared delegation patterns
|
||||||
|
//!
|
||||||
|
//! This module provides a unified interface that automatically selects between
|
||||||
|
//! client-side reactive and server-side static implementations based on context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::ui::page_transition::ssr::{
|
||||||
|
PageTransition as PageTransitionSSR, SimplePageTransition as SimplePageTransitionSSR,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use crate::ui::page_transition::client::{
|
||||||
|
PageTransition as PageTransitionClient,
|
||||||
|
SimplePageTransition as SimplePageTransitionClient,
|
||||||
|
// TransitionStyle will be imported from unified by client module
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define TransitionStyle once in unified module
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum TransitionStyle {
|
||||||
|
Fade,
|
||||||
|
Slide,
|
||||||
|
Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified page transition component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn PageTransition(
|
||||||
|
/// Signal that triggers the transition (usually the route path)
|
||||||
|
trigger: ReadSignal<String>,
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Transition style to use
|
||||||
|
#[prop(default = TransitionStyle::Fade)]
|
||||||
|
style: TransitionStyle,
|
||||||
|
/// Duration of the transition in milliseconds
|
||||||
|
#[prop(default = 300)]
|
||||||
|
duration_ms: u64,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<PageTransitionSSR
|
||||||
|
_trigger=trigger
|
||||||
|
_style=style
|
||||||
|
_duration_ms=duration_ms
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</PageTransitionSSR>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<PageTransitionClient
|
||||||
|
trigger=trigger
|
||||||
|
style=style
|
||||||
|
duration_ms=duration_ms
|
||||||
|
>
|
||||||
|
{children()}
|
||||||
|
</PageTransitionClient>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified simple page transition component that delegates to appropriate implementation
|
||||||
|
#[component]
|
||||||
|
pub fn SimplePageTransition(
|
||||||
|
/// Children to render with transitions
|
||||||
|
children: Children,
|
||||||
|
/// Additional CSS classes
|
||||||
|
#[prop(default = String::new())]
|
||||||
|
class: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
// SSR context: use static implementation
|
||||||
|
view! {
|
||||||
|
<SimplePageTransitionSSR class=class>
|
||||||
|
{children()}
|
||||||
|
</SimplePageTransitionSSR>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// Client context: use reactive implementation
|
||||||
|
view! {
|
||||||
|
<SimplePageTransitionClient class=class>
|
||||||
|
{children()}
|
||||||
|
</SimplePageTransitionClient>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user