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

This commit is contained in:
Jesús Pérez 2026-02-08 20:07:09 +00:00
parent afae617013
commit d3a47108af
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
688 changed files with 195869 additions and 0 deletions

11
.gitignore vendored
View File

@ -1,3 +1,11 @@
wrks
ROOT
OLD
CLAUDE.md
AGENTS.md
.claude
.opencode
.coder
# Generated by Cargo
# will have compiled files and executables
debug/
@ -96,3 +104,6 @@ Thumbs.db
book-output/
# Generated setup report
SETUP_COMPLETE.md
# Archive and working directory
.wrks/

45
CHANGELOG.md Normal file
View 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

View 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
View 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.** 🦀✨

View 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"]

View 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.

View 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(&current_path) {
Some(route_info) => {
hydrate_configured_component(&route_info, page_data);
}
None => {
hydrate_not_found_component(&current_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(&current_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.

View File

@ -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();
}

View File

@ -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(&notification.title, &notification.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,
}

View File

@ -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, &current_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(&current_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
}
}

View 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(&current_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(&current_lang, &current_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(&current_path, &current_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),
);
}
}
}
}

View 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()
}

View File

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

View File

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

View 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")
}
}

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

View 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};

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

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

View File

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

View 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()
}

View File

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

View 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);
}
}

View 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");
}
}

View 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());
}

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

View 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()}</>
}
}

View 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");
}
}

View File

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

View 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())
}
}

View 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"]

View 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"] }
```

View File

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

View File

@ -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(&section);
}
});
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.

View File

@ -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
*/

View File

@ -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! {
&lt;HtmlContent
html="&lt;p&gt;Safe HTML&lt;/p&gt;"
sanitize=true
/&gt;
}</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.
*/

View File

@ -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.
*/

View File

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

View File

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

View File

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

View File

@ -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![]))))
/>
}
}
}

View File

@ -0,0 +1,5 @@
//! Admin components module
//!
//! Provides unified admin layout and utility components.
pub mod admin_layout;

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

View 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()
}
}
}

View File

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

View File

@ -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,
&current_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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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};

View File

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

View 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};

View 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;

View File

@ -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() />
}
}

View File

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

View File

@ -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(&current_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;

View File

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

View File

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

View File

@ -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(&current_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(&current_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(&current_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>
})}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(&current_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"));
}
}

View File

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

View File

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

View File

@ -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(&current_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(&current_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>
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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");
}
}
}
}
}
}
}
}
}

View 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};

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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