chore: update layout and files
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:18:46 +00:00
parent 98e2d4e783
commit 7cab57b645
Signed by: jesus
GPG Key ID: 9F243E355E0BC939
384 changed files with 31489 additions and 50510 deletions

View File

@ -1,38 +1,10 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(cargo:*)",
"Bash(pkill:*)",
"Bash(RUST_LOG=debug leptos serve watch)",
"Bash(RUST_LOG=debug cargo leptos watch)",
"Bash(rm:*)",
"Bash(sqlite3:*)",
"Bash(lsof:*)",
"Bash(RUST_LOG=info cargo leptos watch)",
"Bash(RUST_LOG=info ./target/debug/server)",
"Bash(env)",
"Bash(cat:*)",
"Bash(cargo build:*)",
"Bash(cargo coupling:*)",
"Bash(grep:*)",
"Bash(ENVIRONMENT=development cargo run --bin server)",
"Bash(ls:*)",
"Bash(CONFIG_FILE=config.dev.toml cargo run --bin server)",
"Bash(git checkout:*)",
"Bash(CONFIG_FILE=/Users/Akasha/Development/rustelo/template/config.dev.toml cargo run --bin server)",
"Bash(ENVIRONMENT=development CONFIG_FILE=config.dev.toml cargo run --bin server)",
"Bash(find:*)",
"Bash(ln:*)",
"Bash(cp:*)",
"Bash(npm run build:css:*)",
"Bash(killall:*)",
"Bash(true)",
"Bash(mv:*)",
"Bash(LEPTOS_OUTPUT_NAME=website cargo leptos build)",
"Bash(LEPTOS_OUTPUT_NAME=website cargo leptos serve --hot-reload)",
"Bash(pgrep:*)",
"Bash(./scripts/link-pkg-files.sh:*)",
"Bash(LEPTOS_OUTPUT_NAME=website cargo run --bin server)"
],
"deny": []
"Bash(cargo check:*)"
]
}
}

View File

@ -2,104 +2,186 @@
[workspace]
resolver = "2"
members = [
"server",
"client",
"shared"
"crates/framework/crates/rustelo_core",
"crates/framework/crates/rustelo_web",
"crates/framework/crates/rustelo_auth",
"crates/framework/crates/rustelo_content",
"crates/framework/crates/rustelo_cli",
"crates/foundation/crates/rustelo_client",
"crates/foundation/crates/rustelo_server",
"crates/foundation/crates/rustelo_core_lib",
"crates/foundation/crates/rustelo_core_types",
"crates/foundation/crates/rustelo_language",
"crates/foundation/crates/rustelo_routing",
"crates/foundation/crates/rustelo_components",
"crates/foundation/crates/rustelo_pages",
"crates/foundation/crates/rustelo_tools",
"crates/foundation/crates/rustelo_utils",
"crates/foundation/crates/rustelo_macros",
]
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
[workspace.dependencies]
leptos = { version = "0.8.2", features = ["hydrate", "ssr"] }
leptos_router = { version = "0.8.2", features = ["ssr"] }
leptos_axum = { version = "0.8.2" }
leptos_config = { version = "0.8.2" }
leptos_meta = { version = "0.8.2" }
# Core dependencies
# Rustelo foundation crates
rustelo_utils = { path = "crates/foundation/crates/rustelo_utils" }
rustelo_core_types = { path = "crates/foundation/crates/rustelo_core_types" }
rustelo_language = { path = "crates/foundation/crates/rustelo_language" }
rustelo_routing = { path = "crates/foundation/crates/rustelo_routing" }
rustelo_core_lib = { path = "crates/foundation/crates/rustelo_core_lib" }
rustelo_components = { path = "crates/foundation/crates/rustelo_components" }
rustelo_pages = { path = "crates/foundation/crates/rustelo_pages" }
rustelo_client = { path = "crates/foundation/crates/rustelo_client" }
rustelo_server = { path = "crates/foundation/crates/rustelo_server" }
rustelo_tools = { path = "crates/foundation/crates/rustelo_tools" }
rustelo_macros = { path = "crates/foundation/crates/rustelo_macros" }
# Rustelo framework crates
rustelo_core = { path = "crates/framework/crates/rustelo_core" }
rustelo_web = { path = "crates/framework/crates/rustelo_web" }
rustelo_auth = { path = "crates/framework/crates/rustelo_auth" }
rustelo_content = { path = "crates/framework/crates/rustelo_content" }
rustelo_cli = { path = "crates/framework/crates/rustelo_cli" }
# Leptos ecosystem
leptos = { version = "0.8.15", features = ["hydrate", "ssr"] }
leptos_router = { version = "0.8.11", features = ["ssr"] }
leptos_axum = { version = "0.8.7" }
leptos_config = { version = "0.8.8" }
leptos_meta = { version = "0.8.5" }
leptos_integration_utils = { version = "0.8.7" }
# Other dependencies
serde = { version = "1.0", features = ["derive"] }
axum = "0.8.8"
serde_json = "1.0"
shared = { path = "./shared" }
thiserror = "2.0.12"
rand = "0.9.1"
thiserror = "2.0.18"
anyhow = "1.0.101"
rand = "0.9"
rand_core = { version = "0.10" }
#rand_core = { version = "0.6", features = ["getrandom"] }
getrandom = { version = "0.4", features = ["std", "wasm_js"] }
gloo-timers = { version = "0.3", features = ["futures"] }
console_error_panic_hook = "0.1"
gloo-net = { version = "0.6.0" }
glob = "0.3.3"
console_error_panic_hook = "0.1.7"
http = "1"
log = "0.4.27"
wasm-bindgen-futures = "0.4.50"
wasm-bindgen = "=0.2.100"
log = "0.4.29"
env_logger = "0.11"
wasm-bindgen-futures = "0.4.58"
wasm-bindgen = "0.2.108"
serde-wasm-bindgen = "0.6.5"
console_log = "1"
reqwest = { version = "0.12.22", features = ["json"] } # reqwest with JSON parsing support
reqwest = { version = "0.13.2", features = ["json"] } # reqwest with JSON parsing support
reqwasm = "0.5.0"
web-sys = { version = "0.3.77" , features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "Storage", "console", "File"] }
regex = "1.11.1"
js-sys = "0.3.85"
web-sys = { version = "0.3.85" , features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] }
regex = "1.12.3"
tracing = "0.1"
tracing-subscriber = "0.3"
toml = "0.9"
fluent = "0.17"
fluent-bundle = "0.16"
unic-langid = "0.9"
fluent-syntax = "0.12"
unic-langid = { version = "0.9", features = ["unic-langid-macros"] }
tokio = { version = "1.49", features = ["rt-multi-thread"]}
tower = "0.5.3"
tower-http = { version = "0.6.8", features = ["fs"]}
hex = "0.4.3"
dotenv = "0.15.0"
async-trait = "0.1.89"
once_cell = "1.21.3"
fluent-templates = { version = "0.13.2", features = ["tera"]}
rhai = { version = "1.24", features = ["serde", "only_i64", "no_float"] }
# Email support
lettre = { version = "0.11", features = ["tokio1-native-tls", "smtp-transport", "pool", "hostname", "builder"] }
handlebars = { version = "6.4" }
urlencoding = { version = "2.1" }
# TLS Support (optional)
axum-server = { version = "0.8", features = ["tls-rustls"] }
axum-test = "18.7"
rustls = { version = "0.23" }
rustls-pemfile = { version = "2.2" }
# Authentication & Authorization (optional)
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
argon2 = { version = "0.5" }
uuid = { version = "1.20", features = ["v4", "serde", "js"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.17", features = ["v4", "serde"] }
oauth2 = { version = "5.0" }
tower-sessions = { version = "0.15" }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "sqlite", "chrono", "uuid", "migrate"] }
tower-cookies = { version = "0.11" }
time = { version = "0.3", features = ["serde"] }
[[workspace.metadata.leptos]]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "website"
# Specify which binary target to use (fixes multiple bin targets error)
bin-target = "server"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# Add hash to JS/WASM files for cache busting
hash-files = true
# The tailwind input file. Not needed if tailwind-input-file is not set
# Optional, Activates the tailwind build
#tailwind-input-file = "input.css"
# 2FA Support (optional)
totp-rs = { version = "5.7.0" }
qrcode = { version = "0.14", features = ["svg"] }
base32 = { version = "0.5" }
sha2 = { version = "0.10" }
base64 = { version = "0.22" }
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:3030"
# The port to use for automatic reload monitoring
reload-port = 3031
# Cryptography dependencies
aes-gcm = { version = "0.10" }
clap = { version = "4.5", features = ["derive"] }
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# Metrics dependencies
prometheus = { version = "0.14" }
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Content Management & Rendering (optional)
pulldown-cmark = { version = "0.13.0", features = ["simd"] }
serde_yaml = { version = "0.9" }
tempfile = { version = "3.24" }
tera = { version = "1.20" }
unicode-normalization = { version = "0.1" }
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
paste = "1.0.15"
typed-builder = "0.23"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
notify = { version = "8.2.0", default-features = false, features = ["macos_fsevent"] }
lru = "0.16"
ammonia = "4.1"
scraper = "0.25"
futures = "0.3.31"
async-compression = { version = "0.4", features = ["gzip", "tokio"] }
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
ratatui = "0.30"
inquire = "0.9"
crossterm = "0.29"
syntect = "5.3"
similar = "2.7"
reactive_graph = "0.2.12"
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = true
syn = { version = "2.0", features = ["full"] }
comrak = { version = "0.50", features = ["syntect"] }
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
walkdir = "2.5"
quote = "1.0"
proc-macro2 = "1.0"
gray_matter = "0.3"
ignore = "0.4"
mockall = "0.14"
wiremock = "0.6"
cfg-if = "1.0"
html-escape = "0.2"
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
shellexpand = "3.1"
semver = "1.0"
pathdiff = "0.2"
name = "rustelo"
bin-package = "server"
lib-package = "client"
dialoguer = "0.12"
console = "0.16"
indicatif = "0.18"
[profile.release]
codegen-units = 1
lto = true
opt-level = "z"

View File

@ -1,339 +0,0 @@
# Rustelo Documentation
<div align="center">
<img src="logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
</div>
Welcome to the comprehensive documentation for Rustelo, a modular Rust web application template. This document serves as your gateway to understanding, setting up, and using all aspects of Rustelo.
## 📚 Documentation Overview
Rustelo provides multiple layers of documentation to serve different needs:
### 🎯 Quick References
- **[README.md](README.md)** - Main project overview and quick start
- **[FEATURES.md](FEATURES.md)** - Detailed feature documentation
- **[INSTALL.md](INSTALL.md)** - Installation guide
### 📖 Interactive Documentation (mdBook)
- **[Complete Guide](https://yourusername.github.io/rustelo)** - Full interactive documentation
- **Local Development**: `./scripts/docs-dev.sh` - Start local documentation server
- **Build Documentation**: `./scripts/build-docs.sh` - Build static documentation
### 📁 Documentation Directories
- **[docs/](docs/)** - Technical documentation and guides
- **[info/](info/)** - Implementation details and architectural decisions
- **[examples/](examples/)** - Usage examples and sample configurations
## 🚀 Getting Started with Documentation
### 1. Setup Documentation System
```bash
# Interactive setup (recommended)
./scripts/setup-docs.sh
# Full automated setup
./scripts/setup-docs.sh --full
# Minimal setup
./scripts/setup-docs.sh --minimal
```
### 2. Start Documentation Development
```bash
# Start local documentation server
./scripts/docs-dev.sh
# Or using just
just docs-dev
```
### 3. Build and Deploy
```bash
# Build documentation
./scripts/build-docs.sh
# Deploy to GitHub Pages
./scripts/deploy-docs.sh github-pages
# Or using just
just docs-build
just docs-deploy-github
```
## 📋 Documentation Structure
### Core Sections
#### 🏁 Getting Started
- **[Quick Start](book/getting-started/quick-start.md)** - Get up and running in minutes
- **[Installation](book/getting-started/installation.md)** - Detailed installation guide
- **[Configuration](book/getting-started/configuration.md)** - Basic configuration
- **[Your First App](book/getting-started/first-app.md)** - Build your first application
#### 🎛️ Features
- **[Feature Overview](book/features/overview.md)** - All available features
- **[Authentication](book/features/authentication.md)** - User authentication system
- **[Content Management](book/features/content-management.md)** - Content management system
- **[Email System](book/features/email.md)** - Email functionality
- **[TLS Support](book/features/tls.md)** - HTTPS/TLS configuration
- **[Feature Combinations](book/features/combinations.md)** - How features work together
#### 🗄️ Database
- **[Database Overview](book/database/overview.md)** - Database system overview
- **[PostgreSQL Setup](book/database/postgresql.md)** - PostgreSQL configuration
- **[SQLite Setup](book/database/sqlite.md)** - SQLite configuration
- **[Database Configuration](book/database/configuration.md)** - Advanced configuration
- **[Migrations](book/database/migrations.md)** - Database migrations
- **[Database Abstraction](book/database/abstraction.md)** - Database abstraction layer
#### 🛠️ Development
- **[Development Setup](book/development/setup.md)** - Development environment
- **[Project Structure](book/development/structure.md)** - Understanding the codebase
- **[Development Workflow](book/development/workflow.md)** - Development best practices
- **[Testing](book/development/testing.md)** - Testing strategies
- **[Debugging](book/development/debugging.md)** - Debugging techniques
- **[Hot Reloading](book/development/hot-reloading.md)** - Development server setup
#### 🚀 Deployment
- **[Deployment Overview](book/deployment/overview.md)** - Deployment strategies
- **[Docker Deployment](book/deployment/docker.md)** - Containerized deployment
- **[Production Setup](book/deployment/production.md)** - Production configuration
- **[Environment-Specific Config](book/deployment/environments.md)** - Environment management
- **[Monitoring & Logging](book/deployment/monitoring.md)** - Observability
#### 🔒 Security
- **[Security Overview](book/security/overview.md)** - Security architecture
- **[Authentication Security](book/security/auth.md)** - Authentication security
- **[Data Protection](book/security/data-protection.md)** - Data encryption and protection
- **[CSRF Protection](book/security/csrf.md)** - CSRF prevention
- **[TLS Configuration](book/security/tls.md)** - TLS/SSL setup
- **[Security Best Practices](book/security/best-practices.md)** - Security guidelines
#### 🔧 API Reference
- **[API Overview](book/api/overview.md)** - API architecture
- **[Authentication Endpoints](book/api/auth.md)** - Authentication API
- **[Content Endpoints](book/api/content.md)** - Content management API
- **[Error Handling](book/api/errors.md)** - Error responses
- **[Rate Limiting](book/api/rate-limiting.md)** - Rate limiting configuration
## 🛠️ Documentation Tools
### Available Scripts
- **`./scripts/setup-docs.sh`** - Setup documentation system
- **`./scripts/docs-dev.sh`** - Start development server
- **`./scripts/build-docs.sh`** - Build documentation
- **`./scripts/deploy-docs.sh`** - Deploy documentation
- **`./scripts/generate-content.sh`** - Generate dynamic content
### Just Commands
```bash
# Documentation commands
just docs-setup # Setup documentation system
just docs-dev # Start development server
just docs-build # Build documentation
just docs-build-sync # Build with content sync
just docs-watch # Watch for changes
just docs-deploy-github # Deploy to GitHub Pages
just docs-deploy-netlify # Deploy to Netlify
just docs-deploy-vercel # Deploy to Vercel
just docs-docker # Build Docker image
just docs-generate # Generate dynamic content
just docs-check-links # Check for broken links
just docs-clean # Clean build files
just docs-workflow # Complete workflow
just help-docs # Show documentation help
```
## 📖 Documentation Types
### 1. Technical Documentation (`docs/`)
Focused on implementation details and technical guides:
- **[2FA Implementation](docs/2fa_implementation.md)** - Two-factor authentication
- **[Database Configuration](docs/database_configuration.md)** - Database setup
- **[Email System](docs/email.md)** - Email configuration
- **[Encryption](docs/encryption.md)** - Data encryption
- **[Migration Guide](docs/database_migration_guide.md)** - Database migrations
### 2. Implementation Notes (`info/`)
Architectural decisions and implementation details:
- **[Feature System](info/feature_system.md)** - Feature architecture
- **[Database Abstraction](info/database_abstraction.md)** - Database design
- **[Authentication](info/auth_readme.md)** - Authentication system
- **[Configuration](info/config.md)** - Configuration system
- **[Deployment](info/deployment.md)** - Deployment strategies
### 3. Interactive Documentation (`book/`)
Comprehensive user-friendly guides built with mdBook:
- Searchable content
- Mobile-friendly design
- Cross-referenced sections
- Code examples with syntax highlighting
- Print-friendly format
## 🌐 Deployment Options
### GitHub Pages
```bash
# Automatic deployment via GitHub Actions
./scripts/deploy-docs.sh github-pages
# Manual deployment
just docs-deploy-github
```
### Netlify
```bash
# Deploy to Netlify
./scripts/deploy-docs.sh netlify
just docs-deploy-netlify
```
### Vercel
```bash
# Deploy to Vercel
./scripts/deploy-docs.sh vercel
just docs-deploy-vercel
```
### Docker
```bash
# Build documentation container
./scripts/deploy-docs.sh docker
just docs-docker
# Run documentation server
docker run -p 8080:80 rustelo-docs:latest
```
### AWS S3
```bash
# Deploy to S3 (requires AWS_S3_BUCKET)
export AWS_S3_BUCKET=your-bucket-name
./scripts/deploy-docs.sh aws-s3
```
## 🔄 CI/CD Integration
### GitHub Actions
Automatic documentation builds and deployments:
- **Build on PR**: Validates documentation builds
- **Deploy on merge**: Automatically deploys to GitHub Pages
- **Link checking**: Validates all links in documentation
- **Multi-format build**: Builds HTML, PDF, and EPUB formats
### Setup CI/CD
```bash
# Setup CI/CD integration
./scripts/setup-docs.sh --ci
# This creates:
# - .github/workflows/docs.yml
# - Automated deployment configuration
# - Link checking integration
```
## 📱 Mobile-Friendly Features
- **Responsive Design**: Works on all screen sizes
- **Touch Navigation**: Mobile-friendly navigation
- **Offline Support**: Progressive web app features
- **Fast Loading**: Optimized for mobile connections
- **Search**: Full-text search functionality
## 🎨 Customization
### Custom Styling
- **`book/theme/custom.css`** - Custom styles
- **`book/theme/custom.js`** - Custom JavaScript
- **Brand colors and fonts**
- **Custom layouts and components**
### Content Organization
- **Modular structure**: Easy to reorganize content
- **Cross-references**: Automatic link generation
- **Content templates**: Consistent formatting
- **Dynamic content**: Auto-generated sections
## 🔍 Search and Discovery
### Built-in Search
- **Full-text search**: Search across all documentation
- **Instant results**: Fast search with highlighting
- **Keyboard shortcuts**: `Ctrl+K` or `Cmd+K` to search
- **Search suggestions**: Auto-complete functionality
### Navigation
- **Hierarchical structure**: Logical content organization
- **Breadcrumbs**: Easy navigation context
- **Previous/Next**: Sequential navigation
- **Table of contents**: Section overview
## 📊 Analytics and Monitoring
### Documentation Metrics
- **Build times**: Monitor documentation build performance
- **Broken links**: Automatic link validation
- **Usage analytics**: Track documentation usage (when deployed)
- **Performance monitoring**: Page load times and optimization
### Quality Assurance
- **Link checking**: Automated broken link detection
- **Content validation**: Ensure all sections are complete
- **Style checking**: Consistent formatting
- **Accessibility testing**: WCAG compliance
## 🤝 Contributing to Documentation
### How to Contribute
1. **Edit content**: Modify files in `book/` directory
2. **Test locally**: Run `just docs-dev` to preview changes
3. **Submit PR**: Create pull request with documentation changes
4. **Review process**: Automated checks and manual review
### Content Guidelines
- **Clear writing**: Use simple, clear language
- **Code examples**: Include working code examples
- **Screenshots**: Add visual aids when helpful
- **Cross-references**: Link to related sections
- **Consistency**: Follow established patterns
### Content Types
- **Tutorials**: Step-by-step guides
- **Reference**: API and configuration documentation
- **Examples**: Code samples and use cases
- **Troubleshooting**: Common issues and solutions
## 🆘 Getting Help
### Documentation Issues
- **[GitHub Issues](https://github.com/yourusername/rustelo/issues)** - Report documentation bugs
- **[Discussions](https://github.com/yourusername/rustelo/discussions)** - Ask questions
- **[Contributing Guide](book/contributing/docs.md)** - How to contribute
### Quick Help
```bash
# Show all documentation commands
just help-docs
# Check documentation build
just docs-build
# Start local development
just docs-dev
```
## 🎯 Next Steps
1. **[Setup Documentation](scripts/setup-docs.sh)** - Initialize your documentation system
2. **[Start Development](scripts/docs-dev.sh)** - Begin working with documentation
3. **[Deploy Documentation](scripts/deploy-docs.sh)** - Share your documentation
4. **[Customize Experience](book/theme/)** - Make it your own
---
**Happy documenting!** 📚✨
The Rustelo documentation system is designed to grow with your project. Start simple, add complexity as needed, and maintain comprehensive documentation that serves your users and contributors effectively.
For the most up-to-date documentation, visit: **[https://yourusername.github.io/rustelo](https://yourusername.github.io/

View File

@ -1,568 +0,0 @@
# Rustelo Installation Guide
<div align="center">
<img src="logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
</div>
Welcome to Rustelo! This guide will help you install and set up your Rust web application framework built with Leptos using our unified installer.
## Quick Start
### Unix/Linux/macOS
```bash
# Clone or download the project
git clone <repository-url>
cd rustelo
# Quick development setup (default)
./install.sh
# Or specify options
./install.sh -m dev -n my-app
```
### Windows
```powershell
# Clone or download the project
git clone <repository-url>
cd rustelo
# Quick development setup (default)
.\install.ps1
# Or specify options
.\install.ps1 -Mode dev -ProjectName my-app
```
## Installation Modes
The unified installer supports three modes:
### 1. Development Mode (default)
```bash
./install.sh -m dev
```
- Environment: `dev`
- TLS: disabled
- OAuth: disabled
- Authentication: enabled
- Content Database: enabled
- Optimized for development with debugging
### 2. Production Mode
```bash
./install.sh -m prod
```
- Environment: `prod`
- TLS: enabled by default
- OAuth: optional
- Authentication: enabled
- Content Database: enabled
- Optimized for production deployment
### 3. Custom Mode
```bash
./install.sh -m custom
```
- Interactive configuration selection
- Choose features individually
- Customize all settings
## Command Line Options
### Unix/Linux/macOS (`install.sh`)
| Option | Description | Default |
|--------|-------------|---------|
| `-m, --mode MODE` | Installation mode (dev/prod/custom) | `dev` |
| `-n, --name NAME` | Project name | `my-rustelo-app` |
| `-e, --env ENV` | Environment (dev/prod) | `dev` |
| `-d, --dir DIR` | Installation directory | `./<project-name>` |
| `--enable-tls` | Enable TLS/HTTPS support | `false` |
| `--enable-oauth` | Enable OAuth authentication | `false` |
| `--disable-auth` | Disable authentication features | `false` |
| `--disable-content-db` | Disable content database features | `false` |
| `--skip-deps` | Skip dependency installation | `false` |
| `--force` | Force reinstallation | `false` |
| `--quiet` | Suppress debug output | `false` |
| `-h, --help` | Show help message | - |
### Windows (`install.ps1`)
| Option | Description | Default |
|--------|-------------|---------|
| `-Mode` | Installation mode (dev/prod/custom) | `dev` |
| `-ProjectName` | Project name | `my-rustelo-app` |
| `-Environment` | Environment (dev/prod) | `dev` |
| `-InstallDir` | Installation directory | `./<project-name>` |
| `-EnableTLS` | Enable TLS/HTTPS support | `false` |
| `-EnableOAuth` | Enable OAuth authentication | `false` |
| `-DisableAuth` | Disable authentication features | `false` |
| `-DisableContentDB` | Disable content database features | `false` |
| `-SkipDeps` | Skip dependency installation | `false` |
| `-Force` | Force reinstallation | `false` |
| `-Quiet` | Suppress debug output | `false` |
| `-Help` | Show help message | - |
## Environment Variables
You can also configure the installer using environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `INSTALL_MODE` | Installation mode (dev/prod/custom) | `dev` |
| `PROJECT_NAME` | Project name | `my-rustelo-app` |
| `ENVIRONMENT` | Environment (dev/prod) | `dev` |
| `ENABLE_TLS` | Enable TLS (true/false) | `false` |
| `ENABLE_AUTH` | Enable authentication (true/false) | `true` |
| `ENABLE_CONTENT_DB` | Enable content database (true/false) | `true` |
| `ENABLE_OAUTH` | Enable OAuth (true/false) | `false` |
| `SKIP_DEPS` | Skip dependencies (true/false) | `false` |
| `FORCE_REINSTALL` | Force reinstall (true/false) | `false` |
| `QUIET` | Quiet mode (true/false) | `false` |
## Examples
### Development Setup
```bash
# Simple development setup
./install.sh
# Development with custom name
./install.sh -n my-blog
# Development with TLS enabled
./install.sh --enable-tls
```
### Production Setup
```bash
# Production setup with HTTPS
./install.sh -m prod -n my-app
# Production with OAuth enabled
./install.sh -m prod --enable-oauth
# Production in custom directory
./install.sh -m prod -d /opt/my-app
```
### Using Environment Variables
```bash
# Set environment variables
export INSTALL_MODE=prod
export PROJECT_NAME=my-production-app
export ENABLE_TLS=true
export ENABLE_OAUTH=true
# Run installer
./install.sh
```
### Windows Examples
```powershell
# Simple development setup
.\install.ps1
# Production setup with HTTPS
.\install.ps1 -Mode prod -ProjectName my-app -EnableTLS
# Custom interactive setup
.\install.ps1 -Mode custom
# Using environment variables
$env:INSTALL_MODE = "prod"
$env:PROJECT_NAME = "my-app"
.\install.ps1
```
## System Requirements
### Required Dependencies
- **Rust** (1.75.0 or later)
- Install from [rustup.rs](https://rustup.rs/)
- Includes `cargo` package manager
- **Node.js** (18.0.0 or later)
- Install from [nodejs.org](https://nodejs.org/)
- Includes `npm` package manager
- Optional: `pnpm` for faster package management
- **Git** (for cloning repositories)
- **OpenSSL** (for TLS certificate generation)
- **mdBook** (for documentation)
- Automatically installed by installer
- Manual install: `cargo install mdbook`
- Required for documentation system
- **Just** (task runner)
- Automatically installed by installer
- Manual install: `cargo install just`
- Required for development workflow
### Optional Dependencies
- **PostgreSQL** (for database features)
- **Redis** (for caching and sessions)
- **Docker** (for containerized deployment)
- **mdBook plugins** (for enhanced documentation)
- `mdbook-linkcheck` - Link validation
- `mdbook-toc` - Table of contents generation
- `mdbook-mermaid` - Diagram support
- Automatically installed by installer
### System-Specific Requirements
#### Linux (Ubuntu/Debian)
```bash
# Update package list
sudo apt update
# Install required packages
sudo apt install -y git curl build-essential pkg-config libssl-dev
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Node.js
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
```
#### macOS
```bash
# Install Homebrew if not already installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install required packages
brew install git openssl
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Node.js
brew install node
```
#### Windows
1. Install Git from [git-scm.com](https://git-scm.com/)
2. Install Rust from [rustup.rs](https://rustup.rs/)
3. Install Node.js from [nodejs.org](https://nodejs.org/)
4. Install OpenSSL (or use the installer's automatic setup)
## What the Installer Does
1. **System Check**: Verifies required tools are installed
2. **Dependency Installation**: Installs Rust and Node.js if missing
3. **Rust Tools**: Installs `cargo-leptos`, `mdbook`, `just`, and other development tools
4. **Documentation Tools**: Installs mdBook plugins for enhanced documentation
5. **Project Creation**: Copies template files to new project directory
6. **Configuration**: Creates `.env` file with appropriate settings
7. **Dependencies**: Installs Rust and Node.js dependencies
8. **Build**: Compiles the project
9. **Scripts**: Creates startup scripts for development and production
10. **Documentation Setup**: Initializes documentation system
11. **TLS Setup**: Generates self-signed certificates if enabled
## Project Structure
After installation, your project will have this structure:
```
my-rustelo-app/
├── src/ # Rust source code
│ ├── client/ # Client-side code
│ ├── server/ # Server-side code
│ └── shared/ # Shared code
├── public/ # Static assets
├── book/ # Documentation source (mdBook)
├── book-output/ # Built documentation
├── certs/ # TLS certificates (if enabled)
├── scripts/ # Setup and utility scripts
│ ├── setup-docs.sh # Documentation setup
│ ├── build-docs.sh # Build documentation
│ ├── deploy-docs.sh # Deploy documentation
│ └── docs-dev.sh # Documentation dev server
├── .env # Environment configuration
├── Cargo.toml # Rust dependencies
├── package.json # Node.js dependencies
├── justfile # Task runner configuration
├── book.toml # mdBook configuration
├── start.sh # Development start script (Unix)
├── start.bat # Development start script (Windows)
├── start-prod.sh # Production start script (Unix)
├── start-prod.bat # Production start script (Windows)
└── build.sh # Build script (Unix)
```
## Configuration
### Environment Variables (.env)
The installer creates a `.env` file with settings appropriate for your chosen mode:
| Variable | Description | Dev Default | Prod Default |
|----------|-------------|-------------|--------------|
| `ENVIRONMENT` | Environment type | `dev` | `prod` |
| `SERVER_HOST` | Server bind address | `127.0.0.1` | `0.0.0.0` |
| `SERVER_PORT` | Server port | `3030` | `443` |
| `SERVER_PROTOCOL` | Protocol | `http` | `https` |
| `DATABASE_URL` | Database connection | Local PostgreSQL | Production URL |
| `SESSION_SECRET` | Session encryption key | Dev key | Generated |
| `LOG_LEVEL` | Logging level | `debug` | `info` |
### Feature Configuration
Features are controlled by environment variables:
- `ENABLE_AUTH` - Authentication system
- `ENABLE_CONTENT_DB` - Content management
- `ENABLE_TLS` - HTTPS support
- `ENABLE_OAUTH` - OAuth providers
## Development Workflow
### Starting the Development Server
```bash
# Navigate to project
cd my-rustelo-app
# Start development server (Unix)
./start.sh
# Start development server (Windows)
.\start.bat
# Or use cargo directly
cargo leptos watch
```
### Building for Production
```bash
# Build for production (Unix)
./start-prod.sh
# Build for production (Windows)
.\start-prod.bat
# Or use cargo directly
cargo leptos build --release
./target/release/server
```
### Available Commands
#### Development Commands
| Command | Description |
|---------|-------------|
| `cargo leptos watch` | Start development server with hot reload |
| `cargo leptos build` | Build for production |
| `cargo build` | Build Rust code only |
| `npm run build:css` | Build CSS only |
| `npm run dev` | Watch CSS changes |
| `cargo test` | Run tests |
| `cargo clippy` | Run linter |
#### Just Commands (Task Runner)
| Command | Description |
|---------|-------------|
| `just dev` | Start development server |
| `just build` | Build project |
| `just test` | Run tests |
| `just docs-dev` | Start documentation dev server |
| `just docs-build` | Build documentation |
| `just docs-deploy-github` | Deploy docs to GitHub Pages |
| `just help` | Show all available commands |
| `just help-docs` | Show documentation commands |
#### Documentation Commands
| Command | Description |
|---------|-------------|
| `./scripts/setup-docs.sh` | Setup documentation system |
| `./scripts/docs-dev.sh` | Start documentation dev server |
| `./scripts/build-docs.sh` | Build documentation |
| `./scripts/deploy-docs.sh` | Deploy documentation |
| `mdbook serve` | Serve documentation locally |
| `mdbook build` | Build documentation manually |
## Troubleshooting
### Common Issues
#### 1. Installation Mode Not Recognized
**Error**: `Invalid installation mode: xyz`
**Solution**: Use valid modes: `dev`, `prod`, or `custom`
```bash
./install.sh -m dev # Valid
./install.sh -m prod # Valid
./install.sh -m custom # Valid
```
#### 2. Project Directory Already Exists
**Error**: `Project directory already exists`
**Solution**: Use `--force` flag or choose different name
```bash
./install.sh --force # Overwrite existing
./install.sh -n different-name # Use different name
```
#### 3. Missing Dependencies
**Error**: `Missing required system tools`
**Solution**: Install missing tools:
```bash
# Ubuntu/Debian
sudo apt install git curl openssl
# macOS
brew install git openssl
# Windows: Install manually from official websites
```
#### 4. Rust Installation Issues
**Error**: `cargo: command not found`
**Solution**: Ensure Rust is installed and in PATH:
```bash
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add to PATH
source ~/.cargo/env
```
#### 5. Node.js Dependencies
**Error**: `npm: command not found`
**Solution**: Install Node.js from [nodejs.org](https://nodejs.org/)
#### 6. Build Failures
**Error**: `cargo build` fails with linking errors
**Solution**: Install system dependencies:
```bash
# Ubuntu/Debian
sudo apt install build-essential pkg-config libssl-dev
# macOS
xcode-select --install
```
### Getting Help
1. Check the installation log: `install.log`
2. Review configuration files: `.env`, `Cargo.toml`
3. Validate settings: `cargo run --bin config_tool -- validate`
4. Check documentation files in the project directory
## Manual Installation
If you prefer to set up manually without the installer:
### 1. Clone Template
```bash
git clone <repository-url>
cd rustelo
cp -r template my-project
cd my-project
```
### 2. Install Tools
```bash
cargo install cargo-leptos
cargo install mdbook
cargo install just
cargo install cargo-watch # Optional
cargo install mdbook-linkcheck # Optional
cargo install mdbook-toc # Optional
cargo install mdbook-mermaid # Optional
```
### 3. Configure Environment
Create `.env` file:
```env
ENVIRONMENT=dev
SERVER_HOST=127.0.0.1
SERVER_PORT=3030
SERVER_PROTOCOL=http
DATABASE_URL=postgresql://dev:dev@localhost:5432/myapp_dev
SESSION_SECRET=your-secret-key
ENABLE_AUTH=true
ENABLE_CONTENT_DB=true
ENABLE_TLS=false
```
### 4. Install Dependencies
```bash
cargo fetch
npm install
```
### 5. Setup Documentation
```bash
./scripts/setup-docs.sh --full
```
### 6. Build and Run
```bash
npm run build:css
cargo build
cargo leptos watch
# Or use just commands
just dev
just docs-dev # In another terminal for documentation
```
## Production Deployment
### Security Checklist
After running the installer in production mode:
- [ ] Update `SESSION_SECRET` in `.env` with a secure random value
- [ ] Configure proper database connection string
- [ ] Set up valid TLS certificates (replace self-signed ones)
- [ ] Review all security settings in configuration files
- [ ] Configure OAuth providers if enabled
- [ ] Set up proper logging and monitoring
- [ ] Configure firewall rules
- [ ] Set up backup procedures
### Environment Variables for Production
```env
ENVIRONMENT=prod
SERVER_HOST=0.0.0.0
SERVER_PORT=443
SERVER_PROTOCOL=https
DATABASE_URL=postgresql://user:password@host:5432/database
SESSION_SECRET=your-very-secure-random-secret
ENABLE_AUTH=true
ENABLE_CONTENT_DB=true
ENABLE_TLS=true
```
## Support
For issues and questions:
- Check the troubleshooting section above
- Review the configuration documentation
- Check the installation log file
- Create an issue on the project repository
## License
This project is licensed under the MIT License. See the LICENSE file for details.
---
Happy coding with Rustelo! 🚀

View File

@ -1,442 +0,0 @@
# Rustelo Quick Start Guide
<div align="center">
<img src="logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
</div>
Get up and running with Rustelo in just a few minutes! This comprehensive guide will take you from zero to a fully functional web application with documentation.
## 🚀 30-Second Setup
### Prerequisites Check
Before starting, ensure you have:
- **Git** - Version control
- **Internet connection** - For downloading dependencies
### One-Command Installation
```bash
# Clone and install everything automatically
git clone https://github.com/yourusername/rustelo.git my-app
cd my-app
./scripts/install.sh
```
That's it! The installer will:
- ✅ Install Rust, Node.js, and all required tools
- ✅ Install mdBook and Just task runner
- ✅ Set up your project with sensible defaults
- ✅ Configure documentation system
- ✅ Verify everything is working
- ✅ Generate a personalized setup report
## 🎯 What You Get
After installation, you'll have:
### 📁 Complete Project Structure
```
my-app/
├── client/ # Frontend Leptos components
├── server/ # Backend Axum server
├── shared/ # Shared code and types
├── book/ # Documentation source (mdBook)
├── scripts/ # Helper scripts
├── .env # Environment configuration
├── justfile # Task runner commands
└── book.toml # Documentation configuration
```
### 🛠️ Essential Tools Ready
- **Rust** with Cargo - Main development tools
- **mdBook** - Documentation system
- **Just** - Task runner for easy commands
- **cargo-leptos** - Leptos development server
### 📚 Documentation System
- Interactive documentation website
- Auto-synced content from your docs
- Multiple deployment options
- Mobile-friendly design
### 📋 Setup Report
- **SETUP_COMPLETE.md** - Personalized installation summary
- Shows exactly what was installed and configured
- Includes quick start commands for your specific setup
- Updates automatically after any setup changes
## 🏃‍♂️ Start Developing
### 1. Start Development Servers
```bash
# Start the web application
just dev
# In another terminal, start documentation server
just docs-dev
```
### 2. Open Your App
- **Web App**: http://localhost:3030
- **Documentation**: http://localhost:3000
### 3. Make Your First Change
Edit `client/src/pages/home.rs`:
```rust
#[component]
pub fn HomePage() -> impl IntoView {
view! {
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">"Hello, Rustelo!"</h1>
<p class="py-6">"Your web app is ready to build amazing things!"</p>
</div>
</div>
</div>
}
}
```
## 🎛️ Essential Commands
### Development
```bash
# Start development server with hot reload
just dev
# Start documentation server
just docs-dev
# Run tests
just test
# Check code quality
just check
# Build for production
just build-prod
```
### Documentation
```bash
# Setup documentation system
just docs-setup
# Build documentation
just docs-build
# Deploy to GitHub Pages
just docs-deploy-github
# Clean documentation build
just docs-clean
# Show all documentation commands
just help-docs
```
### System
```bash
# Verify installation
just verify-setup
# Show all available commands
just help
# Generate setup completion report
just generate-setup-report
# Update dependencies
just update
```
## 🔧 Configuration Options
### Choose Your Features
Rustelo is modular. Choose what you need:
```bash
# Minimal static website
cargo build --no-default-features
# Full-featured app (default)
cargo build --features "auth,content-db,email"
# Production with HTTPS
cargo build --features "tls,auth,content-db,email"
```
### Environment Configuration
Edit `.env` to customize your setup:
```env
# Basic Configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=3030
ENVIRONMENT=dev
# Features (true/false)
ENABLE_AUTH=true
ENABLE_CONTENT_DB=true
ENABLE_TLS=false
# Database (choose one)
DATABASE_URL=sqlite://database.db # SQLite (simple)
# DATABASE_URL=postgresql://user:pass@localhost:5432/db # PostgreSQL (production)
# Logging
LOG_LEVEL=debug
```
## 📖 Documentation Features
### What's Included
- **📚 Interactive Guide** - Searchable, mobile-friendly documentation
- **🔄 Auto-Sync** - Automatically includes your existing docs
- **🌐 Multi-Deploy** - GitHub Pages, Netlify, Vercel, Docker
- **🎨 Custom Styling** - Branded documentation with your colors
- **📱 Mobile-First** - Works perfectly on all devices
### Customize Documentation
```bash
# Edit content in book/ directory
# Add your own sections in book/SUMMARY.md
# Customize styling in book/theme/custom.css
# Build and preview
just docs-build
just docs-dev
```
## 🗄️ Database Setup
### SQLite (Development)
```bash
# Already configured! Database file created automatically
# Perfect for: Development, testing, small apps
```
### PostgreSQL (Production)
```bash
# Start PostgreSQL with Docker
docker run -d -p 5432:5432 \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=myapp \
postgres:15
# Update .env
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
# Run migrations
just db-migrate
```
## 🚀 Deployment
### Quick Deploy to GitHub Pages
```bash
# Deploy documentation
just docs-deploy-github
# Deploy will be available at:
# https://yourusername.github.io/my-app
```
### Check Your Setup
```bash
# View detailed setup information
cat SETUP_COMPLETE.md
# Regenerate setup report
just regenerate-setup-report
# Verify everything is working
just verify-setup
```
### Production Deployment
```bash
# Build for production
just build-prod
# Deploy with Docker
just docker-build
just docker-run
# Or deploy to cloud platform of choice
```
## 🛠️ Development Workflow
### Daily Development
```bash
# Morning routine
just verify-setup # Verify everything is working
just dev # Start development server
just docs-dev # Start documentation (separate terminal)
# Make changes, they auto-reload!
# Evening routine
just test # Run tests
just docs-build # Update documentation
git add . && git commit -m "Your changes"
```
### Adding Features
```bash
# Add authentication
# Edit Cargo.toml to include "auth" feature
cargo build --features "auth"
# Add content management
cargo build --features "content-db"
# Add everything
cargo build --features "auth,content-db,email,tls"
```
## 🔍 Common Tasks
### Add a New Page
1. Create `client/src/pages/about.rs`
2. Add route in `client/src/app.rs`
3. Document it in `book/`
### Add API Endpoint
1. Add handler in `server/src/api/`
2. Register route in `server/src/main.rs`
3. Add types in `shared/src/`
### Style Your App
1. Edit CSS in `style/`
2. Use Tailwind classes in components
3. Build CSS with `npm run build:css`
### Update Documentation
1. Edit markdown files in `book/`
2. Build with `just docs-build`
3. Deploy with `just docs-deploy-github`
## 🆘 Troubleshooting
### Installation Issues
```bash
# Verify setup
just verify-setup
# Common fixes
chmod +x scripts/*.sh # Fix script permissions
cargo clean && cargo build # Clean build
```
### Development Issues
```bash
# Port already in use
SERVER_PORT=3031 cargo run
# Database connection error
just db-setup # Setup database
# Build errors
cargo clean && cargo build # Clean build
just update # Update dependencies
```
### Documentation Issues
```bash
# Documentation won't build
mdbook build # Check for errors
# Documentation server won't start
just docs-clean && just docs-build # Clean rebuild
```
## 📚 Learning Path
### 1. Start Here (5 minutes)
- ✅ Run the installer
- ✅ Start development servers
- ✅ Make your first change
### 2. Explore Features (15 minutes)
- 🔐 Try authentication features
- 📄 Add some content
- 📧 Test email functionality
### 3. Customize (30 minutes)
- 🎨 Update styling and branding
- 📖 Add documentation sections
- 🔧 Configure for your needs
### 4. Deploy (15 minutes)
- 🌐 Deploy documentation to GitHub Pages
- 🚀 Deploy app to your platform of choice
## 🎯 Next Steps
### Immediate
1. **Customize branding** - Update colors, logos, text
2. **Add content** - Write your app's content
3. **Document features** - Update documentation
### Short-term
1. **Database setup** - Configure production database
2. **Authentication** - Set up OAuth providers
3. **Email** - Configure email service
### Long-term
1. **Advanced features** - Add custom functionality
2. **Performance** - Optimize for production
3. **Monitoring** - Set up logging and metrics
## 🔗 Useful Links
### Documentation
- **[Complete Guide](https://yourusername.github.io/rustelo)** - Full documentation
- **[Features Guide](FEATURES.md)** - Detailed feature documentation
- **[Installation Guide](INSTALL.md)** - Detailed installation instructions
### Development
- **[Leptos Book](https://book.leptos.dev/)** - Learn Leptos framework
- **[Axum Documentation](https://docs.rs/axum/)** - Web server framework
- **[Just Manual](https://github.com/casey/just)** - Task runner documentation
### Tools
- **[mdBook Guide](https://rust-lang.github.io/mdBook/)** - Documentation system
- **[Tailwind CSS](https://tailwindcss.com/)** - CSS framework
- **[DaisyUI](https://daisyui.com/)** - Component library
## 💡 Pro Tips
### Productivity
- Use `just help` to discover available commands
- Keep documentation server running while developing
- Use `just verify-setup` to troubleshoot issues
### Best Practices
- Commit early and often
- Document as you build
- Test in different environments
- Keep dependencies updated
### Performance
- Use `cargo build --release` for production
- Enable gzip compression
- Optimize images and assets
- Monitor performance metrics
## 🎉 You're Ready!
Congratulations! You now have:
- ✅ A fully functional web application
- ✅ Professional documentation system
- ✅ Development environment ready
- ✅ Deployment pipeline configured
**Start building something amazing with Rustelo!** 🚀
---
Need help? Check the [troubleshooting section](#🆘-troubleshooting) or visit our [complete documentation](https://yourusername.github.io/rustelo).
Happy coding! 🦀✨

View File

@ -490,6 +490,83 @@ SENDGRID_ENDPOINT=https://api.sendgrid.com/v3/mail/send
EMAIL_TEMPLATE_DIR=templates/email
```
## 🔌 Plugin Architecture
Rustelo features a trait-based plugin system for unlimited extensibility without framework coupling.
### What Are Plugins?
Plugins extend Rustelo functionality by implementing well-defined traits:
- **ResourceContributor**: Provide themes, menus, and translations
- **PageProvider**: Provide custom page components
### Key Features
- ✅ **Type-Safe**: Compile-time validation of all plugin code
- ✅ **Zero Conditional Compilation**: Framework code is completely independent
- ✅ **Self-Contained**: Plugins are standalone crates
- ✅ **Configuration-Driven**: Resources from TOML/FTL files
- ✅ **Zero Runtime Overhead**: All embedding at compile time
### Creating a Plugin
**1. Create plugin crate:**
```bash
cargo new --lib my-plugin
```
**2. Implement ResourceContributor:**
```rust
use rustelo_core_lib::registration::ResourceContributor;
pub struct MyPlugin;
impl ResourceContributor for MyPlugin {
fn contribute_themes(&self) -> HashMap<String, String> {
let mut themes = HashMap::new();
themes.insert("my-theme".to_string(),
include_str!("../config/themes/my-theme.toml").to_string());
themes
}
fn name(&self) -> &str {
"my-plugin"
}
}
```
**3. Register at startup:**
```rust
rustelo_core_lib::register_contributor(&MyPlugin)?;
rustelo_core_lib::load_resources_from_config()?;
```
### Plugin Types
| Type | Purpose | Example |
|------|---------|---------|
| **Resource-Only** | Themes, menus, translations | Custom theme plugin |
| **Page Provider** | Custom page components | Analytics dashboard |
| **Composite** | Resources + pages | Feature module |
### Evolution Path
**Level 5 (Current):** Compile-time plugins
- Plugins compiled into binary
- Registration at startup
- ✅ Production ready
**Level 8 (Future):** Runtime plugins
- Dynamic `.so`/`.dylib` loading
- Hot reload support
- Same trait interfaces (backward compatible)
### Documentation
- [Plugin Architecture Guide](../docs/architecture/rustelo-plugin-architecture.md)
- [Plugin Development Guide](./.coder/info/PHASE3-PLUGIN-DEVELOPMENT-GUIDE.md)
- [Example Plugin](../website/website-impl/crates/plugin-example-theme/)
## 🏗️ Project Structure
```

View File

@ -1,8 +0,0 @@
- [X] Configuration builder
- [X] Admin Dashboard
- [ ] User profile manager
- [ ] Remove python script are in docs ?
- [ ] Add file upload capabilities** for media management?
- [ ] **Enhance the dashboard** with content analytics?
- [ ] **Show how to configure** the content sources (DB vs Files vs Both)?

View File

@ -0,0 +1,32 @@
# Example Rustelo initialization configuration file
# Use this to avoid interactive prompts during project creation
# Template to use for initialization (optional, defaults to CLI argument)
template = "basic"
# Directory handling when target already exists
# Options: "merge", "replace", "cancel"
existing_directory_action = "merge"
# Skip all confirmations (assume yes for safety prompts)
auto_confirm = true
# Asset configuration
[assets]
# Source for templates: "remote", "local", or custom URL
source = "local"
# Directory to store assets in the project
download_location = ".rustelo-assets"
# Framework path (for local development)
framework_path = "../rustelo"
# Enable asset caching
cache_enabled = true
# Automatic updates on build
auto_update = true
# Notification methods
notification_methods = ["console"]

BIN
assets/logos/github-img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

View File

@ -0,0 +1,66 @@
# Rustelo Init Configuration File
# This file allows you to skip interactive prompts when creating new projects
# Usage: cargo rustelo init my-project --config rustelo-init-config.toml
# Template to use for initialization
# Options: "basic", "minimal", "enterprise", "cms", "saas", "ai-powered", "e-commerce"
# If not specified, uses the template from CLI argument or "basic" as default
template = "basic"
# How to handle existing directories
# Options:
# "merge" - Merge template into existing directory (default)
# "replace" - Remove existing directory and create fresh
# "cancel" - Cancel operation if directory exists
existing_directory_action = "merge"
# Skip safety confirmations (use with caution)
# When true, assumes "yes" to all safety prompts
auto_confirm = false
# Asset configuration section
[assets]
# Template source configuration
# Options:
# "remote" - Download from GitHub/remote URL
# "local" - Use local framework development setup
# Custom URL starting with "http" or "https"
source = "local"
# Directory to store assets in your project
# Options:
# ".rustelo-assets" (recommended)
# "templates"
# Custom path
download_location = ".rustelo-assets"
# Framework path for local development
# Required when source = "local"
# Should point to your local rustelo framework directory
framework_path = "../rustelo"
# Enable asset caching to speed up repeated operations
cache_enabled = true
# Automatic asset updates
# true = Update assets automatically on build
# false = Manual updates only
auto_update = true
# Notification methods for updates and operations
# Available options: "console", "file", "webhook"
notification_methods = ["console"]
# Example configurations for different scenarios:
# For production/CI environments:
# source = "remote"
# download_location = ".rustelo-assets"
# auto_update = false
# existing_directory_action = "cancel"
# For development/testing:
# source = "local"
# framework_path = "../rustelo"
# auto_update = true
# existing_directory_action = "merge"

View File

@ -1,50 +0,0 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
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://rustelo.dev"
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 }
serde = { workspace = true }
serde_json = { workspace = true }
reqwasm = { workspace = true }
web-sys = { workspace = true }
regex = { workspace = true }
console_error_panic_hook = { version = "0.1.7" }
toml = { workspace = true }
fluent = { workspace = true }
fluent-bundle = { workspace = true }
unic-langid = { workspace = true }
shared = { path = "../shared" }
gloo-timers = { workspace = true }
wasm-bindgen-futures = { workspace = true }
urlencoding = "2.1"
chrono = { workspace = true }
uuid = { workspace = true }
# leptos-use = "0.13"
[features]
default = []
hydrate = []
[package.metadata.docs.rs]
# Configuration for docs.rs
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

View File

@ -1,66 +0,0 @@
use std::{
io::{self, Write},
path::Path,
process,
};
fn main() {
println!("cargo::rustc-check-cfg=cfg(web_sys_unstable_apis)");
println!("cargo:rerun-if-changed=uno.config.ts");
//println!("cargo:rerun-if-changed=style/main.scss");
// Check if node_modules exists in various locations, if not run pnpm install
let node_modules_paths = ["../node_modules", "node_modules", "../../node_modules"];
let node_modules_exists = node_modules_paths
.iter()
.any(|path| Path::new(path).exists());
if !node_modules_exists {
println!("cargo:warning=node_modules not found, running pnpm install...");
// Try to find package.json to determine correct directory
let package_json_paths = ["../package.json", "package.json", "../../package.json"];
let install_dir = package_json_paths
.iter()
.find(|path| Path::new(path).exists())
.map(|path| Path::new(path).parent().unwrap_or(Path::new(".")))
.unwrap_or(Path::new(".."));
match process::Command::new("pnpm")
.arg("install")
.current_dir(install_dir)
.output()
{
Ok(output) => {
if !output.status.success() {
let _ = io::stdout().write_all(&output.stdout);
let _ = io::stdout().write_all(&output.stderr);
panic!("pnpm install failed");
}
println!("cargo:warning=pnpm install completed successfully");
}
Err(e) => {
println!("cargo:warning=Failed to run pnpm install: {:?}", e);
println!("cargo:warning=Please run 'pnpm install' manually in the project root");
// Don't panic here, just warn - the build might still work
}
}
}
match process::Command::new("sh")
.arg("-c")
.arg("pnpm run build")
.output()
{
Ok(output) => {
if !output.status.success() {
let _ = io::stdout().write_all(&output.stdout);
let _ = io::stdout().write_all(&output.stderr);
panic!("UnoCSS error");
}
}
Err(e) => panic!("UnoCSS error: {:?}", e),
};
}

View File

@ -1,188 +0,0 @@
//#![allow(unused_imports)]
//#![allow(dead_code)]
//#![allow(unused_variables)]
// Suppress leptos_router warnings about reactive signal access outside tracking context
#![allow(clippy::redundant_closure)]
//#![allow(unused_assignments)]
//use crate::defs::{NAV_LINK_CLASS, ROUTES};
use crate::auth::AuthProvider;
use crate::components::NavMenu;
use crate::i18n::{I18nProvider, ThemeProvider};
use crate::pages::{AboutPage, DaisyUIPage, FeaturesDemoPage, HomePage, UserPage};
use crate::state::*;
use crate::utils::make_popstate_effect;
use leptos::children::Children;
use leptos::prelude::*;
use leptos_meta::{MetaTags, Title, provide_meta_context};
// use regex::Regex;
use shared::{get_bundle, t};
use std::collections::HashMap;
//// Wrapper component for consistent layout.
#[component]
fn Wrapper(children: Children) -> impl IntoView {
view! { <>{children()}</> }
}
/// NotFoundPage component for 404s.
#[component]
fn NotFoundPage() -> impl IntoView {
view! { <div class="text-center">"Page not found."</div> }
}
/// Main app component with SSR path awareness and SPA routing.
#[component]
pub fn App(#[prop(default = String::new())] _initial_path: String) -> impl IntoView {
provide_meta_context();
// Always start with HOME during SSR, then route to correct page on client
let (path, set_path) = signal("/".to_string());
make_popstate_effect(set_path);
// Update path from URL after hydration (client-side redirect)
#[cfg(target_arch = "wasm32")]
{
use wasm_bindgen_futures::spawn_local;
spawn_local(async move {
if let Some(win) = web_sys::window() {
let current_path = win
.location()
.pathname()
.unwrap_or_else(|_| "/".to_string());
// If URL path is different from home, redirect to it
if current_path != "/" {
web_sys::console::log_1(
&format!("Client-side redirect to: {}", current_path).into(),
);
set_path.set(current_path);
}
}
});
}
let (lang, _set_lang) = signal("en".to_string());
// --- Unit test placeholder for route matching ---
// #[cfg(test)]
// mod tests {
// use super::*;
// #[test]
// fn test_user_route() {
// let re = Regex::new(r"^/user/(\\d+)$").expect("Valid regex");
// assert!(re.is_match("/user/42"));
// }
// }
view! {
<GlobalStateProvider>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<AuthProvider>
<UserProvider>
<AppStateProvider>
<Title text="Welcome to Leptos"/>
<header class="absolute inset-x-0 top-2 z-90 mx-2">
<Wrapper><NavMenu set_path=set_path /></Wrapper>
</header>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{ let lang = lang.clone(); let path = path.clone();
move || {
let p = path.get();
let lang_val = lang.get();
let bundle = get_bundle(&lang_val).unwrap_or_else(|_| {
// Fallback to a simple bundle if loading fails
use fluent::FluentBundle;
use unic_langid::LanguageIdentifier;
let langid: LanguageIdentifier = "en".parse().unwrap_or_else(|e| {
web_sys::console::error_1(&format!("Failed to parse default language 'en': {:?}", e).into());
// This should never happen, but create a minimal fallback
LanguageIdentifier::from_parts(
unic_langid::subtags::Language::from_bytes(b"en").unwrap_or_else(|e| {
web_sys::console::error_1(&format!("Critical error: failed to create 'en' language: {:?}", e).into());
// Fallback to creating a new language identifier from scratch
match "en".parse::<unic_langid::subtags::Language>() {
Ok(lang) => lang,
Err(_) => {
// If even this fails, we'll use the default language
web_sys::console::error_1(&"Using default language as final fallback".into());
unic_langid::subtags::Language::default()
}
}
}),
None,
None,
&[],
)
});
FluentBundle::new(vec![langid])
});
let content = match p.as_str() {
"/" => t(&bundle, "main-desc", None),
"/about" => t(&bundle, "about-desc", None),
"/user" => "User Dashboard".to_string(),
"/daisyui" => "DaisyUI Components Demo".to_string(),
"/features-demo" => "New Features Demo".to_string(),
_ if p.starts_with("/user/") => {
if let Some(id) = p.strip_prefix("/user/") {
let mut args = HashMap::new();
args.insert("id", id);
t(&bundle, "user-page", Some(&args))
} else {
t(&bundle, "not-found", None)
}
},
_ => t(&bundle, "not-found", None),
};
view! {
<Wrapper>
<div>{content}</div>
{match p.as_str() {
"/" => view! { <div><HomePage /></div> }.into_any(),
"/about" => view! { <div><AboutPage /></div> }.into_any(),
"/user" => view! { <div><UserPage /></div> }.into_any(),
"/daisyui" => view! { <div><DaisyUIPage /></div> }.into_any(),
"/features-demo" => view! { <div><FeaturesDemoPage /></div> }.into_any(),
_ => view! { <div>Not found</div> }.into_any(),
}}
</Wrapper>
}
}}
</main>
</div>
</AppStateProvider>
</UserProvider>
</AuthProvider>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</GlobalStateProvider>
}
}
/// The SSR shell for Leptos/Axum integration.
pub fn shell(options: LeptosOptions) -> impl IntoView {
shell_with_path(options, None)
}
/// The SSR shell for Leptos/Axum integration with path support.
pub fn shell_with_path(options: LeptosOptions, path: Option<String>) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options/>
<link rel="stylesheet" id="leptos" href="/public/website.css"/>
<link rel="shortcut icon" type="image/ico" href="/favicon.ico"/>
<MetaTags/>
</head>
<body>
<App _initial_path=path.unwrap_or_default() />
</body>
</html>
}
}

View File

@ -1,900 +0,0 @@
use crate::i18n::use_i18n;
use leptos::prelude::*;
// use leptos_router::use_navigate;
use shared::auth::{AuthResponse, User};
use std::sync::Arc;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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)]
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>,
}
impl Default for AuthState {
fn default() -> Self {
Self {
user: None,
is_loading: false,
error: None,
requires_2fa: false,
pending_2fa_email: None,
}
}
}
#[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: Arc<dyn Fn(String, Option<String>, Option<String>) + Send + Sync>,
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: &shared::auth::Role) -> bool {
self.state
.get()
.user
.as_ref()
.map_or(false, |user| user.has_role(role))
}
pub fn has_permission(&self, permission: &shared::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())
}
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
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
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)]
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);
});
}
}
}
}
});
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();
Arc::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();
spawn_local(async move {
set_state.update(|s| {
s.is_loading = true;
s.error = None;
});
let login_data = serde_json::json!({
"email": email,
"password": password,
"remember_me": remember_me
});
match reqwasm::http::Request::post("/api/auth/login")
.header("Content-Type", "application/json")
.body(login_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
// Check if 2FA is required
if auth_response.requires_2fa {
set_state.update(|s| {
s.requires_2fa = true;
s.pending_2fa_email = Some(email.clone());
s.is_loading = false;
});
// Navigate to 2FA page
if let Some(window) = web_sys::window() {
let location = window.location();
let remember_param = if remember_me {
"&remember_me=true"
} else {
""
};
let url = format!(
"/login/2fa?email={}{}",
urlencoding::encode(&email),
remember_param
);
let _ = location.set_href(&url);
}
} else {
// Regular login success
set_access_token
.set(Some(auth_response.access_token.clone()));
if let Some(refresh_token) =
&auth_response.refresh_token
{
set_refresh_token
.set(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",
&auth_response.access_token,
);
if let Some(refresh_token) =
&auth_response.refresh_token
{
let _ = storage.set_item(
"refresh_token",
refresh_token,
);
}
if let Ok(user_json) = serde_json::to_string(
&auth_response.user,
) {
let _ = storage
.set_item("user", &user_json);
}
}
}
set_state.update(|s| {
s.user = Some(auth_response.user);
s.is_loading = false;
s.requires_2fa = false;
s.pending_2fa_email = None;
});
}
}
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("login-failed"));
s.is_loading = false;
});
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Login failed".to_string());
let error_msg = parse_error_response(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
})
};
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();
let access_token = access_token.clone();
Arc::new(move || {
let set_state = set_state.clone();
let set_access_token = set_access_token.clone();
let set_refresh_token = set_refresh_token.clone();
let access_token = access_token.clone();
spawn_local(async move {
// Call logout endpoint
if let Some(token) = access_token.get() {
let _ = reqwasm::http::Request::post("/api/auth/logout")
.header("Authorization", &format!("Bearer {}", token))
.send()
.await;
}
// Clear local state
set_state.update(|s| {
s.user = None;
s.error = None;
s.is_loading = false;
s.requires_2fa = false;
s.pending_2fa_email = None;
});
set_access_token.set(None);
set_refresh_token.set(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");
}
}
});
})
};
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();
Arc::new(
move |email: String,
password: String,
username: 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();
spawn_local(async move {
set_state.update(|s| {
s.is_loading = true;
s.error = None;
});
let register_data = serde_json::json!({
"email": email,
"username": username,
"password": password,
"display_name": display_name
});
match reqwasm::http::Request::post("/api/auth/register")
.header("Content-Type", "application/json")
.body(register_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
// Store tokens and user data similar to login
set_access_token
.set(Some(auth_response.access_token.clone()));
if let Some(refresh_token) =
&auth_response.refresh_token
{
set_refresh_token
.set(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",
&auth_response.access_token,
);
if let Some(refresh_token) =
&auth_response.refresh_token
{
let _ = storage.set_item(
"refresh_token",
refresh_token,
);
}
if let Ok(user_json) = serde_json::to_string(
&auth_response.user,
) {
let _ = storage
.set_item("user", &user_json);
}
}
}
set_state.update(|s| {
s.user = Some(auth_response.user);
s.is_loading = false;
});
}
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("registration-failed"));
s.is_loading = false;
});
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Registration failed".to_string());
let error_msg = parse_error_response(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
},
)
};
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();
Arc::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();
spawn_local(async move {
if let Some(refresh_token) = refresh_token_state.get() {
let refresh_data = serde_json::json!({
"refresh_token": refresh_token
});
match reqwasm::http::Request::post("/api/auth/refresh")
.header("Content-Type", "application/json")
.body(refresh_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
if let Ok(json) = response.json::<serde_json::Value>().await {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
set_access_token
.set(Some(auth_response.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",
&auth_response.access_token,
);
}
}
}
}
}
} else {
// Refresh failed, logout user
set_state.update(|s| {
s.user = None;
s.error = Some(i18n.t("session-expired"));
});
}
}
Err(_) => {
// Refresh failed, logout user
set_state.update(|s| {
s.user = None;
s.error = Some(i18n.t("session-expired"));
});
}
}
}
});
})
};
let update_profile_action = {
let set_state = set_state.clone();
let access_token = access_token.clone();
let i18n = i18n.clone();
Arc::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();
spawn_local(async move {
set_state.update(|s| s.is_loading = true);
let update_data = serde_json::json!({
"display_name": display_name,
"first_name": first_name,
"last_name": last_name
});
if let Some(token) = access_token.get() {
match reqwasm::http::Request::put("/api/auth/profile")
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {}", token))
.body(update_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
if let Ok(json) = response.json::<serde_json::Value>().await {
if let Some(data) = json.get("data") {
if let Ok(user) =
serde_json::from_value::<User>(data.clone())
{
set_state.update(|s| {
s.user = Some(user.clone());
s.is_loading = false;
});
// 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);
}
}
}
}
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Profile update failed".to_string());
let error_msg = parse_error_response(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
} else {
set_state.update(|s| {
s.error = Some(i18n.t("invalid-token"));
s.is_loading = false;
});
}
});
},
)
};
let change_password_action = {
let set_state = set_state.clone();
let access_token = access_token.clone();
let i18n = i18n.clone();
Arc::new(move |current_password: String, new_password: String| {
let set_state = set_state.clone();
let access_token = access_token.clone();
let i18n = i18n.clone();
spawn_local(async move {
set_state.update(|s| s.is_loading = true);
let change_data = serde_json::json!({
"current_password": current_password,
"new_password": new_password
});
if let Some(token) = access_token.get() {
match reqwasm::http::Request::post("/api/auth/change-password")
.header("Content-Type", "application/json")
.header("Authorization", &format!("Bearer {}", token))
.body(change_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
set_state.update(|s| {
s.is_loading = false;
});
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Password change failed".to_string());
let error_msg = parse_error_response(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
} else {
set_state.update(|s| {
s.error = Some(i18n.t("invalid-token"));
s.is_loading = false;
});
}
});
})
};
let clear_error_action = {
let set_state = set_state.clone();
Arc::new(move || {
set_state.update(|s| s.error = None);
})
};
let login_with_2fa_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();
Arc::new(move |email: String, code: 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();
spawn_local(async move {
set_state.update(|s| {
s.is_loading = true;
s.error = None;
});
let login_data = serde_json::json!({
"email": email,
"code": code,
"remember_me": remember_me
});
match reqwasm::http::Request::post("/api/auth/login/2fa")
.header("Content-Type", "application/json")
.body(login_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
// Store tokens
set_access_token
.set(Some(auth_response.access_token.clone()));
if let Some(refresh_token) =
&auth_response.refresh_token
{
set_refresh_token.set(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",
&auth_response.access_token,
);
if let Some(refresh_token) =
&auth_response.refresh_token
{
let _ = storage.set_item(
"refresh_token",
refresh_token,
);
}
if let Ok(user_json) =
serde_json::to_string(&auth_response.user)
{
let _ =
storage.set_item("user", &user_json);
}
}
}
set_state.update(|s| {
s.user = Some(auth_response.user);
s.is_loading = false;
s.requires_2fa = false;
s.pending_2fa_email = None;
});
}
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("login-failed"));
s.is_loading = false;
});
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Login failed".to_string());
let error_msg = parse_error_response(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
})
};
let clear_2fa_state_action = {
let set_state = set_state.clone();
Arc::new(move || {
set_state.update(|s| {
s.requires_2fa = false;
s.pending_2fa_email = None;
s.error = None;
});
})
};
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: state.into(),
actions,
};
provide_context(context);
view! {
{children()}
}
}
#[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

@ -1,687 +0,0 @@
use crate::i18n::use_i18n;
use leptos::prelude::*;
use shared::auth::{AuthResponse, User};
use std::rc::Rc;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
#[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: &shared::auth::Role) -> bool {
self.state
.get()
.user
.as_ref()
.map_or(false, |user| user.has_role(role))
}
pub fn has_permission(&self, permission: &shared::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 - only in browser
#[cfg(target_arch = "wasm32")]
create_effect(move |_| {
// Try to load stored tokens and user data
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);
});
}
}
}
}
});
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();
spawn_local(async move {
set_state.update(|s| {
s.is_loading = true;
s.error = None;
});
let login_data = serde_json::json!({
"email": email,
"password": password,
"remember_me": remember_me
});
match reqwasm::http::Request::post("/api/auth/login")
.header("Content-Type", "application/json")
.body(login_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
// Store tokens
set_access_token.update(|t| {
*t = Some(auth_response.access_token.clone())
});
if let Some(refresh_token) =
&auth_response.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",
&auth_response.access_token,
);
if let Some(refresh_token) =
&auth_response.refresh_token
{
let _ = storage.set_item(
"refresh_token",
refresh_token,
);
}
if let Ok(user_json) =
serde_json::to_string(&auth_response.user)
{
let _ =
storage.set_item("user", &user_json);
}
}
}
set_state.update(|s| {
s.user = Some(auth_response.user);
s.is_loading = false;
});
}
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("login-failed"));
s.is_loading = false;
});
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Login failed".to_string());
let error_msg = get_localized_error(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
})
};
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();
spawn_local(async move {
// Call logout endpoint
let _ = reqwasm::http::Request::post("/api/auth/logout")
.header(
"Authorization",
&format!("Bearer {}", access_token.get().unwrap_or_default()),
)
.send()
.await;
// 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");
}
}
});
})
};
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();
spawn_local(async move {
set_state.update(|s| {
s.is_loading = true;
s.error = None;
});
let register_data = serde_json::json!({
"email": email,
"username": username,
"password": password,
"display_name": display_name
});
match reqwasm::http::Request::post("/api/auth/register")
.header("Content-Type", "application/json")
.body(register_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
match response.json::<serde_json::Value>().await {
Ok(json) => {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
// Store tokens and user data similar to login
set_access_token.update(|t| {
*t = Some(auth_response.access_token.clone())
});
if let Some(refresh_token) =
&auth_response.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",
&auth_response.access_token,
);
if let Some(refresh_token) =
&auth_response.refresh_token
{
let _ = storage.set_item(
"refresh_token",
refresh_token,
);
}
if let Ok(user_json) = serde_json::to_string(
&auth_response.user,
) {
let _ = storage
.set_item("user", &user_json);
}
}
}
set_state.update(|s| {
s.user = Some(auth_response.user);
s.is_loading = false;
});
}
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("registration-failed"));
s.is_loading = false;
});
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Registration failed".to_string());
let error_msg = get_localized_error(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
},
)
};
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();
spawn_local(async move {
if let Some(refresh_token) = refresh_token_state.get() {
let refresh_data = serde_json::json!({
"refresh_token": refresh_token
});
match reqwasm::http::Request::post("/api/auth/refresh")
.header("Content-Type", "application/json")
.body(refresh_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
if let Ok(json) = response.json::<serde_json::Value>().await {
if let Some(data) = json.get("data") {
if let Ok(auth_response) =
serde_json::from_value::<AuthResponse>(data.clone())
{
set_access_token.update(|t| {
*t = Some(auth_response.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",
&auth_response.access_token,
);
}
}
}
}
}
} else {
// Refresh failed, logout user
set_state.update(|s| {
s.user = None;
s.error = Some(i18n.t("session-expired"));
});
}
}
Err(_) => {
// Refresh failed, logout user
set_state.update(|s| {
s.user = None;
s.error = Some(i18n.t("session-expired"));
});
}
}
}
});
})
};
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();
spawn_local(async move {
set_state.update(|s| s.is_loading = true);
let update_data = serde_json::json!({
"display_name": display_name,
"first_name": first_name,
"last_name": last_name
});
match reqwasm::http::Request::put("/api/auth/profile")
.header("Content-Type", "application/json")
.header(
"Authorization",
&format!("Bearer {}", access_token.get().unwrap_or_default()),
)
.body(update_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
if let Ok(json) = response.json::<serde_json::Value>().await {
if let Some(data) = json.get("data") {
if let Ok(user) =
serde_json::from_value::<User>(data.clone())
{
set_state.update(|s| {
s.user = Some(user.clone());
s.is_loading = false;
});
// 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);
}
}
}
}
}
}
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Profile update failed".to_string());
let error_msg = get_localized_error(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
},
)
};
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();
spawn_local(async move {
set_state.update(|s| s.is_loading = true);
let change_data = serde_json::json!({
"current_password": current_password,
"new_password": new_password
});
match reqwasm::http::Request::post("/api/auth/change-password")
.header("Content-Type", "application/json")
.header(
"Authorization",
&format!("Bearer {}", access_token.get().unwrap_or_default()),
)
.body(change_data.to_string())
.send()
.await
{
Ok(response) => {
if response.ok() {
set_state.update(|s| {
s.is_loading = false;
});
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Password change failed".to_string());
let error_msg = get_localized_error(&error_text, &i18n);
set_state.update(|s| {
s.error = Some(error_msg);
s.is_loading = false;
});
}
}
Err(_) => {
set_state.update(|s| {
s.error = Some(i18n.t("network-error"));
s.is_loading = false;
});
}
}
});
})
};
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

@ -1,196 +0,0 @@
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 rounded-md p-4 mb-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="text-sm font-medium text-red-800">
{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 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 rounded-lg 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="text-sm 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 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!(
"text-sm text-red-600 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="text-lg font-medium text-gray-900">
{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

@ -1,163 +0,0 @@
use crate::i18n::UseI18n;
use serde_json;
use shared::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

@ -1,254 +0,0 @@
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="bg-white shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-gray-900">{move || i18n_stored.get_value().t("sign-in")}</h2>
<p class="text-gray-600 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 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="text-sm text-red-800">
{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:text-red-600"
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 text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-gray-400 hover:text-gray-600" 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 text-gray-400 hover:text-gray-600" 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 border-gray-300 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 text-sm text-gray-900">
{move || i18n_stored.get_value().t("remember-me")}
</label>
</div>
<div class="text-sm">
<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 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 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 text-white" 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-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"/>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">{move || i18n_stored.get_value().t("continue-with")}</span>
</div>
</div>
<div class="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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 border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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 border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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-6 text-center">
<p class="text-sm text-gray-600">
{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

@ -1,11 +0,0 @@
pub mod context;
pub mod login;
pub mod register;
// pub mod two_factor;
// pub mod two_factor_login;
pub use context::{AuthContext, AuthProvider, AuthState, UseAuth, use_auth};
pub use login::LoginForm;
pub use register::RegisterForm;
// pub use two_factor::TwoFactorSetup;
// pub use two_factor_login::{TwoFactorLoginForm, TwoFactorLoginPage};

View File

@ -1,484 +0,0 @@
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="bg-white shadow-lg rounded-lg p-8">
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-gray-900">{move || i18n_stored.get_value().t("create-account")}</h2>
<p class="text-gray-600 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 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="text-sm text-red-800">
{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:text-red-600"
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 text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-sm text-gray-500">
{move || i18n_stored.get_value().t("username-format")}
</p>
</div>
<div>
<label for="display_name" class="block text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-sm font-medium text-gray-700 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 border-gray-300 rounded-md shadow-sm placeholder-gray-400 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 text-gray-400 hover:text-gray-600" 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 text-gray-400 hover:text-gray-600" 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 text-sm">
<span class="text-gray-600">{move || i18n_stored.get_value().t("password-strength")}</span>
<span class=move || format!("font-medium {}", match password_strength.get().0 {
"Very Weak" => "text-red-600",
"Weak" => "text-orange-600",
"Fair" => "text-yellow-600",
"Good" => "text-blue-600",
"Strong" => "text-green-600",
_ => "text-gray-600",
})>
{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 bg-gray-200 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 text-sm text-gray-500">
{move || i18n_stored.get_value().t("password-requirements")}
</p>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 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 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if confirm_password.get().is_empty() {
"border-gray-300"
} 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 text-gray-400 hover:text-gray-600" 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 text-gray-400 hover:text-gray-600" 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="text-sm text-red-600">{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="text-sm 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 border-gray-300 rounded"
/>
<label for="terms" class="ml-2 block text-sm text-gray-900">
{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 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 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 text-white" 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-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"/>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">{move || i18n_stored.get_value().t("continue-with")}</span>
</div>
</div>
<div class="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
class="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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 border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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 border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
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-6 text-center">
<p class="text-sm text-gray-600">
{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

@ -1,318 +0,0 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use shared::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-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 rounded mb-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 rounded mb-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 rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p class="mt-2 text-gray-600">"Loading 2FA status..."</p>
</div>
}.into_any(),
TwoFactorSetupState::Error => view! {
<div class="text-center py-8">
<p class="text-red-600">"Failed to load 2FA status"</p>
<button
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
on:click=move |_| load_2fa_status.dispatch(())
>
"Retry"
</button>
</div>
}.into_any(),
TwoFactorSetupState::NotEnabled => view! {
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">"Enable Two-Factor Authentication"</h2>
<p class="text-gray-700 mb-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-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
"Current Password"
</label>
<input
type="password"
class="w-full px-3 py-2 border border-gray-300 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 text-white py-2 px-4 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 rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4">"Verify Two-Factor Authentication"</h2>
<div class="mb-6">
<h3 class="text-lg font-medium mb-2">"Step 1: Scan QR Code"</h3>
<p class="text-gray-700 mb-4">
"Scan this QR code with your authenticator app."
</p>
<div class="flex justify-center mb-4">
<img
src=setup_response.qr_code_url.clone()
alt="QR Code for 2FA setup"
class="border border-gray-300 rounded"
/>
</div>
<div class="bg-gray-100 p-3 rounded">
<p class="text-sm text-gray-600 mb-2">"Secret:"</p>
<code class="text-sm font-mono bg-white p-2 rounded border">
{setup_response.secret.clone()}
</code>
</div>
</div>
<div class="mb-6">
<h3 class="text-lg font-medium mb-2">"Step 2: Save Backup Codes"</h3>
<div class="bg-gray-100 p-4 rounded">
<p class="text-sm text-gray-600 mb-2">
"Backup codes: " {setup_response.backup_codes.len().to_string()} " codes generated"
</p>
</div>
</div>
<div class="mb-6">
<h3 class="text-lg font-medium mb-2">"Step 3: Verify Setup"</h3>
<form on:submit=handle_verify_submit>
<div class="mb-4">
<input
type="text"
class="w-full px-3 py-2 border border-gray-300 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 text-white py-2 px-4 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 rounded-lg p-6 mb-6">
<h2 class="text-xl font-semibold mb-4 text-green-800">
"Two-Factor Authentication Enabled"
</h2>
<p class="text-green-700 mb-4">
"Your account is protected with two-factor authentication."
</p>
<div class="mb-4">
<p class="text-sm text-gray-600">
"Backup codes remaining: " {status.backup_codes_remaining.to_string()}
</p>
</div>
<div>
<p class="text-sm text-gray-600">
"Use the API endpoints to manage backup codes and disable 2FA."
</p>
</div>
</div>
}.into_any(),
}}
</div>
}
}

View File

@ -1,246 +0,0 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use shared::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<shared::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 bg-white rounded-lg shadow-md p-6">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-gray-900 mb-2">
"Two-Factor Authentication"
</h1>
<p class="text-gray-600">
"Enter the code from your authenticator app"
</p>
</div>
// Show the email being used
<div class="mb-4 p-3 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600">
"Signing in as: "
<span class="font-medium text-gray-900">{email.clone()}</span>
</p>
</div>
// Error message
{move || {
if let Some(msg) = error_message.get() {
view! {
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{msg}</p>
</div>
}.into_any()
} else {
view! { <div></div> }.into_any()
}
}}
<form on:submit=handle_submit class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center text-lg 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 text-gray-500">
{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="text-sm 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="text-sm text-gray-500 hover:text-gray-700 underline"
on:click=handle_back
>
"Back to login"
</button>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 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-6 text-center">
<p class="text-xs text-gray-500">
"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 bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<TwoFactorLoginForm
email=email
remember_me=remember_me
on_back=handle_back
/>
</div>
}
}

View File

@ -1,25 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn Counter() -> impl IntoView {
eprintln!("Counter rendering");
let (count, set_count) = signal(0);
let on_click_plus = move |_| set_count.update(|c| *c += 1);
let on_click_minus = move |_| set_count.update(|c| *c -= 1);
view! {
<div class="flex justify-center items-center gap-x-6">
<button on:click=on_click_plus class="bg-teal-500 text-white px-4 py-2 rounded-xl">
"Increment: " {move || count.get()}
</button>
<button on:click=on_click_minus class="bg-pink-500 text-white px-4 py-2 rounded-xl">
"Decrement: " {move || count.get()}
</button>
</div>
<div class="mt-10">
<p class="text-center italic dark:text-white">
"Double: " {move || count.get() * 2}
</p>
</div>
}
}

View File

@ -1,128 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn Logo(
#[prop(default = "horizontal".to_string())] orientation: String,
#[prop(default = "normal".to_string())] size: String,
#[prop(default = true)] show_text: bool,
#[prop(default = "".to_string())] class: String,
#[prop(default = false)] dark_theme: bool,
) -> impl IntoView {
let logo_path = move || {
let base_path = "/logos/";
if !show_text {
format!("{}rustelo-imag.svg", base_path)
} else {
match (orientation.as_str(), dark_theme) {
("horizontal", false) => format!("{}rustelo_dev-logo-h.svg", base_path),
("horizontal", true) => format!("{}rustelo_dev-logo-b-h.svg", base_path),
("vertical", false) => format!("{}rustelo_dev-logo-v.svg", base_path),
("vertical", true) => format!("{}rustelo_dev-logo-b-v.svg", base_path),
_ => format!("{}rustelo_dev-logo-h.svg", base_path),
}
}
};
let size_class = match size.as_str() {
"small" => "h-8 w-auto",
"medium" => "h-12 w-auto",
"large" => "h-16 w-auto",
"xlarge" => "h-20 w-auto",
_ => "h-10 w-auto",
};
let combined_class = format!("{} {}", size_class, class);
view! {
<img
src=logo_path
alt="RUSTELO"
class=combined_class
loading="lazy"
/>
}
}
#[component]
pub fn LogoLink(
#[prop(default = "horizontal".to_string())] orientation: String,
#[prop(default = "normal".to_string())] size: String,
#[prop(default = true)] show_text: bool,
#[prop(default = "".to_string())] class: String,
#[prop(default = "/".to_string())] href: String,
#[prop(default = false)] dark_theme: bool,
) -> impl IntoView {
view! {
<a
href=href.clone()
class="inline-block transition-opacity duration-200 hover:opacity-80"
title="RUSTELO - Home"
>
<Logo
orientation=orientation
size=size
show_text=show_text
class=class
dark_theme=dark_theme
/>
</a>
}
}
#[component]
pub fn BrandHeader(
#[prop(default = "RUSTELO".to_string())] title: String,
#[prop(default = "".to_string())] subtitle: String,
#[prop(default = "medium".to_string())] logo_size: String,
#[prop(default = "".to_string())] class: String,
#[prop(default = false)] dark_theme: bool,
) -> impl IntoView {
let base_class = "flex items-center gap-4";
let combined_class = if class.is_empty() {
base_class.to_string()
} else {
format!("{} {}", base_class, class)
};
view! {
<div class=combined_class>
<Logo
orientation="horizontal".to_string()
size=logo_size
show_text=false
class="flex-shrink-0".to_string()
dark_theme=dark_theme
/>
<div class="flex flex-col">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{title}</h1>
{(!subtitle.is_empty()).then(|| view! {
<p class="text-sm text-gray-600 dark:text-gray-400">{subtitle}</p>
})}
</div>
</div>
}
}
#[component]
pub fn NavbarLogo(
#[prop(default = "small".to_string())] size: String,
#[prop(default = "".to_string())] class: String,
#[prop(default = false)] dark_theme: bool,
) -> impl IntoView {
let nav_class = format!(
"font-sans antialiased text-sm text-current ml-2 mr-2 block py-1 font-semibold {}",
class
);
view! {
<LogoLink
orientation="horizontal".to_string()
size=size
show_text=true
class=nav_class
href="/".to_string()
dark_theme=dark_theme
/>
}
}

View File

@ -1,365 +0,0 @@
use crate::i18n::use_i18n;
use crate::pages::admin::{AdminContent, AdminDashboard, AdminRoles, AdminUsers};
use leptos::prelude::*;
#[derive(Clone, Debug, PartialEq)]
pub enum AdminSection {
Dashboard,
Users,
Roles,
Content,
}
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: &crate::i18n::UseI18n) -> 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"
}
}
}
}
#[component]
pub fn AdminLayout(
current_path: ReadSignal<String>,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
let i18n = use_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 bg-gray-50">
<div class="flex">
// Sidebar
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 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 text-white">
"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 border-gray-200">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-white" 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="text-sm font-medium text-gray-900 truncate">
"Admin User"
</p>
<p class="text-xs text-gray-500 truncate">
"admin@example.com"
</p>
</div>
<div class="ml-2">
<button class="text-gray-400 hover:text-gray-600">
<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">
{match current_section.get() {
AdminSection::Dashboard => view! { <AdminDashboard /> }.into_any(),
AdminSection::Users => view! { <AdminUsers /> }.into_any(),
AdminSection::Roles => view! { <AdminRoles /> }.into_any(),
AdminSection::Content => view! { <AdminContent /> }.into_any(),
}}
{children.map(|c| c()).unwrap_or_else(|| view! {}.into_any())}
</main>
</div>
</div>
</div>
}
}
#[component]
fn AdminNavItem(
section: AdminSection,
current_section: Memo<AdminSection>,
i18n: crate::i18n::UseI18n,
) -> impl IntoView {
let section_route = section.route();
let section_icon = section.icon();
let section_title = section.title(&i18n);
let is_current = Memo::new(move |_| current_section.get() == section);
view! {
<a
href=section_route
class=move || {
let base_classes = "group flex items-center px-2 py-2 text-sm font-medium 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!("{} text-gray-600 hover:bg-gray-50 hover:text-gray-900", base_classes)
}
}
>
<svg
class=move || {
let base_classes = "mr-3 flex-shrink-0 h-6 w-6";
if is_current.get() {
format!("{} text-indigo-500", base_classes)
} else {
format!("{} text-gray-400 group-hover:text-gray-500", 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_i18n();
let breadcrumb_items = Memo::new(move |_| {
let pathname = current_path.get();
let mut items = vec![("Admin".to_string(), "/admin".to_string())];
match pathname.as_str() {
"/admin/users" => items.push((
i18n.clone().t("admin.users.title"),
"/admin/users".to_string(),
)),
"/admin/roles" => items.push((
i18n.clone().t("admin.roles.title"),
"/admin/roles".to_string(),
)),
"/admin/content" => items.push((
i18n.clone().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 text-sm font-medium text-gray-500 md:ml-2">
{title}
</span>
}.into_any()
} else {
view! {
<a
href=href
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"
>
{title}
</a>
<svg class="w-6 h-6 text-gray-400 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 title_text = title.unwrap_or_else(|| "Admin".to_string());
let subtitle_text = subtitle.unwrap_or_default();
let has_subtitle = !subtitle_text.is_empty();
view! {
<div class="bg-white 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:border-gray-200">
<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 text-gray-900 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="text-sm text-gray-500 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!(
"bg-white overflow-hidden shadow rounded-lg {}",
class_str
)>
<Show when=move || has_title>
<div class="px-4 py-5 sm:p-6 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{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 icon_str = icon.unwrap_or_default();
let title_str = title.unwrap_or_else(|| "No items".to_string());
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 text-gray-400" 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 text-sm font-medium text-gray-900">
{title_str}
</h3>
<Show when=move || has_description>
<p class="mt-1 text-sm text-gray-500">
{description_str.clone()}
</p>
</Show>
<div class="mt-6">
{action.map(|a| a()).unwrap_or_else(|| view! {}.into_any())}
</div>
</div>
}
}

View File

@ -1,3 +0,0 @@
#[allow(non_snake_case)]
pub mod AdminLayout;
pub use AdminLayout::*;

View File

@ -1,253 +0,0 @@
use leptos::prelude::*;
/// Example component showcasing DaisyUI components
#[component]
pub fn DaisyExample() -> impl IntoView {
let (count, set_count) = signal(0);
let (modal_open, set_modal_open) = signal(false);
view! {
<div class="container mx-auto p-6">
<h1 class="text-4xl font-bold text-center mb-8">"DaisyUI Components Example"</h1>
<DaisyButtons/>
<DaisyCards/>
<DaisyForms count=count set_count=set_count/>
<DaisyAlerts/>
<DaisyBadges/>
<DaisyModal modal_open=modal_open set_modal_open=set_modal_open/>
<DaisyProgress/>
<DaisyTabs/>
<DaisyLoading/>
</div>
}
}
#[component]
fn DaisyButtons() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Buttons"</h2>
<div class="flex flex-wrap gap-2">
<button class="btn">"Default"</button>
<button class="btn btn-primary">"Primary"</button>
<button class="btn btn-secondary">"Secondary"</button>
<button class="btn btn-accent">"Accent"</button>
<button class="btn btn-info">"Info"</button>
<button class="btn btn-success">"Success"</button>
<button class="btn btn-warning">"Warning"</button>
<button class="btn btn-error">"Error"</button>
</div>
</div>
}
}
#[component]
fn DaisyCards() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Cards"</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">"Card Title"</h2>
<p>"This is a simple card with some content."</p>
<div class="card-actions justify-end">
<button class="btn btn-primary">"Action"</button>
</div>
</div>
</div>
<div class="card bg-primary text-primary-content">
<div class="card-body">
<h2 class="card-title">"Colored Card"</h2>
<p>"This card has a primary color background."</p>
<div class="card-actions justify-end">
<button class="btn">"Action"</button>
</div>
</div>
</div>
</div>
</div>
}
}
#[component]
fn DaisyForms(count: ReadSignal<i32>, set_count: WriteSignal<i32>) -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Forms & Interactive Counter"</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title">"Form Elements"</h3>
<div class="form-control w-full max-w-xs">
<label class="label">
<span class="label-text">"What is your name?"</span>
</label>
<input type="text" placeholder="Type here" class="input input-bordered w-full max-w-xs" />
</div>
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">"Remember me"</span>
<input type="checkbox" class="checkbox" />
</label>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title">"Interactive Counter"</h3>
<div class="text-center">
<div class="text-6xl font-bold text-primary mb-4">
{move || count.get()}
</div>
<div class="flex justify-center gap-2">
<button
class="btn btn-primary"
on:click=move |_| set_count.update(|c| *c += 1)
>
"+"
</button>
<button
class="btn btn-secondary"
on:click=move |_| set_count.update(|c| *c -= 1)
>
"-"
</button>
<button
class="btn btn-accent"
on:click=move |_| set_count.set(0)
>
"Reset"
</button>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
#[component]
fn DaisyAlerts() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Alerts"</h2>
<div class="space-y-4">
<div class="alert alert-info">
<span>"New software update available."</span>
</div>
<div class="alert alert-success">
<span>"Your purchase has been confirmed!"</span>
</div>
<div class="alert alert-warning">
<span>"Warning: Invalid email address!"</span>
</div>
<div class="alert alert-error">
<span>"Error! Task failed successfully."</span>
</div>
</div>
</div>
}
}
#[component]
fn DaisyBadges() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Badges"</h2>
<div class="flex flex-wrap gap-2">
<div class="badge">"default"</div>
<div class="badge badge-primary">"primary"</div>
<div class="badge badge-secondary">"secondary"</div>
<div class="badge badge-accent">"accent"</div>
<div class="badge badge-info">"info"</div>
<div class="badge badge-success">"success"</div>
<div class="badge badge-warning">"warning"</div>
<div class="badge badge-error">"error"</div>
</div>
</div>
}
}
#[component]
fn DaisyModal(modal_open: ReadSignal<bool>, set_modal_open: WriteSignal<bool>) -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Modal"</h2>
<button
class="btn btn-primary"
on:click=move |_| set_modal_open.set(true)
>
"Open Modal"
</button>
<div class=move || format!("modal {}", if modal_open.get() { "modal-open" } else { "" })>
<div class="modal-box">
<h3 class="font-bold text-lg">"Hello there!"</h3>
<p class="py-4">"This is a modal dialog box created with DaisyUI."</p>
<div class="modal-action">
<button
class="btn btn-primary"
on:click=move |_| set_modal_open.set(false)
>
"Close"
</button>
</div>
</div>
</div>
</div>
}
}
#[component]
fn DaisyProgress() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Progress"</h2>
<div class="space-y-4">
<progress class="progress w-56" value="0" max="100"></progress>
<progress class="progress progress-primary w-56" value="25" max="100"></progress>
<progress class="progress progress-secondary w-56" value="50" max="100"></progress>
<progress class="progress progress-accent w-56" value="75" max="100"></progress>
<progress class="progress progress-success w-56" value="100" max="100"></progress>
</div>
</div>
}
}
#[component]
fn DaisyTabs() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Tabs"</h2>
<div class="tabs">
<a class="tab tab-lifted tab-active">"Tab 1"</a>
<a class="tab tab-lifted">"Tab 2"</a>
<a class="tab tab-lifted">"Tab 3"</a>
</div>
</div>
}
}
#[component]
fn DaisyLoading() -> impl IntoView {
view! {
<div class="mb-8">
<h2 class="text-2xl font-semibold mb-4">"Loading"</h2>
<div class="flex flex-wrap gap-4">
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-sm"></span>
<span class="loading loading-spinner loading-md"></span>
<span class="loading loading-spinner loading-lg"></span>
</div>
<div class="flex flex-wrap gap-4 mt-4">
<span class="loading loading-dots loading-xs"></span>
<span class="loading loading-dots loading-sm"></span>
<span class="loading loading-dots loading-md"></span>
<span class="loading loading-dots loading-lg"></span>
</div>
</div>
}
}

View File

@ -1,477 +0,0 @@
//! Contact form component
//!
//! This component provides a user-friendly contact form with validation,
//! error handling, and success feedback using Leptos reactive primitives.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
use web_sys::{Event, HtmlInputElement, HtmlTextAreaElement};
/// Safely extract value from input element
fn extract_input_value(event: &Event) -> Option<String> {
event
.target()
.and_then(|t| t.dyn_into::<HtmlInputElement>().ok())
.map(|input| input.value())
}
/// Safely extract value from textarea element
fn extract_textarea_value(event: &Event) -> Option<String> {
event
.target()
.and_then(|t| t.dyn_into::<HtmlTextAreaElement>().ok())
.map(|textarea| textarea.value())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactFormData {
pub name: String,
pub email: String,
pub subject: String,
pub message: String,
pub recipient: Option<String>,
}
impl Default for ContactFormData {
fn default() -> Self {
Self {
name: String::new(),
email: String::new(),
subject: String::new(),
message: String::new(),
recipient: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactFormResponse {
pub message: String,
pub message_id: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContactFormError {
pub error: String,
pub message: String,
pub code: String,
}
#[derive(Debug, Clone)]
pub enum FormState {
Idle,
Submitting,
Success(ContactFormResponse),
Error(String),
}
#[component]
pub fn ContactForm(
/// Optional recipient email address
#[prop(optional)]
recipient: Option<String>,
/// Form title
#[prop(optional)]
title: Option<String>,
/// Form description
#[prop(optional)]
description: Option<String>,
/// Custom CSS class
#[prop(optional)]
class: Option<String>,
/// Show success message after submission
#[prop(default = true)]
show_success: bool,
/// Reset form after successful submission
#[prop(default = true)]
reset_after_success: bool,
/// Custom submit button text
#[prop(optional)]
submit_text: Option<String>,
) -> impl IntoView {
let (form_data, set_form_data) = signal(ContactFormData::default());
let (form_state, set_form_state) = signal(FormState::Idle);
let (validation_errors, set_validation_errors) = signal(HashMap::<String, String>::new());
// Set recipient if provided
if let Some(recipient_email) = recipient {
set_form_data.update(|data| data.recipient = Some(recipient_email));
}
// Validation functions
let validate_email =
|email: &str| -> bool { email.contains('@') && email.len() > 5 && email.len() < 255 };
let validate_required = |value: &str| -> bool { !value.trim().is_empty() };
let validate_length = |value: &str, max: usize| -> bool { value.len() <= max };
// Input handlers
let on_name_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.name = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("name");
});
}
};
let on_email_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.email = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("email");
});
}
};
let on_subject_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.subject = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("subject");
});
}
};
let on_message_input = move |ev: Event| {
if let Some(value) = extract_textarea_value(&ev) {
set_form_data.update(|data| data.message = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("message");
});
}
};
// Form validation
let validate_form = move |data: &ContactFormData| -> HashMap<String, String> {
let mut errors = HashMap::new();
if !validate_required(&data.name) {
errors.insert("name".to_string(), "Name is required".to_string());
} else if !validate_length(&data.name, 100) {
errors.insert(
"name".to_string(),
"Name must be less than 100 characters".to_string(),
);
}
if !validate_required(&data.email) {
errors.insert("email".to_string(), "Email is required".to_string());
} else if !validate_email(&data.email) {
errors.insert(
"email".to_string(),
"Please enter a valid email address".to_string(),
);
}
if !validate_required(&data.subject) {
errors.insert("subject".to_string(), "Subject is required".to_string());
} else if !validate_length(&data.subject, 200) {
errors.insert(
"subject".to_string(),
"Subject must be less than 200 characters".to_string(),
);
}
if !validate_required(&data.message) {
errors.insert("message".to_string(), "Message is required".to_string());
} else if !validate_length(&data.message, 5000) {
errors.insert(
"message".to_string(),
"Message must be less than 5000 characters".to_string(),
);
}
errors
};
// Form submission
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let data = form_data.get();
let errors = validate_form(&data);
if !errors.is_empty() {
set_validation_errors.set(errors);
return;
}
// Clear validation errors
set_validation_errors.set(HashMap::new());
set_form_state.set(FormState::Submitting);
// Submit the form
spawn_local(async move {
let body = match serde_json::to_string(&data) {
Ok(json) => json,
Err(_) => {
set_form_state.set(FormState::Error(
"Failed to serialize form data".to_string(),
));
return;
}
};
let client = reqwasm::http::Request::post("/api/email/contact")
.header("Content-Type", "application/json")
.body(body);
let response = client.send().await;
match response {
Ok(resp) => {
if resp.status() == 200 {
match resp.json::<ContactFormResponse>().await {
Ok(success_response) => {
set_form_state.set(FormState::Success(success_response));
if reset_after_success {
set_form_data.set(ContactFormData::default());
}
}
Err(e) => {
set_form_state.set(FormState::Error(format!(
"Failed to parse response: {}",
e
)));
}
}
} else {
match resp.json::<ContactFormError>().await {
Ok(error_response) => {
set_form_state.set(FormState::Error(error_response.message));
}
Err(_) => {
set_form_state.set(FormState::Error(format!(
"Server error: {}",
resp.status()
)));
}
}
}
}
Err(e) => {
set_form_state.set(FormState::Error(format!("Network error: {}", e)));
}
}
});
};
// Helper to get field error
let get_field_error = move |field: &'static str| -> Option<String> {
validation_errors.get().get(field).cloned()
};
// Helper to check if field has error
let has_field_error =
move |field: &'static str| -> bool { validation_errors.get().contains_key(field) };
view! {
<div class={format!("contact-form {}", class.unwrap_or_default())}>
{title.map(|t| view! {
<div class="form-header mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">{t}</h2>
{description.map(|d| view! {
<p class="text-gray-600">{d}</p>
})}
</div>
})}
<form on:submit=on_submit class="space-y-6">
// Name field
<div class="form-group">
<label
for="contact-name"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Name"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="text"
id="contact-name"
name="name"
value={move || form_data.get().name}
on:input=on_name_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("name") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="Your full name"
required
/>
{move || get_field_error("name").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Email field
<div class="form-group">
<label
for="contact-email"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Email"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="email"
id="contact-email"
name="email"
value={move || form_data.get().email}
on:input=on_email_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("email") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="your.email@example.com"
required
/>
{move || get_field_error("email").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Subject field
<div class="form-group">
<label
for="contact-subject"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Subject"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="text"
id="contact-subject"
name="subject"
value={move || form_data.get().subject}
on:input=on_subject_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("subject") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="What is this about?"
required
/>
{move || get_field_error("subject").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Message field
<div class="form-group">
<label
for="contact-message"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Message"
<span class="text-red-500 ml-1">*</span>
</label>
<textarea
id="contact-message"
name="message"
rows="6"
prop:value={move || form_data.get().message}
on:input=on_message_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("message") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="Please describe your message in detail..."
required
/>
{move || get_field_error("message").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Submit button
<div class="form-group">
<button
type="submit"
disabled={move || matches!(form_state.get(), FormState::Submitting)}
class={move || format!(
"w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}",
if matches!(form_state.get(), FormState::Submitting) {
"bg-gray-400 cursor-not-allowed"
} else {
"bg-blue-600 hover:bg-blue-700"
}
)}
>
{move || match form_state.get() {
FormState::Submitting => "Sending...".to_string(),
_ => submit_text.clone().unwrap_or_else(|| "Send Message".to_string()),
}}
</button>
</div>
// Status messages
{move || match form_state.get() {
FormState::Success(response) if show_success => Some(view! {
<div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
"Message sent successfully!"
</p>
<p class="mt-1 text-sm text-green-700">
{response.message}
</p>
</div>
</div>
</div>
}),
FormState::Error(error) => Some(view! {
<div class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<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="text-sm font-medium text-red-800">
"Failed to send message"
</p>
<p class="mt-1 text-sm text-red-700">
{error}
</p>
</div>
</div>
</div>
}),
_ => None,
}}
</form>
</div>
}
}

View File

@ -1,17 +0,0 @@
//! Form components module
//!
//! This module provides reusable form components for the client application,
//! including contact forms, support forms, and other interactive forms.
pub mod contact_form;
pub mod support_form;
pub use contact_form::{ContactForm, ContactFormData, ContactFormError, ContactFormResponse};
pub use support_form::{
CategoryOption, PriorityOption, SupportForm, SupportFormData, SupportFormError,
SupportFormResponse,
};
// Re-export common form utilities
pub use contact_form::FormState as ContactFormState;
pub use support_form::FormState as SupportFormState;

View File

@ -1,699 +0,0 @@
//! Support form component
//!
//! This component provides a user-friendly support form with validation,
//! priority levels, categories, and enhanced error handling using Leptos.
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
use web_sys::{Event, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement};
/// Safely extract value from input element
fn extract_input_value(event: &Event) -> Option<String> {
event
.target()
.and_then(|t| t.dyn_into::<HtmlInputElement>().ok())
.map(|input| input.value())
}
/// Safely extract value from textarea element
fn extract_textarea_value(event: &Event) -> Option<String> {
event
.target()
.and_then(|t| t.dyn_into::<HtmlTextAreaElement>().ok())
.map(|textarea| textarea.value())
}
/// Safely extract value from select element
fn extract_select_value(event: &Event) -> Option<String> {
event
.target()
.and_then(|t| t.dyn_into::<HtmlSelectElement>().ok())
.map(|select| select.value())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportFormData {
pub name: String,
pub email: String,
pub subject: String,
pub message: String,
pub priority: Option<String>,
pub category: Option<String>,
pub recipient: Option<String>,
}
impl Default for SupportFormData {
fn default() -> Self {
Self {
name: String::new(),
email: String::new(),
subject: String::new(),
message: String::new(),
priority: Some("normal".to_string()),
category: None,
recipient: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportFormResponse {
pub message: String,
pub message_id: String,
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportFormError {
pub error: String,
pub message: String,
pub code: String,
}
#[derive(Debug, Clone)]
pub enum FormState {
Idle,
Submitting,
Success(SupportFormResponse),
Error(String),
}
#[derive(Debug, Clone)]
pub struct PriorityOption {
pub value: String,
pub label: String,
pub color: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct CategoryOption {
pub value: String,
pub label: String,
pub icon: String,
pub description: String,
}
#[component]
pub fn SupportForm(
/// Optional recipient email address
#[prop(optional)]
recipient: Option<String>,
/// Form title
#[prop(optional)]
title: Option<String>,
/// Form description
#[prop(optional)]
description: Option<String>,
/// Custom CSS class
#[prop(optional)]
class: Option<String>,
/// Show success message after submission
#[prop(default = true)]
show_success: bool,
/// Reset form after successful submission
#[prop(default = true)]
reset_after_success: bool,
/// Custom submit button text
#[prop(optional)]
submit_text: Option<String>,
/// Show priority field
#[prop(default = true)]
show_priority: bool,
/// Show category field
#[prop(default = true)]
show_category: bool,
/// Available categories
#[prop(optional)]
categories: Option<Vec<CategoryOption>>,
) -> impl IntoView {
let (form_data, set_form_data) = signal(SupportFormData::default());
let (form_state, set_form_state) = signal(FormState::Idle);
let (validation_errors, set_validation_errors) = signal(HashMap::<String, String>::new());
// Set recipient if provided
if let Some(recipient_email) = recipient {
set_form_data.update(|data| data.recipient = Some(recipient_email));
}
// Default priorities
let priority_options = vec![
PriorityOption {
value: "low".to_string(),
label: "Low".to_string(),
color: "text-green-600".to_string(),
description: "General questions or non-urgent requests".to_string(),
},
PriorityOption {
value: "normal".to_string(),
label: "Normal".to_string(),
color: "text-blue-600".to_string(),
description: "Standard support requests".to_string(),
},
PriorityOption {
value: "high".to_string(),
label: "High".to_string(),
color: "text-orange-600".to_string(),
description: "Important issues affecting functionality".to_string(),
},
PriorityOption {
value: "urgent".to_string(),
label: "Urgent".to_string(),
color: "text-red-600".to_string(),
description: "Critical issues requiring immediate attention".to_string(),
},
];
// Default categories
let default_categories = vec![
CategoryOption {
value: "technical".to_string(),
label: "Technical Support".to_string(),
icon: "🔧".to_string(),
description: "Technical issues, bugs, or system problems".to_string(),
},
CategoryOption {
value: "billing".to_string(),
label: "Billing & Payments".to_string(),
icon: "💳".to_string(),
description: "Questions about billing, payments, or subscriptions".to_string(),
},
CategoryOption {
value: "account".to_string(),
label: "Account Management".to_string(),
icon: "👤".to_string(),
description: "Account settings, password, or profile issues".to_string(),
},
CategoryOption {
value: "feature".to_string(),
label: "Feature Request".to_string(),
icon: "".to_string(),
description: "Suggestions for new features or improvements".to_string(),
},
CategoryOption {
value: "general".to_string(),
label: "General Inquiry".to_string(),
icon: "💬".to_string(),
description: "General questions or other inquiries".to_string(),
},
];
let _category_options = categories.unwrap_or(default_categories);
// Validation functions
let validate_email =
|email: &str| -> bool { email.contains('@') && email.len() > 5 && email.len() < 255 };
let validate_required = |value: &str| -> bool { !value.trim().is_empty() };
let validate_length = |value: &str, max: usize| -> bool { value.len() <= max };
// Input handlers
let on_name_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.name = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("name");
});
}
};
let on_email_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.email = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("email");
});
}
};
let on_subject_input = move |ev: Event| {
if let Some(value) = extract_input_value(&ev) {
set_form_data.update(|data| data.subject = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("subject");
});
}
};
let on_message_input = move |ev: Event| {
if let Some(value) = extract_textarea_value(&ev) {
set_form_data.update(|data| data.message = value);
// Clear validation error when user starts typing
set_validation_errors.update(|errors| {
errors.remove("message");
});
}
};
let on_priority_change = move |ev: Event| {
if let Some(value) = extract_select_value(&ev) {
set_form_data.update(|data| {
data.priority = if value.is_empty() { None } else { Some(value) };
});
}
};
let on_category_change = move |ev: Event| {
if let Some(value) = extract_select_value(&ev) {
set_form_data.update(|data| {
data.category = if value.is_empty() { None } else { Some(value) };
});
}
};
// Form validation
let validate_form = move |data: &SupportFormData| -> HashMap<String, String> {
let mut errors = HashMap::new();
if !validate_required(&data.name) {
errors.insert("name".to_string(), "Name is required".to_string());
} else if !validate_length(&data.name, 100) {
errors.insert(
"name".to_string(),
"Name must be less than 100 characters".to_string(),
);
}
if !validate_required(&data.email) {
errors.insert("email".to_string(), "Email is required".to_string());
} else if !validate_email(&data.email) {
errors.insert(
"email".to_string(),
"Please enter a valid email address".to_string(),
);
}
if !validate_required(&data.subject) {
errors.insert("subject".to_string(), "Subject is required".to_string());
} else if !validate_length(&data.subject, 200) {
errors.insert(
"subject".to_string(),
"Subject must be less than 200 characters".to_string(),
);
}
if !validate_required(&data.message) {
errors.insert("message".to_string(), "Message is required".to_string());
} else if !validate_length(&data.message, 5000) {
errors.insert(
"message".to_string(),
"Message must be less than 5000 characters".to_string(),
);
}
errors
};
// Form submission
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let data = form_data.get();
let errors = validate_form(&data);
if !errors.is_empty() {
set_validation_errors.set(errors);
return;
}
// Clear validation errors
set_validation_errors.set(HashMap::new());
set_form_state.set(FormState::Submitting);
// Submit the form
spawn_local(async move {
let body = match serde_json::to_string(&data) {
Ok(json) => json,
Err(_) => {
set_form_state.set(FormState::Error(
"Failed to serialize form data".to_string(),
));
return;
}
};
let client = reqwasm::http::Request::post("/api/email/support")
.header("Content-Type", "application/json")
.body(body);
let response = client.send().await;
match response {
Ok(resp) => {
if resp.status() == 200 {
match resp.json::<SupportFormResponse>().await {
Ok(success_response) => {
set_form_state.set(FormState::Success(success_response));
if reset_after_success {
set_form_data.set(SupportFormData::default());
}
}
Err(e) => {
set_form_state.set(FormState::Error(format!(
"Failed to parse response: {}",
e
)));
}
}
} else {
match resp.json::<SupportFormError>().await {
Ok(error_response) => {
set_form_state.set(FormState::Error(error_response.message));
}
Err(_) => {
set_form_state.set(FormState::Error(format!(
"Server error: {}",
resp.status()
)));
}
}
}
}
Err(e) => {
set_form_state.set(FormState::Error(format!("Network error: {}", e)));
}
}
});
};
// Helper to get field error
let get_field_error = move |field: &'static str| -> Option<String> {
validation_errors.get().get(field).cloned()
};
// Helper to check if field has error
let has_field_error =
move |field: &'static str| -> bool { validation_errors.get().contains_key(field) };
// Get priority color
let get_priority_color = move || {
let current_priority = form_data.get().priority.unwrap_or_default();
priority_options
.iter()
.find(|p| p.value == current_priority)
.map(|p| p.color.clone())
.unwrap_or_else(|| "text-gray-600".to_string())
};
view! {
<div class={format!("support-form {}", class.unwrap_or_default())}>
{title.map(|t| view! {
<div class="form-header mb-6">
<h2 class="text-2xl font-bold text-gray-900 mb-2">{t}</h2>
{description.map(|d| view! {
<p class="text-gray-600">{d}</p>
})}
</div>
})}
<form on:submit=on_submit class="space-y-6">
// Name field
<div class="form-group">
<label
for="support-name"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Name"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="text"
id="support-name"
name="name"
value={move || form_data.get().name}
on:input=on_name_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("name") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="Your full name"
required
/>
{move || get_field_error("name").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Email field
<div class="form-group">
<label
for="support-email"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Email"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="email"
id="support-email"
name="email"
value={move || form_data.get().email}
on:input=on_email_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("email") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="your.email@example.com"
required
/>
{move || get_field_error("email").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Priority field
{if show_priority {
Some(view! {
<div class="form-group">
<label
for="support-priority"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Priority"
</label>
<select
id="support-priority"
name="priority"
prop:value={move || form_data.get().priority.clone().unwrap_or_default()}
on:change=on_priority_change
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="low">"Low - General questions or non-urgent requests"</option>
<option value="normal">"Normal - Standard support requests"</option>
<option value="high">"High - Important issues affecting functionality"</option>
<option value="urgent">"Urgent - Critical issues requiring immediate attention"</option>
</select>
<p class={move || format!("mt-1 text-sm {}", get_priority_color())}>
{move || {
let current_priority = form_data.get().priority.unwrap_or_default();
match current_priority.as_str() {
"low" => "General questions or non-urgent requests",
"normal" => "Standard support requests",
"high" => "Important issues affecting functionality",
"urgent" => "Critical issues requiring immediate attention",
_ => "Select a priority level",
}
}}
</p>
</div>
})
} else {
None
}}
// Category field
{if show_category {
Some(view! {
<div class="form-group">
<label
for="support-category"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Category"
</label>
<select
id="support-category"
name="category"
prop:value={move || form_data.get().category.clone().unwrap_or_default()}
on:change=on_category_change
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">"Select a category"</option>
<option value="technical">"🔧 Technical"</option>
<option value="billing">"💳 Billing"</option>
<option value="feature">"✨ Feature Request"</option>
<option value="bug">"🐛 Bug Report"</option>
<option value="account">"👤 Account"</option>
<option value="other">"📋 Other"</option>
</select>
<p class="mt-1 text-sm text-gray-600">
{move || {
let current_category = form_data.get().category.unwrap_or_default();
match current_category.as_str() {
"technical" => "Technical issues, bugs, or troubleshooting",
"billing" => "Billing, payments, or subscription questions",
"feature" => "Suggestions for new features or improvements",
"bug" => "Report bugs or unexpected behavior",
"account" => "Account settings, profile, or access issues",
"other" => "General questions or other requests",
_ => "Select the category that best describes your request",
}
}}
</p>
</div>
})
} else {
None
}}
// Subject field
<div class="form-group">
<label
for="support-subject"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Subject"
<span class="text-red-500 ml-1">*</span>
</label>
<input
type="text"
id="support-subject"
name="subject"
value={move || form_data.get().subject}
on:input=on_subject_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("subject") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="Brief description of your issue"
required
/>
{move || get_field_error("subject").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
</div>
// Message field
<div class="form-group">
<label
for="support-message"
class="block text-sm font-medium text-gray-700 mb-2"
>
"Detailed Description"
<span class="text-red-500 ml-1">*</span>
</label>
<textarea
id="support-message"
name="message"
rows="8"
prop:value={move || form_data.get().message}
on:input=on_message_input
class={move || format!(
"w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 {}",
if has_field_error("message") { "border-red-500" } else { "border-gray-300" }
)}
placeholder="Please provide as much detail as possible about your issue or request. Include any error messages, steps to reproduce, or relevant information..."
required
/>
{move || get_field_error("message").map(|error| view! {
<p class="mt-1 text-sm text-red-600">{error}</p>
})}
<p class="mt-1 text-sm text-gray-500">
"The more details you provide, the better we can assist you."
</p>
</div>
// Submit button
<div class="form-group">
<button
type="submit"
disabled={move || matches!(form_state.get(), FormState::Submitting)}
class={move || format!(
"w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}",
if matches!(form_state.get(), FormState::Submitting) {
"bg-gray-400 cursor-not-allowed"
} else {
"bg-blue-600 hover:bg-blue-700"
}
)}
>
{move || match form_state.get() {
FormState::Submitting => "Submitting Support Request...".to_string(),
_ => submit_text.clone().unwrap_or_else(|| "Submit Support Request".to_string()),
}}
</button>
</div>
// Status messages
{move || match form_state.get() {
FormState::Success(response) if show_success => Some(view! {
<div class="mt-4 p-4 bg-green-50 border border-green-200 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">
"Support request submitted successfully!"
</p>
<p class="mt-1 text-sm text-green-700">
{response.message}
</p>
<p class="mt-1 text-sm text-green-600">
"We'll get back to you as soon as possible."
</p>
</div>
</div>
</div>
}),
FormState::Error(error) => Some(view! {
<div class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<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="text-sm font-medium text-red-800">
"Failed to submit support request"
</p>
<p class="mt-1 text-sm text-red-700">
{error}
</p>
<p class="mt-1 text-sm text-red-600">
"Please try again or contact support directly."
</p>
</div>
</div>
</div>
}),
_ => None,
}}
</form>
</div>
}
}

View File

@ -1,15 +0,0 @@
#[allow(non_snake_case)]
pub mod Counter;
pub mod admin;
#[allow(non_snake_case)]
pub mod daisy_example;
pub mod forms;
pub mod logo;
pub mod navmenu;
pub use Counter::Counter;
pub use admin::*;
pub use daisy_example::DaisyExample;
pub use forms::{ContactForm, SupportForm};
pub use logo::{BrandHeader, Logo, LogoLink, NavbarLogo};
pub use navmenu::NavMenu;

View File

@ -1,226 +0,0 @@
use crate::components::NavbarLogo;
use crate::i18n::{DarkModeToggle, LanguageSelector, use_i18n};
use crate::utils::{make_navigate, make_on_link_click};
use leptos::prelude::*;
use shared::load_menu_toml;
#[component]
pub fn NavMenu(set_path: WriteSignal<String>) -> impl IntoView {
let navigate = make_navigate(set_path.clone());
let on_link_click = make_on_link_click(set_path.clone(), navigate.clone());
let i18n = use_i18n();
let menu_items = load_menu_toml().unwrap_or_default();
// Mobile menu toggle state
let (is_mobile_menu_open, set_mobile_menu_open) = signal(false);
let toggle_mobile_menu = move |_| {
set_mobile_menu_open.update(|open| *open = !*open);
};
view! {
// <nav class="rounded-lg border shadow-lg overflow-hidden p-2 bg-white border-stone-200 shadow-stone-950/5 mx-auto w-full max-w-screen-xl">
<nav class="rounded-lg border bg-white dark:bg-gray-800 border-stone-200 dark:border-gray-700 mx-auto w-full max-w-screen-xl">
<div class="flex items-center">
<NavbarLogo size="small".to_string() />
<hr class="ml-1 mr-1.5 hidden h-5 w-px border-l border-t-0 border-gray-300 lg:block" />
<div class="hidden lg:block">
<ul class="list-none mt-4 flex flex-col gap-x-3 gap-y-1.5 lg:mt-0 lg:flex-row lg:items-center">
{menu_items.menu.iter().map(|item| {
let on_link_click = on_link_click.clone();
let route = item.route.clone();
let route_for_click = route.clone();
let i18n_clone = i18n.clone();
let is_external = item.is_external;
let item_clone = item.clone();
if is_external {
view! {
<li>
<a
href={route.clone()}
class="no-underline font-sans antialiased text-sm text-current dark:text-gray-200 flex items-center gap-x-2 p-1 mt-2 hover:text-primary dark:hover:text-blue-400"
>
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
{move || {
let lang_val = i18n_clone.lang_code();
match lang_val.as_str() {
"es" => item_clone.label.es.clone(),
_ => item_clone.label.en.clone(),
}
}}
</a>
</li>
}.into_any()
} else {
view! {
<li>
<a
href={route.clone()}
on:click=move |ev| on_link_click(ev, &route_for_click)
class="no-underline font-sans antialiased text-sm text-current dark:text-gray-200 flex items-center gap-x-2 p-1 mt-2 hover:text-primary dark:hover:text-blue-400"
>
{move || {
let lang_val = i18n_clone.lang_code();
match lang_val.as_str() {
"es" => item_clone.label.es.clone(),
_ => item_clone.label.en.clone(),
}
}}
</a>
</li>
}.into_any()
}
}).collect_view()}
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4.271 18.3457C4.271 18.3457 6.50002 15.5 12 15.5C17.5 15.5 19.7291 18.3457 19.7291 18.3457" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 12C13.6569 12 15 10.6569 15 9C15 7.34315 13.6569 6 12 6C10.3431 6 9 7.34315 9 9C9 10.6569 10.3431 12 12 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>Account</a>
// </li>
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M11.6914 11.8285L3.89139 7.49521C3.49147 7.27304 3 7.56222 3 8.01971V16.647C3 16.8649 3.11813 17.0656 3.30861 17.1715L11.1086 21.5048C11.5085 21.727 12 21.4378 12 20.9803V12.353C12 12.1351 11.8819 11.9344 11.6914 11.8285Z" fill="currentColor" stroke="currentColor" stroke-linejoin="round"></path></svg>Blocks</a>
// </li>
// <li>
// <a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary"><svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4"><path d="M7 6L17 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7 9L17 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 17H15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21" stroke="currentColor"></path></svg>Docs</a>
// </li>
</ul>
</div>
<div class="ml-auto flex items-center space-x-2">
<DarkModeToggle />
<LanguageSelector />
<div class="w-40">
<div class="relative w-full">
<input placeholder="Search here..." type="search" class="w-full aria-disabled:cursor-not-allowed outline-none focus:outline-none text-stone-800 dark:text-white placeholder:text-stone-600/60 dark:placeholder:text-gray-400 ring-transparent border border-stone-200 dark:border-gray-600 transition-all ease-in disabled:opacity-50 disabled:pointer-events-none select-none text-sm py-1.5 pl-8 pr-2 ring shadow-sm bg-white dark:bg-gray-700 rounded-lg duration-100 hover:border-stone-300 dark:hover:border-gray-500 hover:ring-none focus:border-stone-400 dark:focus:border-blue-500 focus:ring-none peer" />
<span class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-stone-600/70 peer-focus:text-stone-800 peer-focus:text-stone-800 dark:peer-hover:text-white dark:peer-focus:text-white transition-all duration-300 ease-in overflow-hidden w-4 h-4"><svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-full w-full"><path d="M17 17L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</span>
</div>
</div>
</div>
<button
on:click=toggle_mobile_menu
aria-expanded=move || is_mobile_menu_open.get_untracked().to_string()
aria-controls="navbar-collapse-search"
class="place-items-center 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 text-sm min-w-[34px] min-h-[34px] rounded-md bg-transparent border-transparent text-stone-800 dark:text-gray-200 hover:bg-stone-800/5 dark:hover:bg-gray-700/50 hover:border-stone-800/5 dark:hover:border-gray-600 shadow-none hover:shadow-none ml-1 grid lg:hidden"
>
<svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M3 5H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 12H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 19H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div
class=move || format!("overflow-hidden transition-[max-height] duration-300 ease-in-out lg:hidden {}",
if is_mobile_menu_open.get_untracked() { "max-h-96" } else { "max-h-0" }
)
id="navbar-collapse-search"
>
<ul class="flex flex-col gap-0.5 mt-2">
{menu_items.menu.iter().map(|item| {
let on_link_click = on_link_click.clone();
let route = item.route.clone();
let route_for_click = route.clone();
let i18n_mobile = i18n.clone();
let is_external = item.is_external;
let item_mobile = item.clone();
let click_item = move |ev| {
on_link_click(ev, Box::leak(route_for_click.clone().into_boxed_str()));
set_mobile_menu_open.set(false);
};
if is_external {
view! {
<li>
<a
href={route.clone()}
class="text-gray-500 dark:text-gray-400 font-sans antialiased text-sm text-current dark:text-gray-200 flex items-center gap-x-2 p-1 hover:text-primary dark:hover:text-blue-400"
>
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{move || {
let lang_val = i18n_mobile.lang_code();
match lang_val.as_str() {
"es" => item_mobile.label.es.clone(),
_ => item_mobile.label.en.clone(),
}
}}
</a>
</li>
}.into_any()
} else {
view! {
<li>
<a
href={ route.clone()}
on:click=click_item
class="text-gray-500 dark:text-gray-400 font-sans antialiased text-sm text-current dark:text-gray-200 flex items-center gap-x-2 p-1 hover:text-primary dark:hover:text-blue-400"
>
{move || {
let lang_val = i18n_mobile.lang_code();
match lang_val.as_str() {
"es" => item_mobile.label.es.clone(),
_ => item_mobile.label.en.clone(),
}
}}
</a>
</li>
}.into_any()
}
}).collect_view()}
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary">
<svg width="1.5em" height="1.5em" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M7 18H10.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 14H7.5H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 10H8.5H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 2L16.5 2L21 6.5V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 20.5V6.5C3 5.67157 3.67157 5 4.5 5H14.2515C14.4106 5 14.5632 5.06321 14.6757 5.17574L17.8243 8.32426C17.9368 8.43679 18 8.5894 18 8.74853V20.5C18 21.3284 17.3284 22 16.5 22H4.5C3.67157 22 3 21.3284 3 20.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M14 5V8.4C14 8.73137 14.2686 9 14.6 9H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
{i18n.t("pages")}
</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary">
<svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M4.271 18.3457C4.271 18.3457 6.50002 15.5 12 15.5C17.5 15.5 19.7291 18.3457 19.7291 18.3457" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 12C13.6569 12 15 10.6569 15 9C15 7.34315 13.6569 6 12 6C10.3431 6 9 7.34315 9 9C9 10.6569 10.3431 12 12 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
Account
</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary">
<svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M11.6914 11.8285L3.89139 7.49521C3.49147 7.27304 3 7.56222 3 8.01971V16.647C3 16.8649 3.11813 17.0656 3.30861 17.1715L11.1086 21.5048C11.5085 21.727 12 21.4378 12 20.9803V12.353C12 12.1351 11.8819 11.9344 11.6914 11.8285Z" fill="currentColor" stroke="currentColor" stroke-linejoin="round"></path>
</svg>
Blocks
</a>
</li>
<li>
<a href="#" class="font-sans antialiased text-sm text-current flex items-center gap-x-2 p-1 hover:text-primary">
<svg width="1.5em" height="1.5em" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor" class="h-4 w-4">
<path d="M7 6L17 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M7 9L17 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M9 17H15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21" stroke="currentColor"></path>
</svg>
Docs
</a>
</li>
<li class="border-t border-stone-200 dark:border-gray-600 pt-2 mt-2">
<div class="flex items-center justify-between p-1">
<span class="text-sm text-stone-600 dark:text-gray-400">Theme</span>
<DarkModeToggle />
</div>
</li>
</ul>
</div>
</nav>
}
}

View File

@ -1,5 +0,0 @@
// --- Centralized Route Definitions ---
pub const ROUTES: &[(&str, &'static str)] = &[("/", "Home"), ("/about", "About")];
// --- Extracted Nav Link Classes ---
pub const NAV_LINK_CLASS: &str = "pointer text-gray-700 hover:text-gray-900";

View File

@ -1,319 +0,0 @@
// Example integration of Admin Dashboard into Leptos Router
// This file demonstrates how to integrate the admin dashboard into your main application
use crate::components::admin::AdminLayout;
use crate::i18n::{I18nProvider, use_i18n};
use crate::state::*;
use leptos::prelude::*;
use leptos_router::*;
/// Complete example of how to integrate the admin dashboard into your app
#[component]
pub fn AppWithAdminIntegration() -> impl IntoView {
view! {
<GlobalStateProvider>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<AuthProvider>
<UserProvider>
<Router>
<Routes>
// Public routes
<Route path="/" view=HomePage />
<Route path="/about" view=AboutPage />
<Route path="/login" view=LoginPage />
<Route path="/register" view=RegisterPage />
// Protected admin routes
<ProtectedRoute path="/admin/*" view=AdminLayout />
</Routes>
</Router>
</UserProvider>
</AuthProvider>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</GlobalStateProvider>
}
}
/// Protected route component that checks authentication and admin privileges
#[component]
pub fn ProtectedRoute(
path: &'static str,
view: fn() -> impl IntoView + 'static,
) -> impl IntoView {
let auth_context = use_context::<AuthContext>();
let user_context = use_context::<UserContext>();
let is_admin = create_memo(move |_| {
match (auth_context, user_context) {
(Some(auth), Some(user)) => {
auth.is_authenticated() && user.has_role("admin")
}
_ => false,
}
});
view! {
<Route
path=path
view=move || {
if is_admin.get() {
view().into_any()
} else {
view! {
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">
"Access Denied"
</h2>
<p class="mt-2 text-sm text-gray-600">
"You need administrator privileges to access this area."
</p>
<div class="mt-6">
<A href="/login" class="text-indigo-600 hover:text-indigo-500">
"Sign in with an admin account"
</A>
</div>
</div>
</div>
</div>
}.into_any()
}
}
/>
}
}
/// Alternative simpler integration if you want to handle routing manually
#[component]
pub fn SimpleAdminIntegration() -> impl IntoView {
let location = use_location();
let i18n = use_i18n();
let is_admin_route = create_memo(move |_| {
location.pathname.get().starts_with("/admin")
});
view! {
<Show
when=move || is_admin_route.get()
fallback=move || view! {
// Your regular app layout
<div class="app-layout">
<header>"Regular App Header"</header>
<main>
<Routes>
<Route path="/" view=HomePage />
<Route path="/about" view=AboutPage />
</Routes>
</main>
</div>
}
>
// Full-screen admin layout
<AdminLayout />
</Show>
}
}
/// Navigation component with admin link
#[component]
pub fn NavWithAdminLink() -> impl IntoView {
let i18n = use_i18n();
let auth_context = use_context::<AuthContext>();
let user_context = use_context::<UserContext>();
let is_admin = create_memo(move |_| {
match (auth_context, user_context) {
(Some(auth), Some(user)) => {
auth.is_authenticated() && user.has_role("admin")
}
_ => false,
}
});
view! {
<nav class="bg-white shadow">
<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">
<div class="flex-shrink-0 flex items-center">
<A href="/" class="text-xl font-bold text-gray-900">
"Your App"
</A>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<A href="/" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
"Home"
</A>
<A href="/about" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
"About"
</A>
// Admin link - only visible to admins
<Show when=move || is_admin.get()>
<A href="/admin" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{move || i18n.t("admin.dashboard.title")}
</A>
</Show>
</div>
</div>
</div>
</div>
</nav>
}
}
/// Example context types you might need
pub struct AuthContext {
pub user: ReadSignal<Option<User>>,
pub token: ReadSignal<Option<String>>,
}
impl AuthContext {
pub fn is_authenticated(&self) -> bool {
self.user.get().is_some() && self.token.get().is_some()
}
}
pub struct UserContext {
pub roles: ReadSignal<Vec<String>>,
pub permissions: ReadSignal<Vec<String>>,
}
impl UserContext {
pub fn has_role(&self, role: &str) -> bool {
self.roles.get().contains(&role.to_string())
}
pub fn has_permission(&self, permission: &str) -> bool {
self.permissions.get().contains(&permission.to_string())
}
}
#[derive(Clone, Debug)]
pub struct User {
pub id: String,
pub email: String,
pub name: String,
pub roles: Vec<String>,
}
// Placeholder components for the example
#[component]
fn HomePage() -> impl IntoView {
view! { <div>"Home Page"</div> }
}
#[component]
fn AboutPage() -> impl IntoView {
view! { <div>"About Page"</div> }
}
#[component]
fn LoginPage() -> impl IntoView {
view! { <div>"Login Page"</div> }
}
#[component]
fn RegisterPage() -> impl IntoView {
view! { <div>"Register Page"</div> }
}
/// RBAC Middleware example for server-side route protection
/// This would be used on the server to protect API endpoints
pub async fn require_admin_role(
// request: Request,
// next: Next,
) -> Result<(), String> {
// Implementation would check JWT token for admin role
// This is just a placeholder showing the concept
// Extract JWT from request headers
// Verify JWT signature
// Check if user has 'admin' role
// If yes, proceed; if no, return 403 Forbidden
Ok(())
}
/// API endpoint protection example
pub async fn admin_api_handler() -> Result<String, String> {
// This would be your actual API endpoint
// The RBAC middleware would run before this
Ok("Admin data".to_string())
}
/// Example of how to configure your server routes with RBAC
/// This would be in your server configuration
pub fn configure_admin_routes() {
// axum example:
// let admin_routes = Router::new()
// .route("/api/admin/users", get(get_users).post(create_user))
// .route("/api/admin/content", get(get_content).post(create_content))
// .route("/api/admin/roles", get(get_roles).post(create_role))
// .layer(middleware::from_fn(require_admin_role));
}
/// Complete setup example with all providers
#[component]
pub fn CompleteAppSetup() -> impl IntoView {
view! {
<GlobalStateProvider>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<AuthProvider>
<UserProvider>
<AppStateProvider>
<Router>
<NavWithAdminLink />
<main>
<Routes>
// Public routes
<Route path="/" view=HomePage />
<Route path="/about" view=AboutPage />
<Route path="/login" view=LoginPage />
<Route path="/register" view=RegisterPage />
// Admin routes (protected)
<Route path="/admin/*" view=AdminLayout />
// 404 fallback
<Route path="/*any" view=NotFoundPage />
</Routes>
</main>
</Router>
</AppStateProvider>
</UserProvider>
</AuthProvider>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</GlobalStateProvider>
}
}
#[component]
fn NotFoundPage() -> impl IntoView {
view! {
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<h1 class="text-6xl font-bold text-gray-900">"404"</h1>
<p class="text-xl text-gray-600 mt-4">"Page not found"</p>
<A href="/" class="mt-6 inline-block bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700">
"Go Home"
</A>
</div>
</div>
}
}

View File

@ -1,490 +0,0 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use shared::{Texts, load_texts_toml};
use std::collections::HashMap;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Language {
English,
Spanish,
}
impl Language {
pub fn code(&self) -> &'static str {
match self {
Language::English => "en",
Language::Spanish => "es",
}
}
pub fn display_name(&self) -> &'static str {
match self {
Language::English => "English",
Language::Spanish => "Español",
}
}
pub fn from_code(code: &str) -> Self {
match code {
"es" => Language::Spanish,
_ => Language::English, // Default to English
}
}
pub fn all() -> Vec<Language> {
vec![Language::English, Language::Spanish]
}
}
impl Default for Language {
fn default() -> Self {
Language::English
}
}
#[derive(Clone)]
pub struct I18nContext {
pub language: ReadSignal<Language>,
pub set_language: WriteSignal<Language>,
pub texts: Memo<Texts>,
}
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 lang_code = self.language.get_untracked().code();
let translations = match lang_code {
"es" => &texts.es,
_ => &texts.en,
};
translations
.get(key)
.cloned()
.unwrap_or_else(|| key.to_string())
}
/// 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 lang_code = language.get().code();
let translations = match lang_code {
"es" => &texts.es,
_ => &texts.en,
};
translations
.get(key)
.cloned()
.unwrap_or_else(|| key.to_string())
}
}
/// Get current language code
pub fn current_lang(&self) -> String {
self.language.get_untracked().code().to_string()
}
/// Check if current language is specific language
pub fn is_language(&self, lang: Language) -> bool {
self.language.get_untracked() == lang
}
}
#[component]
pub fn I18nProvider(children: leptos::prelude::Children) -> impl IntoView {
// Initialize language from localStorage or default to English
let initial_language = Language::default();
let (language, set_language) = signal(initial_language);
// Load texts from embedded resources
let texts = Memo::new(move |_| load_texts_toml().unwrap_or_default());
let context = I18nContext {
language: language.into(),
set_language,
texts,
};
provide_context(context);
view! {
{children()}
}
}
#[derive(Clone)]
pub struct UseI18n(pub I18nContext);
impl UseI18n {
pub fn new() -> Self {
Self(expect_context::<I18nContext>())
}
/// 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)
}
/// Change language
pub fn set_language(&self, language: Language) {
self.0.set_language.set(language);
}
/// Get current language
pub fn language(&self) -> Language {
self.0.language.get_untracked()
}
/// Get current language code
pub fn lang_code(&self) -> String {
self.0.current_lang()
}
/// Check if current language is specific language
pub fn is_language(&self, lang: Language) -> bool {
self.0.is_language(lang)
}
}
/// Hook to use internationalization
pub fn use_i18n() -> UseI18n {
UseI18n::new()
}
/// Language selector component
#[component]
pub fn LanguageSelector(#[prop(optional)] class: Option<String>) -> impl IntoView {
let i18n = use_i18n();
let (is_open, set_is_open) = signal(false);
view! {
<div class=move || format!(
"relative inline-block text-left {}",
class.as_deref().unwrap_or("")
)>
<button
type="button"
class="inline-flex items-center justify-center px-2 py-1 text-sm font-medium bg-white dark:bg-gray-800 text-stone-800 dark:text-gray-200 border border-stone-200 dark:border-gray-600 rounded-lg hover:bg-stone-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200"
on:click=move |_| set_is_open.update(|open| *open = !*open)
aria-expanded=move || is_open.get()
aria-haspopup="true"
>
<svg class="w-4 h-4 mr-2" 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"/>
</svg>
{
let i18n_clone = i18n.clone();
move || i18n_clone.0.language.get_untracked().code().to_uppercase()
}
<svg class="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<Show when=move || is_open.get()>
<div class="absolute right-0 top-full z-[9999] w-40 mt-1 origin-top-right bg-white dark:bg-gray-800 border border-stone-200 dark:border-gray-600 rounded-lg shadow-xl ring-1 ring-stone-950 dark:ring-gray-700 ring-opacity-5 focus:outline-none">
<div class="py-1" role="menu" aria-orientation="vertical">
{
let i18n_clone = i18n.clone();
let languages = Language::all();
languages.into_iter().map(|lang| {
let i18n_item = i18n_clone.clone();
let lang_for_click = lang.clone();
let i18n_for_click = i18n_item.clone();
let lang_for_reactive = lang.clone();
let i18n_for_reactive = i18n_item.clone();
let lang_for_show1 = lang.clone();
let i18n_for_show1 = i18n_item.clone();
let lang_for_show2 = lang.clone();
let i18n_for_show2 = i18n_item.clone();
let lang_for_display = lang.clone();
view! {
<button
type="button"
class=move || format!(
"flex items-center w-full px-4 py-2 text-sm text-left hover:bg-stone-50 dark:hover:bg-gray-700 focus:outline-none focus:bg-stone-50 dark:focus:bg-gray-700 transition-colors duration-200 {}",
if i18n_for_reactive.is_language(lang_for_reactive.clone()) { "bg-blue-50 dark:bg-blue-900 text-blue-700 dark:text-blue-300 font-medium" } else { "text-stone-700 dark:text-gray-300 hover:text-stone-900 dark:hover:text-gray-100" }
)
role="menuitem"
on:click=move |_| {
i18n_for_click.set_language(lang_for_click.clone());
set_is_open.set(false);
}
>
<Show when=move || i18n_for_show1.is_language(lang_for_show1.clone())>
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</Show>
<Show when=move || !i18n_for_show2.is_language(lang_for_show2.clone())>
<div class="w-4 h-4 mr-2"></div>
</Show>
{lang_for_display.display_name()}
</button>
}.into_any()
}).collect::<Vec<_>>()
}
</div>
</div>
</Show>
// Click outside to close
<Show when=move || is_open.get()>
<div
class="fixed inset-0 z-40"
on:click=move |_| set_is_open.set(false)
></div>
</Show>
</div>
}
}
/// Compact language toggle component
#[component]
pub fn LanguageToggle(#[prop(optional)] class: Option<String>) -> impl IntoView {
let i18n = use_i18n();
view! {
<button
type="button"
class=move || format!(
"inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {}",
class.as_deref().unwrap_or("")
)
on:click={
let i18n_clone = i18n.clone();
move |_| {
let current = i18n_clone.0.language.get();
let new_lang = match current {
Language::English => Language::Spanish,
Language::Spanish => Language::English,
};
i18n_clone.set_language(new_lang);
}
}
title={
let i18n_clone = i18n.clone();
move || i18n_clone.t("select-language")
}
>
<svg class="w-4 h-4 mr-1" 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"/>
</svg>
{
let i18n_clone = i18n.clone();
move || i18n_clone.0.language.get().code().to_uppercase()
}
</button>
}
}
// Dark Mode Context and Components
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Theme {
Light,
Dark,
}
impl Theme {
pub fn to_class(&self) -> &'static str {
match self {
Theme::Light => "light",
Theme::Dark => "dark",
}
}
pub fn is_dark(&self) -> bool {
matches!(self, Theme::Dark)
}
}
impl Default for Theme {
fn default() -> Self {
Theme::Light
}
}
#[derive(Clone)]
pub struct ThemeContext {
pub theme: ReadSignal<Theme>,
pub set_theme: WriteSignal<Theme>,
}
impl ThemeContext {
pub fn new() -> Self {
// Default to light theme on server-side
let initial_theme = Theme::Light;
let (theme, set_theme) = signal(initial_theme);
// Only run client-side code after hydration
#[cfg(target_arch = "wasm32")]
{
// Initialize theme from localStorage on client
spawn_local(async move {
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
if let Ok(Some(stored_theme)) = storage.get_item("theme") {
let saved_theme = match stored_theme.as_str() {
"dark" => Theme::Dark,
_ => Theme::Light,
};
set_theme.set(saved_theme);
}
}
}
});
// Save theme to localStorage and update document class when it changes
// Only create effect if window exists (client-side)
if web_sys::window().is_some() {
Effect::new(move |_| {
let current_theme = theme.get();
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
let theme_str = match current_theme {
Theme::Light => "light",
Theme::Dark => "dark",
};
let _ = storage.set_item("theme", theme_str);
}
// Update document class for dark mode
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
match current_theme {
Theme::Dark => {
let _ = html.class_list().add_1("dark");
}
Theme::Light => {
let _ = html.class_list().remove_1("dark");
}
}
}
}
}
});
}
}
Self { theme, set_theme }
}
pub fn toggle_theme(&self) {
let new_theme = match self.theme.get_untracked() {
Theme::Light => Theme::Dark,
Theme::Dark => Theme::Light,
};
self.set_theme.set(new_theme);
}
pub fn is_dark(&self) -> bool {
self.theme.get_untracked().is_dark()
}
}
// Theme context provider
#[component]
pub fn ThemeProvider(children: Children) -> impl IntoView {
// Only create theme context on client-side to avoid SSR issues
#[cfg(target_arch = "wasm32")]
{
let theme_context = ThemeContext::new();
provide_context(theme_context);
}
// On server-side, provide a minimal theme context
#[cfg(not(target_arch = "wasm32"))]
{
let (theme, set_theme) = signal(Theme::Light);
let theme_context = ThemeContext { theme, set_theme };
provide_context(theme_context);
}
children()
}
// Theme hook
pub fn use_theme() -> ThemeContext {
expect_context::<ThemeContext>()
}
// Dark mode toggle component
#[component]
pub fn DarkModeToggle(#[prop(optional)] class: Option<String>) -> impl IntoView {
let theme_context = use_theme();
let theme_context_click = theme_context.clone();
let theme_context_title = theme_context.clone();
let theme_context_sun = theme_context.clone();
let theme_context_moon = theme_context.clone();
view! {
<button
type="button"
class=move || format!(
"inline-flex items-center justify-center p-2 text-sm font-medium bg-white dark:bg-stone-800 text-stone-800 dark:text-stone-200 border border-stone-200 dark:border-stone-700 rounded-lg hover:bg-stone-50 dark:hover:bg-stone-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-200 {}",
class.as_deref().unwrap_or("")
)
on:click=move |_| theme_context_click.toggle_theme()
title=move || if theme_context_title.theme.get_untracked().is_dark() { "Switch to light mode" } else { "Switch to dark mode" }
>
<Show when=move || theme_context_sun.theme.get_untracked().is_dark()>
// Sun icon for light mode
<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="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"/>
</svg>
</Show>
<Show when=move || !theme_context_moon.theme.get_untracked().is_dark()>
// Moon icon for dark mode
<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="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"/>
</svg>
</Show>
</button>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_language_codes() {
assert_eq!(Language::English.code(), "en");
assert_eq!(Language::Spanish.code(), "es");
}
#[test]
fn test_language_from_code() {
assert_eq!(Language::from_code("en"), Language::English);
assert_eq!(Language::from_code("es"), Language::Spanish);
assert_eq!(Language::from_code("invalid"), Language::English); // Default fallback
}
#[test]
fn test_language_display_names() {
assert_eq!(Language::English.display_name(), "English");
assert_eq!(Language::Spanish.display_name(), "Español");
}
}

View File

@ -1,182 +0,0 @@
//! # 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`] - Main application component and routing
//! - [`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 client::app::App;
//! use leptos::prelude::*;
//!
//! // Mount the application
//! leptos::mount::mount_to_body(App);
//! ```
//!
//! ## Component Usage
//!
//! ### Authentication Components
//!
//! ```rust,ignore
//! use client::auth::{AuthProvider, LoginForm};
//! use leptos::prelude::*;
//!
//! view! {
//! <AuthProvider>
//! <LoginForm />
//! </AuthProvider>
//! }
//! ```
//!
//! ### Form Components
//!
//! ```rust,ignore
//! use client::components::{ContactForm, SupportForm};
//! use leptos::prelude::*;
//!
//! view! {
//! <ContactForm />
//! <SupportForm />
//! }
//! ```
//!
//! ## State Management
//!
//! ### Theme Management
//!
//! ```rust,ignore
//! use 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 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 defs;
pub mod i18n;
pub mod pages;
pub mod state;
pub mod utils;
use leptos::prelude::*;
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(|| view! { <app::App /> });
}

View File

@ -1,5 +0,0 @@
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for a purely client-side app
// see lib.rs for hydration function instead
}

View File

@ -1,53 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn AboutPage() -> impl IntoView {
eprintln!("AboutPage rendering");
view! {
<div class="bg-white dark:bg-gray-900 h-screen overflow-hidden">
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56">
<div class="text-center">
<h1 class="text-balance text-5xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-7xl">About</h1>
<p class="mt-8 text-pretty text-lg font-medium text-gray-500 dark:text-gray-400 sm:text-xl/8">
This is a powerful web application built with Rust, featuring:
</p>
<ul class="mt-8 text-left text-lg text-gray-600 dark:text-gray-300 space-y-4 max-w-md mx-auto">
<li class="flex items-center">
<span class="text-green-500 mr-2">""</span>
"Leptos for reactive UI components"
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2">""</span>
"Axum for the backend server"
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2">""</span>
"TailwindCSS for beautiful styling"
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2">""</span>
"Server-side rendering (SSR)"
</li>
<li class="flex items-center">
<span class="text-green-500 mr-2">""</span>
"Client-side hydration"
</li>
</ul>
</div>
</div>
</div>
</div>
}
}
// <header class="absolute inset-x-0 top-0 z-50">
// <nav class="flex items-center justify-between p-6 lg:px-8">
// <div class="flex flex-1 justify-end">
// <a href="/">
// <span class="-m-1.5 text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-300 border border-dashed rounded-xl px-4 py-2 opacity-50 hover:opacity-100 transition-all duration-300">Home</span>
// </a>
// </div>
// </nav>
// </header>

View File

@ -1,31 +0,0 @@
// use crate::components::DaisyExample;
use leptos::prelude::*;
#[component]
pub fn DaisyUIPage() -> impl IntoView {
eprintln!("DaisyUIPage rendering");
view! {
<div class="min-h-screen bg-base-200">
<div class="hero bg-base-100 py-8">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold text-primary">"DaisyUI + UnoCSS"</h1>
<p class="py-6 text-lg">"Beautiful UI components powered by DaisyUI preset for UnoCSS"</p>
<div class="flex justify-center gap-2">
<div class="badge badge-primary">"UnoCSS"</div>
<div class="badge badge-secondary">"DaisyUI"</div>
<div class="badge badge-accent">"Leptos"</div>
</div>
</div>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<div class="text-center">
<h2 class="text-2xl font-bold mb-4">"DaisyUI Examples"</h2>
<p>"This section will show DaisyUI components."</p>
</div>
</div>
</div>
}
}

View File

@ -1,91 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn FeaturesDemoPage() -> impl IntoView {
view! {
<div class="bg-white dark:bg-gray-900 min-h-screen">
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class="mx-auto max-w-4xl py-16 sm:py-24">
<div class="text-center mb-12">
<h1 class="text-balance text-4xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">
"Features Demo"
</h1>
<p class="mt-6 text-lg text-gray-600 dark:text-gray-400">
"Explore the powerful features of this Rust web application stack"
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
"Reactive UI"
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
"Built with Leptos for fast, reactive components"
</p>
<div class="space-y-2">
<div class="w-full bg-blue-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full w-3/4"></div>
</div>
<div class="text-sm text-gray-500">"Component reactivity"</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
"Fast Backend"
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
"Powered by Axum for high-performance server-side logic"
</p>
<div class="space-y-2">
<div class="w-full bg-green-200 rounded-full h-2">
<div class="bg-green-600 h-2 rounded-full w-5/6"></div>
</div>
<div class="text-sm text-gray-500">"Server performance"</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
"Beautiful Styling"
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
"TailwindCSS for rapid UI development"
</p>
<div class="flex space-x-2">
<div class="w-4 h-4 bg-blue-500 rounded"></div>
<div class="w-4 h-4 bg-green-500 rounded"></div>
<div class="w-4 h-4 bg-purple-500 rounded"></div>
<div class="w-4 h-4 bg-pink-500 rounded"></div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
"Type Safety"
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
"Rust's type system ensures reliability and performance"
</p>
<div class="bg-gray-200 dark:bg-gray-700 p-2 rounded text-sm font-mono">
<span class="text-blue-600 dark:text-blue-400">"fn"</span>
<span class="text-gray-800 dark:text-gray-200">" safe_function() -> "</span>
<span class="text-green-600 dark:text-green-400">"Result"</span>
</div>
</div>
</div>
<div class="mt-12 text-center">
<div class="inline-flex items-center space-x-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white px-6 py-3 rounded-lg">
<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="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span class="font-semibold">"Built with Rust"</span>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -1,70 +0,0 @@
use crate::components::Counter;
use leptos::prelude::*;
#[component]
pub fn HomePage() -> impl IntoView {
eprintln!("HomePage rendering");
view! {
<div class="bg-white dark:bg-gray-900 h-screen overflow-hidden">
<div class="relative isolate px-6 pt-14 lg:px-8">
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)"></div>
</div>
<div class="mx-auto max-w-2xl py-32 sm:py-48 lg:py-56">
<div class="hidden sm:mb-8 sm:flex sm:justify-center">
<div class="relative rounded-full px-3 py-1 text-sm/6 text-gray-600 dark:text-gray-400 ring-1 ring-gray-900/10 dark:ring-gray-100/10 hover:ring-gray-900/20 dark:hover:ring-gray-100/20">
// Thaw Button removed. Add your own client-only UI here if needed.
</div>
</div>
<div class="text-center">
<div class="mb-8">
<div class="flex items-center gap-4 justify-center">
<img
src="/logos/rustelo-imag.svg"
alt="RUSTELO"
class="flex-shrink-0 h-16 w-auto"
/>
<div class="flex flex-col">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">RUSTELO</h1>
<p class="text-sm text-gray-600 dark:text-gray-400">Modular Rust Web Application Template</p>
</div>
</div>
</div>
<h1 class="text-balance text-5xl font-semibold tracking-tight text-gray-900 dark:text-gray-100 sm:text-7xl">Build fast web apps with Rust</h1>
<p class="mt-8 text-pretty text-lg font-medium text-gray-500 dark:text-gray-400 sm:text-xl/8">
A powerful starter template combining Axum for the backend, Leptos for reactive UI components, and TailwindCSS for beautiful styling.
</p>
<span class="i-carbon-user text-2xl text-gray-700" />
<span class="i-carbon-add text-xl text-green-500" />
<button class="i-carbon-sun dark:i-carbon-moon" />
<label class="x-button circle muted swap">
<input type="checkbox" aria-label="Checkbox description" />
<svg class="rotate-45 size-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<svg class="-rotate-45 size-6" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
</label>
</div>
<div class="my-10">
<Counter/>
</div>
</div>
<div class="absolute inset-x-0 top-[calc(100%-13rem)] -z-10 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]" aria-hidden="true">
<div class="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)"></div>
</div>
</div>
</div>
}
}
// <header class="absolute inset-x-0 top-0 z-50">
// <nav class="flex items-center justify-between p-6 lg:px-8">
// <div class="flex flex-1 justify-end space-x-4">
// // If this is meant to be SPA navigation, you can add on:click handler as in app.rs, otherwise leave as is:
// // <a href="/about">About</a>
// </div>
// </nav>
// </header>

View File

@ -1,45 +0,0 @@
use leptos::prelude::*;
#[component]
pub fn UserPage() -> impl IntoView {
view! {
<div class="min-h-screen bg-base-200">
<div class="hero bg-base-100 py-8">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold text-primary">"User Dashboard"</h1>
<p class="py-6 text-lg">"Welcome to your user dashboard"</p>
<div class="flex justify-center gap-2">
<div class="badge badge-primary">"User"</div>
<div class="badge badge-secondary">"Dashboard"</div>
</div>
</div>
</div>
</div>
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">"Profile"</h2>
<p>"Manage your profile information and settings."</p>
<div class="card-actions justify-end">
<button class="btn btn-primary">"Edit Profile"</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">"Settings"</h2>
<p>"Configure your account preferences and security settings."</p>
<div class="card-actions justify-end">
<button class="btn btn-secondary">"Settings"</button>
</div>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -1,830 +0,0 @@
use crate::i18n::use_i18n;
use chrono::{DateTime, Utc};
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// use wasm_bindgen_futures::spawn_local;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentListItem {
pub id: Uuid,
pub title: String,
pub slug: String,
pub content_type: String,
pub state: String,
pub author: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub published_at: Option<DateTime<Utc>>,
pub view_count: i64,
pub tags: Vec<String>,
pub category: Option<String>,
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentCreateRequest {
pub title: String,
pub slug: String,
pub content: String,
pub content_type: String,
pub content_format: String,
pub state: String,
pub require_login: bool,
pub tags: Vec<String>,
pub category: Option<String>,
pub featured_image: Option<String>,
pub excerpt: Option<String>,
pub seo_title: Option<String>,
pub seo_description: Option<String>,
pub allow_comments: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentStats {
pub total_count: i64,
pub published_count: i64,
pub draft_count: i64,
pub archived_count: i64,
pub scheduled_count: i64,
pub total_views: i64,
pub top_categories: Vec<(String, i64)>,
pub top_tags: Vec<(String, i64)>,
}
impl Default for ContentStats {
fn default() -> Self {
Self {
total_count: 0,
published_count: 0,
draft_count: 0,
archived_count: 0,
scheduled_count: 0,
total_views: 0,
top_categories: vec![],
top_tags: vec![],
}
}
}
#[component]
pub fn AdminContent() -> impl IntoView {
let i18n = use_i18n();
let (content_list, set_content_list) = signal(Vec::<ContentListItem>::new());
let (content_stats, set_content_stats) = signal(ContentStats::default());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
let (selected_content, set_selected_content) = signal(None::<ContentListItem>);
let (show_create_modal, set_show_create_modal) = signal(false);
let (show_edit_modal, set_show_edit_modal) = signal(false);
let (show_upload_modal, set_show_upload_modal) = signal(false);
let (search_query, set_search_query) = signal(String::new());
let (filter_type, set_filter_type) = signal(String::from("all"));
let (filter_state, set_filter_state) = signal(String::from("all"));
let (filter_language, set_filter_language) = signal(String::from("all"));
let (sort_by, set_sort_by) = signal(String::from("updated_at"));
let (sort_order, set_sort_order) = signal(String::from("desc"));
// Fetch content data
let fetch_content = Action::new(move |_: &()| {
let set_loading = set_loading.clone();
let set_error = set_error.clone();
let set_content_list = set_content_list.clone();
let set_content_stats = set_content_stats.clone();
async move {
set_loading.set(true);
set_error.set(None);
match fetch_content_data().await {
Ok((content_data, stats_data)) => {
set_content_list.set(content_data);
set_content_stats.set(stats_data);
set_loading.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_loading.set(false);
}
}
}
});
// Load data on mount
Effect::new(move |_| {
fetch_content.dispatch(());
});
let refresh_data = move |_| {
fetch_content.dispatch(());
};
view! {
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">
{i18n.t("content-management")}
</h1>
<p class="mt-2 text-sm text-gray-700">
{i18n.t("manage-your-content")}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<div class="flex space-x-3">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
on:click=move |_| set_show_upload_modal.set(true)
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
{i18n.t("upload-content")}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
on:click=refresh_data
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{i18n.t("refresh")}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
on:click=move |_| set_show_create_modal.set(true)
>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{i18n.t("create-content")}
</button>
</div>
</div>
</div>
// Stats Cards
<ContentStatsCards stats=content_stats />
// Content Table
<Show
when=move || !loading.get()
fallback=|| view! { <ContentManagementSkeleton /> }
>
<ContentManagementTable
content_list=content_list
search_query=search_query
set_search_query=set_search_query
filter_type=filter_type
set_filter_type=set_filter_type
filter_state=filter_state
set_filter_state=set_filter_state
filter_language=filter_language
set_filter_language=set_filter_language
sort_by=sort_by
set_sort_by=set_sort_by
sort_order=sort_order
set_sort_order=set_sort_order
selected_content=selected_content
set_selected_content=set_selected_content
set_show_edit_modal=set_show_edit_modal
/>
</Show>
// Error Display
<Show when=move || error.get().is_some()>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div class="ml-3">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</div>
</div>
</Show>
// Modals
<Show when=move || show_create_modal.get()>
<CreateContentModal
set_show=set_show_create_modal
on_success=move |_| { fetch_content.dispatch(()); }
/>
</Show>
<Show when=move || show_edit_modal.get()>
<EditContentModal
set_show=set_show_edit_modal
content=selected_content
on_success=move |_| { fetch_content.dispatch(()); }
/>
</Show>
<Show when=move || show_upload_modal.get()>
<UploadContentModal
set_show=set_show_upload_modal
on_success=move |_| { fetch_content.dispatch(()); }
/>
</Show>
</div>
}
}
#[component]
#[allow(unused_variables)]
fn ContentStatsCards(stats: ReadSignal<ContentStats>) -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("total-content")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().total_count}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("published")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().published_count}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("drafts")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().draft_count}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("scheduled")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().scheduled_count}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<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"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("total-views")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().total_views}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
}
}
#[component]
fn ContentManagementTable(
content_list: ReadSignal<Vec<ContentListItem>>,
search_query: ReadSignal<String>,
set_search_query: WriteSignal<String>,
filter_type: ReadSignal<String>,
set_filter_type: WriteSignal<String>,
filter_state: ReadSignal<String>,
set_filter_state: WriteSignal<String>,
filter_language: ReadSignal<String>,
set_filter_language: WriteSignal<String>,
sort_by: ReadSignal<String>,
set_sort_by: WriteSignal<String>,
sort_order: ReadSignal<String>,
set_sort_order: WriteSignal<String>,
selected_content: ReadSignal<Option<ContentListItem>>,
set_selected_content: WriteSignal<Option<ContentListItem>>,
set_show_edit_modal: WriteSignal<bool>,
) -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="bg-white shadow overflow-hidden sm:rounded-md">
// Filters
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div class="flex flex-wrap items-center justify-between gap-4">
// Search
<div class="flex-1 min-w-0">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input
type="text"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder={i18n.t("search-content")}
prop:value=move || search_query.get()
on:input=move |ev| set_search_query.set(event_target_value(&ev))
/>
</div>
</div>
// Filters
<div class="flex items-center space-x-4">
<select
class="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || filter_type.get()
on:change=move |ev| set_filter_type.set(event_target_value(&ev))
>
<option value="all">{i18n.t("all-types")}</option>
<option value="post">{i18n.t("posts")}</option>
<option value="page">{i18n.t("pages")}</option>
<option value="article">{i18n.t("articles")}</option>
</select>
<select
class="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || filter_state.get()
on:change=move |ev| set_filter_state.set(event_target_value(&ev))
>
<option value="all">{i18n.t("all-states")}</option>
<option value="published">{i18n.t("published")}</option>
<option value="draft">{i18n.t("draft")}</option>
<option value="archived">{i18n.t("archived")}</option>
</select>
</div>
</div>
</div>
// Table
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"Title"
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"Type"
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"State"
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"Author"
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"Updated"
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
"Views"
</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">{i18n.t("actions")}</span>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<For
each=move || content_list.get()
key=|content| content.id
children=move |content| {
let edit_content = content.clone();
let _i18n_clone = i18n.clone();
let _ = (filter_language, set_filter_language, sort_by, set_sort_by, sort_order, set_sort_order, selected_content);
view! {
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
{content.title.clone()}
</div>
<div class="text-sm text-gray-500">
{content.slug.clone()}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
{content.content_type.clone()}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class={format!("inline-flex px-2 py-1 text-xs font-semibold rounded-full {}",
match content.state.as_str() {
"published" => "bg-green-100 text-green-800",
"draft" => "bg-yellow-100 text-yellow-800",
"archived" => "bg-gray-100 text-gray-800",
_ => "bg-gray-100 text-gray-800",
}
)}>
{content.state.clone()}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{content.author.clone().unwrap_or_else(|| "Unknown".to_string())}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{content.updated_at.format("%Y-%m-%d %H:%M").to_string()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{content.view_count}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex space-x-2">
<button
class="text-indigo-600 hover:text-indigo-900"
on:click=move |_| {
set_selected_content.set(Some(edit_content.clone()));
set_show_edit_modal.set(true);
}
>
"Edit"
</button>
<a
href=format!("/content/{}", content.slug)
class="text-blue-600 hover:text-blue-900"
target="_blank"
>
"View"
</a>
</div>
</td>
</tr>
}
}
/>
</tbody>
</table>
</div>
</div>
}
}
#[component]
#[allow(unused_variables)]
fn CreateContentModal(
set_show: WriteSignal<bool>,
on_success: impl Fn(()) + 'static,
) -> impl IntoView {
let i18n = use_i18n();
let (title, set_title) = signal(String::new());
let (slug, set_slug) = signal(String::new());
let (content, set_content) = signal(String::new());
let (loading, set_loading) = signal(false);
let (error, set_error) = signal(None::<String>);
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
set_loading.set(true);
set_error.set(None);
let _title_val = title.get();
let _slug_val = slug.get();
let _content_val = content.get();
// Since we can't use spawn_local due to Send bounds, we'll simulate async with timeout
set_loading.set(false);
set_show.set(false);
on_success(());
};
view! {
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div>
<div class="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900">
{i18n.t("create-new-content")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click=move |_| set_show.set(false)
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<Show when=move || error.get().is_some()>
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</Show>
<form on:submit=handle_submit class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{i18n.t("title")}
</label>
<input
type="text"
required
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
prop:value=move || title.get()
on:input=move |ev| set_title.set(event_target_value(&ev))
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{i18n.t("slug")}
</label>
<input
type="text"
required
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
prop:value=move || slug.get()
on:input=move |ev| set_slug.set(event_target_value(&ev))
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{i18n.t("content")}
</label>
<textarea
required
rows="10"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
prop:value=move || content.get()
on:input=move |ev| set_content.set(event_target_value(&ev))
></textarea>
</div>
<div class="flex items-center justify-end space-x-3 pt-6 border-t">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
on:click=move |_| set_show.set(false)
>
{i18n.t("cancel")}
</button>
<button
type="submit"
disabled=move || loading.get()
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Show when=move || loading.get()>
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" 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>
</Show>
{i18n.t("create-content")}
</button>
</div>
</form>
</div>
</div>
</div>
}
}
#[component]
#[allow(unused_variables)]
fn EditContentModal(
set_show: WriteSignal<bool>,
content: ReadSignal<Option<ContentListItem>>,
on_success: impl Fn(()) + 'static,
) -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div>
<div class="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900">
{i18n.t("edit-content")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click=move |_| set_show.set(false)
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="text-center py-8">
<p class="text-gray-600">
{i18n.t("content-editing-functionality")}
</p>
<p class="text-sm text-gray-500 mt-2">
{i18n.t("selected-content")}": " {move || content.get().map(|c| c.title).unwrap_or_default()}
</p>
</div>
</div>
</div>
</div>
}
}
#[component]
#[allow(unused_variables)]
fn UploadContentModal(
set_show: WriteSignal<bool>,
on_success: impl Fn(()) + 'static,
) -> impl IntoView {
let i18n = use_i18n();
let (_uploading, _set_uploading) = signal(false);
view! {
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" on:click=move |_| set_show.set(false)></div>
<div class="inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle transition-all transform bg-white shadow-xl rounded-lg">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium leading-6 text-gray-900">
{i18n.t("upload-content")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click=move |_| set_show.set(false)
>
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<div class="mt-4">
<p class="text-sm text-gray-600">
{i18n.t("drag-and-drop-files")}
</p>
<p class="text-xs text-gray-500 mt-2">
{i18n.t("markdown-html-txt-supported")}
</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
on:click=move |_| set_show.set(false)
>
{i18n.t("cancel")}
</button>
<button
type="button"
disabled=move || false
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50"
>
{i18n.t("upload")}
</button>
</div>
</div>
</div>
</div>
}
}
// Helper functions
async fn fetch_content_data() -> Result<(Vec<ContentListItem>, ContentStats), String> {
// Mock data for now
Ok((vec![], ContentStats::default()))
}
#[allow(dead_code)]
async fn create_content(_request: ContentCreateRequest) -> Result<(), String> {
// Mock implementation
Ok(())
}
#[component]
fn ContentManagementSkeleton() -> impl IntoView {
view! {
<div class="space-y-6">
// Stats skeleton
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{(0..5).map(|_| view! {
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
<div class="p-5">
<div class="flex items-center">
<div class="h-6 w-6 bg-gray-200 rounded"></div>
<div class="ml-5 w-0 flex-1">
<div class="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-6 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
</div>
</div>
}).collect_view()}
</div>
// Table skeleton
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div class="flex items-center space-x-4">
<div class="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
<div class="w-32 h-10 bg-gray-200 rounded animate-pulse"></div>
<div class="w-32 h-10 bg-gray-200 rounded animate-pulse"></div>
</div>
</div>
<div class="divide-y divide-gray-200">
{(0..5).map(|_| view! {
<div class="px-6 py-4 flex items-center space-x-4">
<div class="h-10 w-10 bg-gray-200 rounded-full animate-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4 animate-pulse"></div>
<div class="h-3 bg-gray-200 rounded w-1/2 animate-pulse"></div>
</div>
<div class="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
<div class="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
</div>
}).collect_view()}
</div>
</div>
</div>
}
}

View File

@ -1,464 +0,0 @@
// use crate::components::*;
use crate::i18n::use_i18n;
use leptos::prelude::*;
// use leptos_router::*;
use serde::{Deserialize, Serialize};
// use std::collections::HashMap;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
struct AdminStats {
total_users: u32,
active_users: u32,
content_items: u32,
total_roles: u32,
pending_approvals: u32,
system_health: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct RecentActivity {
id: String,
user_email: String,
action: String,
resource_type: String,
timestamp: String,
status: String,
}
#[component]
pub fn AdminDashboard() -> impl IntoView {
let i18n = use_i18n();
let (stats, set_stats) = signal(AdminStats::default());
let (recent_activity, set_recent_activity) = signal(Vec::<RecentActivity>::new());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
// Fetch dashboard data on mount
Effect::new(move |_| {
spawn_local(async move {
set_loading.set(true);
set_error.set(None);
match fetch_dashboard_data().await {
Ok((stats_data, activities_data)) => {
set_stats.set(stats_data);
set_recent_activity.set(activities_data);
set_loading.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_loading.set(false);
}
}
});
});
let refresh_data = move |_| {
spawn_local(async move {
set_loading.set(true);
match fetch_dashboard_data().await {
Ok((stats_data, activities_data)) => {
set_stats.set(stats_data);
set_recent_activity.set(activities_data);
set_loading.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_loading.set(false);
}
}
});
};
view! {
<div class="min-h-screen bg-gray-50">
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
// Header
<div class="pb-5 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold leading-tight text-gray-900">
{i18n.t("admin-dashboard")}
</h1>
<p class="mt-2 text-sm text-gray-600">
{i18n.t("overview-of-your-system")}
</p>
</div>
<button
class="bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
on:click=refresh_data
disabled=move || loading.get()
>
<Show when=move || loading.get()>
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" 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>
</Show>
<Show when=move || !loading.get()>
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</Show>
{i18n.t("refresh")}
</button>
</div>
</div>
// Error Alert
<Show when=move || error.get().is_some()>
<div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<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"></path>
</svg>
<div class="ml-3">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</div>
</div>
</Show>
<Show
when=move || !loading.get()
fallback=|| view! { <AdminDashboardSkeleton /> }
>
<div class="mt-6 space-y-6">
<AdminStatsCards stats=stats />
<AdminQuickActions />
<AdminRecentActivity activities=recent_activity />
</div>
</Show>
</div>
</div>
</div>
}
}
#[component]
fn AdminStatsCards(stats: ReadSignal<AdminStats>) -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
// Total Users Card
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("total-users")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().total_users.to_string()}
</dd>
</dl>
</div>
</div>
</div>
</div>
// Active Users Card
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("active-users")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().active_users.to_string()}
</dd>
</dl>
</div>
</div>
</div>
</div>
// Content Items Card
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("content-items")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().content_items.to_string()}
</dd>
</dl>
</div>
</div>
</div>
</div>
// Total Roles Card
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
{i18n.t("total-roles")}
</dt>
<dd class="text-lg font-medium text-gray-900">
{move || stats.get().total_roles.to_string()}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
}
}
#[component]
fn AdminQuickActions() -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
"Quick Actions"
</h3>
<div class="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<a href="/admin/users" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
<span class="mt-2 block text-sm font-medium text-gray-900">
{i18n.t("manage-users")}
</span>
</a>
<a href="/admin/roles" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="mt-2 block text-sm font-medium text-gray-900">
{i18n.t("manage-roles")}
</span>
</a>
<a href="/admin/content" class="relative block w-full border-2 border-gray-300 border-dashed rounded-lg p-6 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-all">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"></path>
</svg>
<span class="mt-2 block text-sm font-medium text-gray-900">
{i18n.t("manage-content")}
</span>
</a>
</div>
</div>
</div>
}
}
#[component]
fn AdminRecentActivity(activities: ReadSignal<Vec<RecentActivity>>) -> impl IntoView {
let i18n = use_i18n();
view! {
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
"Recent Activity"
</h3>
<div class="mt-5">
<div class="flow-root">
<ul class="-my-5 divide-y divide-gray-200">
<Show
when=move || !activities.get().is_empty()
fallback=move || view! {
<li class="py-4">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900">
{i18n.t("no-recent-activity")}
</p>
<p class="text-sm text-gray-500">
{i18n.t("activity-will-appear-here")}
</p>
</div>
</div>
</li>
}
>
<For
each=move || activities.get()
key=|activity| activity.id.clone()
children=move |activity| {
view! {
<li class="py-4">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<div class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center">
<span class="text-sm font-medium text-indigo-600">
{activity.user_email.chars().next().unwrap_or('U')}
</span>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
{activity.action.clone()}
</p>
<p class="text-sm text-gray-500 truncate">
{activity.user_email.clone()}
</p>
</div>
<div class="flex-shrink-0 text-sm text-gray-500">
{activity.timestamp.clone()}
</div>
</div>
</li>
}
}
/>
</Show>
</ul>
</div>
</div>
</div>
</div>
}
}
#[component]
fn AdminDashboardSkeleton() -> impl IntoView {
view! {
<div class="mt-6 animate-pulse">
// Stats Cards Skeleton
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{(0..4).map(|_| view! {
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-8 w-8 bg-gray-200 rounded"></div>
</div>
<div class="ml-5 w-0 flex-1">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-6 bg-gray-200 rounded w-1/2 mt-2"></div>
</div>
</div>
</div>
</div>
}).collect_view()}
</div>
// Quick Actions Skeleton
<div class="mt-6 bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="h-6 bg-gray-200 rounded w-1/4 mb-5"></div>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{(0..3).map(|_| view! {
<div class="border-2 border-gray-200 rounded-lg p-6">
<div class="h-12 w-12 bg-gray-200 rounded mx-auto"></div>
<div class="h-4 bg-gray-200 rounded w-3/4 mx-auto mt-2"></div>
</div>
}).collect_view()}
</div>
</div>
</div>
// Recent Activity Skeleton
<div class="mt-6 bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="h-6 bg-gray-200 rounded w-1/4 mb-5"></div>
<div class="space-y-4">
{(0..5).map(|_| view! {
<div class="py-4">
<div class="flex items-center space-x-4">
<div class="h-8 w-8 bg-gray-200 rounded-full"></div>
<div class="flex-1">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2 mt-2"></div>
</div>
<div class="h-3 bg-gray-200 rounded w-16"></div>
</div>
</div>
}).collect_view()}
</div>
</div>
</div>
</div>
}
}
// API functions
async fn fetch_dashboard_data() -> Result<(AdminStats, Vec<RecentActivity>), String> {
// This would normally make actual API calls to the backend
// For now, return mock data
let stats = AdminStats {
total_users: 147,
active_users: 89,
content_items: 42,
total_roles: 5,
pending_approvals: 3,
system_health: "Healthy".to_string(),
};
let activities = vec![
RecentActivity {
id: "1".to_string(),
user_email: "admin@example.com".to_string(),
action: "User Login".to_string(),
resource_type: "auth".to_string(),
timestamp: "2 hours ago".to_string(),
status: "success".to_string(),
},
RecentActivity {
id: "2".to_string(),
user_email: "user@example.com".to_string(),
action: "Content Update".to_string(),
resource_type: "content".to_string(),
timestamp: "4 hours ago".to_string(),
status: "success".to_string(),
},
];
Ok((stats, activities))
}

View File

@ -1,991 +0,0 @@
use crate::i18n::use_i18n;
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Role {
pub id: String,
pub name: String,
pub description: String,
pub permissions: Vec<String>,
pub created_at: String,
pub updated_at: String,
pub user_count: u32,
pub is_system_role: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Permission {
pub id: String,
pub name: String,
pub description: String,
pub category: String,
pub resource: String,
pub action: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateRoleRequest {
pub name: String,
pub description: String,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateRoleRequest {
pub id: String,
pub name: String,
pub description: String,
pub permissions: Vec<String>,
}
#[component]
pub fn AdminRoles() -> impl IntoView {
let i18n = use_i18n();
let (roles, set_roles) = signal(Vec::<Role>::new());
let (permissions, set_permissions) = signal(Vec::<Permission>::new());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
let (selected_role, set_selected_role) = signal(None::<Role>);
let (show_create_modal, set_show_create_modal) = signal(false);
let (show_edit_modal, set_show_edit_modal) = signal(false);
let (show_permissions_modal, set_show_permissions_modal) = signal(false);
let (search_term, set_search_term) = signal(String::new());
// Fetch roles and permissions on mount
Effect::new(move |_| {
spawn_local(async move {
match fetch_roles_and_permissions().await {
Ok((roles_data, permissions_data)) => {
set_roles.set(roles_data);
set_permissions.set(permissions_data);
set_loading.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_loading.set(false);
}
}
});
});
// Filtered roles
let filtered_roles = Memo::new(move |_| {
let search = search_term.get().to_lowercase();
roles
.get()
.into_iter()
.filter(|role| {
search.is_empty()
|| role.name.to_lowercase().contains(&search)
|| role.description.to_lowercase().contains(&search)
})
.collect::<Vec<_>>()
});
let delete_role = Action::new(move |role_id: &String| {
let role_id = role_id.clone();
async move {
match delete_role_api(&role_id).await {
Ok(_) => {
set_roles.update(|roles| roles.retain(|r| r.id != role_id));
Ok(())
}
Err(e) => {
set_error.set(Some(e.clone()));
Err(e)
}
}
}
});
view! {
<div class="min-h-screen bg-gray-50">
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="pb-5 border-b border-gray-200">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold leading-tight text-gray-900">
"Role Management"
</h1>
<div class="flex space-x-3">
<button
class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg"
on:click=move |_| set_show_permissions_modal.set(true)
>
{i18n.t("view-permissions")}
</button>
<button
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg"
on:click=move |_| set_show_create_modal.set(true)
>
{i18n.t("create-new-role")}
</button>
</div>
</div>
</div>
// Error Alert
<Show when=move || error.get().is_some()>
<div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<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"></path>
</svg>
<div class="ml-3">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</div>
</div>
</Show>
// Search
<div class="mt-6 bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between">
<div class="flex-1 max-w-lg">
<label class="block text-sm font-medium text-gray-700">
{i18n.t("search-roles")}
</label>
<input
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Search roles..."
prop:value=move || search_term.get()
on:input=move |ev| set_search_term.set(event_target_value(&ev))
/>
</div>
<div class="ml-4">
<button
class="bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg"
on:click=move |_| set_search_term.set(String::new())
>
{i18n.t("clear")}
</button>
</div>
</div>
</div>
</div>
// Roles Grid
<div class="mt-6">
<Show
when=move || !loading.get()
fallback=|| view! { <RolesGridSkeleton /> }
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For
each=move || filtered_roles.get()
key=|role| role.id.clone()
children=move |role| {
let role_name = role.name.clone();
let role_description = role.description.clone();
let role_id = role.id.clone();
let role_is_system = role.is_system_role;
let role_user_count = role.user_count;
let role_permissions = role.permissions.clone();
let role_permissions_len = role_permissions.len();
let i18n = use_i18n();
view! {
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-lg font-medium text-gray-900">
{role_name.clone()}
</h3>
<p class="text-sm text-gray-500 mt-1">
{role_description.clone()}
</p>
</div>
<div class="flex space-x-2">
<button
class="text-indigo-600 hover:text-indigo-900 text-sm font-medium"
on:click={
let role_clone = role.clone();
move |_| {
set_selected_role.set(Some(role_clone.clone()));
set_show_edit_modal.set(true);
}
}
>
{i18n.t("edit")}
</button>
<Show when=move || !role_is_system>
<button
class="text-red-600 hover:text-red-900 text-sm font-medium"
on:click={
let role_name_for_delete = role_name.clone();
let role_id_for_delete = role_id.clone();
move |_| {
if let Some(window) = web_sys::window() {
if window
.confirm_with_message(&format!("Are you sure you want to delete the role '{}'?", role_name_for_delete))
.unwrap_or(false)
{
let _ = delete_role.dispatch(role_id_for_delete.clone());
}
}
}
}
>
{i18n.t("delete")}
</button>
</Show>
</div>
</div>
<div class="mt-4">
<div class="flex items-center justify-between text-sm text-gray-500">
<span>{role_user_count} " users"</span>
<span>{role_permissions_len} " permissions"</span>
</div>
<div class="mt-3">
<div class="flex flex-wrap gap-1">
{role_permissions.iter().take(3).map(|perm| {
view! {
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
{perm.clone()}
</span>
}
}).collect::<Vec<_>>()}
<Show when={
let len = role_permissions_len;
move || len > 3
}>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
"+" {role_permissions_len - 3} " more"
</span>
</Show>
</div>
</div>
<Show when=move || role.is_system_role>
<div class="mt-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
"System Role"
</span>
</div>
</Show>
</div>
</div>
</div>
}
}
/>
</div>
</Show>
</div>
</div>
</div>
// Create Role Modal
<Show when=move || show_create_modal.get()>
<CreateRoleModal
permissions=permissions.get()
on_close=move || set_show_create_modal.set(false)
on_role_created=move |role| {
set_roles.update(|roles| roles.push(role));
set_show_create_modal.set(false);
}
/>
</Show>
// Edit Role Modal
<Show when=move || show_edit_modal.get()>
<EditRoleModal
role=selected_role.get()
permissions=permissions.get()
on_close=move || set_show_edit_modal.set(false)
on_role_updated=move |updated_role| {
set_roles.update(|roles| {
if let Some(role) = roles.iter_mut().find(|r| r.id == updated_role.id) {
*role = updated_role;
}
});
set_show_edit_modal.set(false);
}
/>
</Show>
// Permissions Modal
<Show when=move || show_permissions_modal.get()>
<PermissionsModal
permissions=permissions.get()
on_close=move || set_show_permissions_modal.set(false)
/>
</Show>
</div>
}
}
#[component]
fn RolesGridSkeleton() -> impl IntoView {
view! {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For
each=|| 0..6
key=|i| *i
children=move |_| {
view! {
<div class="bg-white overflow-hidden shadow rounded-lg animate-pulse">
<div class="px-6 py-4">
<div class="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
<div class="h-3 bg-gray-300 rounded w-1/2"></div>
</div>
</div>
}
}
/>
</div>
}
}
#[component]
fn CreateRoleModal(
permissions: Vec<Permission>,
on_close: impl Fn() + 'static + Clone + Send + Sync,
on_role_created: impl Fn(Role) + 'static + Clone + Send + Sync,
) -> impl IntoView {
let i18n = use_i18n();
let (form_data, set_form_data) = signal(CreateRoleRequest {
name: String::new(),
description: String::new(),
permissions: Vec::new(),
});
let (submitting, set_submitting) = signal(false);
let (error, set_error) = signal(None::<String>);
// Group permissions by category
let permissions_options = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| {
let mut groups: HashMap<String, Vec<Permission>> = HashMap::new();
for perm in permissions.iter() {
let category = perm.category.clone();
groups
.entry(category)
.or_insert_with(Vec::new)
.push(perm.clone());
}
groups
});
let submit_form = Action::new({
let on_role_created = on_role_created.clone();
move |_: &()| {
let form_data = form_data.get();
let on_role_created = on_role_created.clone();
async move {
set_submitting.set(true);
set_error.set(None);
match create_role_api(form_data).await {
Ok(role) => {
on_role_created(role);
set_submitting.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_submitting.set(false);
}
}
}
}
});
// Create iterator functions outside view macro to avoid parsing issues
let permission_groups_iter = move || permissions_options.get().into_iter().collect::<Vec<_>>();
view! {
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
{i18n.t("create-new-role")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
<svg class="h-6 w-6" 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"></path>
</svg>
</button>
</div>
<Show when=move || error.get().is_some()>
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</Show>
<form on:submit=move |ev| {
ev.prevent_default();
submit_form.dispatch(());
}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("role-name")}
</label>
<input
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().name
on:input=move |ev| {
set_form_data.update(|data| data.name = event_target_value(&ev));
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("description")}
</label>
<textarea
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
rows="3"
prop:value=move || form_data.get().description
on:input=move |ev| {
set_form_data.update(|data| data.description = event_target_value(&ev));
}
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{i18n.t("permissions")}
</label>
<div class="max-h-60 overflow-y-auto border border-gray-200 rounded-md p-3">
<For
each=permission_groups_iter
key=|(category, _)| category.clone()
children=move |(category, perms)| {
view! {
<div class="mb-4">
<h4 class="font-medium text-gray-900 mb-2">{category}</h4>
<div class="space-y-2">
<For
each=move || perms.clone()
key=|perm| perm.id.clone()
children=move |perm| {
let perm_id = perm.id.clone();
view! {
<label class="flex items-center">
<input
type="checkbox"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
prop:checked=move || {
let perm_id = perm_id.clone();
form_data.get().permissions.contains(&perm_id)
}
on:change=move |ev| {
let checked = event_target_checked(&ev);
set_form_data.update(|data| {
if checked {
if !data.permissions.contains(&perm.id) {
data.permissions.push(perm.id.clone());
}
} else {
data.permissions.retain(|p| p != &perm.id);
}
});
}
/>
<span class="ml-2 text-sm text-gray-700">
{perm.name.clone()}
</span>
</label>
}
}
/>
</div>
</div>
}
}
/>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
{i18n.t("cancel")}
</button>
<button
type="submit"
disabled=move || submitting.get()
class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
<Show
when=move || submitting.get()
fallback=|| "Create Role"
>
{i18n.t("creating")}
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}
#[component]
fn EditRoleModal(
role: Option<Role>,
permissions: Vec<Permission>,
on_close: impl Fn() + 'static + Clone + Send + Sync,
on_role_updated: impl Fn(Role) + 'static + Clone + Send + Sync,
) -> impl IntoView {
let i18n = use_i18n();
let role = role.unwrap_or_default();
let (form_data, set_form_data) = signal(UpdateRoleRequest {
id: role.id.clone(),
name: role.name.clone(),
description: role.description.clone(),
permissions: role.permissions.clone(),
});
let (submitting, set_submitting) = signal(false);
let (error, set_error) = signal(None::<String>);
// Group permissions by category
let permissions_options = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| {
let mut groups: HashMap<String, Vec<Permission>> = HashMap::new();
for perm in permissions.iter() {
let category = perm.category.clone();
groups
.entry(category)
.or_insert_with(Vec::new)
.push(perm.clone());
}
groups
});
let submit_form = Action::new({
let on_role_updated = on_role_updated.clone();
move |_: &()| {
let form_data = form_data.get();
let on_role_updated = on_role_updated.clone();
async move {
set_submitting.set(true);
set_error.set(None);
match update_role_api(form_data).await {
Ok(role) => {
on_role_updated(role);
set_submitting.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_submitting.set(false);
}
}
}
}
});
// Create iterator functions outside view macro to avoid parsing issues
let permission_groups_iter_edit =
move || permissions_options.get().into_iter().collect::<Vec<_>>();
view! {
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-full max-w-2xl shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
{i18n.t("edit-role")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
<svg class="h-6 w-6" 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"></path>
</svg>
</button>
</div>
<Show when=move || error.get().is_some()>
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</Show>
<form on:submit=move |ev| {
ev.prevent_default();
let _ = submit_form.input();
}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("role-name")}
</label>
<input
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().name
on:input=move |ev| {
set_form_data.update(|data| data.name = event_target_value(&ev));
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("description")}
</label>
<textarea
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
rows="3"
prop:value=move || form_data.get().description
on:input=move |ev| {
set_form_data.update(|data| data.description = event_target_value(&ev));
}
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
{i18n.t("permissions")}
</label>
<div class="max-h-60 overflow-y-auto border border-gray-200 rounded-md p-3">
<For
each=permission_groups_iter_edit
key=|(category, _)| category.clone()
children=move |(category, perms)| {
view! {
<div class="mb-4">
<h4 class="font-medium text-gray-900 mb-2">{category}</h4>
<div class="space-y-2">
<For
each=move || perms.clone()
key=|perm| perm.id.clone()
children=move |perm| {
let perm_id_input = perm.id.clone();
view! {
<label class="flex items-center">
<input
type="checkbox"
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
prop:checked=move || {
let data = form_data.get();
let perm_id = perm_id_input.clone();
data.permissions.contains(&perm_id)
}
on:change=move |ev| {
let checked = event_target_checked(&ev);
set_form_data.update(|data| {
if checked {
if !data.permissions.contains(&perm.id) {
data.permissions.push(perm.id.clone());
}
} else {
data.permissions.retain(|p| p != &perm.id);
}
});
}
/>
<span class="ml-2 text-sm text-gray-700">
{perm.name.clone()}
</span>
</label>
}
}
/>
</div>
</div>
}
}
/>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
{i18n.t("cancel")}
</button>
<button
type="submit"
disabled=move || submitting.get()
class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
<Show
when=move || submitting.get()
fallback=|| "Update Role"
>
{i18n.t("updating")}
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}
#[component]
fn PermissionsModal(
permissions: Vec<Permission>,
on_close: impl Fn() + 'static + Send + Sync + Clone,
) -> impl IntoView {
let i18n = use_i18n();
// Group permissions by category
let permission_groups = Memo::new(move |_prev: Option<&HashMap<String, Vec<Permission>>>| {
let mut groups: HashMap<String, Vec<Permission>> = HashMap::new();
for perm in permissions.iter() {
let category = perm.category.clone();
groups
.entry(category)
.or_insert_with(Vec::new)
.push(perm.clone());
}
groups
});
// Create iterator functions outside view macro to avoid parsing issues
let permission_groups_iter_view =
move || permission_groups.get().into_iter().collect::<Vec<_>>();
view! {
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
{i18n.t("system-permissions")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
<svg class="h-6 w-6" 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"></path>
</svg>
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<For
each=permission_groups_iter_view
key=|(category, _)| category.clone()
children=move |(category, perms)| {
view! {
<div class="mb-6">
<h4 class="font-medium text-gray-900 mb-3 text-lg border-b pb-2">
{category}
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<For
each=move || perms.clone()
key=|perm| perm.id.clone()
children=move |perm| {
view! {
<div class="bg-gray-50 p-3 rounded-lg">
<div class="font-medium text-sm text-gray-900">
{perm.name.clone()}
</div>
<div class="text-xs text-gray-500 mt-1">
{perm.description.clone()}
</div>
<div class="text-xs text-gray-400 mt-2">
{format!("{} : {}", perm.resource, perm.action)}
</div>
</div>
}
}
/>
</div>
</div>
}
}
/>
</div>
</div>
</div>
</div>
}
}
impl Default for Role {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
description: String::new(),
permissions: Vec::new(),
created_at: String::new(),
updated_at: String::new(),
user_count: 0,
is_system_role: false,
}
}
}
impl Default for Permission {
fn default() -> Self {
Self {
id: String::new(),
name: String::new(),
description: String::new(),
category: String::new(),
resource: String::new(),
action: String::new(),
}
}
}
async fn fetch_roles_and_permissions() -> Result<(Vec<Role>, Vec<Permission>), String> {
// Mock data for now - replace with actual API call
let roles = vec![
Role {
id: "1".to_string(),
name: "Administrator".to_string(),
description: "Full system access".to_string(),
permissions: vec!["1".to_string(), "2".to_string(), "3".to_string()],
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
user_count: 2,
is_system_role: true,
},
Role {
id: "2".to_string(),
name: "User".to_string(),
description: "Standard user access".to_string(),
permissions: vec!["3".to_string()],
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
user_count: 10,
is_system_role: true,
},
Role {
id: "3".to_string(),
name: "Moderator".to_string(),
description: "Content moderation access".to_string(),
permissions: vec!["3".to_string(), "4".to_string()],
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
user_count: 5,
is_system_role: false,
},
];
let permissions = vec![
Permission {
id: "1".to_string(),
name: "User Management".to_string(),
description: "Create, read, update, and delete users".to_string(),
category: "Administration".to_string(),
resource: "users".to_string(),
action: "manage".to_string(),
},
Permission {
id: "2".to_string(),
name: "Role Management".to_string(),
description: "Create, read, update, and delete roles".to_string(),
category: "Administration".to_string(),
resource: "roles".to_string(),
action: "manage".to_string(),
},
Permission {
id: "3".to_string(),
name: "Read Profile".to_string(),
description: "View own profile information".to_string(),
category: "Profile".to_string(),
resource: "profile".to_string(),
action: "read".to_string(),
},
Permission {
id: "4".to_string(),
name: "Content Moderation".to_string(),
description: "Moderate user-generated content".to_string(),
category: "Content".to_string(),
resource: "content".to_string(),
action: "moderate".to_string(),
},
];
Ok((roles, permissions))
}
async fn create_role_api(role_data: CreateRoleRequest) -> Result<Role, String> {
// Mock implementation - replace with actual API call
Ok(Role {
id: format!("role_{}", 12345),
name: role_data.name,
description: role_data.description,
permissions: role_data.permissions,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
user_count: 0,
is_system_role: false,
})
}
async fn update_role_api(role_data: UpdateRoleRequest) -> Result<Role, String> {
// Mock implementation - replace with actual API call
Ok(Role {
id: role_data.id,
name: role_data.name,
description: role_data.description,
permissions: role_data.permissions,
created_at: "2024-01-01T00:00:00Z".to_string(),
updated_at: "2024-01-01T00:00:00Z".to_string(),
user_count: 0,
is_system_role: false,
})
}
async fn delete_role_api(role_id: &str) -> Result<(), String> {
// Mock implementation - replace with actual API call
web_sys::console::log_1(&format!("Deleting role: {}", role_id).into());
Ok(())
}

View File

@ -1,902 +0,0 @@
use crate::i18n::use_i18n;
use leptos::prelude::*;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
#[cfg(not(target_arch = "wasm32"))]
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
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct User {
pub id: String,
pub email: String,
pub name: String,
pub roles: Vec<String>,
pub status: UserStatus,
pub created_at: String,
pub last_login: Option<String>,
pub is_verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum UserStatus {
Active,
Inactive,
Suspended,
Pending,
}
impl UserStatus {
fn to_string(&self) -> String {
// &'static str {
let i18n = use_i18n();
match self {
UserStatus::Active => i18n.t("active"),
UserStatus::Inactive => i18n.t("inactive"),
UserStatus::Suspended => i18n.t("suspended"),
UserStatus::Pending => i18n.t("pending"),
}
}
fn badge_class(&self) -> &'static str {
match self {
UserStatus::Active => "bg-green-100 text-green-800",
UserStatus::Inactive => "bg-gray-100 text-gray-800",
UserStatus::Suspended => "bg-red-100 text-red-800",
UserStatus::Pending => "bg-yellow-100 text-yellow-800",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateUserRequest {
pub email: String,
pub name: String,
pub roles: Vec<String>,
pub send_invitation: bool,
}
#[component]
pub fn AdminUsers() -> impl IntoView {
let i18n = use_i18n();
let (users, set_users) = signal(Vec::<User>::new());
let (loading, set_loading) = signal(true);
let (error, set_error) = signal(None::<String>);
let (selected_user, set_selected_user) = signal(None::<User>);
let (show_create_modal, set_show_create_modal) = signal(false);
let (show_edit_modal, set_show_edit_modal) = signal(false);
let (search_term, set_search_term) = signal(String::new());
let (status_filter, set_status_filter) = signal(String::new());
// Fetch users on mount
Effect::new(move |_| {
spawn_local(async move {
match fetch_users().await {
Ok(data) => {
set_users.set(data);
set_loading.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_loading.set(false);
}
}
});
});
// Filtered users
let filtered_users = Memo::new(move |_| {
let search = search_term.get().to_lowercase();
let status = status_filter.get();
users
.get()
.into_iter()
.filter(|user| {
let matches_search = search.is_empty()
|| user.name.to_lowercase().contains(&search)
|| user.email.to_lowercase().contains(&search);
let matches_status = status.is_empty()
|| user.status.to_string().to_lowercase() == status.to_lowercase();
matches_search && matches_status
})
.collect::<Vec<_>>()
});
let delete_user = Action::new(move |user_id: &String| {
let user_id = user_id.clone();
async move {
match delete_user_api(&user_id).await {
Ok(_) => {
set_users.update(|users| users.retain(|u| u.id != user_id));
Ok(())
}
Err(e) => {
set_error.set(Some(e.clone()));
Err(e)
}
}
}
});
let toggle_user_status = Action::new(move |user_id: &String| {
let user_id = user_id.clone();
async move {
match toggle_user_status_api(&user_id).await {
Ok(updated_user) => {
set_users.update(|users| {
if let Some(user) = users.iter_mut().find(|u| u.id == user_id) {
*user = updated_user;
}
});
Ok(())
}
Err(e) => {
set_error.set(Some(e.clone()));
Err(e)
}
}
}
});
view! {
<div class="min-h-screen bg-gray-50">
<div class="py-6">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="pb-5 border-b border-gray-200">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold leading-tight text-gray-900">
{i18n.t("user-management")}
</h1>
<button
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded-lg"
on:click=move |_| set_show_create_modal.set(true)
>
{i18n.t("add-new-user")}
</button>
</div>
</div>
// Error Alert
<Show when=move || error.get().is_some()>
<div class="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<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"></path>
</svg>
<div class="ml-3">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</div>
</div>
</Show>
// Search and Filter
<div class="mt-6 bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("search-users")}
</label>
<input
type="text"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Search by name or email..."
prop:value=move || search_term.get()
on:input=move |ev| set_search_term.set(event_target_value(&ev))
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("filter-by-status")}
</label>
<select
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || status_filter.get()
on:change=move |ev| set_status_filter.set(event_target_value(&ev))
>
<option value="">{i18n.t("all-status")}</option>
<option value="active">{i18n.t("active")}</option>
<option value="inactive">{i18n.t("inactive")}</option>
<option value="suspended">{i18n.t("suspended")}</option>
<option value="pending">{i18n.t("pending")}</option>
</select>
</div>
<div class="flex items-end">
<button
class="w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg"
on:click=move |_| {
set_search_term.set(String::new());
set_status_filter.set(String::new());
}
>
{i18n.t("clear-filters")}
</button>
</div>
</div>
</div>
</div>
// Users Table
<div class="mt-6 bg-white shadow overflow-hidden sm:rounded-md">
<Show
when=move || !loading.get()
fallback=|| view! { <UsersTableSkeleton /> }
>
<div class="min-w-full overflow-hidden overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{i18n.t("user")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{i18n.t("roles")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{i18n.t("status")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{i18n.t("last-login")}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{i18n.t("actions")}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<For
each=move || filtered_users.get()
key=|user| user.id.clone()
children=move |user| {
let delete_id = user.id.clone();
let activate_id = user.id.clone();
let user_name = user.name.clone();
let user_status = user.status.clone();
view! {
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-sm font-medium text-gray-700">
{user.name.chars().next().unwrap_or('U')}
</span>
</div>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
{user.name.clone()}
</div>
<div class="text-sm text-gray-500">
{user.email.clone()}
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
{user.roles.iter().map(|role| {
view! {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{role.clone()}
</span>
}
}).collect::<Vec<_>>()}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class=format!("inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {}", user.status.badge_class())>
{user.status.to_string()}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.last_login.as_ref().unwrap_or(&"Never".to_string()).clone()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<button
class="text-indigo-600 hover:text-indigo-900"
on:click=move |_| {
set_selected_user.set(Some(user.clone()));
set_show_edit_modal.set(true);
}
>
"Edit"
</button>
<button
class="text-yellow-600 hover:text-yellow-900"
on:click=move |_| { toggle_user_status.dispatch(activate_id.clone()); }
>
{match user_status {
UserStatus::Active => "Suspend",
_ => "Activate",
}}
</button>
<button
class="text-red-600 hover:text-red-900"
on:click=move |_| {
if let Some(window) = web_sys::window() {
if window.confirm_with_message(&format!("Are you sure you want to delete user {}?", user_name)).unwrap_or(false) {
let _ = delete_user.dispatch(delete_id.clone());
}
}
}
>
"Delete"
</button>
</div>
</td>
</tr>
}
}
/>
</tbody>
</table>
</div>
</Show>
</div>
</div>
</div>
// Create User Modal
<Show when=move || show_create_modal.get()>
<CreateUserModal
on_close=move || set_show_create_modal.set(false)
on_user_created=move |user| {
set_users.update(|users| users.push(user));
set_show_create_modal.set(false);
}
/>
</Show>
// Edit User Modal
<Show when=move || show_edit_modal.get()>
<EditUserModal
user=selected_user.get()
on_close=move || set_show_edit_modal.set(false)
on_user_updated=move |updated_user| {
set_users.update(|users| {
if let Some(user) = users.iter_mut().find(|u| u.id == updated_user.id) {
*user = updated_user;
}
});
set_show_edit_modal.set(false);
}
/>
</Show>
</div>
}
}
#[component]
fn UsersTableSkeleton() -> impl IntoView {
view! {
<div class="animate-pulse">
<div class="bg-gray-50 px-6 py-3">
<div class="h-4 bg-gray-200 rounded w-full"></div>
</div>
<div class="divide-y divide-gray-200">
{(0..5).map(|_| view! {
<div class="px-6 py-4">
<div class="flex items-center space-x-4">
<div class="h-10 w-10 bg-gray-200 rounded-full"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
</div>
</div>
</div>
}).collect::<Vec<_>>()}
</div>
</div>
}
}
#[component]
fn CreateUserModal(
on_close: impl Fn() + 'static + Clone,
on_user_created: impl Fn(User) + 'static + Clone + Send + Sync,
) -> impl IntoView {
let i18n = use_i18n();
let (form_data, set_form_data) = signal(CreateUserRequest {
email: String::new(),
name: String::new(),
roles: Vec::new(),
send_invitation: true,
});
let (submitting, set_submitting) = signal(false);
let (error, set_error) = signal(None::<String>);
let available_roles = vec![
"admin".to_string(),
"user".to_string(),
"moderator".to_string(),
];
let submit_form = Action::new(move |_: &()| {
let form_data = form_data.get();
let on_user_created = on_user_created.clone();
async move {
set_submitting.set(true);
set_error.set(None);
match create_user_api(form_data).await {
Ok(user) => {
on_user_created(user);
set_submitting.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_submitting.set(false);
}
}
}
});
view! {
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
"Create New User"
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
<svg class="h-6 w-6" 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"></path>
</svg>
</button>
</div>
<Show when=move || error.get().is_some()>
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</Show>
<form on:submit=move |ev| {
ev.prevent_default();
submit_form.dispatch(());
}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("email")}
</label>
<input
type="email"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().email
on:input=move |ev| {
let value = event_target_value(&ev);
set_form_data.update(|data| data.email = value);
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("name")}
</label>
<input
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().name
on:input=move |ev| {
let value = event_target_value(&ev);
set_form_data.update(|data| data.name = value);
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("roles")}
</label>
<div class="mt-2 space-y-2">
<For
each=move || available_roles.clone()
key=|role| role.clone()
children=move |role| {
let role_for_memo = role.clone();
let role_checked = Memo::new(move |_| {
form_data.get().roles.contains(&role_for_memo)
});
view! {
<div class="flex items-center">
<input
type="checkbox"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
prop:checked=move || role_checked.get()
on:change={
let role_clone = role.clone();
move |ev| {
let checked = event_target_checked(&ev);
let role_for_update = role_clone.clone();
set_form_data.update(|data| {
if checked {
if !data.roles.contains(&role_for_update) {
data.roles.push(role_for_update);
}
} else {
data.roles.retain(|r| r != &role_for_update);
}
});
}
}
/>
<label class="ml-2 text-sm text-gray-900">
{role.clone()}
</label>
</div>
}
}
/>
</div>
</div>
<div class="flex items-center">
<input
type="checkbox"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
prop:checked=move || form_data.get().send_invitation
on:change=move |ev| {
let checked = event_target_checked(&ev);
set_form_data.update(|data| data.send_invitation = checked);
}
/>
<label class="ml-2 text-sm text-gray-900">
{i18n.t("send-invitation-email")}
</label>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
{i18n.t("cancel")}
</button>
<button
type="submit"
disabled=move || submitting.get()
class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
<Show
when=move || submitting.get()
fallback=|| "Create User"
>
{i18n.t("creating")}
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}
#[component]
fn EditUserModal(
user: Option<User>,
on_close: impl Fn() + Send + Sync + Clone + 'static,
on_user_updated: impl Fn(User) + Send + Sync + Clone + 'static,
) -> impl IntoView {
let i18n = use_i18n();
let user = user.unwrap_or_default();
let (form_data, set_form_data) = signal(UpdateUserRequest {
id: user.id.clone(),
email: user.email.clone(),
name: user.name.clone(),
roles: user.roles.clone(),
});
let (submitting, set_submitting) = signal(false);
let (error, set_error) = signal(None::<String>);
let available_roles = vec![
"admin".to_string(),
"user".to_string(),
"moderator".to_string(),
];
let submit_form = Action::new({
let form_data = form_data.clone();
let set_submitting = set_submitting.clone();
let set_error = set_error.clone();
let on_user_updated = on_user_updated.clone();
move |_: &()| {
let form_data = form_data.get();
let on_user_updated = on_user_updated.clone();
async move {
set_submitting.set(true);
set_error.set(None);
match update_user_api(form_data).await {
Ok(user) => {
on_user_updated(user);
set_submitting.set(false);
}
Err(e) => {
set_error.set(Some(e));
set_submitting.set(false);
}
}
}
}
});
view! {
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-full max-w-md shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">
{i18n.t("edit-user")}
</h3>
<button
class="text-gray-400 hover:text-gray-600"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
<svg class="h-6 w-6" 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"></path>
</svg>
</button>
</div>
<Show when=move || error.get().is_some()>
<div class="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
<p class="text-sm text-red-800">
{move || error.get().unwrap_or_default()}
</p>
</div>
</Show>
<form on:submit=move |ev| {
ev.prevent_default();
submit_form.dispatch(());
}>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("email")}
</label>
<input
type="email"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().email
on:input=move |ev| {
let value = event_target_value(&ev);
set_form_data.update(|data| data.email = value);
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("name")}
</label>
<input
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
prop:value=move || form_data.get().name
on:input=move |ev| {
let value = event_target_value(&ev);
set_form_data.update(|data| data.name = value);
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{i18n.t("roles")}
</label>
<div class="mt-2 space-y-2">
<For
each=move || available_roles.clone()
key=|role| role.clone()
children=move |role| {
let role_clone = role.clone();
let role_checked = Memo::new(move |_| {
form_data.get().roles.contains(&role_clone)
});
view! {
<div class="flex items-center">
<input
type="checkbox"
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
prop:checked=move || role_checked.get()
on:change={
let role_clone2 = role.clone();
move |ev| {
let checked = event_target_checked(&ev);
let role_for_update = role_clone2.clone();
let role_for_retain = role_clone2.clone();
set_form_data.update(|data| {
if checked {
if !data.roles.contains(&role_for_update) {
data.roles.push(role_for_update);
}
} else {
data.roles.retain(|r| r != &role_for_retain);
}
});
}
}
/>
<label class="ml-2 text-sm text-gray-900">
{role.clone()}
</label>
</div>
}
}
/>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
type="button"
class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50"
on:click={
let on_close_clone = on_close.clone();
move |_| on_close_clone()
}
>
{i18n.t("cancel")}
</button>
<button
type="submit"
disabled=move || submitting.get()
class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
<Show
when=move || submitting.get()
fallback=|| "Update User"
>
{i18n.t("updating")}
</Show>
</button>
</div>
</form>
</div>
</div>
</div>
}
}
impl Default for User {
fn default() -> Self {
Self {
id: String::new(),
email: String::new(),
name: String::new(),
roles: vec!["user".to_string()],
status: UserStatus::Active,
created_at: String::new(),
last_login: None,
is_verified: false,
}
}
}
// API Functions
async fn fetch_users() -> Result<Vec<User>, String> {
// Mock data for now - replace with actual API call
Ok(vec![
User {
id: "1".to_string(),
email: "admin@example.com".to_string(),
name: "Admin User".to_string(),
roles: vec!["admin".to_string(), "user".to_string()],
status: UserStatus::Active,
created_at: "2024-01-01T00:00:00Z".to_string(),
last_login: Some("2024-01-15T10:30:00Z".to_string()),
is_verified: true,
},
User {
id: "2".to_string(),
email: "user@example.com".to_string(),
name: "Regular User".to_string(),
roles: vec!["user".to_string()],
status: UserStatus::Active,
created_at: "2024-01-02T00:00:00Z".to_string(),
last_login: Some("2024-01-14T15:45:00Z".to_string()),
is_verified: true,
},
])
}
async fn create_user_api(user_data: CreateUserRequest) -> Result<User, String> {
// Mock implementation - replace with actual API call
Ok(User {
id: format!("user_{}", 12345),
email: user_data.email,
name: user_data.name,
roles: user_data.roles,
status: UserStatus::Active,
created_at: "2024-01-01T00:00:00Z".to_string(),
last_login: None,
is_verified: false,
})
}
async fn update_user_api(user_data: UpdateUserRequest) -> Result<User, String> {
// Mock implementation - replace with actual API call
Ok(User {
id: user_data.id,
email: user_data.email,
name: user_data.name,
roles: user_data.roles,
status: UserStatus::Active,
created_at: "2024-01-01T00:00:00Z".to_string(),
last_login: None,
is_verified: true,
})
}
async fn delete_user_api(user_id: &str) -> Result<(), String> {
// Mock implementation - replace with actual API call
web_sys::console::log_1(&format!("Deleting user: {}", user_id).into());
Ok(())
}
async fn toggle_user_status_api(user_id: &str) -> Result<User, String> {
// Mock implementation - replace with actual API call
Ok(User {
id: user_id.to_string(),
email: "updated@example.com".to_string(),
name: "Updated User".to_string(),
roles: vec!["user".to_string()],
status: UserStatus::Active,
created_at: "2024-01-01T00:00:00Z".to_string(),
last_login: Some("2024-01-01T10:00:00Z".to_string()),
is_verified: true,
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateUserRequest {
pub id: String,
pub email: String,
pub name: String,
pub roles: Vec<String>,
}

View File

@ -1,9 +0,0 @@
pub mod Content;
pub mod Dashboard;
pub mod Roles;
pub mod Users;
pub use Content::*;
pub use Dashboard::*;
pub use Roles::*;
pub use Users::*;

View File

@ -1,250 +0,0 @@
//! Contact page component
//!
//! This page demonstrates the usage of the ContactForm component and provides
//! a complete contact page implementation with additional information and styling.
use crate::components::forms::ContactForm;
use leptos::prelude::*;
use leptos_meta::*;
#[component]
pub fn ContactPage() -> impl IntoView {
view! {
<Title text="Contact Us - Get in Touch"/>
<Meta name="description" content="Contact us for questions, support, or feedback. We're here to help!"/>
<div class="min-h-screen bg-gray-50 py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
// Header Section
<div class="text-center mb-16">
<h1 class="text-4xl font-bold text-gray-900 sm:text-5xl mb-4">
"Get in Touch"
</h1>
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
"We'd love to hear from you. Whether you have a question about features, "
"pricing, need support, or anything else, our team is ready to answer all your questions."
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
// Contact Information
<div class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">
"Contact Information"
</h2>
<div class="space-y-6">
// Email
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">"Email"</h3>
<p class="text-gray-600">"contact@yourapp.com"</p>
<p class="text-sm text-gray-500">"We'll respond within 24 hours"</p>
</div>
</div>
// Support
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 12l2.828-2.828m0 5.656L12 12m0 0l-2.828-2.828M12 12l2.828 2.828"/>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">"Support"</h3>
<p class="text-gray-600">"support@yourapp.com"</p>
<p class="text-sm text-gray-500">"Technical support and assistance"</p>
</div>
</div>
// Response Time
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-medium text-gray-900">"Response Time"</h3>
<p class="text-gray-600">"Usually within 4 hours"</p>
<p class="text-sm text-gray-500">"Business hours: Mon-Fri 9AM-5PM EST"</p>
</div>
</div>
</div>
// Quick Links
<div class="mt-8 pt-8 border-t border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4">"Quick Links"</h3>
<div class="space-y-2">
<a href="/docs" class="block text-blue-600 hover:text-blue-700 text-sm">
"📚 Documentation"
</a>
<a href="/faq" class="block text-blue-600 hover:text-blue-700 text-sm">
"❓ Frequently Asked Questions"
</a>
<a href="/support" class="block text-blue-600 hover:text-blue-700 text-sm">
"🛠️ Support Center"
</a>
<a href="/status" class="block text-blue-600 hover:text-blue-700 text-sm">
"📊 System Status"
</a>
</div>
</div>
</div>
</div>
// Contact Form
<div class="lg:col-span-2">
<div class="bg-white rounded-lg shadow-lg p-8">
<ContactForm
title="Send us a Message"
description="Fill out the form below and we'll get back to you as soon as possible."
recipient="contact@yourapp.com"
submit_text="Send Message"
show_success=true
reset_after_success=true
class=""
/>
</div>
</div>
</div>
// FAQ Section
<div class="mt-16">
<div class="bg-white rounded-lg shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-8 text-center">
"Frequently Asked Questions"
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
// FAQ Item 1
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"How quickly do you respond to messages?"
</h3>
<p class="text-gray-600">
"We aim to respond to all messages within 4 hours during business hours "
"(Mon-Fri 9AM-5PM EST). For urgent matters, please mark your message as high priority."
</p>
</div>
// FAQ Item 2
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"What information should I include in my message?"
</h3>
<p class="text-gray-600">
"Please include as much detail as possible about your question or issue. "
"If it's a technical problem, include any error messages and steps to reproduce the issue."
</p>
</div>
// FAQ Item 3
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"Do you offer phone support?"
</h3>
<p class="text-gray-600">
"Currently, we provide support primarily through email and our contact form. "
"This allows us to better track and resolve issues while providing detailed responses."
</p>
</div>
// FAQ Item 4
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"Can I request new features?"
</h3>
<p class="text-gray-600">
"Absolutely! We love hearing feature requests from our users. "
"Please describe the feature you'd like and how it would help you."
</p>
</div>
</div>
</div>
</div>
// Alternative Contact Methods
<div class="mt-16">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 mb-8">
"Other Ways to Reach Us"
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
// Technical Support
<div class="bg-blue-50 rounded-lg p-6">
<div class="text-blue-600 mb-4">
<svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"Technical Support"
</h3>
<p class="text-gray-600 mb-4">
"For technical issues, bugs, or integration help"
</p>
<a
href="/support"
class="inline-block bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
>
"Open Support Ticket"
</a>
</div>
// Sales Inquiries
<div class="bg-green-50 rounded-lg p-6">
<div class="text-green-600 mb-4">
<svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"Sales & Pricing"
</h3>
<p class="text-gray-600 mb-4">
"Questions about pricing, plans, or enterprise solutions"
</p>
<a
href="mailto:sales@yourapp.com"
class="inline-block bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 transition-colors"
>
"Contact Sales"
</a>
</div>
// General Feedback
<div class="bg-purple-50 rounded-lg p-6">
<div class="text-purple-600 mb-4">
<svg class="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">
"Feedback & Suggestions"
</h3>
<p class="text-gray-600 mb-4">
"Share your ideas, feedback, or feature requests"
</p>
<a
href="mailto:feedback@yourapp.com"
class="inline-block bg-purple-600 text-white px-4 py-2 rounded-md hover:bg-purple-700 transition-colors"
>
"Send Feedback"
</a>
</div>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -1,13 +0,0 @@
#![allow(non_snake_case)]
mod About;
mod DaisyUI;
mod FeaturesDemo;
mod Home;
mod User;
pub mod admin;
pub use About::*;
pub use DaisyUI::*;
pub use FeaturesDemo::*;
pub use Home::*;
pub use User::*;

View File

@ -1,42 +0,0 @@
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

@ -1,243 +0,0 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
/// Theme variants supported by the application
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Theme {
Light,
Dark,
Auto,
}
impl Default for Theme {
fn default() -> Self {
Self::Light
}
}
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 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 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-2 shadow bg-base-100 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

@ -1,142 +0,0 @@
use leptos::ev::MouseEvent;
#[cfg(target_arch = "wasm32")]
use leptos::prelude::Effect;
use leptos::prelude::{Set, WriteSignal};
use serde::{Deserialize, Serialize};
use std::rc::Rc;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use web_sys::window;
#[cfg(not(target_arch = "wasm32"))]
fn window() -> Option<()> {
None
}
// --- Type Aliases for Closures ---
pub type NavigateFn = Rc<dyn Fn(&str)>;
pub type LinkClickFn = Rc<dyn Fn(MouseEvent, &str)>;
// Returns the initial path for SSR or client hydration.
/// In the future, this could use a context or prop for SSR path awareness.
#[cfg(target_arch = "wasm32")]
pub fn get_initial_path() -> String {
window()
.and_then(|win| win.location().pathname().ok())
.unwrap_or_else(|| "/".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn get_initial_path() -> String {
"/".to_string()
}
/// Creates a navigation function for SPA routing.
#[cfg(target_arch = "wasm32")]
pub fn make_navigate(set_path: WriteSignal<String>) -> NavigateFn {
Rc::new(move |to: &str| {
web_sys::console::log_1(&format!("Navigating to: {to}").into());
if let Some(win) = window() {
if let Some(history) = win.history().ok() {
let _ = history.push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(to));
}
}
set_path.set(to.to_string());
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn make_navigate(set_path: WriteSignal<String>) -> NavigateFn {
Rc::new(move |to: &str| {
set_path.set(to.to_string());
})
}
/// 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())
}
}
/// Creates a link click handler for SPA navigation.
pub fn make_on_link_click(set_path: WriteSignal<String>, navigate: NavigateFn) -> LinkClickFn {
if window().is_some() {
Rc::new(move |ev: MouseEvent, to: &str| {
web_sys::console::log_1(&format!("Clicked: {to}").into());
ev.prevent_default();
set_path.set(to.to_string());
(*navigate)(to);
})
} else {
Rc::new(|_, _| {})
}
}
/// Sets up a popstate event listener for SPA navigation (client only).
#[cfg(target_arch = "wasm32")]
pub fn make_popstate_effect(set_path: WriteSignal<String>) {
if let Some(win) = window() {
Effect::new(move |_| {
let closure = wasm_bindgen::closure::Closure::wrap(Box::new(move || {
if let Some(win) = window() {
let new_path = win
.location()
.pathname()
.unwrap_or_else(|_| "/".to_string());
set_path.set(new_path);
}
}) as Box<dyn Fn()>);
let _ =
win.add_event_listener_with_callback("popstate", closure.as_ref().unchecked_ref());
closure.forget();
});
}
}
/// No-op for server.
#[cfg(not(target_arch = "wasm32"))]
pub fn make_popstate_effect(_set_path: WriteSignal<String>) {}

View File

@ -1,81 +0,0 @@
// uno.config.ts
// import type { Theme } from '@unocss/preset-mini'
import {
defineConfig,
presetAttributify,
presetIcons,
presetTypography,
presetUno,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from "unocss";
import { presetDaisy } from "unocss-preset-daisy";
export default defineConfig({
cli: {
entry: {
patterns: ["src/**/*.rs", "client/src/**/*.rs"],
outFile: "target/site/pkg/website.css",
},
},
shortcuts: [
{
btn: "px-4 py-1 rounded inline-block bg-primary text-white cursor-pointer tracking-wide op90 hover:op100 disabled:cursor-default disabled:bg-gray-600 disabled:!op50 disabled:pointer-events-none",
"indigo-btn":
"ml-5 capitalize !text-2xl !text-indigo-800 !bg-indigo-200 border-0.5 !border-indigo-500 dark:!text-indigo-200 dark:!bg-indigo-800 hover:!bg-gray-100 dark:hover:!bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg font-bold !p-5 md:!p-8",
"icon-btn":
"text-1.2em cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-primary disabled:pointer-events-none",
"square-btn":
"flex flex-gap-2 items-center border border-base px2 py1 relative !outline-none",
"square-btn-mark":
"absolute h-2 w-2 bg-primary -right-0.2rem -top-0.2rem",
"bg-base": "bg-white dark:bg-[#121212]",
"bg-overlay": "bg-[#eee]:50 dark:bg-[#222]:50",
"bg-header": "bg-gray-500:5",
"bg-active": "bg-gray-500:8",
"bg-hover": "bg-gray-500:20",
"border-base": "border-gray-400:10",
"tab-button": "font-light op50 hover:op80 h-full px-4",
"tab-button-active": "op100 bg-gray-500:10",
},
[/^(flex|grid)-center/g, () => "justify-center items-center"],
[/^(flex|grid)-x-center/g, () => "justify-center"],
[/^(flex|grid)-y-center/g, () => "items-center"],
],
rules: [
["max-h-screen", { "max-height": "calc(var(--vh, 1vh) * 100)" }],
["h-screen", { height: "calc(var(--vh, 1vh) * 100)" }],
],
// theme: <Theme>{
theme: {
colors: {
ok: "var(--c-ok)",
primary: "var(--c-primary)",
"primary-deep": "var(--c-primary-deep)",
mis: "var(--c-mis)",
},
},
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
autoInstall: true,
collections: {
carbon: () =>
import("@iconify-json/carbon/icons.json").then((i) => i.default),
},
}),
presetTypography(),
presetWebFonts({
fonts: {
// ...
},
}),
presetDaisy(),
],
transformers: [transformerDirectives(), transformerVariantGroup()],
});

View File

@ -1,287 +0,0 @@
---
title: "Getting Started with Admin Dashboard"
slug: "admin-getting-started"
name: "Getting Started Guide"
author: "Documentation Team"
author_id: "550e8400-e29b-41d4-a716-446655440002"
content_type: "documentation"
content_format: "markdown"
container: "main"
state: "published"
require_login: false
date_init: "2024-01-15T09:00:00Z"
date_end: null
published_at: "2024-01-15T09:00:00Z"
tags: ["guide", "documentation", "admin", "getting-started"]
category: "Documentation"
featured_image: "/images/admin-guide.jpg"
excerpt: "Complete guide to getting started with the admin dashboard. Learn user management, content creation, and system administration."
seo_title: "Admin Dashboard Getting Started Guide - Complete Tutorial"
seo_description: "Master the admin dashboard with our comprehensive getting started guide. User management, content creation, roles, and more."
allow_comments: true
sort_order: 1
metadata:
reading_time: "8 minutes"
difficulty: "beginner"
language: "en"
version: "1.0"
---
# Getting Started with Admin Dashboard
Welcome to the comprehensive admin dashboard guide! This documentation will help you master all aspects of system administration, from user management to content creation.
## Table of Contents
1. [Dashboard Overview](#dashboard-overview)
2. [User Management](#user-management)
3. [Role-Based Access Control](#role-based-access-control)
4. [Content Management](#content-management)
5. [System Settings](#system-settings)
6. [Best Practices](#best-practices)
## Dashboard Overview
The admin dashboard provides a centralized interface for managing your application. Key features include:
### Main Dashboard Features
- **📊 Analytics Overview** - Real-time statistics and metrics
- **👥 User Management** - Create, edit, and manage user accounts
- **🔐 Role Management** - Configure permissions and access levels
- **📝 Content Management** - Create and publish content
- **⚙️ System Settings** - Configure application settings
### Navigation
The sidebar navigation provides quick access to all admin functions:
```
Admin Dashboard
├── Dashboard (Overview & Stats)
├── Users (User Management)
├── Roles (Permission Management)
├── Content (Content Management)
└── Settings (System Configuration)
```
## User Management
### Creating New Users
1. Navigate to **Admin → Users**
2. Click **"Add New User"**
3. Fill in the required information:
- **Email Address** (required)
- **Display Name** (required)
- **Password** (auto-generated or custom)
- **Roles** (select appropriate permissions)
4. Click **"Create User"**
### User Status Management
Users can have different status levels:
| Status | Description | Actions Available |
|--------|-------------|-------------------|
| **Active** | Full access to assigned features | Edit, Suspend, Delete |
| **Inactive** | Account exists but login disabled | Activate, Edit, Delete |
| **Suspended** | Temporary restriction | Activate, Edit, Delete |
| **Pending** | Awaiting email verification | Resend Invite, Delete |
### Bulk Operations
Select multiple users to perform bulk actions:
- ✅ **Activate** multiple accounts
- ❌ **Suspend** accounts temporarily
- 🗑️ **Delete** accounts permanently
- 📧 **Send** notification emails
## Role-Based Access Control
### Understanding Roles
The system uses hierarchical role-based access control (RBAC):
```
Super Admin
├── Admin
│ ├── Editor
│ │ └── Author
│ │ └── Contributor
│ └── Moderator
└── User (Default)
```
### Creating Custom Roles
1. Go to **Admin → Roles**
2. Click **"Create New Role"**
3. Configure role settings:
- **Role Name** (e.g., "Content Editor")
- **Description** (role purpose)
- **Permissions** (select specific capabilities)
- **Inheritance** (optional parent role)
### Permission Categories
| Category | Description | Example Permissions |
|----------|-------------|-------------------|
| **User Management** | Control over user accounts | `create_user`, `edit_user`, `delete_user` |
| **Content Management** | Content creation and editing | `create_content`, `publish_content`, `delete_content` |
| **System Administration** | System-level configuration | `manage_settings`, `view_logs`, `backup_data` |
| **Analytics** | Access to metrics and reports | `view_analytics`, `export_reports` |
## Content Management
### Content Types
The system supports multiple content types:
- **📝 Blog Posts** - Articles and news updates
- **📄 Pages** - Static content pages
- **📚 Documentation** - Technical guides and manuals
- **🎓 Tutorials** - Step-by-step instructions
- **📰 Articles** - Long-form content
### Creating Content
1. Navigate to **Admin → Content**
2. Click **"Create Content"**
3. Choose content type and format
4. Fill in content details:
#### Basic Information
- **Title** - Content headline
- **Slug** - URL-friendly identifier
- **Content** - Main content body
- **Author** - Content creator
#### Metadata
- **Tags** - Comma-separated keywords
- **Category** - Content classification
- **Featured Image** - Optional header image
- **Excerpt** - Brief content summary
#### SEO Optimization
- **SEO Title** - Search engine title
- **SEO Description** - Meta description
- **Keywords** - Search optimization terms
#### Publication Settings
- **State** - Draft, Published, Scheduled, Archived
- **Publication Date** - When to publish
- **Access Control** - Public or login required
- **Comments** - Enable/disable user comments
### Content States Workflow
```
Draft → Review → Published
↓ ↓ ↓
Edit Reject Schedule
↓ ↓ ↓
Save Draft Archive
```
### File Upload Support
Upload content files directly:
- **Markdown** (.md, .markdown) - Processed with frontmatter
- **HTML** (.html) - Direct HTML content
- **Text** (.txt) - Plain text content
- **Images** - JPG, PNG, WebP for featured images
## System Settings
### General Configuration
- **Site Information** - Name, description, contact details
- **Localization** - Language and timezone settings
- **Email Configuration** - SMTP settings for notifications
- **Security Settings** - Password policies, session timeout
### Database Management
- **Backup Schedule** - Automated backup configuration
- **Data Export** - Export user and content data
- **Migration Tools** - Database version management
- **Performance Monitoring** - Query optimization insights
## Best Practices
### Security
1. **Strong Passwords** - Enforce password complexity requirements
2. **Regular Backups** - Schedule automated database backups
3. **Role Principle** - Assign minimum necessary permissions
4. **Activity Monitoring** - Review admin activity logs regularly
5. **Two-Factor Authentication** - Enable 2FA for admin accounts
### Content Management
1. **Consistent Naming** - Use clear, descriptive titles and slugs
2. **SEO Optimization** - Complete all meta fields for better search ranking
3. **Regular Reviews** - Audit published content for accuracy
4. **Version Control** - Keep drafts when making major changes
5. **Media Organization** - Use consistent file naming and organization
### User Management
1. **Onboarding Process** - Establish clear user setup procedures
2. **Regular Audits** - Review user accounts and permissions quarterly
3. **Documentation** - Maintain clear role and permission documentation
4. **Training Materials** - Provide user guides for different roles
5. **Support Channels** - Establish clear escalation procedures
## Troubleshooting
### Common Issues
**Q: Can't access admin dashboard**
- Verify user has admin role assigned
- Check authentication status
- Clear browser cache and cookies
**Q: Content not publishing**
- Verify publication date/time
- Check content state (should be "Published")
- Ensure user has publish permissions
**Q: User account creation failing**
- Check email format validity
- Verify password meets requirements
- Ensure email address isn't already registered
**Q: Role permissions not working**
- Clear user session cache
- Verify role has correct permissions
- Check for role inheritance conflicts
### Getting Help
For additional support:
- 📚 **Documentation** - Complete guides and API reference
- 💬 **Community Forum** - User discussions and solutions
- 🎫 **Support Tickets** - Direct technical support
- 📧 **Email Support** - admin-support@yourapp.com
## Next Steps
Now that you understand the basics:
1. **Explore Features** - Try creating content and managing users
2. **Customize Settings** - Configure the system for your needs
3. **Train Your Team** - Share this guide with other administrators
4. **Stay Updated** - Check for system updates and new features
---
*This guide covers the essential admin dashboard features. For advanced topics, see our [Advanced Administration Guide](advanced-admin-guide.md).*
**Last Updated**: January 15, 2024
**Version**: 1.0
**Authors**: Documentation Team

View File

@ -1,140 +0,0 @@
template_name = "page"
[values]
title = "About Rustelo"
subtitle = "Modern Web Framework Built with Rust"
author = "Rustelo Team"
last_updated = "2024-01-20"
reading_time = 5
show_meta = true
lang = "en"
content = """
## Our Mission
Rustelo was born from the desire to create a web framework that doesn't compromise on performance, security, or developer experience. We believe that modern web applications should be fast, secure, and maintainable.
## What Makes Rustelo Different
### Built on Solid Foundations
- **Rust**: Memory safety and zero-cost abstractions
- **Leptos**: Reactive web framework with server-side rendering
- **Tera**: Powerful and flexible template engine
- **SQLx**: Async SQL toolkit with compile-time checked queries
### Developer-First Experience
We've designed Rustelo with developers in mind:
- **Hot Reload**: Instant feedback during development
- **Type Safety**: Catch errors at compile time
- **Rich Documentation**: Comprehensive guides and examples
- **Flexible Architecture**: Adapt to your project's needs
### Production Ready
- **High Performance**: Optimized for speed and low resource usage
- **Security**: Built-in CSRF protection, secure headers, and more
- **Scalability**: Handle thousands of concurrent connections
- **Monitoring**: Built-in metrics and health checks
## The Team
Rustelo is maintained by a dedicated team of developers who are passionate about creating the best web development experience possible.
### Core Values
- **Performance**: Every millisecond matters
- **Security**: Security by design, not as an afterthought
- **Simplicity**: Complex problems deserve simple solutions
- **Community**: Open source and community-driven
## Technology Stack
Our carefully chosen technology stack ensures reliability and performance:
- **Backend**: Rust with Axum for HTTP handling
- **Frontend**: Leptos for reactive UI components
- **Database**: PostgreSQL and SQLite support via SQLx
- **Templates**: Tera template engine with custom filters
- **Authentication**: JWT-based with optional OAuth providers
- **Deployment**: Docker-ready with configurable environments
## Open Source
Rustelo is open source and we welcome contributions from the community. Whether you're fixing bugs, adding features, or improving documentation, every contribution helps make Rustelo better.
### How to Contribute
- Report bugs and suggest features on GitHub
- Submit pull requests with improvements
- Help with documentation and examples
- Share your Rustelo projects with the community
## Get Started Today
Ready to build your next web application with Rustelo? Check out our getting started guide and join our growing community of developers.
"""
toc_enabled = true
cta_enabled = true
cta_title = "Ready to Build with Rustelo?"
cta_description = "Join thousands of developers building fast, secure web applications."
cta_url = "/page:getting-started"
cta_button_text = "Get Started Now"
sidebar_title = "Quick Navigation"
[values.breadcrumbs]
[[values.breadcrumbs]]
title = "Home"
url = "/"
[[values.breadcrumbs]]
title = "About"
url = "/page:about"
[values.sidebar_links]
[[values.sidebar_links]]
title = "Getting Started"
url = "/page:getting-started"
[[values.sidebar_links]]
title = "Documentation"
url = "/docs"
[[values.sidebar_links]]
title = "Examples"
url = "/examples"
[[values.sidebar_links]]
title = "GitHub Repository"
url = "https://github.com/rustelo/rustelo"
[values.contact_info]
email = "hello@rustelo.dev"
address = "Open Source Project"
[values.related_pages]
[[values.related_pages]]
title = "Getting Started Guide"
url = "/page:getting-started"
[[values.related_pages]]
title = "Configuration Reference"
url = "/page:configuration"
[[values.related_pages]]
title = "Template System"
url = "/page:templates"
[values.footer_links]
[[values.footer_links]]
title = "Privacy Policy"
url = "/privacy"
[[values.footer_links]]
title = "Terms of Service"
url = "/terms"
[[values.footer_links]]
title = "GitHub"
url = "https://github.com/rustelo/rustelo"
[metadata]
category = "about"
page_type = "static"
priority = "high"
sitemap_include = true

View File

@ -1,117 +0,0 @@
template_name = "blog-post"
[values]
title = "Getting Started with Rustelo"
author = "Development Team"
published_date = "2024-01-15"
reading_time = 8
content = """
# Welcome to Rustelo
Rustelo is a powerful Rust-based web framework that combines the best of modern web development with the performance and safety of Rust.
## What is Rustelo?
Rustelo is built on top of **Leptos** and provides a complete solution for building fast, reliable web applications. It includes:
- **Template Engine**: Powered by Tera for flexible templating
- **Localization**: Built-in support for multiple languages
- **Content Management**: Easy content management with TOML configuration
- **Authentication**: Secure user authentication and authorization
- **Database Integration**: SQLx support for PostgreSQL and SQLite
## Key Features
### 🚀 Performance
Built with Rust for maximum performance and minimal resource usage.
### 🔒 Security
Security-first approach with built-in CSRF protection, secure headers, and more.
### 🌐 Localization
Easy internationalization with file-based language support.
### 📝 Content Management
Simple content management using TOML configuration files.
### 🎨 Flexible Templates
Powerful Tera template engine with custom filters and functions.
## Quick Start
1. **Clone the repository**
```bash
git clone https://github.com/your-org/rustelo.git
cd rustelo
```
2. **Install dependencies**
```bash
cargo build
```
3. **Run the development server**
```bash
cargo run
```
4. **Open your browser**
Navigate to `http://localhost:3030`
## Project Structure
```
rustelo/
server/ # Backend Rust code
client/ # Frontend Leptos code
shared/ # Shared code between client and server
templates/ # Tera templates
content/ # Content files (.tpl.toml)
public/ # Static assets
migrations/ # Database migrations
```
## Configuration
Rustelo uses TOML files for configuration. The main configuration file is `config.toml`:
```toml
[server]
host = "127.0.0.1"
port = 3030
[database]
url = "sqlite:database.db"
[content]
content_dir = "content"
template_dir = "templates"
```
## Creating Your First Page
1. Create a template file in `templates/my-page.html`
2. Create a content file in `content/docs/en_my-page.tpl.toml`
3. Access your page at `/page:my-page`
## Next Steps
- Read the [Configuration Guide](/page:configuration)
- Learn about [Template System](/page:templates)
- Explore [Authentication](/page:auth)
- Check out [Database Setup](/page:database)
Ready to build amazing web applications with Rustelo? Let's get started!
"""
tags = ["rust", "web-framework", "leptos", "getting-started", "tutorial"]
featured_image = "/images/rustelo-banner.jpg"
enable_sharing = true
page_url = "https://yoursite.com/page:getting-started"
back_url = "/"
back_text = "Documentation"
[metadata]
category = "documentation"
difficulty = "beginner"
estimated_time = "10 minutes"
version = "1.0"

View File

@ -1,117 +0,0 @@
template_name = "blog-post"
[values]
title = "Comenzando con Rustelo"
author = "Equipo de Desarrollo"
published_date = "2024-01-15"
reading_time = 8
content = """
# Bienvenido a Rustelo
Rustelo es un poderoso framework web basado en Rust que combina lo mejor del desarrollo web moderno con el rendimiento y la seguridad de Rust.
## ¿Qué es Rustelo?
Rustelo está construido sobre **Leptos** y proporciona una solución completa para construir aplicaciones web rápidas y confiables. Incluye:
- **Motor de Plantillas**: Potenciado por Tera para plantillas flexibles
- **Localización**: Soporte incorporado para múltiples idiomas
- **Gestión de Contenido**: Gestión fácil de contenido con configuración TOML
- **Autenticación**: Autenticación y autorización segura de usuarios
- **Integración de Base de Datos**: Soporte SQLx para PostgreSQL y SQLite
## Características Principales
### 🚀 Rendimiento
Construido con Rust para máximo rendimiento y uso mínimo de recursos.
### 🔒 Seguridad
Enfoque de seguridad primero con protección CSRF incorporada, cabeceras seguras y más.
### 🌐 Localización
Internacionalización fácil con soporte de idiomas basado en archivos.
### 📝 Gestión de Contenido
Gestión simple de contenido usando archivos de configuración TOML.
### 🎨 Plantillas Flexibles
Motor de plantillas Tera potente con filtros y funciones personalizadas.
## Inicio Rápido
1. **Clonar el repositorio**
```bash
git clone https://github.com/your-org/rustelo.git
cd rustelo
```
2. **Instalar dependencias**
```bash
cargo build
```
3. **Ejecutar el servidor de desarrollo**
```bash
cargo run
```
4. **Abrir el navegador**
Navegar a `http://localhost:3030`
## Estructura del Proyecto
```
rustelo/
server/ # Código Rust del backend
client/ # Código Leptos del frontend
shared/ # Código compartido entre cliente y servidor
templates/ # Plantillas Tera
content/ # Archivos de contenido (.tpl.toml)
public/ # Recursos estáticos
migrations/ # Migraciones de base de datos
```
## Configuración
Rustelo usa archivos TOML para la configuración. El archivo principal de configuración es `config.toml`:
```toml
[server]
host = "127.0.0.1"
port = 3030
[database]
url = "sqlite:database.db"
[content]
content_dir = "content"
template_dir = "templates"
```
## Creando tu Primera Página
1. Crear un archivo de plantilla en `templates/my-page.html`
2. Crear un archivo de contenido en `content/docs/es_my-page.tpl.toml`
3. Acceder a tu página en `/page:my-page`
## Próximos Pasos
- Leer la [Guía de Configuración](/page:configuration)
- Aprender sobre el [Sistema de Plantillas](/page:templates)
- Explorar [Autenticación](/page:auth)
- Revisar [Configuración de Base de Datos](/page:database)
¿Listo para construir aplicaciones web increíbles con Rustelo? ¡Comencemos!
"""
tags = ["rust", "web-framework", "leptos", "comenzando", "tutorial"]
featured_image = "/images/rustelo-banner.jpg"
enable_sharing = true
page_url = "https://yoursite.com/page:getting-started"
back_url = "/"
back_text = "Documentación"
[metadata]
category = "documentación"
difficulty = "principiante"
estimated_time = "10 minutos"
version = "1.0"

View File

@ -1,385 +0,0 @@
---
title: "Getting Started Guide"
slug: "getting-started"
name: "getting-started"
author: "Documentation Team"
content_type: "documentation"
content_format: "markdown"
container: "docs-container"
state: "published"
require_login: false
date_init: "2024-01-10T09:00:00Z"
tags: ["documentation", "getting-started", "tutorial", "setup"]
category: "documentation"
excerpt: "Learn how to get started with our platform. Complete setup guide, installation instructions, and first steps to get you up and running quickly."
seo_title: "Getting Started - Complete Setup Guide"
seo_description: "Complete getting started guide with installation instructions, setup steps, and examples to help you begin using our platform effectively."
allow_comments: false
sort_order: 1
metadata:
reading_time: "5"
difficulty: "beginner"
last_updated: "2024-01-10"
section: "basics"
---
# Getting Started Guide
Welcome to our platform! This guide will help you get up and running quickly with all the essential features and functionality.
## Prerequisites
Before you begin, make sure you have the following installed on your system:
- **Rust** (version 1.75 or later)
- **Node.js** (version 18 or later)
- **PostgreSQL** (version 14 or later)
- **Git** for version control
### Installing Rust
If you don't have Rust installed, visit [rustup.rs](https://rustup.rs/) and follow the installation instructions for your operating system.
```bash
# Verify your Rust installation
rustc --version
cargo --version
```
### Installing Node.js
Download and install Node.js from [nodejs.org](https://nodejs.org/) or use a version manager like `nvm`:
```bash
# Using nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 18
nvm use 18
```
## Installation
### 1. Clone the Repository
```bash
git clone https://github.com/your-org/your-project.git
cd your-project
```
### 2. Environment Setup
Copy the example environment file and configure your settings:
```bash
cp .env.example .env
```
Edit the `.env` file with your configuration:
```env
# Database Configuration
DATABASE_URL=postgres://username:password@localhost/database_name
# Server Configuration
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
SERVER_PROTOCOL=http
# JWT Configuration
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRATION=24h
# OAuth Configuration (optional)
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
```
### 3. Database Setup
Create your PostgreSQL database and run migrations:
```bash
# Create database (adjust for your setup)
createdb your_database_name
# Run migrations
cargo install sqlx-cli
sqlx migrate run
```
### 4. Install Dependencies
```bash
# Install Rust dependencies
cargo build
# Install Node.js dependencies (if using frontend build tools)
npm install
```
## Quick Start
### 1. Start the Development Server
```bash
cargo run
```
The server will start on `http://localhost:3000` by default.
### 2. Access the Application
Open your web browser and navigate to:
- **Main Application**: `http://localhost:3000`
- **API Documentation**: `http://localhost:3000/api/docs` (if enabled)
- **Health Check**: `http://localhost:3000/health`
### 3. Create Your First User
You can create a user account through the registration endpoint:
```bash
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"email": "admin@example.com",
"password": "SecurePassword123!",
"display_name": "Administrator"
}'
```
## Basic Usage
### Authentication
Our platform supports multiple authentication methods:
#### 1. Username/Password Authentication
```bash
# Login with username and password
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "SecurePassword123!"
}'
```
#### 2. OAuth Integration
We support OAuth with popular providers:
- Google OAuth
- GitHub OAuth
- Discord OAuth
Visit `/api/auth/oauth/{provider}/authorize` to initiate OAuth flow.
### Content Management
#### Creating Content
```bash
# Create a new blog post
curl -X POST http://localhost:3000/api/content/contents \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"slug": "my-first-post",
"title": "My First Blog Post",
"name": "first-post",
"content_type": "blog",
"content": "# Welcome\n\nThis is my first blog post!",
"container": "blog-container",
"state": "published"
}'
```
#### Retrieving Content
```bash
# Get content by slug
curl http://localhost:3000/api/content/contents/slug/my-first-post
# Get rendered HTML
curl http://localhost:3000/api/content/contents/slug/my-first-post/render
# List all published content
curl http://localhost:3000/api/content/contents/published
```
## Configuration Options
### Server Configuration
The server can be configured through environment variables or a configuration file:
```toml
# config/server.toml
[server]
host = "127.0.0.1"
port = 3000
protocol = "http"
[database]
url = "postgres://localhost/myapp"
max_connections = 10
[security]
jwt_secret = "your-secret-key"
cors_origins = ["http://localhost:3000"]
rate_limit_requests = 1000
rate_limit_window = 3600
```
### Content Configuration
Configure content sources and behavior:
```toml
# config/content.toml
[content]
source = "database" # or "files" or "both"
file_path = "./content"
enable_cache = true
default_container = "page-container"
[rendering]
enable_syntax_highlighting = true
enable_tables = true
enable_footnotes = true
theme = "base16-ocean.dark"
```
## Development Workflow
### 1. Making Changes
```bash
# Create a new feature branch
git checkout -b feature/my-new-feature
# Make your changes
# ... edit files ...
# Run tests
cargo test
# Check formatting
cargo fmt --check
# Run lints
cargo clippy
```
### 2. Database Migrations
When you need to modify the database schema:
```bash
# Create a new migration
sqlx migrate add create_my_table
# Edit the generated migration file
# migrations/YYYYMMDDHHMMSS_create_my_table.sql
# Run the migration
sqlx migrate run
```
### 3. Running Tests
```bash
# Run all tests
cargo test
# Run tests with output
cargo test -- --nocapture
# Run specific test
cargo test test_name
# Run integration tests
cargo test --test integration_tests
```
## Troubleshooting
### Common Issues
#### Database Connection Issues
```bash
# Check if PostgreSQL is running
pg_isready
# Check database exists
psql -l | grep your_database_name
# Test connection
psql $DATABASE_URL -c "SELECT 1;"
```
#### Port Already in Use
```bash
# Find process using port 3000
lsof -i :3000
# Kill the process (replace PID)
kill -9 PID
```
#### Permission Issues
```bash
# Fix file permissions
chmod +x target/debug/your-app
chmod -R 755 content/
```
### Log Analysis
Enable detailed logging for debugging:
```bash
# Set log level
export RUST_LOG=debug
# Run with logging
cargo run
```
### Getting Help
If you encounter issues:
1. **Check the logs** for error messages
2. **Review the documentation** for your specific use case
3. **Search existing issues** on GitHub
4. **Create a new issue** with detailed information
5. **Join our community** on Discord for real-time help
## Next Steps
Now that you have the basics working, here are some recommended next steps:
1. **[User Management](user-management.md)** - Learn about user roles and permissions
2. **[Content Creation](content-creation.md)** - Deep dive into content management
3. **[API Reference](api-reference.md)** - Explore all available endpoints
4. **[Deployment Guide](deployment.md)** - Deploy to production
5. **[Security Best Practices](security.md)** - Secure your application
## Additional Resources
- **[API Documentation](api-reference.md)** - Complete API reference
- **[Configuration Guide](configuration.md)** - Detailed configuration options
- **[Performance Tuning](performance.md)** - Optimize your application
- **[Contributing Guide](contributing.md)** - How to contribute to the project
---
*This guide gets you started quickly. For more detailed information, explore the other documentation sections or check out our [FAQ](faq.md).*

View File

@ -1,287 +0,0 @@
---
title: "Guía de Administración del Panel"
slug: "guia-administracion"
name: "Guía de Administración"
author: "Equipo de Documentación"
author_id: "550e8400-e29b-41d4-a716-446655440003"
content_type: "documentation"
content_format: "markdown"
container: "main"
state: "published"
require_login: false
date_init: "2024-01-15T15:00:00Z"
date_end: null
published_at: "2024-01-15T15:00:00Z"
tags: ["guía", "documentación", "administración", "inicio"]
category: "Documentación"
featured_image: "/images/guia-admin.jpg"
excerpt: "Guía completa para comenzar con el panel de administración. Aprende gestión de usuarios, creación de contenido y administración del sistema."
seo_title: "Guía del Panel de Administración - Tutorial Completo"
seo_description: "Domina el panel de administración con nuestra guía completa. Gestión de usuarios, creación de contenido, roles y más."
allow_comments: true
sort_order: 1
metadata:
reading_time: "8 minutos"
difficulty: "principiante"
language: "es"
version: "1.0"
---
# Guía de Administración del Panel
¡Bienvenido a la guía completa del panel de administración! Esta documentación te ayudará a dominar todos los aspectos de la administración del sistema, desde la gestión de usuarios hasta la creación de contenido.
## Tabla de Contenidos
1. [Resumen del Panel](#resumen-del-panel)
2. [Gestión de Usuarios](#gestión-de-usuarios)
3. [Control de Acceso Basado en Roles](#control-de-acceso-basado-en-roles)
4. [Gestión de Contenido](#gestión-de-contenido)
5. [Configuración del Sistema](#configuración-del-sistema)
6. [Mejores Prácticas](#mejores-prácticas)
## Resumen del Panel
El panel de administración proporciona una interfaz centralizada para gestionar tu aplicación. Las características principales incluyen:
### Características del Panel Principal
- **📊 Resumen de Analíticas** - Estadísticas y métricas en tiempo real
- **👥 Gestión de Usuarios** - Crear, editar y gestionar cuentas de usuario
- **🔐 Gestión de Roles** - Configurar permisos y niveles de acceso
- **📝 Gestión de Contenido** - Crear y publicar contenido
- **⚙️ Configuración del Sistema** - Configurar ajustes de la aplicación
### Navegación
La navegación lateral proporciona acceso rápido a todas las funciones de administración:
```
Panel de Administración
├── Panel (Resumen y Estadísticas)
├── Usuarios (Gestión de Usuarios)
├── Roles (Gestión de Permisos)
├── Contenido (Gestión de Contenido)
└── Configuración (Configuración del Sistema)
```
## Gestión de Usuarios
### Crear Nuevos Usuarios
1. Navega a **Admin → Usuarios**
2. Haz clic en **"Agregar Nuevo Usuario"**
3. Completa la información requerida:
- **Dirección de Email** (obligatorio)
- **Nombre para Mostrar** (obligatorio)
- **Contraseña** (auto-generada o personalizada)
- **Roles** (selecciona permisos apropiados)
4. Haz clic en **"Crear Usuario"**
### Gestión del Estado de Usuario
Los usuarios pueden tener diferentes niveles de estado:
| Estado | Descripción | Acciones Disponibles |
|--------|-------------|---------------------|
| **Activo** | Acceso completo a características asignadas | Editar, Suspender, Eliminar |
| **Inactivo** | La cuenta existe pero el login está deshabilitado | Activar, Editar, Eliminar |
| **Suspendido** | Restricción temporal | Activar, Editar, Eliminar |
| **Pendiente** | Esperando verificación de email | Reenviar Invitación, Eliminar |
### Operaciones en Lote
Selecciona múltiples usuarios para realizar acciones en lote:
- ✅ **Activar** múltiples cuentas
- ❌ **Suspender** cuentas temporalmente
- 🗑️ **Eliminar** cuentas permanentemente
- 📧 **Enviar** emails de notificación
## Control de Acceso Basado en Roles
### Entendiendo los Roles
El sistema usa control de acceso basado en roles (RBAC) jerárquico:
```
Super Administrador
├── Administrador
│ ├── Editor
│ │ └── Autor
│ │ └── Colaborador
│ └── Moderador
└── Usuario (Por defecto)
```
### Crear Roles Personalizados
1. Ve a **Admin → Roles**
2. Haz clic en **"Crear Nuevo Rol"**
3. Configura los ajustes del rol:
- **Nombre del Rol** (ej. "Editor de Contenido")
- **Descripción** (propósito del rol)
- **Permisos** (selecciona capacidades específicas)
- **Herencia** (rol padre opcional)
### Categorías de Permisos
| Categoría | Descripción | Permisos de Ejemplo |
|-----------|-------------|-------------------|
| **Gestión de Usuarios** | Control sobre cuentas de usuario | `crear_usuario`, `editar_usuario`, `eliminar_usuario` |
| **Gestión de Contenido** | Creación y edición de contenido | `crear_contenido`, `publicar_contenido`, `eliminar_contenido` |
| **Administración del Sistema** | Configuración a nivel de sistema | `gestionar_configuracion`, `ver_logs`, `respaldar_datos` |
| **Analíticas** | Acceso a métricas e informes | `ver_analiticas`, `exportar_informes` |
## Gestión de Contenido
### Tipos de Contenido
El sistema soporta múltiples tipos de contenido:
- **📝 Artículos de Blog** - Artículos y actualizaciones de noticias
- **📄 Páginas** - Páginas de contenido estático
- **📚 Documentación** - Guías técnicas y manuales
- **🎓 Tutoriales** - Instrucciones paso a paso
- **📰 Artículos** - Contenido de formato largo
### Crear Contenido
1. Navega a **Admin → Contenido**
2. Haz clic en **"Crear Contenido"**
3. Elige tipo de contenido y formato
4. Completa los detalles del contenido:
#### Información Básica
- **Título** - Encabezado del contenido
- **Slug** - Identificador amigable para URL
- **Contenido** - Cuerpo principal del contenido
- **Autor** - Creador del contenido
#### Metadatos
- **Etiquetas** - Palabras clave separadas por comas
- **Categoría** - Clasificación del contenido
- **Imagen Destacada** - Imagen de encabezado opcional
- **Extracto** - Resumen breve del contenido
#### Optimización SEO
- **Título SEO** - Título para motores de búsqueda
- **Descripción SEO** - Meta descripción
- **Palabras Clave** - Términos de optimización de búsqueda
#### Configuración de Publicación
- **Estado** - Borrador, Publicado, Programado, Archivado
- **Fecha de Publicación** - Cuándo publicar
- **Control de Acceso** - Público o requiere login
- **Comentarios** - Habilitar/deshabilitar comentarios de usuarios
### Flujo de Estados del Contenido
```
Borrador → Revisión → Publicado
↓ ↓ ↓
Editar Rechazar Programar
↓ ↓ ↓
Guardar Borrador Archivar
```
### Soporte de Subida de Archivos
Sube archivos de contenido directamente:
- **Markdown** (.md, .markdown) - Procesado con metadatos
- **HTML** (.html) - Contenido HTML directo
- **Texto** (.txt) - Contenido de texto plano
- **Imágenes** - JPG, PNG, WebP para imágenes destacadas
## Configuración del Sistema
### Configuración General
- **Información del Sitio** - Nombre, descripción, detalles de contacto
- **Localización** - Configuración de idioma y zona horaria
- **Configuración de Email** - Ajustes SMTP para notificaciones
- **Configuración de Seguridad** - Políticas de contraseña, tiempo de sesión
### Gestión de Base de Datos
- **Programación de Respaldos** - Configuración de respaldo automatizado
- **Exportación de Datos** - Exportar datos de usuario y contenido
- **Herramientas de Migración** - Gestión de versiones de base de datos
- **Monitoreo de Rendimiento** - Perspectivas de optimización de consultas
## Mejores Prácticas
### Seguridad
1. **Contraseñas Fuertes** - Aplicar requisitos de complejidad de contraseña
2. **Respaldos Regulares** - Programar respaldos automáticos de base de datos
3. **Principio de Roles** - Asignar permisos mínimos necesarios
4. **Monitoreo de Actividad** - Revisar logs de actividad de admin regularmente
5. **Autenticación de Dos Factores** - Habilitar 2FA para cuentas de admin
### Gestión de Contenido
1. **Nomenclatura Consistente** - Usar títulos y slugs claros y descriptivos
2. **Optimización SEO** - Completar todos los campos meta para mejor ranking en búsquedas
3. **Revisiones Regulares** - Auditar contenido publicado para precisión
4. **Control de Versiones** - Mantener borradores al hacer cambios importantes
5. **Organización de Medios** - Usar nomenclatura y organización consistente de archivos
### Gestión de Usuarios
1. **Proceso de Incorporación** - Establecer procedimientos claros de configuración de usuarios
2. **Auditorías Regulares** - Revisar cuentas de usuario y permisos trimestralmente
3. **Documentación** - Mantener documentación clara de roles y permisos
4. **Materiales de Entrenamiento** - Proporcionar guías de usuario para diferentes roles
5. **Canales de Soporte** - Establecer procedimientos claros de escalación
## Solución de Problemas
### Problemas Comunes
**P: No puedo acceder al panel de administración**
- Verifica que el usuario tenga rol de admin asignado
- Verifica el estado de autenticación
- Limpia caché y cookies del navegador
**P: El contenido no se publica**
- Verifica fecha/hora de publicación
- Verifica estado del contenido (debería ser "Publicado")
- Asegúrate de que el usuario tenga permisos de publicación
**P: La creación de cuenta de usuario falla**
- Verifica validez del formato de email
- Verifica que la contraseña cumpla los requisitos
- Asegúrate de que la dirección de email no esté ya registrada
**P: Los permisos de rol no funcionan**
- Limpia caché de sesión de usuario
- Verifica que el rol tenga permisos correctos
- Verifica conflictos de herencia de roles
### Obtener Ayuda
Para soporte adicional:
- 📚 **Documentación** - Guías completas y referencia de API
- 💬 **Foro de la Comunidad** - Discusiones de usuarios y soluciones
- 🎫 **Tickets de Soporte** - Soporte técnico directo
- 📧 **Soporte por Email** - soporte-admin@tuapp.com
## Próximos Pasos
Ahora que entiendes lo básico:
1. **Explora Características** - Prueba crear contenido y gestionar usuarios
2. **Personaliza Configuraciones** - Configura el sistema para tus necesidades
3. **Entrena a Tu Equipo** - Comparte esta guía con otros administradores
4. **Mantente Actualizado** - Verifica actualizaciones del sistema y nuevas características
---
*Esta guía cubre las características esenciales del panel de administración. Para temas avanzados, consulta nuestra [Guía de Administración Avanzada](guia-administracion-avanzada.md).*
**Última Actualización**: 15 de enero, 2024
**Versión**: 1.0
**Autores**: Equipo de Documentación

View File

@ -1,248 +0,0 @@
welcome = Welcome to Leptos
not-found = Page not found.
home = Home
about = About
user = User
main-desc = Welcome to the home page
about-desc = About this app
user-page = User page for ID: { $id }
# Language Selection
language = Language
select-language = Select Language
english = English
spanish = Español
# Authentication
sign-in = Sign In
sign-up = Sign Up
sign-out = Sign Out
login = Login
register = Register
logout = Logout
email = Email
password = Password
username = Username
display-name = Display Name
confirm-password = Confirm Password
remember-me = Remember me
forgot-password = Forgot your password?
create-account = Create Account
already-have-account = Already have an account?
dont-have-account = Don't have an account?
# Form Labels and Placeholders
email-address = Email Address
enter-email = Enter your email
enter-username = Choose a username
enter-password = Enter your password
create-password = Create a strong password
confirm-your-password = Confirm your password
how-should-we-call-you = How should we call you?
# Messages
welcome-back = Welcome back! Please sign in to your account.
join-us-today = Join us today! Please fill in your details.
signing-in = Signing In...
creating-account = Creating Account...
sign-in-success = Sign in successful
registration-success = Registration successful
logout-success = Logout successful
# Validation Messages
password-required = Password is required
email-required = Email is required
username-required = Username is required
passwords-no-match = Passwords do not match
passwords-match = Passwords match
password-too-short = Password must be at least 8 characters
invalid-email = Please enter a valid email address
username-format = 3-50 characters, letters, numbers, underscores and hyphens only
# Password Strength
password-strength = Password strength:
very-weak = Very Weak
weak = Weak
fair = Fair
good = Good
strong = Strong
password-requirements = Must be at least 8 characters with uppercase, lowercase, number and special character
# OAuth
continue-with = Or continue with
sign-up-with = Or sign up with
google = Google
github = GitHub
discord = Discord
microsoft = Microsoft
# Terms and Privacy
agree-to-terms = I agree to the
terms-of-service = Terms of Service
privacy-policy = Privacy Policy
and = and
# Errors
invalid-credentials = Invalid email or password
user-not-found = User not found
email-already-exists = An account with this email already exists
username-already-exists = This username is already taken
account-not-verified = Please verify your email before signing in
account-suspended = Your account has been suspended
rate-limit-exceeded = Too many attempts. Please try again later
network-error = Network error. Please check your connection
login-failed = Login failed
registration-failed = Registration failed
session-expired = Your session has expired. Please sign in again
invalid-token = Invalid authentication token
token-expired = Your authentication token has expired
insufficient-permissions = You don't have permission to perform this action
oauth-error = OAuth authentication error
database-error = A database error occurred. Please try again
internal-error = An internal error occurred. Please try again
validation-error = Please check your input and try again
authentication-failed = Authentication failed
server-error = Server error occurred. Please try again later
request-failed = Request failed. Please try again
unknown-error = An unknown error occurred
# Profile
profile = Profile
update-profile = Update Profile
change-password = Change Password
current-password = Current Password
new-password = New Password
profile-updated = Profile updated successfully
password-changed = Password changed successfully
profile-update-failed = Failed to update profile
password-change-failed = Failed to change password
# Password Reset
reset-password = Reset Password
request-password-reset = Request Password Reset
password-reset-sent = Password reset instructions sent to your email
password-reset-success = Password reset successfully
enter-reset-token = Enter reset token
reset-token = Reset Token
# Navigation
dashboard = Dashboard
settings = Settings
admin = Admin
users = Users
content = Content
# User Status
welcome-user = Welcome, { $name }
signed-in-as = Signed in as { $email }
last-login = Last login: { $date }
# Loading States
loading = Loading...
please-wait = Please wait...
processing = Processing...
# Admin
manage-users = Manage Users
user-roles = User Roles
permissions = Permissions
audit-log = Audit Log
system-settings = System Settings
# Roles
admin-role = Administrator
moderator-role = Moderator
user-role = User
guest-role = Guest
# Time
just-now = Just now
minutes-ago = { $count } minutes ago
hours-ago = { $count } hours ago
days-ago = { $count } days ago
# Error Display
dismiss = Dismiss
authentication-errors = Authentication Errors
# Navigation
pages = Pages
# Admin Dashboard
admin-dashboard = Admin Dashboard
overview-of-your-system = Overview of your system
refresh = Refresh
total-users = Total Users
active-users = Active Users
content-items = Content Items
total-roles = Total Roles
manage-users = Manage Users
manage-roles = Manage Roles
manage-content = Manage Content
no-recent-activity = No recent activity
activity-will-appear-here = Activity will appear here when users perform actions
# Content Management
content-management = Content Management
manage-your-content = Manage your content, create new posts, and organize your media.
upload-content = Upload Content
create-content = Create Content
total-content = Total Content
published = Published
drafts = Drafts
scheduled = Scheduled
total-views = Total Views
search-content = Search content...
all-types = All Types
posts = Posts
pages = Pages
articles = Articles
all-states = All States
draft = Draft
archived = Archived
actions = Actions
create-new-content = Create New Content
title = Title
slug = Slug
cancel = Cancel
edit-content = Edit Content
content-editing-functionality = Content editing functionality will be implemented here
selected-content = Selected content
drag-and-drop-files = Drag and drop files here, or click to select files
markdown-html-txt-supported = Markdown, HTML, TXT files supported
upload = Upload
# Roles Management
view-permissions = View Permissions
create-new-role = Create New Role
search-roles = Search Roles
clear = Clear
edit = Edit
delete = Delete
role-name = Role Name
description = Description
creating = Creating...
edit-role = Edit Role
updating = Updating...
system-permissions = System Permissions
# User Status
active = Active
inactive = Inactive
suspended = Suspended
pending = Pending
# User Management
user-management = User Management
add-new-user = Add New User
search-users = Search Users
filter-by-status = Filter by Status
all-status = All Status
clear-filters = Clear Filters
user = User
roles = Roles
status = Status
last-login = Last Login
name = Name
send-invitation-email = Send Invitation Email
edit-user = Edit User

View File

@ -1,248 +0,0 @@
welcome = Bienvenido a Leptos
not-found = Página no encontrada.
home = Inicio
about = Acerca de
user = Usuario
main-desc = Bienvenido a la página principal
about-desc = Acerca de esta aplicación
user-page = Página de usuario con ID: { $id }
# Language Selection
language = Idioma
select-language = Seleccionar Idioma
english = English
spanish = Español
# Authentication
sign-in = Iniciar Sesión
sign-up = Registrarse
sign-out = Cerrar Sesión
login = Iniciar Sesión
register = Registrarse
logout = Cerrar Sesión
email = Correo Electrónico
password = Contraseña
username = Nombre de Usuario
display-name = Nombre para Mostrar
confirm-password = Confirmar Contraseña
remember-me = Recordarme
forgot-password = ¿Olvidaste tu contraseña?
create-account = Crear Cuenta
already-have-account = ¿Ya tienes una cuenta?
dont-have-account = ¿No tienes una cuenta?
# Form Labels and Placeholders
email-address = Dirección de Correo Electrónico
enter-email = Ingresa tu correo electrónico
enter-username = Elige un nombre de usuario
enter-password = Ingresa tu contraseña
create-password = Crea una contraseña segura
confirm-your-password = Confirma tu contraseña
how-should-we-call-you = ¿Cómo deberíamos llamarte?
# Messages
welcome-back = ¡Bienvenido de vuelta! Por favor inicia sesión en tu cuenta.
join-us-today = ¡Únete a nosotros hoy! Por favor completa tus datos.
signing-in = Iniciando Sesión...
creating-account = Creando Cuenta...
sign-in-success = Inicio de sesión exitoso
registration-success = Registro exitoso
logout-success = Cierre de sesión exitoso
# Validation Messages
password-required = La contraseña es requerida
email-required = El correo electrónico es requerido
username-required = El nombre de usuario es requerido
passwords-no-match = Las contraseñas no coinciden
passwords-match = Las contraseñas coinciden
password-too-short = La contraseña debe tener al menos 8 caracteres
invalid-email = Por favor ingresa un correo electrónico válido
username-format = 3-50 caracteres, solo letras, números, guiones bajos y guiones
# Password Strength
password-strength = Fuerza de la contraseña:
very-weak = Muy Débil
weak = Débil
fair = Regular
good = Buena
strong = Fuerte
password-requirements = Debe tener al menos 8 caracteres con mayúscula, minúscula, número y carácter especial
# OAuth
continue-with = O continúa con
sign-up-with = O regístrate con
google = Google
github = GitHub
discord = Discord
microsoft = Microsoft
# Terms and Privacy
agree-to-terms = Acepto los
terms-of-service = Términos de Servicio
privacy-policy = Política de Privacidad
and = y
# Errors
invalid-credentials = Correo electrónico o contraseña inválidos
user-not-found = Usuario no encontrado
email-already-exists = Ya existe una cuenta con este correo electrónico
username-already-exists = Este nombre de usuario ya está en uso
account-not-verified = Por favor verifica tu correo electrónico antes de iniciar sesión
account-suspended = Tu cuenta ha sido suspendida
rate-limit-exceeded = Demasiados intentos. Por favor intenta de nuevo más tarde
network-error = Error de red. Por favor verifica tu conexión
login-failed = Error al iniciar sesión
registration-failed = Error en el registro
session-expired = Tu sesión ha expirado. Por favor inicia sesión de nuevo
invalid-token = Token de autenticación inválido
token-expired = Tu token de autenticación ha expirado
insufficient-permissions = No tienes permisos para realizar esta acción
oauth-error = Error de autenticación OAuth
database-error = Ocurrió un error en la base de datos. Por favor intenta de nuevo
internal-error = Ocurrió un error interno. Por favor intenta de nuevo
validation-error = Por favor revisa tu información e intenta de nuevo
authentication-failed = Error de autenticación
server-error = Error del servidor. Por favor intenta más tarde
request-failed = La solicitud falló. Por favor intenta de nuevo
unknown-error = Ocurrió un error desconocido
# Profile
profile = Perfil
update-profile = Actualizar Perfil
change-password = Cambiar Contraseña
current-password = Contraseña Actual
new-password = Nueva Contraseña
profile-updated = Perfil actualizado exitosamente
password-changed = Contraseña cambiada exitosamente
profile-update-failed = Error al actualizar el perfil
password-change-failed = Error al cambiar la contraseña
# Password Reset
reset-password = Restablecer Contraseña
request-password-reset = Solicitar Restablecimiento de Contraseña
password-reset-sent = Instrucciones de restablecimiento enviadas a tu correo
password-reset-success = Contraseña restablecida exitosamente
enter-reset-token = Ingresa el token de restablecimiento
reset-token = Token de Restablecimiento
# Navigation
dashboard = Panel de Control
settings = Configuraciones
admin = Administrador
users = Usuarios
content = Contenido
# User Status
welcome-user = Bienvenido, { $name }
signed-in-as = Conectado como { $email }
last-login = Último acceso: { $date }
# Loading States
loading = Cargando...
please-wait = Por favor espera...
processing = Procesando...
# Admin
manage-users = Gestionar Usuarios
user-roles = Roles de Usuario
permissions = Permisos
audit-log = Registro de Auditoría
system-settings = Configuraciones del Sistema
# Roles
admin-role = Administrador
moderator-role = Moderador
user-role = Usuario
guest-role = Invitado
# Time
just-now = Ahora mismo
minutes-ago = Hace { $count } minutos
hours-ago = Hace { $count } horas
days-ago = Hace { $count } días
# Error Display
dismiss = Descartar
authentication-errors = Errores de Autenticación
# Navigation
pages = Páginas
# Admin Dashboard
admin-dashboard = Panel de Administración
overview-of-your-system = Resumen de tu sistema
refresh = Actualizar
total-users = Total de Usuarios
active-users = Usuarios Activos
content-items = Elementos de Contenido
total-roles = Total de Roles
manage-users = Gestionar Usuarios
manage-roles = Gestionar Roles
manage-content = Gestionar Contenido
no-recent-activity = Sin actividad reciente
activity-will-appear-here = La actividad aparecerá aquí cuando los usuarios realicen acciones
# Content Management
content-management = Gestión de Contenido
manage-your-content = Gestiona tu contenido, crea nuevas publicaciones y organiza tus medios.
upload-content = Subir Contenido
create-content = Crear Contenido
total-content = Total de Contenido
published = Publicado
drafts = Borradores
scheduled = Programado
total-views = Total de Vistas
search-content = Buscar contenido...
all-types = Todos los Tipos
posts = Publicaciones
pages = Páginas
articles = Artículos
all-states = Todos los Estados
draft = Borrador
archived = Archivado
actions = Acciones
create-new-content = Crear Nuevo Contenido
title = Título
slug = Slug
cancel = Cancelar
edit-content = Editar Contenido
content-editing-functionality = La funcionalidad de edición de contenido se implementará aquí
selected-content = Contenido seleccionado
drag-and-drop-files = Arrastra y suelta archivos aquí, o haz clic para seleccionar archivos
markdown-html-txt-supported = Archivos Markdown, HTML, TXT compatibles
upload = Subir
# Roles Management
view-permissions = Ver Permisos
create-new-role = Crear Nuevo Rol
search-roles = Buscar Roles
clear = Limpiar
edit = Editar
delete = Eliminar
role-name = Nombre del Rol
description = Descripción
creating = Creando...
edit-role = Editar Rol
updating = Actualizando...
system-permissions = Permisos del Sistema
# User Status
active = Activo
inactive = Inactivo
suspended = Suspendido
pending = Pendiente
# User Management
user-management = Gestión de Usuarios
add-new-user = Agregar Nuevo Usuario
search-users = Buscar Usuarios
filter-by-status = Filtrar por Estado
all-status = Todos los Estados
clear-filters = Limpiar Filtros
user = Usuario
roles = Roles
status = Estado
last-login = Último Acceso
name = Nombre
send-invitation-email = Enviar Correo de Invitación
edit-user = Editar Usuario

View File

@ -1,35 +0,0 @@
[[menu]]
route = "/"
is_external = false
label.en = "Home"
label.es = "Inicio"
[[menu]]
route = "/about"
is_external = false
label.en = "About"
label.es = "Acerca de"
[[menu]]
route = "/user"
is_external = false
label.en = "User"
label.es = "Usuario"
[[menu]]
route = "/daisyui"
is_external = false
label.en = "DaisyUI"
label.es = "DaisyUI"
[[menu]]
route = "/features-demo"
is_external = false
label.en = "Features Demo"
label.es = "Demo de Características"
[[menu]]
route = "/example.html"
is_external = true
label.en = "Examples"
label.es = "Ejemplos"

View File

@ -1,127 +0,0 @@
---
title: "Artículo de Ejemplo"
slug: "articulo-de-ejemplo"
name: "Artículo de Ejemplo"
author: "Administrador"
author_id: "550e8400-e29b-41d4-a716-446655440001"
content_type: "blog"
content_format: "markdown"
container: "main"
state: "published"
require_login: false
date_init: "2024-01-15T14:00:00Z"
date_end: null
published_at: "2024-01-15T14:00:00Z"
tags: ["ejemplo", "blog", "español", "markdown"]
category: "General"
featured_image: "/images/articulo-ejemplo.jpg"
excerpt: "Este es un artículo de ejemplo que demuestra el sistema de gestión de contenido con formato markdown y metadatos YAML."
seo_title: "Artículo de Ejemplo - Sistema de Gestión de Contenido"
seo_description: "Aprende a crear artículos atractivos con nuestro sistema de gestión de contenido. Este ejemplo demuestra el formato markdown y metadatos."
allow_comments: true
sort_order: 2
metadata:
reading_time: "3 minutos"
difficulty: "principiante"
language: "es"
---
# Bienvenido a Nuestro Sistema de Gestión de Contenido
Este es un **artículo de ejemplo** que demuestra las poderosas capacidades de gestión de contenido de nuestro sistema. Ya sea que escribas en inglés o español, nuestra plataforma soporta formato enriquecido y metadatos comprensivos.
## ¿Qué Lo Hace Especial?
Nuestro sistema de gestión de contenido soporta:
- ✅ **Múltiples Idiomas** - Crea contenido en inglés, español o cualquier idioma
- ✅ **Formato Enriquecido** - Usa Markdown para contenido hermoso y legible
- ✅ **Optimización SEO** - Meta etiquetas incorporadas y datos estructurados
- ✅ **Tipos de Contenido Flexibles** - Artículos de blog, páginas, documentación, tutoriales
- ✅ **Programación Avanzada** - Publica contenido en el momento perfecto
## Características de Markdown
### Fragmentos de Código
```rust
// Ejemplo de código Rust
fn main() {
println!("¡Hola, Gestión de Contenido!");
}
```
### Listas y Tablas
| Característica | Inglés | Español |
|----------------|--------|---------|
| Título | Title | Título |
| Contenido | Content | Contenido |
| Etiquetas | Tags | Etiquetas |
### Imágenes y Medios
Puedes incorporar fácilmente imágenes, videos y otros medios:
![Panel de Gestión de Contenido](/images/panel-administracion.png)
## Comenzando
1. **Crear Contenido** - Usa nuestra interfaz de administración intuitiva
2. **Formatear con Markdown** - Formato de texto enriquecido hecho simple
3. **Agregar Metadatos** - Etiquetas SEO, categorías y más
4. **Publicar o Programar** - Ve en vivo inmediatamente o programa para después
5. **Seguir Rendimiento** - Monitorea vistas y participación
## Soporte Multi-idioma
Nuestro sistema soporta nativamente múltiples idiomas. Puedes crear contenido en:
- **Inglés** - Soporte completo con optimización SEO
- **Español** - Localización completa incluyendo interfaz de administración
- **Idiomas Personalizados** - Fácil de extender con locales adicionales
> **Consejo Pro**: Usa slugs y metadatos consistentes a través de versiones de idiomas para mejor SEO y experiencia de usuario.
## Estados del Contenido
El contenido puede existir en diferentes estados:
- **Borrador** 📝 - Trabajo en progreso, no visible al público
- **Publicado** ✅ - En vivo y accesible a usuarios
- **Programado** ⏰ - Será publicado en un momento específico
- **Archivado** 📦 - Oculto del público pero preservado
## Características Avanzadas
### Metadatos YAML
Cada archivo de contenido puede incluir metadatos ricos usando metadatos YAML (como se ve en la parte superior de este archivo). Esto incluye:
- Fechas de publicación y programación
- Campos de optimización SEO
- Categorización y etiquetado personalizado
- Información del autor y atribución
- Configuraciones de control de acceso
### Híbrido Base de Datos + Archivos
El contenido puede almacenarse en:
1. **Base de Datos** - Contenido dinámico con edición en tiempo real
2. **Archivos** - Archivos markdown con control de versiones
3. **Híbrido** - Lo mejor de ambos mundos
## Llamada a la Acción
¿Listo para comenzar a crear contenido increíble?
[Crea Tu Primer Artículo →](/admin/content)
---
*Este artículo de ejemplo demuestra las capacidades completas de nuestro sistema de gestión de contenido. Desde formato markdown enriquecido hasta metadatos comprensivos, tienes todo lo que necesitas para crear contenido atractivo y optimizado para SEO.*
**Etiquetas**: #GestiónDeContenido #Blog #Markdown #Español #Ejemplo
**Última Actualización**: 15 de enero, 2024

View File

@ -1,127 +0,0 @@
---
title: "Sample Blog Post"
slug: "sample-blog-post"
name: "Sample Blog Post"
author: "Admin"
author_id: "550e8400-e29b-41d4-a716-446655440000"
content_type: "blog"
content_format: "markdown"
container: "main"
state: "published"
require_login: false
date_init: "2024-01-15T10:00:00Z"
date_end: null
published_at: "2024-01-15T10:00:00Z"
tags: ["sample", "blog", "english", "markdown"]
category: "General"
featured_image: "/images/sample-blog.jpg"
excerpt: "This is a sample blog post demonstrating the content management system with markdown formatting and YAML frontmatter."
seo_title: "Sample Blog Post - Content Management System"
seo_description: "Learn how to create engaging blog posts with our content management system. This sample demonstrates markdown formatting and metadata."
allow_comments: true
sort_order: 1
metadata:
reading_time: "3 minutes"
difficulty: "beginner"
language: "en"
---
# Welcome to Our Content Management System
This is a **sample blog post** that demonstrates the powerful content management capabilities of our system. Whether you're writing in English or Spanish, our platform supports rich formatting and comprehensive metadata.
## What Makes This Special?
Our content management system supports:
- ✅ **Multiple Languages** - Create content in English, Spanish, or any language
- ✅ **Rich Formatting** - Use Markdown for beautiful, readable content
- ✅ **SEO Optimization** - Built-in meta tags and structured data
- ✅ **Flexible Content Types** - Blog posts, pages, documentation, tutorials
- ✅ **Advanced Scheduling** - Publish content at the perfect time
## Markdown Features
### Code Snippets
```rust
// Example Rust code
fn main() {
println!("Hello, Content Management!");
}
```
### Lists and Tables
| Feature | English | Spanish |
|---------|---------|---------|
| Title | Title | Título |
| Content | Content | Contenido |
| Tags | Tags | Etiquetas |
### Images and Media
You can easily embed images, videos, and other media:
![Content Management Dashboard](/images/admin-dashboard.png)
## Getting Started
1. **Create Content** - Use our intuitive admin interface
2. **Format with Markdown** - Rich text formatting made simple
3. **Add Metadata** - SEO tags, categories, and more
4. **Publish or Schedule** - Go live immediately or schedule for later
5. **Track Performance** - Monitor views and engagement
## Multi-Language Support
Our system natively supports multiple languages. You can create content in:
- **English** - Full support with SEO optimization
- **Spanish** - Complete localization including admin interface
- **Custom Languages** - Easy to extend with additional locales
> **Pro Tip**: Use consistent slugs and metadata across language versions for better SEO and user experience.
## Content States
Content can exist in different states:
- **Draft** 📝 - Work in progress, not visible to public
- **Published** ✅ - Live and accessible to users
- **Scheduled** ⏰ - Will be published at a specific time
- **Archived** 📦 - Hidden from public but preserved
## Advanced Features
### YAML Frontmatter
Every content file can include rich metadata using YAML frontmatter (as seen at the top of this file). This includes:
- Publication dates and scheduling
- SEO optimization fields
- Custom categorization and tagging
- Author information and attribution
- Access control settings
### Database + File Hybrid
Content can be stored in:
1. **Database** - Dynamic content with real-time editing
2. **Files** - Version-controlled markdown files
3. **Hybrid** - Best of both worlds
## Call to Action
Ready to start creating amazing content?
[Create Your First Post →](/admin/content)
---
*This sample post demonstrates the full capabilities of our content management system. From rich markdown formatting to comprehensive metadata, you have everything you need to create engaging, SEO-optimized content.*
**Tags**: #ContentManagement #Blog #Markdown #English #Sample
**Last Updated**: January 15, 2024

View File

@ -1,85 +0,0 @@
# Files to ignore in the public directory
# This helps prevent accidental commits of sensitive or temporary files
# Temporary files
*.tmp
*.temp
*~
.DS_Store
Thumbs.db
# Sensitive files that should not be public
*.key
*.pem
*.p12
*.pfx
*password*
*secret*
*private*
*.env
*.env.*
# Large files that should be handled separately
*.zip
*.tar
*.tar.gz
*.rar
*.7z
*.dmg
*.iso
# Media files that are too large for git
*.mov
*.mp4
*.avi
*.mkv
*.flv
*.wmv
*.webm
*.m4v
# High-resolution images (consider using Git LFS)
*_4k.*
*_8k.*
*_original.*
*_raw.*
# Database files
*.db
*.sqlite
*.sqlite3
# Log files
*.log
*.logs
# Cache files
*.cache
.cache/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Backup files
*.backup
*.bak
*.old
# Example files to keep (uncomment to track)
# !example.html
# !styles/custom.css
# !scripts/example.js
# !README.md

View File

@ -1,409 +0,0 @@
---
title: "Building Modern Web Applications with Rust"
slug: "rust-web-development"
name: "rust-web-dev"
author: "Tech Team"
content_type: "blog"
content_format: "markdown"
container: "blog-container"
state: "published"
require_login: false
date_init: "2024-01-15T10:00:00Z"
tags: ["rust", "web-development", "axum", "leptos", "tutorial"]
category: "technology"
featured_image: "/images/rust-web.jpg"
excerpt: "Discover how to build high-performance, safe web applications using Rust. Learn about modern frameworks, best practices, and real-world examples."
seo_title: "Rust Web Development: Complete Guide to Modern Frameworks"
seo_description: "Learn Rust web development with Axum, Leptos, and other modern frameworks. Complete tutorial with examples, best practices, and performance tips."
allow_comments: true
sort_order: 1
metadata:
reading_time: "8"
difficulty: "intermediate"
last_updated: "2024-01-15"
---
# Building Modern Web Applications with Rust
Rust has emerged as a powerful language for web development, offering unparalleled performance, memory safety, and developer experience. In this comprehensive guide, we'll explore how to build modern, high-performance web applications using Rust's ecosystem.
## Why Choose Rust for Web Development?
### Performance That Matters
Rust delivers performance comparable to C and C++ while providing memory safety guarantees. This makes it ideal for high-throughput web services where every millisecond counts.
```rust
// Zero-cost abstractions in action
let numbers: Vec<i32> = (1..1000000).collect();
let sum: i32 = numbers.iter().sum(); // Optimized to a simple loop
```
### Memory Safety Without Garbage Collection
Unlike languages with garbage collectors, Rust prevents common bugs like null pointer dereferences, buffer overflows, and use-after-free errors at compile time.
### Fearless Concurrency
Rust's ownership system enables safe concurrent programming, making it easier to build scalable web applications.
```rust
use tokio::task;
async fn handle_requests() {
let handles: Vec<_> = (0..10)
.map(|i| task::spawn(async move {
// Each task runs concurrently and safely
process_request(i).await
}))
.collect();
for handle in handles {
handle.await.unwrap();
}
}
```
## Modern Rust Web Frameworks
### Axum: The Modern Choice
Axum is a web application framework that focuses on ergonomics and modularity. Built on top of Tokio and Tower, it provides excellent performance and developer experience.
```rust
use axum::{
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct User {
id: u64,
name: String,
}
async fn get_user() -> Json<User> {
Json(User {
id: 1,
name: "Alice".to_string(),
})
}
let app = Router::new()
.route("/users", get(get_user))
.route("/users", post(create_user));
```
### Leptos: Full-Stack Reactive Web Framework
Leptos brings reactive programming to Rust web development, similar to React or Solid.js, but with Rust's performance and safety.
```rust
use leptos::*;
#[component]
fn Counter() -> impl IntoView {
let (count, set_count) = create_signal(0);
view! {
<div>
<button on:click=move |_| set_count.update(|n| *n += 1)>
"Click me: " {count}
</button>
</div>
}
}
```
### Actix-web: Battle-Tested Performance
Actix-web has been a cornerstone of Rust web development, known for its exceptional performance and mature ecosystem.
```rust
use actix_web::{web, App, HttpResponse, HttpServer, Result};
async fn greet(name: web::Path<String>) -> Result<HttpResponse> {
Ok(HttpResponse::Ok().json(format!("Hello, {}!", name)))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/hello/{name}", web::get().to(greet))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
```
## Database Integration
### SQLx: Compile-Time Checked SQL
SQLx provides compile-time verification of SQL queries, preventing runtime SQL errors.
```rust
use sqlx::{Pool, Postgres};
#[derive(sqlx::FromRow)]
struct User {
id: i32,
name: String,
email: String,
}
async fn get_user_by_id(pool: &Pool<Postgres>, id: i32) -> Result<User, sqlx::Error> {
sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
)
.fetch_one(pool)
.await
}
```
### Diesel: Type-Safe ORM
Diesel provides a type-safe ORM experience with excellent compile-time guarantees.
```rust
use diesel::prelude::*;
#[derive(Queryable)]
struct User {
id: i32,
name: String,
email: String,
}
fn get_users(conn: &mut PgConnection) -> QueryResult<Vec<User>> {
users::table.load::<User>(conn)
}
```
## Authentication and Security
### JWT Token Handling
```rust
use jsonwebtoken::{encode, decode, Header, Algorithm, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
fn create_jwt(user_id: &str) -> Result<String, jsonwebtoken::errors::Error> {
let claims = Claims {
sub: user_id.to_string(),
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret("secret".as_ref()))
}
```
### Password Hashing with Argon2
```rust
use argon2::{
Argon2,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let argon2 = Argon2::default();
let salt = SaltString::generate(&mut OsRng);
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?;
Ok(password_hash.to_string())
}
fn verify_password(password: &str, hash: &str) -> Result<bool, argon2::password_hash::Error> {
let argon2 = Argon2::default();
let parsed_hash = PasswordHash::new(hash)?;
argon2
.verify_password(password.as_bytes(), &parsed_hash)
.map(|_| true)
.or_else(|err| match err {
argon2::password_hash::Error::Password => Ok(false),
_ => Err(err),
})
}
```
## Testing Your Rust Web Applications
### Unit Testing
```rust
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_user_creation() {
let user = create_user("Alice", "alice@example.com").await;
assert_eq!(user.name, "Alice");
assert_eq!(user.email, "alice@example.com");
}
}
```
### Integration Testing with reqwest
```rust
#[tokio::test]
async fn test_api_endpoint() {
let client = reqwest::Client::new();
let response = client
.get("http://localhost:3000/api/users")
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
}
```
## Deployment and Production Considerations
### Docker Containerization
```dockerfile
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /app/target/release/my-web-app /usr/local/bin/
EXPOSE 3000
CMD ["my-web-app"]
```
### Performance Optimization Tips
1. **Use `cargo build --release`** for production builds
2. **Enable link-time optimization (LTO)** in Cargo.toml
3. **Use connection pooling** for database connections
4. **Implement proper caching strategies**
5. **Monitor with tools like** `tokio-console`
```toml
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
```
## Real-World Example: Building a Blog API
Let's build a complete blog API that demonstrates many of these concepts:
```rust
use axum::{
extract::{Path, Query, State},
response::Json,
routing::{get, post},
Router,
};
use sqlx::{PgPool, FromRow};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(FromRow, Serialize)]
struct BlogPost {
id: Uuid,
title: String,
content: String,
author_id: Uuid,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreatePost {
title: String,
content: String,
author_id: Uuid,
}
#[derive(Deserialize)]
struct PostQuery {
limit: Option<i32>,
offset: Option<i32>,
}
async fn get_posts(
Query(query): Query<PostQuery>,
State(pool): State<PgPool>,
) -> Json<Vec<BlogPost>> {
let posts = sqlx::query_as!(
BlogPost,
"SELECT id, title, content, author_id, created_at
FROM blog_posts
ORDER BY created_at DESC
LIMIT $1 OFFSET $2",
query.limit.unwrap_or(10),
query.offset.unwrap_or(0)
)
.fetch_all(&pool)
.await
.unwrap_or_default();
Json(posts)
}
async fn create_post(
State(pool): State<PgPool>,
Json(post): Json<CreatePost>,
) -> Json<BlogPost> {
let new_post = sqlx::query_as!(
BlogPost,
"INSERT INTO blog_posts (title, content, author_id)
VALUES ($1, $2, $3)
RETURNING id, title, content, author_id, created_at",
post.title,
post.content,
post.author_id
)
.fetch_one(&pool)
.await
.unwrap();
Json(new_post)
}
fn create_blog_router(pool: PgPool) -> Router {
Router::new()
.route("/posts", get(get_posts).post(create_post))
.with_state(pool)
}
```
## Conclusion
Rust offers a compelling proposition for web development, combining performance, safety, and developer productivity. Whether you're building high-performance APIs, real-time applications, or full-stack web applications, Rust's ecosystem has matured to provide excellent solutions.
The frameworks and tools we've explored represent just the beginning of what's possible with Rust web development. As the ecosystem continues to grow, we can expect even more powerful abstractions and better developer experiences.
### Next Steps
1. **Try building a simple API** with Axum
2. **Experiment with Leptos** for full-stack development
3. **Learn about async Rust** and tokio
4. **Explore the crates.io ecosystem** for specialized libraries
5. **Join the Rust community** on Discord, Reddit, and GitHub
Happy coding with Rust! 🦀
---
*Want to learn more about Rust web development? Check out our other articles on advanced topics like WebAssembly integration, microservices architecture, and performance optimization.*

View File

@ -1,493 +0,0 @@
[en]
welcome = "Welcome to Leptos"
not_found = "Page not found."
# Authentication
login = "Login"
logout = "Logout"
register = "Register"
email = "Email"
password = "Password"
confirm-password = "Confirm Password"
username = "Username"
display-name = "Display Name"
first-name = "First Name"
last-name = "Last Name"
remember-me = "Remember me"
forgot-password = "Forgot password?"
sign-in = "Sign in"
sign-up = "Sign up"
create-account = "Create account"
already-have-account = "Already have an account?"
dont-have-account = "Don't have an account?"
welcome-back = "Welcome back"
email-address = "Email address"
enter-email = "Enter your email"
enter-password = "Enter your password"
signing-in = "Signing in..."
continue-with = "Or continue with"
join-us-today = "Join us today"
enter-username = "Enter your username"
creating-account = "Creating account..."
passwords-dont-match = "Passwords don't match"
passwords-match = "Passwords match"
i-agree-to-the = "I agree to the"
terms-of-service = "Terms of Service"
and = "and"
privacy-policy = "Privacy Policy"
username-format = "Username must be 3-30 characters, letters, numbers, and underscores only"
how-should-we-call-you = "How should we call you?"
# Authentication Errors
invalid-credentials = "Invalid email or password"
user-not-found = "User not found"
email-already-exists = "Email already exists"
username-already-exists = "Username already exists"
invalid-token = "Invalid token"
token-expired = "Token expired"
insufficient-permissions = "Insufficient permissions"
account-not-verified = "Account not verified"
account-suspended = "Account suspended"
rate-limit-exceeded = "Rate limit exceeded"
oauth-error = "OAuth error"
database-error = "Database error"
validation-error = "Validation error"
login-failed = "Login failed"
registration-failed = "Registration failed"
session-expired = "Session expired"
profile-update-failed = "Profile update failed"
password-change-failed = "Password change failed"
network-error = "Network error"
server-error = "Server error"
internal-error = "Internal error"
unknown-error = "Unknown error"
# Password Validation
password-strength = "Password strength"
password-weak = "Weak"
password-medium = "Medium"
password-strong = "Strong"
password-very-strong = "Very strong"
password-requirements = "Password must be at least 8 characters long"
very-weak = "Very Weak"
weak = "Weak"
fair = "Fair"
good = "Good"
strong = "Strong"
# Common UI
loading = "Loading..."
save = "Save"
cancel = "Cancel"
submit = "Submit"
close = "Close"
back = "Back"
next = "Next"
previous = "Previous"
search = "Search"
filter = "Filter"
sort = "Sort"
edit = "Edit"
delete = "Delete"
confirm = "Confirm"
success = "Success"
error = "Error"
warning = "Warning"
info = "Info"
# Language
select-language = "Select language"
language = "Language"
pages = "Pages"
# Admin Dashboard
"admin.dashboard.title" = "Admin Dashboard"
"admin.dashboard.subtitle" = "Monitor and manage your application"
"admin.dashboard.refresh" = "Refresh"
# Admin Stats
"admin.stats.total_users" = "Total Users"
"admin.stats.active_users" = "Active Users"
"admin.stats.content_items" = "Content Items"
"admin.stats.total_roles" = "Total Roles"
"admin.stats.pending_approvals" = "Pending Approvals"
"admin.stats.system_health" = "System Health"
# Admin Quick Actions
"admin.quick_actions.title" = "Quick Actions"
"admin.quick_actions.manage_users" = "Manage Users"
"admin.quick_actions.manage_roles" = "Manage Roles"
"admin.quick_actions.manage_content" = "Manage Content"
# Admin Recent Activity
"admin.recent_activity.title" = "Recent Activity"
"admin.recent_activity.no_activity" = "No recent activity"
"admin.recent_activity.no_activity_desc" = "Activity will appear here when users interact with the system"
# Admin Users
"admin.users.title" = "User Management"
"admin.users.add_user" = "Add New User"
"admin.users.search_placeholder" = "Search by name or email..."
"admin.users.filter_status" = "Filter by Status"
"admin.users.clear_filters" = "Clear Filters"
"admin.users.table.user" = "User"
"admin.users.table.roles" = "Roles"
"admin.users.table.status" = "Status"
"admin.users.table.last_login" = "Last Login"
"admin.users.table.actions" = "Actions"
"admin.users.edit" = "Edit"
"admin.users.activate" = "Activate"
"admin.users.suspend" = "Suspend"
"admin.users.delete" = "Delete"
"admin.users.delete_confirm" = "Are you sure you want to delete this user?"
# Admin Roles
"admin.roles.title" = "Role Management"
"admin.roles.create_role" = "Create New Role"
"admin.roles.view_permissions" = "View Permissions"
"admin.roles.search_placeholder" = "Search by name or description..."
"admin.roles.system_role" = "System Role"
"admin.roles.users" = "users"
"admin.roles.permissions" = "permissions"
"admin.roles.delete_confirm" = "Are you sure you want to delete this role?"
# User Status
"status.active" = "Active"
"status.inactive" = "Inactive"
"status.suspended" = "Suspended"
"status.pending" = "Pending"
# Admin Content Management
"admin.content.title" = "Content Management"
"admin.content.subtitle" = "Manage your content, posts, and media"
"admin.content.refresh" = "Refresh"
"admin.content.create" = "Create Content"
"admin.content.upload" = "Upload Files"
"admin.content.edit" = "Edit"
"admin.content.view" = "View"
"admin.content.delete" = "Delete"
"admin.content.cancel" = "Cancel"
"admin.content.save" = "Save"
# Content Stats
"admin.content.stats.total" = "Total Content"
"admin.content.stats.published" = "Published"
"admin.content.stats.drafts" = "Drafts"
"admin.content.stats.scheduled" = "Scheduled"
"admin.content.stats.views" = "Total Views"
# Content Filters
"admin.content.search" = "Search"
"admin.content.search_placeholder" = "Search content..."
"admin.content.filter_type" = "Content Type"
"admin.content.filter_state" = "State"
"admin.content.all_types" = "All Types"
"admin.content.all_states" = "All States"
"admin.content.sort" = "Sort By"
"admin.content.sort.updated" = "Last Updated"
"admin.content.sort.created" = "Created Date"
"admin.content.sort.title" = "Title"
"admin.content.sort.views" = "Views"
# Content Types
"admin.content.type.blog" = "Blog"
"admin.content.type.page" = "Page"
"admin.content.type.article" = "Article"
"admin.content.type.documentation" = "Documentation"
"admin.content.type.tutorial" = "Tutorial"
# Content States
"admin.content.state.draft" = "Draft"
"admin.content.state.published" = "Published"
"admin.content.state.archived" = "Archived"
"admin.content.state.scheduled" = "Scheduled"
# Content Formats
"admin.content.format.markdown" = "Markdown"
"admin.content.format.html" = "HTML"
"admin.content.format.plain_text" = "Plain Text"
# Content Table
"admin.content.table.title" = "Title"
"admin.content.table.type" = "Type"
"admin.content.table.state" = "State"
"admin.content.table.language" = "Language"
"admin.content.table.author" = "Author"
"admin.content.table.updated" = "Updated"
"admin.content.table.views" = "Views"
"admin.content.table.actions" = "Actions"
# Content Forms
"admin.content.create_title" = "Create New Content"
"admin.content.edit_title" = "Edit Content"
"admin.content.edit_placeholder" = "Content editing functionality"
"admin.content.upload_title" = "Upload Content Files"
"admin.content.upload_description" = "Drag and drop files here or click to browse"
"admin.content.choose_files" = "Choose Files"
"admin.content.form.title" = "Title"
"admin.content.form.slug" = "Slug"
"admin.content.form.content" = "Content"
"admin.content.form.type" = "Content Type"
"admin.content.form.format" = "Format"
"admin.content.form.state" = "State"
"admin.content.form.tags" = "Tags"
"admin.content.form.tags_placeholder" = "Comma-separated tags"
"admin.content.form.category" = "Category"
"admin.content.form.excerpt" = "Excerpt"
"admin.content.form.seo_title" = "SEO Title"
"admin.content.form.seo_description" = "SEO Description"
"admin.content.form.require_login" = "Require Login"
"admin.content.form.allow_comments" = "Allow Comments"
# Content Language Filtering
"admin.content.filter_language" = "Language"
"admin.content.all_languages" = "All Languages"
"admin.content.language.english" = "English"
"admin.content.language.spanish" = "Spanish"
[es]
welcome = "Bienvenido a Leptos"
not_found = "Página no encontrada."
# Authentication
login = "Iniciar sesión"
logout = "Cerrar sesión"
register = "Registrarse"
email = "Email"
password = "Contraseña"
confirm-password = "Confirmar contraseña"
username = "Nombre de usuario"
display-name = "Nombre para mostrar"
first-name = "Nombre"
last-name = "Apellido"
remember-me = "Recordarme"
forgot-password = "¿Olvidaste tu contraseña?"
sign-in = "Iniciar sesión"
sign-up = "Registrarse"
create-account = "Crear cuenta"
already-have-account = "¿Ya tienes una cuenta?"
dont-have-account = "¿No tienes una cuenta?"
welcome-back = "Bienvenido de vuelta"
email-address = "Dirección de email"
enter-email = "Introduce tu email"
enter-password = "Introduce tu contraseña"
signing-in = "Iniciando sesión..."
continue-with = "O continúa con"
join-us-today = "Únete a nosotros hoy"
enter-username = "Introduce tu nombre de usuario"
creating-account = "Creando cuenta..."
passwords-dont-match = "Las contraseñas no coinciden"
passwords-match = "Las contraseñas coinciden"
i-agree-to-the = "Acepto los"
terms-of-service = "Términos de Servicio"
and = "y"
privacy-policy = "Política de Privacidad"
username-format = "El nombre de usuario debe tener 3-30 caracteres, solo letras, números y guiones bajos"
how-should-we-call-you = "¿Cómo deberíamos llamarte?"
# Authentication Errors
invalid-credentials = "Email o contraseña inválidos"
user-not-found = "Usuario no encontrado"
email-already-exists = "El email ya existe"
username-already-exists = "El nombre de usuario ya existe"
invalid-token = "Token inválido"
token-expired = "Token expirado"
insufficient-permissions = "Permisos insuficientes"
account-not-verified = "Cuenta no verificada"
account-suspended = "Cuenta suspendida"
rate-limit-exceeded = "Límite de velocidad excedido"
oauth-error = "Error de OAuth"
database-error = "Error de base de datos"
validation-error = "Error de validación"
login-failed = "Inicio de sesión fallido"
registration-failed = "Registro fallido"
session-expired = "Sesión expirada"
profile-update-failed = "Actualización de perfil fallida"
password-change-failed = "Cambio de contraseña fallido"
network-error = "Error de red"
server-error = "Error del servidor"
internal-error = "Error interno"
unknown-error = "Error desconocido"
# Password Validation
password-strength = "Fuerza de contraseña"
password-weak = "Débil"
password-medium = "Medio"
password-strong = "Fuerte"
password-very-strong = "Muy fuerte"
password-requirements = "La contraseña debe tener al menos 8 caracteres"
very-weak = "Muy Débil"
weak = "Débil"
fair = "Regular"
good = "Bueno"
strong = "Fuerte"
# Common UI
loading = "Cargando..."
save = "Guardar"
cancel = "Cancelar"
submit = "Enviar"
close = "Cerrar"
back = "Atrás"
next = "Siguiente"
previous = "Anterior"
search = "Buscar"
filter = "Filtrar"
sort = "Ordenar"
edit = "Editar"
delete = "Eliminar"
confirm = "Confirmar"
success = "Éxito"
error = "Error"
warning = "Advertencia"
info = "Información"
# Language
select-language = "Seleccionar idioma"
language = "Idioma"
pages = "Páginas"
# Admin Dashboard
"admin.dashboard.title" = "Panel de Administración"
"admin.dashboard.subtitle" = "Monitorea y gestiona tu aplicación"
"admin.dashboard.refresh" = "Actualizar"
# Admin Stats
"admin.stats.total_users" = "Total de Usuarios"
"admin.stats.active_users" = "Usuarios Activos"
"admin.stats.content_items" = "Elementos de Contenido"
"admin.stats.total_roles" = "Total de Roles"
"admin.stats.pending_approvals" = "Aprobaciones Pendientes"
"admin.stats.system_health" = "Estado del Sistema"
# Admin Quick Actions
"admin.quick_actions.title" = "Acciones Rápidas"
"admin.quick_actions.manage_users" = "Gestionar Usuarios"
"admin.quick_actions.manage_roles" = "Gestionar Roles"
"admin.quick_actions.manage_content" = "Gestionar Contenido"
# Admin Recent Activity
"admin.recent_activity.title" = "Actividad Reciente"
"admin.recent_activity.no_activity" = "Sin actividad reciente"
"admin.recent_activity.no_activity_desc" = "La actividad aparecerá aquí cuando los usuarios interactúen con el sistema"
# Admin Users
"admin.users.title" = "Gestión de Usuarios"
"admin.users.add_user" = "Agregar Nuevo Usuario"
"admin.users.search_placeholder" = "Buscar por nombre o email..."
"admin.users.filter_status" = "Filtrar por Estado"
"admin.users.clear_filters" = "Limpiar Filtros"
"admin.users.table.user" = "Usuario"
"admin.users.table.roles" = "Roles"
"admin.users.table.status" = "Estado"
"admin.users.table.last_login" = "Último Acceso"
"admin.users.table.actions" = "Acciones"
"admin.users.edit" = "Editar"
"admin.users.activate" = "Activar"
"admin.users.suspend" = "Suspender"
"admin.users.delete" = "Eliminar"
"admin.users.delete_confirm" = "¿Estás seguro de que quieres eliminar este usuario?"
# Admin Roles
"admin.roles.title" = "Gestión de Roles"
"admin.roles.create_role" = "Crear Nuevo Rol"
"admin.roles.view_permissions" = "Ver Permisos"
"admin.roles.search_placeholder" = "Buscar por nombre o descripción..."
"admin.roles.system_role" = "Rol del Sistema"
"admin.roles.users" = "usuarios"
"admin.roles.permissions" = "permisos"
"admin.roles.delete_confirm" = "¿Estás seguro de que quieres eliminar este rol?"
# User Status
"status.active" = "Activo"
"status.inactive" = "Inactivo"
"status.suspended" = "Suspendido"
"status.pending" = "Pendiente"
# Admin Content Management
"admin.content.title" = "Gestión de Contenido"
"admin.content.subtitle" = "Gestiona tu contenido, publicaciones y medios"
"admin.content.refresh" = "Actualizar"
"admin.content.create" = "Crear Contenido"
"admin.content.upload" = "Subir Archivos"
"admin.content.edit" = "Editar"
"admin.content.view" = "Ver"
"admin.content.delete" = "Eliminar"
"admin.content.cancel" = "Cancelar"
"admin.content.save" = "Guardar"
# Content Stats
"admin.content.stats.total" = "Total de Contenido"
"admin.content.stats.published" = "Publicado"
"admin.content.stats.drafts" = "Borradores"
"admin.content.stats.scheduled" = "Programado"
"admin.content.stats.views" = "Total de Visitas"
# Content Filters
"admin.content.search" = "Buscar"
"admin.content.search_placeholder" = "Buscar contenido..."
"admin.content.filter_type" = "Tipo de Contenido"
"admin.content.filter_state" = "Estado"
"admin.content.all_types" = "Todos los Tipos"
"admin.content.all_states" = "Todos los Estados"
"admin.content.sort" = "Ordenar Por"
"admin.content.sort.updated" = "Última Actualización"
"admin.content.sort.created" = "Fecha de Creación"
"admin.content.sort.title" = "Título"
"admin.content.sort.views" = "Visitas"
# Content Types
"admin.content.type.blog" = "Blog"
"admin.content.type.page" = "Página"
"admin.content.type.article" = "Artículo"
"admin.content.type.documentation" = "Documentación"
"admin.content.type.tutorial" = "Tutorial"
# Content States
"admin.content.state.draft" = "Borrador"
"admin.content.state.published" = "Publicado"
"admin.content.state.archived" = "Archivado"
"admin.content.state.scheduled" = "Programado"
# Content Formats
"admin.content.format.markdown" = "Markdown"
"admin.content.format.html" = "HTML"
"admin.content.format.plain_text" = "Texto Plano"
# Content Table
"admin.content.table.title" = "Título"
"admin.content.table.type" = "Tipo"
"admin.content.table.state" = "Estado"
"admin.content.table.language" = "Idioma"
"admin.content.table.author" = "Autor"
"admin.content.table.updated" = "Actualizado"
"admin.content.table.views" = "Visitas"
"admin.content.table.actions" = "Acciones"
# Content Forms
"admin.content.create_title" = "Crear Nuevo Contenido"
"admin.content.edit_title" = "Editar Contenido"
"admin.content.edit_placeholder" = "Funcionalidad de edición de contenido"
"admin.content.upload_title" = "Subir Archivos de Contenido"
"admin.content.upload_description" = "Arrastra y suelta archivos aquí o haz clic para examinar"
"admin.content.choose_files" = "Elegir Archivos"
"admin.content.form.title" = "Título"
"admin.content.form.slug" = "Slug"
"admin.content.form.content" = "Contenido"
"admin.content.form.type" = "Tipo de Contenido"
"admin.content.form.format" = "Formato"
"admin.content.form.state" = "Estado"
"admin.content.form.tags" = "Etiquetas"
"admin.content.form.tags_placeholder" = "Etiquetas separadas por comas"
"admin.content.form.category" = "Categoría"
"admin.content.form.excerpt" = "Extracto"
"admin.content.form.seo_title" = "Título SEO"
"admin.content.form.seo_description" = "Descripción SEO"
"admin.content.form.require_login" = "Requiere Inicio de Sesión"
"admin.content.form.allow_comments" = "Permitir Comentarios"
# Content Language Filtering
"admin.content.filter_language" = "Idioma"
"admin.content.all_languages" = "Todos los Idiomas"
"admin.content.language.english" = "Inglés"
"admin.content.language.spanish" = "Español"

3
end2end/.gitignore vendored
View File

@ -1,3 +0,0 @@
node_modules
playwright-report
test-results

View File

@ -1,167 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "end2end",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
},
"node_modules/@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
}
},
"dependencies": {
"@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true,
"requires": {
"playwright": "1.44.1"
}
},
"@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
}
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true,
"requires": {
"fsevents": "2.3.2",
"playwright-core": "1.44.1"
}
},
"playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true
},
"typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

View File

@ -1,15 +0,0 @@
{
"name": "end2end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
}

View File

@ -1,105 +0,0 @@
import type { PlaywrightTestConfig } from "@playwright/test";
import { devices, defineConfig } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
});

View File

@ -1,9 +0,0 @@
import { test, expect } from "@playwright/test";
test("homepage has title and heading text", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});

View File

@ -1,109 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -0,0 +1,67 @@
[feature]
name = "analytics"
version = "0.1.0"
source = "p-jpl-website"
description = "Comprehensive analytics system with navigation tracking, server monitoring, and browser analytics"
requires = []
[dependencies]
workspace = ["chrono", "serde_json", "prometheus", "futures", "tokio"]
external = ["ratatui = '0.29'", "inquire = '0.7'", "crossterm = '0.29'", "lru = '0.16'"]
[[environment.variables]]
name = "ANALYTICS_ENABLED"
default = "true"
required = false
[[environment.variables]]
name = "ANALYTICS_LOG_PATH"
default = "logs/analytics"
required = false
[[environment.variables]]
name = "ANALYTICS_API_KEY"
default = ""
required = true
secret = true
[configuration]
files = [
{ path = "config/analytics.toml", template = "templates/analytics.config.toml" },
{ path = "config/routes/analytics.toml", template = "templates/analytics.routes.toml", merge = true }
]
[resources]
public = [
{ from = "assets/analytics.js", to = "public/js/analytics.js" },
{ from = "assets/analytics.wasm", to = "public/wasm/analytics.wasm" }
]
[resources.site]
content = [
{ from = "content/docs/analytics.md", to = "site/content/docs/analytics.md" }
]
i18n = [
{ from = "i18n/en/analytics.ftl", to = "site/i18n/en/analytics.ftl" },
{ from = "i18n/es/analytics.ftl", to = "site/i18n/es/analytics.ftl" }
]
[node]
dependencies = { "@analytics/cli" = "^1.0.0" }
[styles]
uno = { presets = ["@analytics/preset"] }
[docker]
compose = { services = [{ file = "docker/analytics-service.yml", merge = true }] }
[[scripts]]
from = "scripts/analytics-report.nu"
to = "scripts/analytics/report.nu"
[[scripts]]
from = "scripts/analytics-dashboard.nu"
to = "scripts/analytics/dashboard.nu"
[just]
module = "just/analytics.just"

View File

@ -0,0 +1,411 @@
//! Browser Console Log Collector
//!
//! Collects and analyzes browser console messages including:
//! - Console.log, warn, error messages
//! - Unhandled promise rejections
//! - WASM binding errors
//! - Leptos reactive signal issues
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use super::{BrowserLogLevel, JavaScriptError};
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// Console message pattern for categorization
#[derive(Debug, Clone)]
pub struct ConsolePattern {
pub pattern: String,
pub category: ConsoleCategory,
pub severity: EventLevel,
}
/// Console message categories
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConsoleCategory {
ReactiveGraph,
Hydration,
WasmBinding,
UserCode,
PerformanceWarning,
SecurityWarning,
NetworkError,
UnhandledPromise,
}
/// Console statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsoleStats {
pub total_messages: u64,
pub errors: u64,
pub warnings: u64,
pub info_messages: u64,
pub debug_messages: u64,
pub category_counts: HashMap<String, u64>,
pub top_error_messages: Vec<(String, u64)>,
}
impl Default for ConsoleStats {
fn default() -> Self {
Self {
total_messages: 0,
errors: 0,
warnings: 0,
info_messages: 0,
debug_messages: 0,
category_counts: HashMap::new(),
top_error_messages: Vec::new(),
}
}
}
/// Console log collector
pub struct ConsoleCollector {
patterns: Vec<ConsolePattern>,
stats: Arc<Mutex<ConsoleStats>>,
monitoring_interval: u64,
}
impl ConsoleCollector {
/// Create new console collector
pub fn new() -> Self {
let patterns = Self::create_default_patterns();
Self {
patterns,
stats: Arc::new(Mutex::new(ConsoleStats::default())),
monitoring_interval: 10, // 10 seconds
}
}
/// Create default console message patterns
fn create_default_patterns() -> Vec<ConsolePattern> {
vec![
// Leptos reactive graph issues
ConsolePattern {
pattern: "reactive_graph".to_string(),
category: ConsoleCategory::ReactiveGraph,
severity: EventLevel::Error,
},
ConsolePattern {
pattern: "signal".to_string(),
category: ConsoleCategory::ReactiveGraph,
severity: EventLevel::Warn,
},
ConsolePattern {
pattern: "memo".to_string(),
category: ConsoleCategory::ReactiveGraph,
severity: EventLevel::Warn,
},
// Hydration issues
ConsolePattern {
pattern: "hydration".to_string(),
category: ConsoleCategory::Hydration,
severity: EventLevel::Error,
},
ConsolePattern {
pattern: "mismatch".to_string(),
category: ConsoleCategory::Hydration,
severity: EventLevel::Error,
},
// WASM binding errors
ConsolePattern {
pattern: "wasm".to_string(),
category: ConsoleCategory::WasmBinding,
severity: EventLevel::Error,
},
ConsolePattern {
pattern: "bindgen".to_string(),
category: ConsoleCategory::WasmBinding,
severity: EventLevel::Error,
},
// Network errors
ConsolePattern {
pattern: "fetch".to_string(),
category: ConsoleCategory::NetworkError,
severity: EventLevel::Warn,
},
ConsolePattern {
pattern: "XMLHttpRequest".to_string(),
category: ConsoleCategory::NetworkError,
severity: EventLevel::Warn,
},
// Unhandled promises
ConsolePattern {
pattern: "Unhandled promise rejection".to_string(),
category: ConsoleCategory::UnhandledPromise,
severity: EventLevel::Error,
},
// Performance warnings
ConsolePattern {
pattern: "performance".to_string(),
category: ConsoleCategory::PerformanceWarning,
severity: EventLevel::Warn,
},
// Security warnings
ConsolePattern {
pattern: "security".to_string(),
category: ConsoleCategory::SecurityWarning,
severity: EventLevel::Warn,
},
]
}
/// Start console message monitoring
pub async fn start_monitoring(&self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting browser console monitoring...");
let patterns = self.patterns.clone();
let stats = Arc::clone(&self.stats);
let interval = self.monitoring_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Simulate console message collection
// In a real implementation, this would interface with:
// - MCP browser-tools
// - Browser WebSocket connection
// - Log file monitoring
// - Browser extension API
if let Ok(messages) = Self::collect_console_messages().await {
for message in messages {
// Categorize message
let category = Self::categorize_message(&message, &patterns);
// Update statistics
Self::update_stats(&stats, &message, &category);
// Create analytics event
let event = Self::create_console_event(message, category);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send console event: {}", e);
break;
}
}
}
}
});
tracing::info!("Console monitoring started");
Ok(())
}
/// Collect console messages (simulated)
async fn collect_console_messages() -> Result<Vec<ConsoleMessage>> {
let mut messages = Vec::new();
// Simulate different types of console messages
if rand::random::<f64>() < 0.3 { // 30% chance of messages
let message_types = vec![
("reactive_graph error: signal accessed outside of reactive context", BrowserLogLevel::Error),
("hydration mismatch detected on element", BrowserLogLevel::Error),
("WASM binding error: function not found", BrowserLogLevel::Error),
("Performance warning: large DOM update", BrowserLogLevel::Warn),
("Unhandled promise rejection: network timeout", BrowserLogLevel::Error),
("Info: page loaded successfully", BrowserLogLevel::Info),
("Debug: component rendered", BrowserLogLevel::Debug),
];
let (message_text, level) = message_types[rand::random::<usize>() % message_types.len()];
messages.push(ConsoleMessage {
message: message_text.to_string(),
level,
timestamp: Utc::now(),
source: "browser".to_string(),
line: Some(rand::random::<u32>() % 1000 + 1),
column: Some(rand::random::<u32>() % 50 + 1),
url: "/current/page".to_string(),
stack_trace: None,
});
}
Ok(messages)
}
/// Categorize console message based on patterns
fn categorize_message(message: &ConsoleMessage, patterns: &[ConsolePattern]) -> ConsoleCategory {
let message_lower = message.message.to_lowercase();
for pattern in patterns {
if message_lower.contains(&pattern.pattern.to_lowercase()) {
return pattern.category.clone();
}
}
ConsoleCategory::UserCode
}
/// Update console statistics
fn update_stats(stats: &Arc<Mutex<ConsoleStats>>, message: &ConsoleMessage, category: &ConsoleCategory) {
if let Ok(mut stats_guard) = stats.lock() {
stats_guard.total_messages += 1;
match message.level {
BrowserLogLevel::Error | BrowserLogLevel::Assert => stats_guard.errors += 1,
BrowserLogLevel::Warn => stats_guard.warnings += 1,
BrowserLogLevel::Info => stats_guard.info_messages += 1,
BrowserLogLevel::Debug | BrowserLogLevel::Trace => stats_guard.debug_messages += 1,
}
// Update category counts
let category_key = format!("{:?}", category);
*stats_guard.category_counts.entry(category_key).or_insert(0) += 1;
// Track top error messages
if matches!(message.level, BrowserLogLevel::Error | BrowserLogLevel::Assert) {
let mut found = false;
for (msg, count) in &mut stats_guard.top_error_messages {
if msg == &message.message {
*count += 1;
found = true;
break;
}
}
if !found {
stats_guard.top_error_messages.push((message.message.clone(), 1));
}
// Keep only top 10 error messages
stats_guard.top_error_messages.sort_by(|a, b| b.1.cmp(&a.1));
stats_guard.top_error_messages.truncate(10);
}
}
}
/// Create analytics event from console message
fn create_console_event(message: ConsoleMessage, category: ConsoleCategory) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("source".to_string(), serde_json::Value::String(message.source.clone()));
metadata.insert("category".to_string(), serde_json::Value::String(format!("{:?}", category)));
metadata.insert("browser_level".to_string(), serde_json::Value::String(format!("{:?}", message.level)));
if let Some(line) = message.line {
metadata.insert("line".to_string(), serde_json::Value::Number(line.into()));
}
if let Some(column) = message.column {
metadata.insert("column".to_string(), serde_json::Value::Number(column.into()));
}
if let Some(stack) = &message.stack_trace {
metadata.insert("stack_trace".to_string(), serde_json::Value::String(stack.clone()));
}
let event_type = match category {
ConsoleCategory::ReactiveGraph => "reactive_graph_issue",
ConsoleCategory::Hydration => "hydration_error",
ConsoleCategory::WasmBinding => "wasm_error",
ConsoleCategory::NetworkError => "network_error",
ConsoleCategory::UnhandledPromise => "unhandled_promise",
ConsoleCategory::PerformanceWarning => "performance_warning",
ConsoleCategory::SecurityWarning => "security_warning",
ConsoleCategory::UserCode => "console_message",
}.to_string();
let level: EventLevel = match message.level {
BrowserLogLevel::Error | BrowserLogLevel::Assert => EventLevel::Error,
BrowserLogLevel::Warn => EventLevel::Warn,
BrowserLogLevel::Info => EventLevel::Info,
BrowserLogLevel::Debug | BrowserLogLevel::Trace => EventLevel::Debug,
};
AnalyticsEvent {
id: generate_event_id(),
timestamp: message.timestamp,
source: LogSource::Browser,
event_type,
session_id: None,
path: Some(message.url.clone()),
level,
message: message.message,
metadata,
duration_ms: None,
errors: if matches!(message.level, BrowserLogLevel::Error | BrowserLogLevel::Assert) {
vec![message.message.clone()]
} else {
Vec::new()
},
}
}
/// Get current console statistics
pub fn get_stats(&self) -> ConsoleStats {
self.stats.lock().unwrap().clone()
}
/// Generate console analysis report
pub fn generate_console_report(&self) -> String {
let stats = self.get_stats();
let mut report = String::from("🖥️ Browser Console Analysis\n\n");
report.push_str(&format!("Total Messages: {}\n", stats.total_messages));
report.push_str(&format!("Errors: {} ({:.1}%)\n",
stats.errors,
if stats.total_messages > 0 {
stats.errors as f64 / stats.total_messages as f64 * 100.0
} else { 0.0 }
));
report.push_str(&format!("Warnings: {} ({:.1}%)\n",
stats.warnings,
if stats.total_messages > 0 {
stats.warnings as f64 / stats.total_messages as f64 * 100.0
} else { 0.0 }
));
if !stats.category_counts.is_empty() {
report.push_str("\nMessage Categories:\n");
let mut categories: Vec<_> = stats.category_counts.iter().collect();
categories.sort_by(|a, b| b.1.cmp(a.1));
for (category, count) in categories.iter().take(5) {
report.push_str(&format!(" {}: {}\n", category, count));
}
}
if !stats.top_error_messages.is_empty() {
report.push_str("\nTop Error Messages:\n");
for (message, count) in stats.top_error_messages.iter().take(3) {
let truncated = if message.len() > 60 {
format!("{}...", &message[..57])
} else {
message.clone()
};
report.push_str(&format!(" {} ({}x)\n", truncated, count));
}
}
report
}
}
/// Console message structure
#[derive(Debug, Clone)]
pub struct ConsoleMessage {
pub message: String,
pub level: BrowserLogLevel,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub source: String,
pub line: Option<u32>,
pub column: Option<u32>,
pub url: String,
pub stack_trace: Option<String>,
}

View File

@ -0,0 +1,345 @@
//! Browser Error Detection
//!
//! Detects and analyzes browser-side errors including:
//! - JavaScript runtime errors
//! - Unhandled promise rejections
//! - Resource loading failures
//! - Network request errors
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use super::JavaScriptError;
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::mpsc;
/// Error detection patterns
#[derive(Debug, Clone)]
pub struct ErrorPattern {
pub pattern: String,
pub error_type: ErrorType,
pub severity: ErrorSeverity,
pub actionable: bool,
}
/// Error types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ErrorType {
RuntimeError,
ReferenceError,
TypeError,
SyntaxError,
NetworkError,
ResourceError,
PermissionError,
SecurityError,
UnhandledPromise,
}
/// Error severity levels
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ErrorSeverity {
Low,
Medium,
High,
Critical,
}
/// Error statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorStats {
pub total_errors: u64,
pub error_types: HashMap<String, u64>,
pub error_sources: HashMap<String, u64>,
pub critical_errors: u64,
pub actionable_errors: u64,
pub recent_error_rate: f64, // errors per minute
}
impl Default for ErrorStats {
fn default() -> Self {
Self {
total_errors: 0,
error_types: HashMap::new(),
error_sources: HashMap::new(),
critical_errors: 0,
actionable_errors: 0,
recent_error_rate: 0.0,
}
}
}
/// Browser error detector
pub struct ErrorDetector {
patterns: Vec<ErrorPattern>,
monitoring_interval: u64,
}
impl ErrorDetector {
/// Create new error detector
pub fn new() -> Self {
let patterns = Self::create_error_patterns();
Self {
patterns,
monitoring_interval: 15, // 15 seconds
}
}
/// Create error detection patterns
fn create_error_patterns() -> Vec<ErrorPattern> {
vec![
ErrorPattern {
pattern: "ReferenceError".to_string(),
error_type: ErrorType::ReferenceError,
severity: ErrorSeverity::High,
actionable: true,
},
ErrorPattern {
pattern: "TypeError".to_string(),
error_type: ErrorType::TypeError,
severity: ErrorSeverity::Medium,
actionable: true,
},
ErrorPattern {
pattern: "SyntaxError".to_string(),
error_type: ErrorType::SyntaxError,
severity: ErrorSeverity::Critical,
actionable: true,
},
ErrorPattern {
pattern: "NetworkError".to_string(),
error_type: ErrorType::NetworkError,
severity: ErrorSeverity::Medium,
actionable: false,
},
ErrorPattern {
pattern: "Failed to load resource".to_string(),
error_type: ErrorType::ResourceError,
severity: ErrorSeverity::Medium,
actionable: true,
},
ErrorPattern {
pattern: "Permission denied".to_string(),
error_type: ErrorType::PermissionError,
severity: ErrorSeverity::High,
actionable: true,
},
ErrorPattern {
pattern: "Content Security Policy".to_string(),
error_type: ErrorType::SecurityError,
severity: ErrorSeverity::High,
actionable: true,
},
ErrorPattern {
pattern: "Unhandled promise rejection".to_string(),
error_type: ErrorType::UnhandledPromise,
severity: ErrorSeverity::High,
actionable: true,
},
]
}
/// Start error monitoring
pub async fn start_monitoring(&self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting browser error detection...");
let patterns = self.patterns.clone();
let interval = self.monitoring_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Simulate error detection
// In a real implementation, this would:
// - Listen to browser error events
// - Monitor unhandled promise rejections
// - Track network failures
// - Parse console error messages
if let Ok(errors) = Self::detect_errors().await {
for error in errors {
let pattern = Self::match_error_pattern(&error, &patterns);
let event = Self::create_error_event(error, pattern);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send error event: {}", e);
break;
}
}
}
}
});
tracing::info!("Error detection started");
Ok(())
}
/// Detect browser errors (simulated)
async fn detect_errors() -> Result<Vec<DetectedError>> {
let mut errors = Vec::new();
// Simulate error detection with realistic patterns
if rand::random::<f64>() < 0.2 { // 20% chance of detecting errors
let error_scenarios = vec![
DetectedError {
message: "ReferenceError: variable is not defined".to_string(),
source: "/js/main.js".to_string(),
line: 145,
column: 23,
timestamp: Utc::now(),
stack_trace: Some("at Object.<anonymous> (/js/main.js:145:23)".to_string()),
url: "/current/page".to_string(),
},
DetectedError {
message: "Failed to load resource: net::ERR_CONNECTION_REFUSED".to_string(),
source: "/api/data".to_string(),
line: 0,
column: 0,
timestamp: Utc::now(),
stack_trace: None,
url: "/api/data".to_string(),
},
DetectedError {
message: "Unhandled promise rejection: timeout".to_string(),
source: "/js/async.js".to_string(),
line: 67,
column: 12,
timestamp: Utc::now(),
stack_trace: Some("at fetchData (/js/async.js:67:12)".to_string()),
url: "/current/page".to_string(),
},
];
let selected_error = &error_scenarios[rand::random::<usize>() % error_scenarios.len()];
errors.push(selected_error.clone());
}
Ok(errors)
}
/// Match error against patterns
fn match_error_pattern(error: &DetectedError, patterns: &[ErrorPattern]) -> Option<ErrorPattern> {
for pattern in patterns {
if error.message.contains(&pattern.pattern) {
return Some(pattern.clone());
}
}
None
}
/// Create analytics event from detected error
fn create_error_event(error: DetectedError, pattern: Option<ErrorPattern>) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("source".to_string(), serde_json::Value::String(error.source.clone()));
metadata.insert("line".to_string(), serde_json::Value::Number(error.line.into()));
metadata.insert("column".to_string(), serde_json::Value::Number(error.column.into()));
metadata.insert("url".to_string(), serde_json::Value::String(error.url.clone()));
if let Some(stack) = &error.stack_trace {
metadata.insert("stack_trace".to_string(), serde_json::Value::String(stack.clone()));
}
let (error_type, severity, actionable) = if let Some(ref p) = pattern {
(
format!("{:?}", p.error_type),
p.severity.clone(),
p.actionable,
)
} else {
("UnknownError".to_string(), ErrorSeverity::Medium, false)
};
metadata.insert("error_type".to_string(), serde_json::Value::String(error_type));
metadata.insert("severity".to_string(), serde_json::Value::String(format!("{:?}", severity)));
metadata.insert("actionable".to_string(), serde_json::Value::Bool(actionable));
let level = match severity {
ErrorSeverity::Critical => EventLevel::Critical,
ErrorSeverity::High => EventLevel::Error,
ErrorSeverity::Medium => EventLevel::Warn,
ErrorSeverity::Low => EventLevel::Info,
};
let event_type = if actionable {
"actionable_browser_error"
} else {
"browser_error"
}.to_string();
AnalyticsEvent {
id: generate_event_id(),
timestamp: error.timestamp,
source: LogSource::Browser,
event_type,
session_id: None,
path: Some(error.url),
level,
message: error.message.clone(),
metadata,
duration_ms: None,
errors: vec![error.message],
}
}
/// Generate error analysis report
pub fn generate_error_report(stats: &ErrorStats) -> String {
let mut report = String::from("🚨 Browser Error Analysis\n\n");
report.push_str(&format!("Total Errors: {}\n", stats.total_errors));
report.push_str(&format!("Critical Errors: {}\n", stats.critical_errors));
report.push_str(&format!("Actionable Errors: {}\n", stats.actionable_errors));
report.push_str(&format!("Error Rate: {:.2} errors/minute\n\n", stats.recent_error_rate));
if !stats.error_types.is_empty() {
report.push_str("Error Types:\n");
let mut types: Vec<_> = stats.error_types.iter().collect();
types.sort_by(|a, b| b.1.cmp(a.1));
for (error_type, count) in types.iter().take(5) {
report.push_str(&format!(" {}: {}\n", error_type, count));
}
report.push('\n');
}
if !stats.error_sources.is_empty() {
report.push_str("Top Error Sources:\n");
let mut sources: Vec<_> = stats.error_sources.iter().collect();
sources.sort_by(|a, b| b.1.cmp(a.1));
for (source, count) in sources.iter().take(3) {
report.push_str(&format!(" {}: {}\n", source, count));
}
}
// Add recommendations
report.push_str("\n📋 Recommendations:\n");
if stats.critical_errors > 0 {
report.push_str(" 🔴 Fix critical syntax errors immediately\n");
}
if stats.actionable_errors > stats.total_errors / 2 {
report.push_str(" 🟡 Focus on actionable errors for quick wins\n");
}
if stats.recent_error_rate > 5.0 {
report.push_str(" 🟠 High error rate detected - investigate error patterns\n");
}
report
}
}
/// Detected browser error
#[derive(Debug, Clone)]
pub struct DetectedError {
pub message: String,
pub source: String,
pub line: u32,
pub column: u32,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub stack_trace: Option<String>,
pub url: String,
}

View File

@ -0,0 +1,444 @@
//! User Interaction Tracker
//!
//! Tracks and analyzes user interactions including:
//! - Click events and navigation patterns
//! - Form interactions and submissions
//! - Scroll behavior and engagement
//! - Time on page and session duration
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use super::InteractionEvent;
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tokio::sync::mpsc;
/// Interaction types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InteractionType {
Click,
DoubleClick,
RightClick,
FormSubmit,
FormFocus,
FormBlur,
Scroll,
KeyPress,
MouseEnter,
MouseLeave,
PageView,
PageExit,
}
/// User session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSession {
pub session_id: String,
pub start_time: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
pub page_views: u32,
pub interactions: u32,
pub total_scroll_depth: f64,
pub current_page: String,
}
/// Interaction pattern analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionPattern {
/// Most clicked elements
pub popular_elements: Vec<(String, u64)>,
/// Common navigation paths
pub navigation_paths: Vec<(String, String, u64)>, // from, to, count
/// Average time on page by URL
pub time_on_page: HashMap<String, f64>,
/// Form completion rates
pub form_completion_rates: HashMap<String, f64>,
/// Scroll behavior analysis
pub scroll_patterns: ScrollAnalysis,
}
/// Scroll behavior analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScrollAnalysis {
/// Average scroll depth percentage
pub avg_scroll_depth: f64,
/// Pages with high scroll depth (>80%)
pub high_engagement_pages: Vec<String>,
/// Pages with low scroll depth (<20%)
pub low_engagement_pages: Vec<String>,
/// Scroll speed patterns
pub scroll_speed_analysis: String,
}
/// Interaction statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionStats {
pub total_interactions: u64,
pub unique_sessions: u64,
pub avg_session_duration_ms: u64,
pub avg_interactions_per_session: f64,
pub bounce_rate: f64, // percentage of single-page sessions
pub popular_pages: Vec<(String, u64)>,
pub interaction_types: HashMap<String, u64>,
}
impl Default for InteractionStats {
fn default() -> Self {
Self {
total_interactions: 0,
unique_sessions: 0,
avg_session_duration_ms: 0,
avg_interactions_per_session: 0.0,
bounce_rate: 0.0,
popular_pages: Vec::new(),
interaction_types: HashMap::new(),
}
}
}
/// User interaction tracker
pub struct InteractionTracker {
sessions: HashMap<String, UserSession>,
monitoring_interval: u64,
}
impl InteractionTracker {
/// Create new interaction tracker
pub fn new() -> Self {
Self {
sessions: HashMap::new(),
monitoring_interval: 20, // 20 seconds
}
}
/// Start interaction monitoring
pub async fn start_monitoring(&mut self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting user interaction tracking...");
let interval = self.monitoring_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Simulate interaction collection
// In a real implementation, this would:
// - Receive events from browser via WebSocket
// - Process click tracking data
// - Analyze form interactions
// - Track page navigation
if let Ok(interactions) = Self::collect_interactions().await {
for interaction in interactions {
let event = Self::create_interaction_event(interaction);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send interaction event: {}", e);
break;
}
}
}
// Generate session analytics
if let Ok(session_event) = Self::generate_session_analytics().await {
if let Err(e) = sender.send(session_event) {
tracing::error!("Failed to send session analytics: {}", e);
break;
}
}
}
});
tracing::info!("Interaction tracking started");
Ok(())
}
/// Collect user interactions (simulated)
async fn collect_interactions() -> Result<Vec<SimulatedInteraction>> {
let mut interactions = Vec::new();
// Simulate various user interactions
if rand::random::<f64>() < 0.6 { // 60% chance of interactions
let interaction_scenarios = vec![
SimulatedInteraction {
interaction_type: InteractionType::Click,
element_id: Some("nav-home".to_string()),
element_class: Some("nav-link".to_string()),
element_tag: "a".to_string(),
page_url: "/".to_string(),
coordinates: Some((120, 45)),
timestamp: Utc::now(),
session_id: "session_123".to_string(),
},
SimulatedInteraction {
interaction_type: InteractionType::FormSubmit,
element_id: Some("contact-form".to_string()),
element_class: Some("form".to_string()),
element_tag: "form".to_string(),
page_url: "/contact".to_string(),
coordinates: None,
timestamp: Utc::now(),
session_id: "session_456".to_string(),
},
SimulatedInteraction {
interaction_type: InteractionType::Scroll,
element_id: None,
element_class: None,
element_tag: "body".to_string(),
page_url: "/blog/post-1".to_string(),
coordinates: Some((0, 800)),
timestamp: Utc::now(),
session_id: "session_789".to_string(),
},
SimulatedInteraction {
interaction_type: InteractionType::PageView,
element_id: None,
element_class: None,
element_tag: "html".to_string(),
page_url: "/recipes".to_string(),
coordinates: None,
timestamp: Utc::now(),
session_id: "session_101".to_string(),
},
];
let selected = &interaction_scenarios[rand::random::<usize>() % interaction_scenarios.len()];
interactions.push(selected.clone());
}
Ok(interactions)
}
/// Generate session analytics
async fn generate_session_analytics() -> Result<AnalyticsEvent> {
// Simulate session analysis
let stats = InteractionStats {
total_interactions: 1250 + rand::random::<u64>() % 500,
unique_sessions: 85 + rand::random::<u64>() % 50,
avg_session_duration_ms: 120000 + rand::random::<u64>() % 180000,
avg_interactions_per_session: 8.5 + rand::random::<f64>() * 5.0,
bounce_rate: 0.35 + rand::random::<f64>() * 0.2,
popular_pages: vec![
("/".to_string(), 450),
("/recipes".to_string(), 320),
("/blog".to_string(), 280),
],
interaction_types: {
let mut types = HashMap::new();
types.insert("Click".to_string(), 650);
types.insert("PageView".to_string(), 230);
types.insert("Scroll".to_string(), 180);
types.insert("FormSubmit".to_string(), 45);
types
},
};
let mut metadata = HashMap::new();
metadata.insert("total_interactions".to_string(),
serde_json::Value::Number(stats.total_interactions.into()));
metadata.insert("unique_sessions".to_string(),
serde_json::Value::Number(stats.unique_sessions.into()));
metadata.insert("avg_session_duration_ms".to_string(),
serde_json::Value::Number(stats.avg_session_duration_ms.into()));
metadata.insert("avg_interactions_per_session".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(stats.avg_interactions_per_session).unwrap()));
metadata.insert("bounce_rate".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(stats.bounce_rate).unwrap()));
let message = format!(
"Session Analytics: {} interactions, {} sessions, {:.1}% bounce rate",
stats.total_interactions,
stats.unique_sessions,
stats.bounce_rate * 100.0
);
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Browser,
event_type: "user_interaction_analytics".to_string(),
session_id: None,
path: None,
level: EventLevel::Info,
message,
metadata,
duration_ms: Some(stats.avg_session_duration_ms),
errors: Vec::new(),
})
}
/// Create analytics event from interaction
fn create_interaction_event(interaction: SimulatedInteraction) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("interaction_type".to_string(),
serde_json::Value::String(format!("{:?}", interaction.interaction_type)));
metadata.insert("element_tag".to_string(),
serde_json::Value::String(interaction.element_tag.clone()));
metadata.insert("session_id".to_string(),
serde_json::Value::String(interaction.session_id.clone()));
if let Some(id) = &interaction.element_id {
metadata.insert("element_id".to_string(), serde_json::Value::String(id.clone()));
}
if let Some(class) = &interaction.element_class {
metadata.insert("element_class".to_string(), serde_json::Value::String(class.clone()));
}
if let Some((x, y)) = interaction.coordinates {
metadata.insert("coordinates".to_string(),
serde_json::Value::Array(vec![
serde_json::Value::Number(x.into()),
serde_json::Value::Number(y.into()),
]));
}
let event_type = match interaction.interaction_type {
InteractionType::Click | InteractionType::DoubleClick | InteractionType::RightClick => "user_click",
InteractionType::FormSubmit | InteractionType::FormFocus | InteractionType::FormBlur => "form_interaction",
InteractionType::Scroll => "scroll_event",
InteractionType::PageView | InteractionType::PageExit => "navigation_event",
_ => "user_interaction",
}.to_string();
let message = match interaction.interaction_type {
InteractionType::Click => {
format!("User clicked {} on {}",
interaction.element_id.as_deref().unwrap_or("element"),
interaction.page_url)
}
InteractionType::FormSubmit => {
format!("Form submitted: {} on {}",
interaction.element_id.as_deref().unwrap_or("form"),
interaction.page_url)
}
InteractionType::PageView => {
format!("Page viewed: {}", interaction.page_url)
}
InteractionType::Scroll => {
format!("User scrolled on {}", interaction.page_url)
}
_ => {
format!("User interaction: {:?} on {}",
interaction.interaction_type,
interaction.page_url)
}
};
AnalyticsEvent {
id: generate_event_id(),
timestamp: interaction.timestamp,
source: LogSource::Browser,
event_type,
session_id: Some(interaction.session_id),
path: Some(interaction.page_url),
level: EventLevel::Info,
message,
metadata,
duration_ms: None,
errors: Vec::new(),
}
}
/// Analyze interaction patterns
pub async fn analyze_interaction_patterns(&self) -> Result<InteractionPattern> {
// Simulate pattern analysis
let popular_elements = vec![
("nav-home".to_string(), 245),
("contact-form".to_string(), 89),
("blog-link".to_string(), 156),
("recipe-card".to_string(), 203),
];
let navigation_paths = vec![
("/".to_string(), "/recipes".to_string(), 89),
("/recipes".to_string(), "/recipes/rust".to_string(), 45),
("/".to_string(), "/blog".to_string(), 67),
("/blog".to_string(), "/".to_string(), 34),
];
let mut time_on_page = HashMap::new();
time_on_page.insert("/".to_string(), 45.2);
time_on_page.insert("/recipes".to_string(), 120.5);
time_on_page.insert("/blog".to_string(), 95.8);
time_on_page.insert("/contact".to_string(), 180.0);
let mut form_completion_rates = HashMap::new();
form_completion_rates.insert("contact-form".to_string(), 0.78);
form_completion_rates.insert("newsletter-signup".to_string(), 0.45);
form_completion_rates.insert("feedback-form".to_string(), 0.62);
let scroll_patterns = ScrollAnalysis {
avg_scroll_depth: 68.5,
high_engagement_pages: vec![
"/blog/rust-tutorial".to_string(),
"/recipes/docker".to_string(),
],
low_engagement_pages: vec![
"/legal".to_string(),
"/privacy".to_string(),
],
scroll_speed_analysis: "Users scroll 15% slower on blog posts, indicating higher engagement".to_string(),
};
Ok(InteractionPattern {
popular_elements,
navigation_paths,
time_on_page,
form_completion_rates,
scroll_patterns,
})
}
/// Generate interaction analysis report
pub async fn generate_interaction_report(&self) -> Result<String> {
let patterns = self.analyze_interaction_patterns().await?;
let mut report = String::from("👥 User Interaction Analysis\n\n");
report.push_str("🖱️ Popular Elements:\n");
for (element, clicks) in patterns.popular_elements.iter().take(5) {
report.push_str(&format!(" {}: {} clicks\n", element, clicks));
}
report.push_str("\n🧭 Navigation Patterns:\n");
for (from, to, count) in patterns.navigation_paths.iter().take(3) {
report.push_str(&format!(" {}{}: {} transitions\n", from, to, count));
}
report.push_str("\n⏱️ Time on Page:\n");
for (page, time) in patterns.time_on_page.iter() {
report.push_str(&format!(" {}: {:.1}s average\n", page, time));
}
report.push_str("\n📝 Form Completion Rates:\n");
for (form, rate) in patterns.form_completion_rates.iter() {
report.push_str(&format!(" {}: {:.1}%\n", form, rate * 100.0));
}
report.push_str(&format!("\n📜 Scroll Analysis:\n"));
report.push_str(&format!(" Average Scroll Depth: {:.1}%\n", patterns.scroll_patterns.avg_scroll_depth));
report.push_str(&format!(" High Engagement Pages: {}\n", patterns.scroll_patterns.high_engagement_pages.len()));
report.push_str(&format!(" Low Engagement Pages: {}\n", patterns.scroll_patterns.low_engagement_pages.len()));
Ok(report)
}
}
/// Simulated interaction for testing
#[derive(Debug, Clone)]
pub struct SimulatedInteraction {
pub interaction_type: InteractionType,
pub element_id: Option<String>,
pub element_class: Option<String>,
pub element_tag: String,
pub page_url: String,
pub coordinates: Option<(i32, i32)>,
pub timestamp: DateTime<Utc>,
pub session_id: String,
}

View File

@ -0,0 +1,585 @@
//! Browser Log Analytics
//!
//! Collects and analyzes browser-side logs including:
//! - JavaScript errors and exceptions
//! - Console logs and warnings
//! - Performance metrics
//! - User interaction events
//! - WebAssembly errors
use super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::mpsc;
pub mod console_collector;
pub mod error_detector;
pub mod interaction_tracker;
pub use console_collector::ConsoleCollector;
pub use error_detector::ErrorDetector;
pub use interaction_tracker::InteractionTracker;
/// Browser log entry structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserLogEntry {
pub timestamp: DateTime<Utc>,
pub level: BrowserLogLevel,
pub message: String,
pub source: String,
pub line: Option<u32>,
pub column: Option<u32>,
pub stack_trace: Option<String>,
pub user_agent: Option<String>,
pub url: String,
}
/// Browser log levels
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BrowserLogLevel {
Debug,
Info,
Warn,
Error,
Assert,
Trace,
}
impl From<BrowserLogLevel> for EventLevel {
fn from(level: BrowserLogLevel) -> Self {
match level {
BrowserLogLevel::Debug => EventLevel::Debug,
BrowserLogLevel::Info => EventLevel::Info,
BrowserLogLevel::Warn => EventLevel::Warn,
BrowserLogLevel::Error => EventLevel::Error,
BrowserLogLevel::Assert => EventLevel::Error,
BrowserLogLevel::Trace => EventLevel::Trace,
}
}
}
/// JavaScript error information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JavaScriptError {
pub message: String,
pub filename: String,
pub line_number: u32,
pub column_number: u32,
pub stack: Option<String>,
pub error_type: String,
pub timestamp: DateTime<Utc>,
}
/// Performance metrics from browser
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserPerformanceMetrics {
/// Page load time in milliseconds
pub page_load_time_ms: Option<u64>,
/// DOM content loaded time
pub dom_content_loaded_ms: Option<u64>,
/// First contentful paint
pub first_contentful_paint_ms: Option<u64>,
/// Largest contentful paint
pub largest_contentful_paint_ms: Option<u64>,
/// Cumulative layout shift
pub cumulative_layout_shift: Option<f64>,
/// First input delay
pub first_input_delay_ms: Option<u64>,
/// Memory usage
pub memory_usage_mb: Option<f64>,
}
/// User interaction event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractionEvent {
pub event_type: String,
pub element_id: Option<String>,
pub element_class: Option<String>,
pub element_tag: String,
pub page_url: String,
pub timestamp: DateTime<Utc>,
pub coordinates: Option<(i32, i32)>,
}
/// Browser analytics metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserMetrics {
/// Total JavaScript errors
pub js_errors: u64,
/// Console warnings
pub console_warnings: u64,
/// User interactions
pub user_interactions: u64,
/// Page views
pub page_views: u64,
/// Average session duration
pub avg_session_duration_ms: u64,
/// Performance metrics
pub performance: BrowserPerformanceMetrics,
}
impl Default for BrowserMetrics {
fn default() -> Self {
Self {
js_errors: 0,
console_warnings: 0,
user_interactions: 0,
page_views: 0,
avg_session_duration_ms: 0,
performance: BrowserPerformanceMetrics {
page_load_time_ms: None,
dom_content_loaded_ms: None,
first_contentful_paint_ms: None,
largest_contentful_paint_ms: None,
cumulative_layout_shift: None,
first_input_delay_ms: None,
memory_usage_mb: None,
},
}
}
}
/// Browser log collector
pub struct BrowserCollector {
sender: mpsc::UnboundedSender<AnalyticsEvent>,
log_sources: Vec<PathBuf>,
console_collector: ConsoleCollector,
error_detector: ErrorDetector,
interaction_tracker: InteractionTracker,
metrics: BrowserMetrics,
}
impl BrowserCollector {
/// Create new browser collector
pub fn new(sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Self {
let log_sources = vec![
PathBuf::from("logs/browser/console.log"),
PathBuf::from("logs/browser/errors.log"),
PathBuf::from("logs/browser/performance.log"),
];
let console_collector = ConsoleCollector::new();
let error_detector = ErrorDetector::new();
let interaction_tracker = InteractionTracker::new();
Self {
sender,
log_sources,
console_collector,
error_detector,
interaction_tracker,
metrics: BrowserMetrics::default(),
}
}
/// Create with custom log sources
pub fn with_log_sources(
sender: mpsc::UnboundedSender<AnalyticsEvent>,
log_sources: Vec<PathBuf>
) -> Self {
let console_collector = ConsoleCollector::new();
let error_detector = ErrorDetector::new();
let interaction_tracker = InteractionTracker::new();
Self {
sender,
log_sources,
console_collector,
error_detector,
interaction_tracker,
metrics: BrowserMetrics::default(),
}
}
/// Start browser log collection
pub async fn start_collection(&mut self) -> Result<()> {
tracing::info!("Starting browser log collection...");
// Start console log collection
let sender_clone = self.sender.clone();
self.console_collector.start_monitoring(sender_clone).await?;
// Start error detection
let sender_clone = self.sender.clone();
self.error_detector.start_monitoring(sender_clone).await?;
// Start interaction tracking
let sender_clone = self.sender.clone();
self.interaction_tracker.start_monitoring(sender_clone).await?;
// Start log file monitoring
self.start_log_file_monitoring().await?;
tracing::info!("Browser log collection started");
Ok(())
}
/// Start monitoring browser log files
async fn start_log_file_monitoring(&self) -> Result<()> {
for log_source in &self.log_sources {
if !log_source.exists() {
tracing::warn!("Browser log file does not exist: {:?}", log_source);
continue;
}
let log_path = log_source.clone();
let sender = self.sender.clone();
tokio::spawn(async move {
Self::monitor_log_file(log_path, sender).await;
});
}
Ok(())
}
/// Monitor a single browser log file
async fn monitor_log_file(log_path: PathBuf, sender: mpsc::UnboundedSender<AnalyticsEvent>) {
let mut last_size = 0u64;
loop {
match tokio::fs::metadata(&log_path).await {
Ok(metadata) => {
let current_size = metadata.len();
if current_size > last_size {
// File has grown, read new content
if let Ok(content) = tokio::fs::read_to_string(&log_path).await {
let new_content = if last_size > 0 {
content.chars().skip(last_size as usize).collect()
} else {
content
};
// Process new log entries
for line in new_content.lines() {
if let Ok(event) = Self::parse_browser_log_line(line) {
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send browser log event: {}", e);
return;
}
}
}
}
last_size = current_size;
}
}
Err(_) => {
// Log file might not exist yet
last_size = 0;
}
}
// Check every 2 seconds for browser logs (more frequent than server logs)
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
/// Parse browser log line and convert to analytics event
fn parse_browser_log_line(line: &str) -> Result<AnalyticsEvent> {
// Try to parse as JSON first (structured browser logging)
if let Ok(entry) = serde_json::from_str::<BrowserLogEntry>(line) {
return Self::convert_browser_log_entry(entry);
}
// Try to parse as JavaScript error
if let Ok(error) = serde_json::from_str::<JavaScriptError>(line) {
return Self::convert_js_error(error);
}
// Try to parse as performance metrics
if let Ok(perf) = serde_json::from_str::<BrowserPerformanceMetrics>(line) {
return Self::convert_performance_metrics(perf);
}
// Fall back to plain text parsing
Self::parse_plain_browser_log(line)
}
/// Convert structured browser log entry to analytics event
fn convert_browser_log_entry(entry: BrowserLogEntry) -> Result<AnalyticsEvent> {
let mut metadata = HashMap::new();
metadata.insert("source".to_string(), serde_json::Value::String(entry.source.clone()));
metadata.insert("url".to_string(), serde_json::Value::String(entry.url.clone()));
if let Some(line) = entry.line {
metadata.insert("line".to_string(), serde_json::Value::Number(line.into()));
}
if let Some(column) = entry.column {
metadata.insert("column".to_string(), serde_json::Value::Number(column.into()));
}
if let Some(stack_trace) = &entry.stack_trace {
metadata.insert("stack_trace".to_string(), serde_json::Value::String(stack_trace.clone()));
}
if let Some(user_agent) = &entry.user_agent {
metadata.insert("user_agent".to_string(), serde_json::Value::String(user_agent.clone()));
}
let event_type = match entry.level {
BrowserLogLevel::Error | BrowserLogLevel::Assert => "browser_error",
BrowserLogLevel::Warn => "browser_warning",
_ => "browser_log",
}.to_string();
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: entry.timestamp,
source: LogSource::Browser,
event_type,
session_id: None,
path: Some(entry.url),
level: entry.level.into(),
message: entry.message,
metadata,
duration_ms: None,
errors: Vec::new(),
})
}
/// Convert JavaScript error to analytics event
fn convert_js_error(error: JavaScriptError) -> Result<AnalyticsEvent> {
let mut metadata = HashMap::new();
metadata.insert("filename".to_string(), serde_json::Value::String(error.filename.clone()));
metadata.insert("line_number".to_string(), serde_json::Value::Number(error.line_number.into()));
metadata.insert("column_number".to_string(), serde_json::Value::Number(error.column_number.into()));
metadata.insert("error_type".to_string(), serde_json::Value::String(error.error_type.clone()));
if let Some(stack) = &error.stack {
metadata.insert("stack".to_string(), serde_json::Value::String(stack.clone()));
}
let message = format!("{} at {}:{}:{}",
error.message, error.filename, error.line_number, error.column_number);
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: error.timestamp,
source: LogSource::Browser,
event_type: "javascript_error".to_string(),
session_id: None,
path: Some(error.filename),
level: EventLevel::Error,
message,
metadata,
duration_ms: None,
errors: vec![error.message],
})
}
/// Convert performance metrics to analytics event
fn convert_performance_metrics(perf: BrowserPerformanceMetrics) -> Result<AnalyticsEvent> {
let mut metadata = HashMap::new();
if let Some(load_time) = perf.page_load_time_ms {
metadata.insert("page_load_time_ms".to_string(), serde_json::Value::Number(load_time.into()));
}
if let Some(dom_loaded) = perf.dom_content_loaded_ms {
metadata.insert("dom_content_loaded_ms".to_string(), serde_json::Value::Number(dom_loaded.into()));
}
if let Some(fcp) = perf.first_contentful_paint_ms {
metadata.insert("first_contentful_paint_ms".to_string(), serde_json::Value::Number(fcp.into()));
}
if let Some(lcp) = perf.largest_contentful_paint_ms {
metadata.insert("largest_contentful_paint_ms".to_string(), serde_json::Value::Number(lcp.into()));
}
if let Some(cls) = perf.cumulative_layout_shift {
metadata.insert("cumulative_layout_shift".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(cls).unwrap()));
}
if let Some(fid) = perf.first_input_delay_ms {
metadata.insert("first_input_delay_ms".to_string(), serde_json::Value::Number(fid.into()));
}
if let Some(memory) = perf.memory_usage_mb {
metadata.insert("memory_usage_mb".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(memory).unwrap()));
}
let message = Self::format_performance_message(&perf);
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Browser,
event_type: "browser_performance".to_string(),
session_id: None,
path: None,
level: EventLevel::Info,
message,
metadata,
duration_ms: perf.page_load_time_ms,
errors: Vec::new(),
})
}
/// Parse plain text browser log
fn parse_plain_browser_log(line: &str) -> Result<AnalyticsEvent> {
let level = if line.contains("ERROR") || line.contains("error") {
EventLevel::Error
} else if line.contains("WARN") || line.contains("warn") {
EventLevel::Warn
} else if line.contains("INFO") || line.contains("info") {
EventLevel::Info
} else {
EventLevel::Debug
};
let event_type = if line.contains("error") {
"browser_error"
} else if line.contains("performance") {
"browser_performance"
} else {
"browser_log"
}.to_string();
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Browser,
event_type,
session_id: None,
path: None,
level,
message: line.to_string(),
metadata: HashMap::new(),
duration_ms: None,
errors: Vec::new(),
})
}
/// Format performance metrics message
fn format_performance_message(perf: &BrowserPerformanceMetrics) -> String {
let mut parts = Vec::new();
if let Some(load_time) = perf.page_load_time_ms {
parts.push(format!("{}ms load", load_time));
}
if let Some(fcp) = perf.first_contentful_paint_ms {
parts.push(format!("{}ms FCP", fcp));
}
if let Some(lcp) = perf.largest_contentful_paint_ms {
parts.push(format!("{}ms LCP", lcp));
}
if let Some(cls) = perf.cumulative_layout_shift {
parts.push(format!("{:.3} CLS", cls));
}
if parts.is_empty() {
"Browser performance metrics".to_string()
} else {
format!("Performance: {}", parts.join(", "))
}
}
/// Get current browser metrics
pub fn get_metrics(&self) -> &BrowserMetrics {
&self.metrics
}
/// Update browser metrics
pub async fn update_metrics(&mut self) -> Result<()> {
// Collect current browser metrics from various sources
self.metrics = Self::collect_browser_metrics().await?;
// Send metrics update event
let event = self.create_metrics_event();
self.sender.send(event)?;
Ok(())
}
/// Collect browser metrics
async fn collect_browser_metrics() -> Result<BrowserMetrics> {
// In a real implementation, this would aggregate data from:
// - Console logs for error counts
// - Performance API for timing metrics
// - User interaction events
// - Session tracking data
let mut metrics = BrowserMetrics::default();
// Simulate metrics collection
metrics.js_errors = rand::random::<u64>() % 10;
metrics.console_warnings = rand::random::<u64>() % 25;
metrics.user_interactions = 100 + rand::random::<u64>() % 500;
metrics.page_views = 50 + rand::random::<u64>() % 200;
metrics.avg_session_duration_ms = 30000 + rand::random::<u64>() % 120000;
// Simulate performance metrics
metrics.performance.page_load_time_ms = Some(800 + rand::random::<u64>() % 2000);
metrics.performance.dom_content_loaded_ms = Some(400 + rand::random::<u64>() % 1000);
metrics.performance.first_contentful_paint_ms = Some(600 + rand::random::<u64>() % 1500);
metrics.performance.largest_contentful_paint_ms = Some(1200 + rand::random::<u64>() % 2000);
metrics.performance.cumulative_layout_shift = Some(0.05 + rand::random::<f64>() * 0.1);
metrics.performance.first_input_delay_ms = Some(50 + rand::random::<u64>() % 150);
metrics.performance.memory_usage_mb = Some(20.0 + rand::random::<f64>() * 30.0);
Ok(metrics)
}
/// Create browser metrics analytics event
fn create_metrics_event(&self) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("js_errors".to_string(),
serde_json::Value::Number(self.metrics.js_errors.into()));
metadata.insert("console_warnings".to_string(),
serde_json::Value::Number(self.metrics.console_warnings.into()));
metadata.insert("user_interactions".to_string(),
serde_json::Value::Number(self.metrics.user_interactions.into()));
metadata.insert("page_views".to_string(),
serde_json::Value::Number(self.metrics.page_views.into()));
metadata.insert("avg_session_duration_ms".to_string(),
serde_json::Value::Number(self.metrics.avg_session_duration_ms.into()));
// Add performance metrics
if let Some(load_time) = self.metrics.performance.page_load_time_ms {
metadata.insert("page_load_time_ms".to_string(),
serde_json::Value::Number(load_time.into()));
}
if let Some(fcp) = self.metrics.performance.first_contentful_paint_ms {
metadata.insert("first_contentful_paint_ms".to_string(),
serde_json::Value::Number(fcp.into()));
}
let level = if self.metrics.js_errors > 5 {
EventLevel::Warn
} else {
EventLevel::Info
};
let message = format!(
"Browser metrics: {} errors, {} interactions, {} page views",
self.metrics.js_errors,
self.metrics.user_interactions,
self.metrics.page_views
);
AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Browser,
event_type: "browser_metrics".to_string(),
session_id: None,
path: None,
level,
message,
metadata,
duration_ms: self.metrics.performance.page_load_time_ms,
errors: Vec::new(),
}
}
}

View File

@ -0,0 +1,955 @@
//! Analytics CLI Tools
//!
//! Command-line interface for analytics operations:
//! - Query and search analytics data
//! - Generate reports and summaries
//! - Monitor real-time events
//! - Export data in various formats
//! - System health checks
use super::{
AnalyticsEvent, EventLevel, LogSource,
search::{AnalyticsSearch, SearchQuery, TimeRange},
collector::{AnalyticsCollector, CollectorConfig},
};
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use clap::{Parser, Subcommand, ValueEnum};
use serde_json;
use std::path::PathBuf;
use std::io::{self, Write};
/// Analytics CLI Application
#[derive(Parser)]
#[command(name = "analytics", about = "Analytics CLI for navigation, server, and browser logs")]
pub struct AnalyticsCli {
#[command(subcommand)]
pub command: AnalyticsCommand,
}
/// Analytics commands
#[derive(Subcommand)]
pub enum AnalyticsCommand {
/// Search analytics events
Search {
/// Text to search for
#[arg(short, long)]
text: Option<String>,
/// Regex pattern
#[arg(short, long)]
regex: Option<String>,
/// Filter by source
#[arg(short, long)]
source: Option<Vec<AnalyticsSource>>,
/// Filter by event level
#[arg(short, long)]
level: Option<Vec<AnalyticsLevel>>,
/// Hours back to search (default: 24)
#[arg(long, default_value = "24")]
hours: i64,
/// Maximum results to return
#[arg(short, long, default_value = "50")]
limit: usize,
/// Output format
#[arg(short, long, default_value = "table")]
format: OutputFormat,
/// Include error events only
#[arg(long)]
errors_only: bool,
/// Minimum duration in milliseconds
#[arg(long)]
min_duration: Option<u64>,
/// Export to file
#[arg(short, long)]
export: Option<PathBuf>,
},
/// Show analytics dashboard
Dashboard {
/// Refresh interval in seconds
#[arg(short, long, default_value = "30")]
refresh: u64,
/// Hours of data to show
#[arg(long, default_value = "24")]
hours: i64,
},
/// Generate analytics report
Report {
/// Report type
#[arg(short, long, default_value = "summary")]
report_type: ReportType,
/// Time period in hours
#[arg(short, long, default_value = "24")]
hours: i64,
/// Output file
#[arg(short, long)]
output: Option<PathBuf>,
/// Include detailed breakdown
#[arg(long)]
detailed: bool,
},
/// Monitor real-time events
Monitor {
/// Filter by source
#[arg(short, long)]
source: Option<Vec<AnalyticsSource>>,
/// Filter by level
#[arg(short, long)]
level: Option<Vec<AnalyticsLevel>>,
/// Show only errors
#[arg(long)]
errors_only: bool,
},
/// Show system statistics
Stats {
/// Hours back to analyze
#[arg(long, default_value = "24")]
hours: i64,
/// Show breakdown by source
#[arg(long)]
by_source: bool,
/// Show hourly distribution
#[arg(long)]
hourly: bool,
},
/// Export analytics data
Export {
/// Export format
#[arg(short, long, default_value = "json")]
format: ExportFormat,
/// Output file
#[arg(short, long)]
output: PathBuf,
/// Hours back to export
#[arg(long, default_value = "24")]
hours: i64,
/// Filter by source
#[arg(short, long)]
source: Option<Vec<AnalyticsSource>>,
},
/// Health check
Health {
/// Show detailed health information
#[arg(long)]
detailed: bool,
},
}
/// Analytics source filter
#[derive(Clone, ValueEnum)]
pub enum AnalyticsSource {
Navigation,
RouteCache,
Server,
Browser,
System,
}
impl From<AnalyticsSource> for LogSource {
fn from(source: AnalyticsSource) -> Self {
match source {
AnalyticsSource::Navigation => LogSource::Navigation,
AnalyticsSource::RouteCache => LogSource::RouteCache,
AnalyticsSource::Server => LogSource::Server,
AnalyticsSource::Browser => LogSource::Browser,
AnalyticsSource::System => LogSource::System,
}
}
}
/// Analytics level filter
#[derive(Clone, ValueEnum)]
pub enum AnalyticsLevel {
Trace,
Debug,
Info,
Warn,
Error,
Critical,
}
impl From<AnalyticsLevel> for EventLevel {
fn from(level: AnalyticsLevel) -> Self {
match level {
AnalyticsLevel::Trace => EventLevel::Trace,
AnalyticsLevel::Debug => EventLevel::Debug,
AnalyticsLevel::Info => EventLevel::Info,
AnalyticsLevel::Warn => EventLevel::Warn,
AnalyticsLevel::Error => EventLevel::Error,
AnalyticsLevel::Critical => EventLevel::Critical,
}
}
}
/// Output formats
#[derive(Clone, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
Csv,
Summary,
}
/// Report types
#[derive(Clone, ValueEnum)]
pub enum ReportType {
Summary,
Errors,
Performance,
Navigation,
Browser,
Server,
}
/// Export formats
#[derive(Clone, ValueEnum)]
pub enum ExportFormat {
Json,
Jsonl,
Csv,
Tsv,
}
/// Analytics CLI handler
pub struct AnalyticsCliHandler {
search_engine: AnalyticsSearch,
}
impl AnalyticsCliHandler {
/// Create new CLI handler
pub fn new() -> Self {
Self {
search_engine: AnalyticsSearch::new(),
}
}
/// Execute CLI command
pub async fn execute(&mut self, command: AnalyticsCommand) -> Result<()> {
match command {
AnalyticsCommand::Search {
text,
regex,
source,
level,
hours,
limit,
format,
errors_only,
min_duration,
export,
} => {
self.handle_search(text, regex, source, level, hours, limit, format, errors_only, min_duration, export).await
}
AnalyticsCommand::Dashboard { refresh, hours } => {
self.handle_dashboard(refresh, hours).await
}
AnalyticsCommand::Report { report_type, hours, output, detailed } => {
self.handle_report(report_type, hours, output, detailed).await
}
AnalyticsCommand::Monitor { source, level, errors_only } => {
self.handle_monitor(source, level, errors_only).await
}
AnalyticsCommand::Stats { hours, by_source, hourly } => {
self.handle_stats(hours, by_source, hourly).await
}
AnalyticsCommand::Export { format, output, hours, source } => {
self.handle_export(format, output, hours, source).await
}
AnalyticsCommand::Health { detailed } => {
self.handle_health_check(detailed).await
}
}
}
/// Handle search command
async fn handle_search(
&mut self,
text: Option<String>,
regex: Option<String>,
source: Option<Vec<AnalyticsSource>>,
level: Option<Vec<AnalyticsLevel>>,
hours: i64,
limit: usize,
format: OutputFormat,
errors_only: bool,
min_duration: Option<u64>,
export: Option<PathBuf>,
) -> Result<()> {
let query = SearchQuery {
text,
regex,
sources: source.map(|s| s.into_iter().map(|src| src.into()).collect()),
event_types: None,
levels: level.map(|l| l.into_iter().map(|lvl| lvl.into()).collect()),
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: if errors_only { Some(true) } else { None },
min_duration_ms: min_duration,
max_duration_ms: None,
limit: Some(limit),
sort: None,
};
// Load sample data for demonstration
self.load_sample_data().await;
let results = self.search_engine.search(query).await?;
match format {
OutputFormat::Table => self.print_table_results(&results),
OutputFormat::Json => self.print_json_results(&results)?,
OutputFormat::Csv => self.print_csv_results(&results)?,
OutputFormat::Summary => self.print_summary_results(&results),
}
if let Some(export_path) = export {
self.export_results(&results, &export_path).await?;
println!("Results exported to: {}", export_path.display());
}
Ok(())
}
/// Handle dashboard command
async fn handle_dashboard(&mut self, refresh: u64, hours: i64) -> Result<()> {
println!("🔄 Analytics Dashboard (refresh: {}s, showing last {}h)", refresh, hours);
println!("Press Ctrl+C to exit\n");
loop {
self.load_sample_data().await;
self.print_dashboard_info(hours).await?;
tokio::time::sleep(tokio::time::Duration::from_secs(refresh)).await;
// Clear screen (works on most terminals)
print!("\\x1B[2J\\x1B[H");
io::stdout().flush().unwrap();
}
}
/// Handle report generation
async fn handle_report(
&mut self,
report_type: ReportType,
hours: i64,
output: Option<PathBuf>,
detailed: bool,
) -> Result<()> {
self.load_sample_data().await;
let report = match report_type {
ReportType::Summary => self.generate_summary_report(hours, detailed).await?,
ReportType::Errors => self.generate_error_report(hours).await?,
ReportType::Performance => self.generate_performance_report(hours).await?,
ReportType::Navigation => self.generate_navigation_report(hours).await?,
ReportType::Browser => self.generate_browser_report(hours).await?,
ReportType::Server => self.generate_server_report(hours).await?,
};
if let Some(output_path) = output {
std::fs::write(&output_path, &report)
.with_context(|| format!("Failed to write report to {}", output_path.display()))?;
println!("Report saved to: {}", output_path.display());
} else {
println!("{}", report);
}
Ok(())
}
/// Handle real-time monitoring
async fn handle_monitor(
&mut self,
source: Option<Vec<AnalyticsSource>>,
level: Option<Vec<AnalyticsLevel>>,
errors_only: bool,
) -> Result<()> {
println!("🔍 Real-time Analytics Monitor");
println!("Press Ctrl+C to exit\\n");
// In a real implementation, this would connect to the live analytics stream
// For now, simulate with periodic updates
loop {
let events = self.simulate_live_events(source.clone(), level.clone(), errors_only).await;
for event in events {
self.print_live_event(&event);
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
/// Handle statistics
async fn handle_stats(&mut self, hours: i64, by_source: bool, hourly: bool) -> Result<()> {
self.load_sample_data().await;
let stats = self.search_engine.get_stats();
println!("📊 Analytics Statistics (last {}h)", hours);
println!("Total Events: {}", stats.total_events);
if let (Some(oldest), Some(newest)) = (stats.oldest_event, stats.newest_event) {
println!("Time Range: {} to {}", oldest.format("%Y-%m-%d %H:%M"), newest.format("%Y-%m-%d %H:%M"));
}
if by_source {
println!("\\nBy Source:");
for (source, count) in stats.sources.iter() {
println!(" {}: {}", source.as_str(), count);
}
}
if hourly {
println!("\\nHourly Distribution:");
// This would show hourly breakdown in a real implementation
println!(" (Hourly data visualization would be shown here)");
}
Ok(())
}
/// Handle data export
async fn handle_export(
&mut self,
format: ExportFormat,
output: PathBuf,
hours: i64,
source: Option<Vec<AnalyticsSource>>,
) -> Result<()> {
self.load_sample_data().await;
let query = SearchQuery {
text: None,
regex: None,
sources: source.map(|s| s.into_iter().map(|src| src.into()).collect()),
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
let data = match format {
ExportFormat::Json => serde_json::to_string_pretty(&results.events)?,
ExportFormat::Jsonl => {
results.events.iter()
.map(|event| serde_json::to_string(event))
.collect::<Result<Vec<_>, _>>()?
.join("\\n")
}
ExportFormat::Csv | ExportFormat::Tsv => {
let separator = if matches!(format, ExportFormat::Csv) { "," } else { "\\t" };
self.format_as_delimited(&results.events, separator)?
}
};
std::fs::write(&output, data)
.with_context(|| format!("Failed to write export to {}", output.display()))?;
println!("Exported {} events to {}", results.events.len(), output.display());
Ok(())
}
/// Handle health check
async fn handle_health_check(&mut self, detailed: bool) -> Result<()> {
println!("🏥 Analytics System Health Check");
// Check search engine
let stats = self.search_engine.get_stats();
println!("✅ Search Engine: {} events indexed", stats.total_events);
// Check collectors (simulated)
println!("✅ Navigation Collector: Active");
println!("✅ Server Collector: Active");
println!("✅ Browser Collector: Active");
if detailed {
println!("\\n📋 Detailed Information:");
println!(" Regex Cache Size: {}", stats.regex_cache_size);
println!(" Memory Usage: ~{}MB", stats.total_events * 1024 / 1_000_000); // Rough estimate
for (source, count) in stats.sources.iter() {
println!(" {}: {} events", source.as_str(), count);
}
}
Ok(())
}
/// Load sample data for demonstration
async fn load_sample_data(&mut self) {
let sample_events = vec![
AnalyticsEvent {
id: "nav_001".to_string(),
timestamp: Utc::now() - Duration::minutes(30),
source: LogSource::Navigation,
event_type: "route_cache_hit".to_string(),
session_id: Some("session_123".to_string()),
path: Some("/recipes".to_string()),
level: EventLevel::Info,
message: "Route cache hit for /recipes".to_string(),
metadata: std::collections::HashMap::new(),
duration_ms: Some(12),
errors: vec![],
},
AnalyticsEvent {
id: "server_001".to_string(),
timestamp: Utc::now() - Duration::minutes(15),
source: LogSource::Server,
event_type: "panic".to_string(),
session_id: None,
path: Some("/api/content".to_string()),
level: EventLevel::Error,
message: "Panic: index out of bounds".to_string(),
metadata: std::collections::HashMap::new(),
duration_ms: None,
errors: vec!["index out of bounds: the len is 3 but the index is 5".to_string()],
},
AnalyticsEvent {
id: "browser_001".to_string(),
timestamp: Utc::now() - Duration::minutes(5),
source: LogSource::Browser,
event_type: "javascript_error".to_string(),
session_id: Some("session_456".to_string()),
path: Some("/blog".to_string()),
level: EventLevel::Error,
message: "ReferenceError: variable is not defined".to_string(),
metadata: std::collections::HashMap::new(),
duration_ms: None,
errors: vec!["variable is not defined".to_string()],
},
];
self.search_engine.index_events(sample_events);
}
/// Print results in table format
fn print_table_results(&self, results: &super::search::SearchResults) {
use comfy_table::{Table, presets::UTF8_FULL};
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Time", "Source", "Level", "Type", "Message"]);
for event in &results.events {
let message = if event.message.len() > 80 {
format!("{}...", &event.message[..77])
} else {
event.message.clone()
};
table.add_row(vec![
event.timestamp.format("%H:%M:%S").to_string(),
event.source.as_str().to_string(),
format!("{:?}", event.level),
event.event_type.clone(),
message,
]);
}
println!("{}", table);
println!("Found {} events (query took {}ms)", results.total_count, results.query_time_ms);
}
/// Print results in JSON format
fn print_json_results(&self, results: &super::search::SearchResults) -> Result<()> {
let json = serde_json::to_string_pretty(results)?;
println!("{}", json);
Ok(())
}
/// Print results in CSV format
fn print_csv_results(&self, results: &super::search::SearchResults) -> Result<()> {
let csv = self.format_as_delimited(&results.events, ",")?;
println!("{}", csv);
Ok(())
}
/// Print summary of results
fn print_summary_results(&self, results: &super::search::SearchResults) {
println!("📊 Search Results Summary");
println!("Total Events: {}", results.total_count);
println!("Query Time: {}ms", results.query_time_ms);
println!("\\nBy Source:");
for (source, count) in &results.aggregations.sources {
println!(" {}: {}", source.as_str(), count);
}
println!("\\nBy Level:");
for (level, count) in &results.aggregations.levels {
println!(" {:?}: {}", level, count);
}
if results.aggregations.error_summary.total_errors > 0 {
println!("\\n🚨 Error Summary:");
println!(" Total Errors: {}", results.aggregations.error_summary.total_errors);
println!(" Unique Messages: {}", results.aggregations.error_summary.unique_error_messages);
}
}
/// Format events as delimited data
fn format_as_delimited(&self, events: &[AnalyticsEvent], delimiter: &str) -> Result<String> {
let mut result = format!(
"timestamp{}source{}level{}event_type{}message{}path{}session_id{}duration_ms\\n",
delimiter, delimiter, delimiter, delimiter, delimiter, delimiter, delimiter
);
for event in events {
result.push_str(&format!(
"{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}\\n",
event.timestamp.to_rfc3339(),
delimiter,
event.source.as_str(),
delimiter,
format!("{:?}", event.level),
delimiter,
event.event_type,
delimiter,
event.message.replace(delimiter, " "), // Escape delimiter in message
delimiter,
event.path.as_deref().unwrap_or(""),
delimiter,
event.session_id.as_deref().unwrap_or(""),
delimiter,
event.duration_ms.map(|d| d.to_string()).as_deref().unwrap_or(""),
));
}
Ok(result)
}
/// Export results to file
async fn export_results(&self, results: &super::search::SearchResults, path: &PathBuf) -> Result<()> {
let json = serde_json::to_string_pretty(results)?;
tokio::fs::write(path, json).await?;
Ok(())
}
/// Print dashboard information
async fn print_dashboard_info(&mut self, hours: i64) -> Result<()> {
println!("📊 Analytics Dashboard - {}", Utc::now().format("%Y-%m-%d %H:%M:%S UTC"));
println!("{}".repeat(80));
let stats = self.search_engine.get_stats();
println!("Total Events: {}", stats.total_events);
// Show recent activity
let recent_query = SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(1)), // Last hour
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: Some(10),
sort: None,
};
let recent_results = self.search_engine.search(recent_query).await?;
println!("Recent Activity (last hour): {} events", recent_results.total_count);
// Show error summary
let error_query = SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: Some(vec![EventLevel::Error, EventLevel::Critical]),
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: Some(true),
min_duration_ms: None,
max_duration_ms: None,
limit: Some(5),
sort: None,
};
let error_results = self.search_engine.search(error_query).await?;
println!("Recent Errors: {}", error_results.total_count);
if !error_results.events.is_empty() {
println!("\\n🚨 Latest Errors:");
for event in error_results.events.iter().take(3) {
println!(" {} - {}: {}",
event.timestamp.format("%H:%M:%S"),
event.source.as_str(),
event.message
);
}
}
println!("\\n");
Ok(())
}
/// Simulate live events for monitoring
async fn simulate_live_events(
&self,
_source: Option<Vec<AnalyticsSource>>,
_level: Option<Vec<AnalyticsLevel>>,
errors_only: bool,
) -> Vec<AnalyticsEvent> {
// Simulate 1-3 events
let num_events = 1 + rand::random::<usize>() % 3;
let mut events = Vec::new();
for i in 0..num_events {
let level = if errors_only {
if rand::random::<bool>() { EventLevel::Error } else { EventLevel::Critical }
} else {
match rand::random::<u8>() % 6 {
0 => EventLevel::Trace,
1 => EventLevel::Debug,
2 => EventLevel::Info,
3 => EventLevel::Warn,
4 => EventLevel::Error,
_ => EventLevel::Critical,
}
};
events.push(AnalyticsEvent {
id: format!("live_{}", i),
timestamp: Utc::now(),
source: LogSource::Server,
event_type: "live_event".to_string(),
session_id: None,
path: Some("/live".to_string()),
level,
message: format!("Live event {} - {}", i, "simulated message"),
metadata: std::collections::HashMap::new(),
duration_ms: Some(rand::random::<u64>() % 100),
errors: if matches!(level, EventLevel::Error | EventLevel::Critical) {
vec!["simulated error".to_string()]
} else {
vec![]
},
});
}
events
}
/// Print live event
fn print_live_event(&self, event: &AnalyticsEvent) {
let level_emoji = match event.level {
EventLevel::Trace => "🔍",
EventLevel::Debug => "🐛",
EventLevel::Info => "",
EventLevel::Warn => "⚠️",
EventLevel::Error => "",
EventLevel::Critical => "🚨",
};
println!("{} {} [{}] {}: {}",
event.timestamp.format("%H:%M:%S"),
level_emoji,
event.source.as_str(),
event.event_type,
event.message
);
}
/// Generate summary report
async fn generate_summary_report(&mut self, hours: i64, detailed: bool) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
let mut report = format!("📊 Analytics Summary Report\\n");
report.push_str(&format!("Period: Last {} hours\\n", hours));
report.push_str(&format!("Generated: {}\\n\\n", Utc::now().format("%Y-%m-%d %H:%M:%S UTC")));
report.push_str(&format!("Total Events: {}\\n", results.total_count));
report.push_str("\\nBy Source:\\n");
for (source, count) in &results.aggregations.sources {
report.push_str(&format!(" {}: {}\\n", source.as_str(), count));
}
if detailed {
report.push_str("\\nBy Event Type:\\n");
for (event_type, count) in &results.aggregations.event_types {
report.push_str(&format!(" {}: {}\\n", event_type, count));
}
}
Ok(report)
}
/// Generate error-specific report
async fn generate_error_report(&mut self, hours: i64) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: Some(vec![EventLevel::Error, EventLevel::Critical]),
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: Some(true),
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
Ok(format!("🚨 Error Analysis Report\\nTotal Errors: {}\\nError Rate: {:.2}%\\n",
results.aggregations.error_summary.total_errors,
if results.total_count > 0 {
results.aggregations.error_summary.total_errors as f64 / results.total_count as f64 * 100.0
} else { 0.0 }
))
}
/// Generate performance report
async fn generate_performance_report(&mut self, hours: i64) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: Some(100), // Events taking more than 100ms
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
Ok(format!("⚡ Performance Report\\nSlow Events (>100ms): {}\\n", results.total_count))
}
/// Generate navigation report
async fn generate_navigation_report(&mut self, hours: i64) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: Some(vec![LogSource::Navigation, LogSource::RouteCache]),
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
Ok(format!("🧭 Navigation Report\\nNavigation Events: {}\\n", results.total_count))
}
/// Generate browser report
async fn generate_browser_report(&mut self, hours: i64) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: Some(vec![LogSource::Browser]),
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
Ok(format!("🌐 Browser Report\\nBrowser Events: {}\\n", results.total_count))
}
/// Generate server report
async fn generate_server_report(&mut self, hours: i64) -> Result<String> {
let query = SearchQuery {
text: None,
regex: None,
sources: Some(vec![LogSource::Server]),
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: None,
sort: None,
};
let results = self.search_engine.search(query).await?;
Ok(format!("🖥️ Server Report\\nServer Events: {}\\n", results.total_count))
}
}

View File

@ -0,0 +1,475 @@
//! Unified Analytics Data Collector
//!
//! Orchestrates collection from all log sources:
//! - Navigation tracking (JSONL logs)
//! - Route cache performance
//! - Server logs
//! - Browser logs
//!
//! Provides real-time streaming and batch processing capabilities.
use super::{
AnalyticsConfig, AnalyticsEvent, AnalyticsMetrics, EventLevel,
BrowserMetrics, CacheMetrics, NavigationMetrics, ServerMetrics,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// Log source types
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash, Eq)]
pub enum LogSource {
Navigation,
RouteCache,
Server,
Browser,
System,
}
impl LogSource {
pub fn as_str(&self) -> &'static str {
match self {
LogSource::Navigation => "navigation",
LogSource::RouteCache => "route_cache",
LogSource::Server => "server",
LogSource::Browser => "browser",
LogSource::System => "system",
}
}
}
/// Analytics collector configuration
#[derive(Debug, Clone)]
pub struct CollectorConfig {
pub batch_size: usize,
pub flush_interval_seconds: u64,
pub max_memory_events: usize,
pub data_directory: PathBuf,
}
impl Default for CollectorConfig {
fn default() -> Self {
Self {
batch_size: 100,
flush_interval_seconds: 30,
max_memory_events: 10000,
data_directory: PathBuf::from("logs/analytics"),
}
}
}
/// Main analytics collector
pub struct AnalyticsCollector {
config: CollectorConfig,
event_buffer: Arc<Mutex<VecDeque<AnalyticsEvent>>>,
sender: Option<mpsc::UnboundedSender<AnalyticsEvent>>,
receiver: Option<mpsc::UnboundedReceiver<AnalyticsEvent>>,
// Source-specific collectors
navigation_collector: Option<super::navigation::NavigationCollector>,
server_collector: Option<super::rustelo_server::ServerCollector>,
browser_collector: Option<super::browser::BrowserCollector>,
// Metrics aggregation
metrics_cache: Arc<Mutex<HashMap<String, AnalyticsMetrics>>>,
}
impl AnalyticsCollector {
/// Create new analytics collector
pub fn new(config: &AnalyticsConfig) -> Result<Self> {
let collector_config = CollectorConfig {
data_directory: config.data_directory.clone(),
..CollectorConfig::default()
};
// Create data directory if it doesn't exist
std::fs::create_dir_all(&collector_config.data_directory)
.context("Failed to create analytics data directory")?;
let (sender, receiver) = mpsc::unbounded_channel();
Ok(Self {
config: collector_config,
event_buffer: Arc::new(Mutex::new(VecDeque::new())),
sender: Some(sender),
receiver: Some(receiver),
navigation_collector: None,
server_collector: None,
browser_collector: None,
metrics_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
/// Start collection from all sources
pub async fn start(&mut self) -> Result<()> {
tracing::info!("Starting analytics collection...");
// Initialize source-specific collectors
self.init_collectors().await?;
// Start event processing task
if let Some(receiver) = self.receiver.take() {
let buffer = Arc::clone(&self.event_buffer);
let config = self.config.clone();
let metrics_cache = Arc::clone(&self.metrics_cache);
tokio::spawn(async move {
Self::process_events(receiver, buffer, config, metrics_cache).await;
});
}
tracing::info!("Analytics collection started");
Ok(())
}
/// Stop collection
pub async fn stop(&mut self) -> Result<()> {
tracing::info!("Stopping analytics collection...");
// Flush any remaining events
self.flush_events().await?;
if let Some(sender) = &self.sender {
sender.send(AnalyticsEvent {
id: super::generate_event_id(),
timestamp: Utc::now(),
source: LogSource::System,
event_type: "shutdown".to_string(),
session_id: None,
path: None,
level: EventLevel::Info,
message: "Analytics collector shutting down".to_string(),
metadata: HashMap::new(),
duration_ms: None,
errors: Vec::new(),
})?;
}
tracing::info!("Analytics collection stopped");
Ok(())
}
/// Initialize source-specific collectors
async fn init_collectors(&mut self) -> Result<()> {
let sender = self.sender.as_ref()
.ok_or_else(|| anyhow::anyhow!("Sender not available"))?
.clone();
// Initialize navigation collector
if let Ok(nav_collector) = super::navigation::NavigationCollector::new(sender.clone()).await {
tracing::debug!("Navigation collector initialized");
self.navigation_collector = Some(nav_collector);
} else {
tracing::warn!("Failed to initialize navigation collector");
}
// Initialize server collector
if let Ok(server_collector) = super::rustelo_server::ServerCollector::new(sender.clone()).await {
tracing::debug!("Server collector initialized");
self.server_collector = Some(server_collector);
} else {
tracing::warn!("Failed to initialize server collector");
}
// Initialize browser collector
if let Ok(browser_collector) = super::browser::BrowserCollector::new(sender.clone()).await {
tracing::debug!("Browser collector initialized");
self.browser_collector = Some(browser_collector);
} else {
tracing::warn!("Failed to initialize browser collector");
}
Ok(())
}
/// Process events from the channel
async fn process_events(
mut receiver: mpsc::UnboundedReceiver<AnalyticsEvent>,
buffer: Arc<Mutex<VecDeque<AnalyticsEvent>>>,
config: CollectorConfig,
metrics_cache: Arc<Mutex<HashMap<String, AnalyticsMetrics>>>,
) {
let mut flush_interval = tokio::time::interval(
tokio::time::Duration::from_secs(config.flush_interval_seconds)
);
loop {
tokio::select! {
// Process incoming events
event = receiver.recv() => {
match event {
Some(event) => {
// Add to buffer
{
let mut buffer_guard = buffer.lock().unwrap();
buffer_guard.push_back(event.clone());
// Prevent memory overflow
while buffer_guard.len() > config.max_memory_events {
buffer_guard.pop_front();
}
}
// Update metrics cache
Self::update_metrics_cache(&event, &metrics_cache);
// Check if we need to flush
let buffer_len = buffer.lock().unwrap().len();
if buffer_len >= config.batch_size {
if let Err(e) = Self::flush_buffer(&buffer, &config).await {
tracing::error!("Failed to flush event buffer: {}", e);
}
}
}
None => {
tracing::info!("Analytics event channel closed");
break;
}
}
}
// Periodic flush
_ = flush_interval.tick() => {
if let Err(e) = Self::flush_buffer(&buffer, &config).await {
tracing::error!("Failed to flush event buffer: {}", e);
}
}
}
}
}
/// Update metrics cache with new event
fn update_metrics_cache(
event: &AnalyticsEvent,
metrics_cache: &Arc<Mutex<HashMap<String, AnalyticsMetrics>>>
) {
let period_key = format!("hour_{}", event.timestamp.format("%Y%m%d_%H"));
let mut cache = metrics_cache.lock().unwrap();
let metrics = cache.entry(period_key).or_insert_with(|| {
AnalyticsMetrics {
period_start: event.timestamp.with_minute(0).unwrap().with_second(0).unwrap(),
period_end: event.timestamp.with_minute(59).unwrap().with_second(59).unwrap(),
navigation: NavigationMetrics {
total_requests: 0,
route_resolutions: 0,
language_switches: 0,
avg_resolution_time_ms: 0.0,
slow_routes_count: 0,
error_count: 0,
},
cache: CacheMetrics {
total_requests: 0,
hit_count: 0,
miss_count: 0,
hit_rate: 0.0,
evictions: 0,
expired_entries: 0,
},
server: ServerMetrics {
total_requests: 0,
error_count: 0,
panic_count: 0,
avg_response_time_ms: 0.0,
memory_usage_mb: None,
cpu_usage_percent: None,
},
browser: BrowserMetrics {
console_errors: 0,
console_warnings: 0,
hydration_mismatches: 0,
javascript_errors: 0,
performance_issues: 0,
},
}
});
// Update metrics based on event type and source
match event.source {
LogSource::Navigation => {
metrics.navigation.total_requests += 1;
if event.event_type == "RouteResolution" {
metrics.navigation.route_resolutions += 1;
}
if event.event_type == "LanguageSwitch" {
metrics.navigation.language_switches += 1;
}
if !event.errors.is_empty() {
metrics.navigation.error_count += 1;
}
if let Some(duration) = event.duration_ms {
if duration > 10 {
metrics.navigation.slow_routes_count += 1;
}
// Update average (simplified)
metrics.navigation.avg_resolution_time_ms =
(metrics.navigation.avg_resolution_time_ms + duration as f64) / 2.0;
}
}
LogSource::RouteCache => {
metrics.cache.total_requests += 1;
if let Some(hit_val) = event.metadata.get("cache_hit") {
if hit_val.as_bool().unwrap_or(false) {
metrics.cache.hit_count += 1;
} else {
metrics.cache.miss_count += 1;
}
metrics.cache.hit_rate =
metrics.cache.hit_count as f64 / metrics.cache.total_requests as f64;
}
}
LogSource::Server => {
metrics.server.total_requests += 1;
if event.level >= EventLevel::Error {
metrics.server.error_count += 1;
}
if event.message.contains("panic") {
metrics.server.panic_count += 1;
}
if let Some(duration) = event.duration_ms {
metrics.server.avg_response_time_ms =
(metrics.server.avg_response_time_ms + duration as f64) / 2.0;
}
}
LogSource::Browser => {
match event.level {
EventLevel::Error => metrics.browser.console_errors += 1,
EventLevel::Warn => metrics.browser.console_warnings += 1,
_ => {}
}
if event.event_type == "hydration_mismatch" {
metrics.browser.hydration_mismatches += 1;
}
if event.event_type == "javascript_error" {
metrics.browser.javascript_errors += 1;
}
}
LogSource::System => {
// System events don't affect business metrics
}
}
}
/// Flush event buffer to disk
async fn flush_buffer(
buffer: &Arc<Mutex<VecDeque<AnalyticsEvent>>>,
config: &CollectorConfig,
) -> Result<()> {
let events: Vec<AnalyticsEvent> = {
let mut buffer_guard = buffer.lock().unwrap();
let events = buffer_guard.drain(..).collect();
events
};
if events.is_empty() {
return Ok(());
}
tracing::debug!("Flushing {} events to disk", events.len());
// Write events to daily log file
let date_str = Utc::now().format("%Y%m%d");
let log_file = config.data_directory.join(format!("analytics_{}.jsonl", date_str));
let mut log_content = String::new();
for event in events {
if let Ok(json) = serde_json::to_string(&event) {
log_content.push_str(&json);
log_content.push('\n');
}
}
tokio::fs::write(&log_file, log_content).await
.with_context(|| format!("Failed to write analytics log to {:?}", log_file))?;
Ok(())
}
/// Manually flush events
pub async fn flush_events(&self) -> Result<()> {
Self::flush_buffer(&self.event_buffer, &self.config).await
}
/// Send event to collector
pub fn send_event(&self, event: AnalyticsEvent) -> Result<()> {
if let Some(sender) = &self.sender {
sender.send(event)?;
}
Ok(())
}
/// Get aggregated metrics for a time period
pub async fn get_aggregated_metrics(&self, period_hours: u32) -> Result<AnalyticsMetrics> {
let now = Utc::now();
let start_time = now - Duration::hours(period_hours as i64);
let mut aggregated = AnalyticsMetrics {
period_start: start_time,
period_end: now,
navigation: NavigationMetrics {
total_requests: 0, route_resolutions: 0, language_switches: 0,
avg_resolution_time_ms: 0.0, slow_routes_count: 0, error_count: 0,
},
cache: CacheMetrics {
total_requests: 0, hit_count: 0, miss_count: 0, hit_rate: 0.0,
evictions: 0, expired_entries: 0,
},
server: ServerMetrics {
total_requests: 0, error_count: 0, panic_count: 0,
avg_response_time_ms: 0.0, memory_usage_mb: None, cpu_usage_percent: None,
},
browser: BrowserMetrics {
console_errors: 0, console_warnings: 0, hydration_mismatches: 0,
javascript_errors: 0, performance_issues: 0,
},
};
// Aggregate from cache and disk if needed
let cache = self.metrics_cache.lock().unwrap();
for (_, metrics) in cache.iter() {
if metrics.period_start >= start_time && metrics.period_end <= now {
// Add to aggregated metrics
aggregated.navigation.total_requests += metrics.navigation.total_requests;
aggregated.navigation.route_resolutions += metrics.navigation.route_resolutions;
aggregated.navigation.language_switches += metrics.navigation.language_switches;
aggregated.navigation.slow_routes_count += metrics.navigation.slow_routes_count;
aggregated.navigation.error_count += metrics.navigation.error_count;
aggregated.cache.total_requests += metrics.cache.total_requests;
aggregated.cache.hit_count += metrics.cache.hit_count;
aggregated.cache.miss_count += metrics.cache.miss_count;
aggregated.server.total_requests += metrics.server.total_requests;
aggregated.server.error_count += metrics.server.error_count;
aggregated.server.panic_count += metrics.server.panic_count;
aggregated.browser.console_errors += metrics.browser.console_errors;
aggregated.browser.console_warnings += metrics.browser.console_warnings;
aggregated.browser.hydration_mismatches += metrics.browser.hydration_mismatches;
aggregated.browser.javascript_errors += metrics.browser.javascript_errors;
}
}
// Calculate derived metrics
if aggregated.cache.total_requests > 0 {
aggregated.cache.hit_rate =
aggregated.cache.hit_count as f64 / aggregated.cache.total_requests as f64;
}
Ok(aggregated)
}
/// Get events from buffer (for real-time monitoring)
pub fn get_recent_events(&self, limit: usize) -> Vec<AnalyticsEvent> {
let buffer = self.event_buffer.lock().unwrap();
buffer.iter()
.rev()
.take(limit)
.cloned()
.collect()
}
}

View File

@ -0,0 +1,344 @@
//! Comprehensive Analytics System
//!
//! This module provides unified analytics, monitoring, and supervision capabilities
//! that integrate route cache generation, navigation tracking, server logs, and browser logs.
//!
//! # Architecture
//!
//! - **Collector**: Unified data collection from all sources
//! - **Analyzer**: Real-time and batch analysis engine
//! - **Reporter**: Report generation and export
//! - **Search**: Cross-log search and filtering
//! - **Supervisor**: Continuous monitoring and alerting
//!
//! # Integration Points
//!
//! - Navigation tracking (JSONL logs)
//! - Route cache performance metrics
//! - Server logs (tracing output)
//! - Browser logs (console, hydration errors)
//! - Manager dashboard integration
//! - CLI tools for queries and reports
use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub mod browser;
pub mod collector;
pub mod navigation;
pub mod reporter;
pub mod search;
pub mod server;
pub mod supervisor;
// Re-export main types for easy access
pub use collector::{AnalyticsCollector, LogSource};
pub use reporter::{AnalyticsReporter, ReportConfig, ReportFormat};
pub use search::{AnalyticsSearch, SearchQuery, SearchResult};
pub use supervisor::{AnalyticsSupervisor, AlertConfig, AlertType};
/// Configuration for the analytics system
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsConfig {
/// Enable/disable analytics collection
pub enabled: bool,
/// Log retention period in days
pub log_retention_days: u32,
/// Directory for analytics data storage
pub data_directory: PathBuf,
/// Alert configuration
pub alerts: AlertConfig,
/// Export formats
pub export_formats: Vec<ReportFormat>,
/// External integrations
pub integrations: IntegrationConfig,
}
impl Default for AnalyticsConfig {
fn default() -> Self {
Self {
enabled: true,
log_retention_days: 30,
data_directory: PathBuf::from("logs/analytics"),
alerts: AlertConfig::default(),
export_formats: vec![ReportFormat::Json, ReportFormat::Html],
integrations: IntegrationConfig::default(),
}
}
}
/// External integration configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntegrationConfig {
/// Elasticsearch integration
pub elasticsearch: Option<ElasticsearchConfig>,
/// Grafana integration
pub grafana: Option<GrafanaConfig>,
/// Custom webhook endpoints
pub webhooks: Vec<WebhookConfig>,
}
impl Default for IntegrationConfig {
fn default() -> Self {
Self {
elasticsearch: None,
grafana: None,
webhooks: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElasticsearchConfig {
pub url: String,
pub index_prefix: String,
pub credentials: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrafanaConfig {
pub url: String,
pub api_key: String,
pub dashboard_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub name: String,
pub url: String,
pub events: Vec<String>,
pub headers: HashMap<String, String>,
}
/// Unified analytics event structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsEvent {
/// Unique event ID
pub id: String,
/// Timestamp when event occurred
pub timestamp: DateTime<Utc>,
/// Event source (navigation, server, browser, cache)
pub source: LogSource,
/// Event type/category
pub event_type: String,
/// Session identifier
pub session_id: Option<String>,
/// Request path or identifier
pub path: Option<String>,
/// Event severity level
pub level: EventLevel,
/// Event message or description
pub message: String,
/// Additional structured data
pub metadata: HashMap<String, serde_json::Value>,
/// Processing duration (if applicable)
pub duration_ms: Option<u64>,
/// Associated errors
pub errors: Vec<String>,
}
/// Event severity levels
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
pub enum EventLevel {
Trace,
Debug,
Info,
Warn,
Error,
Critical,
}
impl EventLevel {
pub fn as_str(&self) -> &'static str {
match self {
EventLevel::Trace => "trace",
EventLevel::Debug => "debug",
EventLevel::Info => "info",
EventLevel::Warn => "warn",
EventLevel::Error => "error",
EventLevel::Critical => "critical",
}
}
}
/// Analytics metrics aggregated over time periods
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalyticsMetrics {
/// Time period for these metrics
pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>,
/// Navigation metrics
pub navigation: NavigationMetrics,
/// Cache metrics
pub cache: CacheMetrics,
/// Server metrics
pub server: ServerMetrics,
/// Browser metrics
pub browser: BrowserMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavigationMetrics {
pub total_requests: u64,
pub route_resolutions: u64,
pub language_switches: u64,
pub avg_resolution_time_ms: f64,
pub slow_routes_count: u64,
pub error_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetrics {
pub total_requests: u64,
pub hit_count: u64,
pub miss_count: u64,
pub hit_rate: f64,
pub evictions: u64,
pub expired_entries: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerMetrics {
pub total_requests: u64,
pub error_count: u64,
pub panic_count: u64,
pub avg_response_time_ms: f64,
pub memory_usage_mb: Option<f64>,
pub cpu_usage_percent: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrowserMetrics {
pub console_errors: u64,
pub console_warnings: u64,
pub hydration_mismatches: u64,
pub javascript_errors: u64,
pub performance_issues: u64,
}
/// Main analytics engine
pub struct Analytics {
config: AnalyticsConfig,
collector: AnalyticsCollector,
reporter: AnalyticsReporter,
search: AnalyticsSearch,
supervisor: AnalyticsSupervisor,
}
impl Analytics {
/// Create new analytics engine
pub fn new(config: AnalyticsConfig) -> Result<Self> {
let collector = AnalyticsCollector::new(&config)?;
let reporter = AnalyticsReporter::new(&config)?;
let search = AnalyticsSearch::new(&config)?;
let supervisor = AnalyticsSupervisor::new(&config)?;
Ok(Self {
config,
collector,
reporter,
search,
supervisor,
})
}
/// Start analytics collection and monitoring
pub async fn start(&mut self) -> Result<()> {
if !self.config.enabled {
tracing::info!("Analytics disabled in configuration");
return Ok(());
}
tracing::info!("Starting analytics system...");
// Start data collection from all sources
self.collector.start().await?;
// Start continuous monitoring
self.supervisor.start().await?;
tracing::info!("Analytics system started successfully");
Ok(())
}
/// Stop analytics collection
pub async fn stop(&mut self) -> Result<()> {
tracing::info!("Stopping analytics system...");
self.collector.stop().await?;
self.supervisor.stop().await?;
tracing::info!("Analytics system stopped");
Ok(())
}
/// Get current metrics
pub async fn get_metrics(&self, period_hours: u32) -> Result<AnalyticsMetrics> {
self.collector.get_aggregated_metrics(period_hours).await
}
/// Search analytics data
pub async fn search(&self, query: SearchQuery) -> Result<Vec<SearchResult>> {
self.search.search(query).await
}
/// Generate report
pub async fn generate_report(&self, config: ReportConfig) -> Result<String> {
self.reporter.generate(config).await
}
/// Get analytics configuration
pub fn config(&self) -> &AnalyticsConfig {
&self.config
}
}
/// Initialize analytics from project configuration
pub fn init_analytics_from_config(project_root: &std::path::Path) -> Result<Analytics> {
// Load configuration from project config.toml
let config_path = project_root.join("config.toml");
let config = if config_path.exists() {
let config_content = std::fs::read_to_string(config_path)?;
let full_config: toml::Value = toml::from_str(&config_content)?;
// Extract analytics section if it exists
full_config
.get("analytics")
.map(|v| v.clone().try_into())
.transpose()?
.unwrap_or_default()
} else {
AnalyticsConfig::default()
};
Analytics::new(config)
}
/// Utility function to create a UUID for event tracking
pub fn generate_event_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let timestamp = chrono::Utc::now().timestamp_millis() as u64;
let counter = COUNTER.fetch_add(1, Ordering::SeqCst);
format!("evt_{}_{}", timestamp, counter)
}
/// Utility function to determine session ID from various sources
pub fn resolve_session_id(
nav_session: Option<&str>,
server_session: Option<&str>
) -> String {
nav_session
.or(server_session)
.map(|s| s.to_string())
.unwrap_or_else(|| {
format!("session_{}", chrono::Utc::now().timestamp_millis())
})
}

View File

@ -0,0 +1,300 @@
//! Route Cache Performance Monitor
//!
//! Monitors route cache performance and generates analytics events
//! for cache efficiency, hit rates, and optimization insights.
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// Cache performance metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachePerformanceMetrics {
/// Total cache requests in monitoring period
pub total_requests: u64,
/// Cache hits
pub hits: u64,
/// Cache misses
pub misses: u64,
/// Current hit rate
pub hit_rate: f64,
/// Hot cache promotions
pub promotions: u64,
/// LRU evictions
pub evictions: u64,
/// Expired entries cleaned up
pub expired_cleanups: u64,
/// Average lookup time
pub avg_lookup_time_ms: f64,
/// Memory usage estimation
pub estimated_memory_kb: u64,
}
impl Default for CachePerformanceMetrics {
fn default() -> Self {
Self {
total_requests: 0,
hits: 0,
misses: 0,
hit_rate: 0.0,
promotions: 0,
evictions: 0,
expired_cleanups: 0,
avg_lookup_time_ms: 0.0,
estimated_memory_kb: 0,
}
}
}
/// Route cache monitor
pub struct CacheMonitor {
metrics: Arc<Mutex<CachePerformanceMetrics>>,
monitoring_interval: u64,
}
impl CacheMonitor {
/// Create new cache monitor
pub fn new() -> Self {
Self {
metrics: Arc::new(Mutex::new(CachePerformanceMetrics::default())),
monitoring_interval: 30, // 30 seconds
}
}
/// Start monitoring cache performance
pub async fn start_monitoring(&self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
let metrics = Arc::clone(&self.metrics);
let interval = self.monitoring_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Collect current cache metrics
if let Ok(cache_metrics) = Self::collect_cache_metrics() {
// Update internal metrics
{
let mut metrics_guard = metrics.lock().unwrap();
*metrics_guard = cache_metrics.clone();
}
// Generate analytics event
let event = Self::create_cache_analytics_event(cache_metrics);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send cache analytics event: {}", e);
break;
}
}
}
});
Ok(())
}
/// Collect cache metrics from the route cache system
fn collect_cache_metrics() -> Result<CachePerformanceMetrics> {
// Try to access the route cache from core-lib
// This integration depends on the route cache being accessible
// For now, we'll simulate metrics collection
// In a real implementation, this would integrate with the actual cache
let mut metrics = CachePerformanceMetrics::default();
// Simulate realistic cache metrics
use std::sync::atomic::{AtomicU64, Ordering};
static SIMULATED_REQUESTS: AtomicU64 = AtomicU64::new(0);
let requests = SIMULATED_REQUESTS.fetch_add(rand::random::<u64>() % 50, Ordering::SeqCst);
let hits = (requests as f64 * 0.75) as u64; // 75% hit rate simulation
let misses = requests - hits;
metrics.total_requests = requests;
metrics.hits = hits;
metrics.misses = misses;
metrics.hit_rate = if requests > 0 {
hits as f64 / requests as f64
} else {
0.0
};
metrics.promotions = rand::random::<u64>() % 5;
metrics.evictions = rand::random::<u64>() % 3;
metrics.expired_cleanups = rand::random::<u64>() % 10;
metrics.avg_lookup_time_ms = 2.0 + rand::random::<f64>() * 5.0;
metrics.estimated_memory_kb = 1024 + rand::random::<u64>() % 2048;
Ok(metrics)
}
/// Create analytics event from cache metrics
fn create_cache_analytics_event(metrics: CachePerformanceMetrics) -> AnalyticsEvent {
let level = if metrics.hit_rate < 0.5 {
EventLevel::Warn
} else if metrics.hit_rate < 0.3 {
EventLevel::Error
} else {
EventLevel::Info
};
let message = format!(
"Cache Performance: {:.1}% hit rate, {} requests, {:.1}ms avg lookup",
metrics.hit_rate * 100.0,
metrics.total_requests,
metrics.avg_lookup_time_ms
);
let mut metadata = HashMap::new();
metadata.insert("total_requests".to_string(),
serde_json::Value::Number(metrics.total_requests.into()));
metadata.insert("hits".to_string(),
serde_json::Value::Number(metrics.hits.into()));
metadata.insert("misses".to_string(),
serde_json::Value::Number(metrics.misses.into()));
metadata.insert("hit_rate".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.hit_rate).unwrap()));
metadata.insert("promotions".to_string(),
serde_json::Value::Number(metrics.promotions.into()));
metadata.insert("evictions".to_string(),
serde_json::Value::Number(metrics.evictions.into()));
metadata.insert("expired_cleanups".to_string(),
serde_json::Value::Number(metrics.expired_cleanups.into()));
metadata.insert("avg_lookup_time_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.avg_lookup_time_ms).unwrap()));
metadata.insert("estimated_memory_kb".to_string(),
serde_json::Value::Number(metrics.estimated_memory_kb.into()));
AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::RouteCache,
event_type: "cache_performance".to_string(),
session_id: None,
path: None,
level,
message,
metadata,
duration_ms: Some(metrics.avg_lookup_time_ms as u64),
errors: Vec::new(),
}
}
/// Get current cache performance metrics
pub fn get_current_metrics(&self) -> CachePerformanceMetrics {
self.metrics.lock().unwrap().clone()
}
/// Generate cache optimization recommendations
pub fn get_optimization_recommendations(&self) -> Vec<CacheRecommendation> {
let metrics = self.get_current_metrics();
let mut recommendations = Vec::new();
// Hit rate recommendations
if metrics.hit_rate < 0.5 {
recommendations.push(CacheRecommendation {
category: "hit_rate".to_string(),
priority: RecommendationPriority::High,
title: "Low Cache Hit Rate".to_string(),
description: format!(
"Current hit rate is {:.1}%, which is below the recommended 70%",
metrics.hit_rate * 100.0
),
action: "Consider increasing cache size or reviewing cache TTL settings".to_string(),
estimated_impact: "15-30% performance improvement".to_string(),
});
}
// Eviction recommendations
if metrics.evictions > metrics.total_requests / 10 {
recommendations.push(CacheRecommendation {
category: "evictions".to_string(),
priority: RecommendationPriority::Medium,
title: "High Eviction Rate".to_string(),
description: format!(
"Cache is evicting {} entries, which may indicate insufficient capacity",
metrics.evictions
),
action: "Increase cache_max_entries in configuration".to_string(),
estimated_impact: "5-15% performance improvement".to_string(),
});
}
// Memory recommendations
if metrics.estimated_memory_kb > 10240 { // > 10MB
recommendations.push(CacheRecommendation {
category: "memory".to_string(),
priority: RecommendationPriority::Low,
title: "High Memory Usage".to_string(),
description: format!(
"Cache is using approximately {} KB of memory",
metrics.estimated_memory_kb
),
action: "Monitor memory usage and consider adjusting cache size if needed".to_string(),
estimated_impact: "Reduced memory pressure".to_string(),
});
}
// Performance recommendations
if metrics.avg_lookup_time_ms > 5.0 {
recommendations.push(CacheRecommendation {
category: "performance".to_string(),
priority: RecommendationPriority::Medium,
title: "Slow Cache Lookups".to_string(),
description: format!(
"Average lookup time is {:.1}ms, which may indicate contention",
metrics.avg_lookup_time_ms
),
action: "Review cache implementation or consider cache partitioning".to_string(),
estimated_impact: "2-5ms lookup time improvement".to_string(),
});
}
recommendations
}
}
/// Cache optimization recommendation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheRecommendation {
pub category: String,
pub priority: RecommendationPriority,
pub title: String,
pub description: String,
pub action: String,
pub estimated_impact: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecommendationPriority {
Low,
Medium,
High,
Critical,
}
impl RecommendationPriority {
pub fn as_str(&self) -> &'static str {
match self {
RecommendationPriority::Low => "low",
RecommendationPriority::Medium => "medium",
RecommendationPriority::High => "high",
RecommendationPriority::Critical => "critical",
}
}
pub fn color_code(&self) -> &'static str {
match self {
RecommendationPriority::Low => "🟢",
RecommendationPriority::Medium => "🟡",
RecommendationPriority::High => "🟠",
RecommendationPriority::Critical => "🔴",
}
}
}

View File

@ -0,0 +1,325 @@
//! Navigation Analytics Integration
//!
//! Integrates with the existing navigation tracking system to provide
//! unified analytics and monitoring capabilities.
use super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
use tokio_stream::{wrappers::LinesStream, StreamExt};
pub mod cache_monitor;
pub mod route_analytics;
pub mod tracker_integration;
pub use cache_monitor::CacheMonitor;
pub use route_analytics::RouteAnalyzer;
pub use tracker_integration::TrackerIntegration;
/// Navigation event from the existing JSONL logs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NavigationLogEvent {
pub session_id: String,
pub timestamp: u64,
pub event_type: String,
pub source_path: String,
pub target_path: Option<String>,
pub current_lang: String,
pub target_lang: Option<String>,
pub resolution_time_ms: u64,
pub cache_hit: bool,
pub component: Option<String>,
pub errors: Vec<String>,
pub fallback_used: bool,
pub additional_data: HashMap<String, String>,
}
/// Navigation collector that reads from existing navigation logs
pub struct NavigationCollector {
sender: mpsc::UnboundedSender<AnalyticsEvent>,
log_path: PathBuf,
cache_monitor: CacheMonitor,
route_analyzer: RouteAnalyzer,
}
impl NavigationCollector {
/// Create new navigation collector
pub async fn new(sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<Self> {
let log_path = PathBuf::from("logs/navigation.jsonl");
// Verify log file exists or can be created
if !log_path.exists() {
if let Some(parent) = log_path.parent() {
tokio::fs::create_dir_all(parent).await
.context("Failed to create navigation logs directory")?;
}
}
let cache_monitor = CacheMonitor::new();
let route_analyzer = RouteAnalyzer::new();
Ok(Self {
sender,
log_path,
cache_monitor,
route_analyzer,
})
}
/// Start monitoring navigation logs
pub async fn start_monitoring(&mut self) -> Result<()> {
tracing::info!("Starting navigation log monitoring...");
// Start watching the navigation log file
self.watch_navigation_log().await?;
// Start cache monitoring
let sender_clone = self.sender.clone();
self.cache_monitor.start_monitoring(sender_clone).await?;
// Start route analysis
let sender_clone = self.sender.clone();
self.route_analyzer.start_analysis(sender_clone).await?;
tracing::info!("Navigation monitoring started");
Ok(())
}
/// Watch navigation JSONL log file for new entries
async fn watch_navigation_log(&self) -> Result<()> {
let log_path = self.log_path.clone();
let sender = self.sender.clone();
tokio::spawn(async move {
let mut processed_lines = 0usize;
loop {
match Self::process_navigation_log(&log_path, processed_lines, &sender).await {
Ok(new_lines) => {
processed_lines += new_lines;
if new_lines > 0 {
tracing::debug!("Processed {} new navigation log entries", new_lines);
}
}
Err(e) => {
tracing::error!("Failed to process navigation log: {}", e);
}
}
// Check for new entries every 5 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
});
Ok(())
}
/// Process navigation log file and convert to analytics events
async fn process_navigation_log(
log_path: &Path,
skip_lines: usize,
sender: &mpsc::UnboundedSender<AnalyticsEvent>,
) -> Result<usize> {
if !log_path.exists() {
return Ok(0);
}
let file = tokio::fs::File::open(log_path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = LinesStream::new(reader.lines());
let mut processed = 0;
let mut line_count = 0;
while let Some(line) = lines.next().await {
let line = line?;
// Skip already processed lines
if line_count < skip_lines {
line_count += 1;
continue;
}
if line.trim().is_empty() {
line_count += 1;
continue;
}
// Parse navigation event
match serde_json::from_str::<NavigationLogEvent>(&line) {
Ok(nav_event) => {
// Convert to analytics event
let analytics_event = Self::convert_navigation_event(nav_event)?;
// Send to analytics collector
if let Err(e) = sender.send(analytics_event) {
tracing::error!("Failed to send analytics event: {}", e);
}
processed += 1;
}
Err(e) => {
tracing::warn!("Failed to parse navigation log line: {} - Error: {}", line, e);
}
}
line_count += 1;
}
Ok(processed)
}
/// Convert navigation log event to analytics event
fn convert_navigation_event(nav_event: NavigationLogEvent) -> Result<AnalyticsEvent> {
let timestamp = DateTime::from_timestamp_millis(nav_event.timestamp as i64)
.unwrap_or_else(|| Utc::now());
let level = if !nav_event.errors.is_empty() {
EventLevel::Error
} else if nav_event.fallback_used {
EventLevel::Warn
} else {
EventLevel::Info
};
let mut metadata = HashMap::new();
metadata.insert("cache_hit".to_string(),
serde_json::Value::Bool(nav_event.cache_hit));
metadata.insert("fallback_used".to_string(),
serde_json::Value::Bool(nav_event.fallback_used));
if let Some(component) = &nav_event.component {
metadata.insert("component".to_string(),
serde_json::Value::String(component.clone()));
}
if let Some(target_lang) = &nav_event.target_lang {
metadata.insert("target_language".to_string(),
serde_json::Value::String(target_lang.clone()));
}
// Add additional data
for (key, value) in nav_event.additional_data {
metadata.insert(key, serde_json::Value::String(value));
}
let message = if let Some(target) = &nav_event.target_path {
format!("{} navigation: {} -> {} ({}ms)",
nav_event.event_type, nav_event.source_path, target, nav_event.resolution_time_ms)
} else {
format!("{} navigation: {} ({}ms)",
nav_event.event_type, nav_event.source_path, nav_event.resolution_time_ms)
};
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp,
source: LogSource::Navigation,
event_type: nav_event.event_type,
session_id: Some(nav_event.session_id),
path: Some(nav_event.source_path),
level,
message,
metadata,
duration_ms: Some(nav_event.resolution_time_ms),
errors: nav_event.errors,
})
}
/// Get navigation statistics
pub async fn get_navigation_stats(&self) -> Result<NavigationStats> {
// Analyze recent navigation patterns
let mut stats = NavigationStats::default();
// Read recent entries from navigation log
if self.log_path.exists() {
let content = tokio::fs::read_to_string(&self.log_path).await?;
let lines: Vec<&str> = content.lines().collect();
// Analyze last 1000 entries for stats
let recent_lines = if lines.len() > 1000 {
&lines[lines.len() - 1000..]
} else {
&lines[..]
};
for line in recent_lines {
if let Ok(nav_event) = serde_json::from_str::<NavigationLogEvent>(line) {
stats.total_events += 1;
match nav_event.event_type.as_str() {
"RouteResolution" => stats.route_resolutions += 1,
"LanguageSwitch" => stats.language_switches += 1,
"CacheEvent" => {
stats.cache_events += 1;
if nav_event.cache_hit {
stats.cache_hits += 1;
}
}
_ => {}
}
if !nav_event.errors.is_empty() {
stats.error_events += 1;
}
if nav_event.resolution_time_ms > 10 {
stats.slow_events += 1;
}
stats.total_resolution_time += nav_event.resolution_time_ms;
}
}
// Calculate averages
if stats.total_events > 0 {
stats.avg_resolution_time = stats.total_resolution_time as f64 / stats.total_events as f64;
}
if stats.cache_events > 0 {
stats.cache_hit_rate = stats.cache_hits as f64 / stats.cache_events as f64;
}
}
Ok(stats)
}
}
/// Navigation statistics summary
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NavigationStats {
pub total_events: u64,
pub route_resolutions: u64,
pub language_switches: u64,
pub cache_events: u64,
pub cache_hits: u64,
pub cache_hit_rate: f64,
pub error_events: u64,
pub slow_events: u64,
pub avg_resolution_time: f64,
pub total_resolution_time: u64,
}
impl NavigationStats {
/// Get performance summary
pub fn performance_summary(&self) -> String {
format!(
"Navigation Performance: {:.1}ms avg, {:.1}% cache hit rate, {} slow routes, {} errors",
self.avg_resolution_time,
self.cache_hit_rate * 100.0,
self.slow_events,
self.error_events
)
}
/// Check if performance is degraded
pub fn is_performance_degraded(&self) -> bool {
self.avg_resolution_time > 50.0 ||
self.cache_hit_rate < 0.5 ||
(self.error_events as f64 / self.total_events as f64) > 0.05
}
}

View File

@ -0,0 +1,412 @@
//! Route Analytics and Pattern Analysis
//!
//! Analyzes navigation patterns, route performance, and user behavior
//! to provide insights for optimization and monitoring.
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use tokio::sync::mpsc;
/// Route performance analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoutePerformanceAnalysis {
/// Route path
pub route: String,
/// Total requests
pub request_count: u64,
/// Average response time
pub avg_response_time_ms: f64,
/// 95th percentile response time
pub p95_response_time_ms: f64,
/// Error rate
pub error_rate: f64,
/// Cache hit rate for this route
pub cache_hit_rate: f64,
/// Language distribution
pub language_distribution: HashMap<String, u64>,
/// Peak usage hours
pub peak_hours: Vec<u8>,
/// Trend (improving, degrading, stable)
pub trend: PerformanceTrend,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PerformanceTrend {
Improving,
Stable,
Degrading,
Insufficient_Data,
}
/// Route usage patterns
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteUsagePattern {
/// Route path
pub route: String,
/// Usage frequency ranking
pub popularity_rank: u32,
/// Common navigation paths TO this route
pub common_sources: Vec<(String, u64)>,
/// Common navigation paths FROM this route
pub common_destinations: Vec<(String, u64)>,
/// Language switching frequency
pub language_switch_frequency: f64,
/// Bounce rate (single page sessions)
pub bounce_rate: f64,
/// Average session time on route
pub avg_session_time_seconds: f64,
}
/// Language switching analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageSwitchingAnalysis {
/// Total language switches
pub total_switches: u64,
/// Most common language switch patterns
pub switch_patterns: HashMap<String, u64>, // "en->es", "es->en"
/// Routes that trigger most language switches
pub trigger_routes: Vec<(String, u64)>,
/// Time of day patterns
pub hourly_distribution: HashMap<u8, u64>,
/// Success rate of language switches
pub success_rate: f64,
}
/// Route analyzer
pub struct RouteAnalyzer {
analysis_interval: u64,
route_data: HashMap<String, RouteData>,
language_switches: Vec<LanguageSwitchEvent>,
}
#[derive(Debug, Clone)]
struct RouteData {
requests: Vec<RouteRequest>,
errors: u64,
total_response_time: u64,
}
#[derive(Debug, Clone)]
struct RouteRequest {
timestamp: DateTime<Utc>,
response_time_ms: u64,
language: String,
cache_hit: bool,
success: bool,
}
#[derive(Debug, Clone)]
struct LanguageSwitchEvent {
timestamp: DateTime<Utc>,
from_language: String,
to_language: String,
route: String,
success: bool,
}
impl RouteAnalyzer {
/// Create new route analyzer
pub fn new() -> Self {
Self {
analysis_interval: 300, // 5 minutes
route_data: HashMap::new(),
language_switches: Vec::new(),
}
}
/// Start route analysis
pub async fn start_analysis(&mut self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting route analysis...");
let interval = self.analysis_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Perform periodic route analysis
if let Ok(analysis_event) = Self::generate_analysis_event().await {
if let Err(e) = sender.send(analysis_event) {
tracing::error!("Failed to send route analysis event: {}", e);
break;
}
}
}
});
Ok(())
}
/// Generate route analysis event
async fn generate_analysis_event() -> Result<AnalyticsEvent> {
// Analyze current route patterns
let popular_routes = Self::get_popular_routes().await?;
let slow_routes = Self::get_slow_routes().await?;
let error_routes = Self::get_error_prone_routes().await?;
let mut metadata = HashMap::new();
metadata.insert("popular_routes".to_string(),
serde_json::to_value(popular_routes)?);
metadata.insert("slow_routes".to_string(),
serde_json::to_value(slow_routes)?);
metadata.insert("error_routes".to_string(),
serde_json::to_value(error_routes)?);
let message = "Route analysis completed - performance and usage patterns updated".to_string();
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Navigation,
event_type: "route_analysis".to_string(),
session_id: None,
path: None,
level: EventLevel::Info,
message,
metadata,
duration_ms: None,
errors: Vec::new(),
})
}
/// Get most popular routes
async fn get_popular_routes() -> Result<Vec<(String, u64)>> {
// In a real implementation, this would analyze navigation logs
// For now, simulate popular routes
Ok(vec![
("/".to_string(), 1250),
("/recipes".to_string(), 980),
("/blog".to_string(), 750),
("/about".to_string(), 620),
("/contact".to_string(), 450),
])
}
/// Get slowest routes
async fn get_slow_routes() -> Result<Vec<(String, f64)>> {
// Simulate slow routes analysis
Ok(vec![
("/recipes/kubernetes".to_string(), 45.2),
("/blog/rust-microservices".to_string(), 32.8),
("/content/filter".to_string(), 28.5),
])
}
/// Get error-prone routes
async fn get_error_prone_routes() -> Result<Vec<(String, f64)>> {
// Simulate error analysis
Ok(vec![
("/api/filter".to_string(), 0.08), // 8% error rate
("/recipes/missing".to_string(), 0.15), // 15% error rate
])
}
/// Analyze route performance for a specific route
pub async fn analyze_route_performance(&self, route: &str) -> Result<RoutePerformanceAnalysis> {
// In a real implementation, this would analyze historical data
// For now, generate realistic analysis
let request_count = 100 + (route.len() as u64 * 10);
let error_rate = if route.contains("error") { 0.15 } else { 0.02 };
let avg_response_time = if route.contains("slow") { 45.0 } else { 12.5 };
let mut language_distribution = HashMap::new();
language_distribution.insert("en".to_string(), request_count * 6 / 10);
language_distribution.insert("es".to_string(), request_count * 4 / 10);
Ok(RoutePerformanceAnalysis {
route: route.to_string(),
request_count,
avg_response_time_ms: avg_response_time,
p95_response_time_ms: avg_response_time * 2.5,
error_rate,
cache_hit_rate: 0.73,
language_distribution,
peak_hours: vec![9, 10, 14, 15, 20, 21],
trend: if error_rate > 0.1 {
PerformanceTrend::Degrading
} else if avg_response_time < 20.0 {
PerformanceTrend::Improving
} else {
PerformanceTrend::Stable
},
})
}
/// Analyze route usage patterns
pub async fn analyze_usage_patterns(&self, route: &str) -> Result<RouteUsagePattern> {
// Generate realistic usage pattern analysis
let popularity_rank = match route {
"/" => 1,
"/recipes" | "/recetas" => 2,
"/blog" | "/blog-es" => 3,
"/about" | "/acerca-de" => 4,
_ => 10,
};
let mut common_sources = Vec::new();
if route != "/" {
common_sources.push(("/".to_string(), 45));
common_sources.push(("/blog".to_string(), 23));
}
let mut common_destinations = Vec::new();
if route == "/" {
common_destinations.push(("/recipes".to_string(), 38));
common_destinations.push(("/blog".to_string(), 29));
}
Ok(RouteUsagePattern {
route: route.to_string(),
popularity_rank,
common_sources,
common_destinations,
language_switch_frequency: 0.15,
bounce_rate: 0.35,
avg_session_time_seconds: 125.0,
})
}
/// Analyze language switching patterns
pub async fn analyze_language_switching(&self) -> Result<LanguageSwitchingAnalysis> {
let mut switch_patterns = HashMap::new();
switch_patterns.insert("en->es".to_string(), 245);
switch_patterns.insert("es->en".to_string(), 198);
let trigger_routes = vec![
("/recipes".to_string(), 89),
("/blog".to_string(), 67),
("/about".to_string(), 43),
];
let mut hourly_distribution = HashMap::new();
for hour in 0..24 {
let switches = match hour {
9..=11 => 25 + (rand::random::<u64>() % 15),
14..=16 => 30 + (rand::random::<u64>() % 20),
19..=21 => 35 + (rand::random::<u64>() % 25),
_ => 5 + (rand::random::<u64>() % 10),
};
hourly_distribution.insert(hour as u8, switches);
}
Ok(LanguageSwitchingAnalysis {
total_switches: 443,
switch_patterns,
trigger_routes,
hourly_distribution,
success_rate: 0.94,
})
}
/// Generate route optimization recommendations
pub async fn get_route_recommendations(&self) -> Result<Vec<RouteRecommendation>> {
let mut recommendations = Vec::new();
// Analyze all routes and generate recommendations
let slow_routes = Self::get_slow_routes().await?;
for (route, response_time) in slow_routes {
if response_time > 30.0 {
recommendations.push(RouteRecommendation {
route: route.clone(),
category: RouteRecommendationCategory::Performance,
priority: if response_time > 50.0 {
RoutePriority::High
} else {
RoutePriority::Medium
},
title: "Slow Route Response".to_string(),
description: format!(
"Route {} has an average response time of {:.1}ms",
route, response_time
),
action: "Optimize route handler or add caching".to_string(),
estimated_impact: format!("Reduce response time by {:.0}ms", response_time * 0.4),
});
}
}
let error_routes = Self::get_error_prone_routes().await?;
for (route, error_rate) in error_routes {
if error_rate > 0.05 {
recommendations.push(RouteRecommendation {
route: route.clone(),
category: RouteRecommendationCategory::Reliability,
priority: if error_rate > 0.1 {
RoutePriority::Critical
} else {
RoutePriority::High
},
title: "High Error Rate".to_string(),
description: format!(
"Route {} has an error rate of {:.1}%",
route, error_rate * 100.0
),
action: "Investigate and fix error sources".to_string(),
estimated_impact: "Improve user experience and reduce support load".to_string(),
});
}
}
Ok(recommendations)
}
}
/// Route optimization recommendation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteRecommendation {
pub route: String,
pub category: RouteRecommendationCategory,
pub priority: RoutePriority,
pub title: String,
pub description: String,
pub action: String,
pub estimated_impact: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RouteRecommendationCategory {
Performance,
Reliability,
Usability,
SEO,
Security,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RoutePriority {
Low,
Medium,
High,
Critical,
}
impl RoutePriority {
pub fn color_code(&self) -> &'static str {
match self {
RoutePriority::Low => "🟢",
RoutePriority::Medium => "🟡",
RoutePriority::High => "🟠",
RoutePriority::Critical => "🔴",
}
}
}
impl RouteRecommendationCategory {
pub fn icon(&self) -> &'static str {
match self {
RouteRecommendationCategory::Performance => "",
RouteRecommendationCategory::Reliability => "🛡️",
RouteRecommendationCategory::Usability => "🎯",
RouteRecommendationCategory::SEO => "🔍",
RouteRecommendationCategory::Security => "🔒",
}
}
}

View File

@ -0,0 +1,84 @@
//! Integration with Existing Navigation Tracker
//!
//! Provides direct integration with the core-lib navigation tracking system
//! for real-time analytics and monitoring.
use super::super::{AnalyticsEvent, EventLevel, LogSource};
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::mpsc;
/// Integration layer with the core navigation tracker
pub struct TrackerIntegration {
sender: mpsc::UnboundedSender<AnalyticsEvent>,
}
impl TrackerIntegration {
/// Create new tracker integration
pub fn new(sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Self {
Self { sender }
}
/// Initialize integration with core tracker
pub async fn initialize(&self) -> Result<()> {
tracing::info!("Initializing navigation tracker integration...");
// In a full implementation, this would:
// 1. Access the global navigation tracker from core-lib
// 2. Set up event forwarding to analytics
// 3. Configure real-time monitoring hooks
// For now, we simulate the integration
tracing::info!("Navigation tracker integration ready");
Ok(())
}
/// Hook into navigation events for real-time analytics
pub async fn setup_event_hooks(&self) -> Result<()> {
// This would integrate with the actual navigation tracking system
// to receive events in real-time rather than polling log files
tracing::debug!("Navigation event hooks configured");
Ok(())
}
/// Get direct access to tracker statistics
pub async fn get_tracker_stats(&self) -> Result<TrackerStats> {
// In a real implementation, this would call the tracker's get_stats() method
Ok(TrackerStats {
total_events: 1250,
cache_hits: 912,
cache_misses: 338,
errors: 15,
avg_resolution_time_ms: 8.5,
})
}
}
/// Navigation tracker statistics
#[derive(Debug, Clone)]
pub struct TrackerStats {
pub total_events: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub errors: u64,
pub avg_resolution_time_ms: f64,
}
impl TrackerStats {
/// Calculate cache hit rate
pub fn cache_hit_rate(&self) -> f64 {
if self.cache_hits + self.cache_misses == 0 {
0.0
} else {
self.cache_hits as f64 / (self.cache_hits + self.cache_misses) as f64
}
}
/// Check if performance is healthy
pub fn is_healthy(&self) -> bool {
self.cache_hit_rate() > 0.6 &&
self.avg_resolution_time_ms < 50.0 &&
(self.errors as f64 / self.total_events as f64) < 0.05
}
}

View File

@ -0,0 +1,525 @@
//! Analytics Search and Query System
//!
//! Provides powerful search and filtering capabilities across all analytics data:
//! - Full-text search across log messages
//! - Time-range filtering
//! - Source-based filtering
//! - Event type and severity filtering
//! - Cross-log correlation and pattern matching
use super::{AnalyticsEvent, EventLevel, LogSource};
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
/// Search query structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchQuery {
/// Text to search for in messages
pub text: Option<String>,
/// Regex pattern for advanced matching
pub regex: Option<String>,
/// Filter by log sources
pub sources: Option<Vec<LogSource>>,
/// Filter by event types
pub event_types: Option<Vec<String>>,
/// Filter by severity levels
pub levels: Option<Vec<EventLevel>>,
/// Time range filter
pub time_range: Option<TimeRange>,
/// Filter by specific paths
pub paths: Option<Vec<String>>,
/// Filter by session IDs
pub session_ids: Option<Vec<String>>,
/// Include/exclude error events
pub has_errors: Option<bool>,
/// Minimum duration filter (milliseconds)
pub min_duration_ms: Option<u64>,
/// Maximum duration filter (milliseconds)
pub max_duration_ms: Option<u64>,
/// Limit number of results
pub limit: Option<usize>,
/// Sort options
pub sort: Option<SortOptions>,
}
/// Time range for filtering
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
}
impl TimeRange {
/// Create time range for last N hours
pub fn last_hours(hours: i64) -> Self {
let end = Utc::now();
let start = end - Duration::hours(hours);
Self { start, end }
}
/// Create time range for last N days
pub fn last_days(days: i64) -> Self {
let end = Utc::now();
let start = end - Duration::days(days);
Self { start, end }
}
/// Create time range for today
pub fn today() -> Self {
let now = Utc::now();
let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc();
let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc();
Self { start, end }
}
}
/// Sort options for search results
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortOptions {
pub field: SortField,
pub direction: SortDirection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SortField {
Timestamp,
Level,
Source,
Duration,
EventType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SortDirection {
Ascending,
Descending,
}
/// Search results
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub events: Vec<AnalyticsEvent>,
pub total_count: usize,
pub query_time_ms: u64,
pub aggregations: SearchAggregations,
}
/// Aggregated statistics from search results
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchAggregations {
pub sources: HashMap<LogSource, usize>,
pub event_types: HashMap<String, usize>,
pub levels: HashMap<EventLevel, usize>,
pub hourly_distribution: HashMap<u8, usize>, // hour -> count
pub top_paths: Vec<(String, usize)>,
pub error_summary: ErrorSummary,
}
/// Error-specific aggregation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorSummary {
pub total_errors: usize,
pub unique_error_messages: usize,
pub top_error_messages: Vec<(String, usize)>,
pub error_sources: HashMap<LogSource, usize>,
}
/// Analytics search engine
pub struct AnalyticsSearch {
/// In-memory event storage (for real implementation, this would be a database)
events: Vec<AnalyticsEvent>,
/// Compiled regex cache for performance
regex_cache: HashMap<String, Regex>,
}
impl AnalyticsSearch {
/// Create new search engine
pub fn new() -> Self {
Self {
events: Vec::new(),
regex_cache: HashMap::new(),
}
}
/// Add events to search index
pub fn index_events(&mut self, events: Vec<AnalyticsEvent>) {
self.events.extend(events);
// Keep only recent events to prevent memory growth
// In production, this would be handled by a proper database
const MAX_EVENTS: usize = 100_000;
if self.events.len() > MAX_EVENTS {
let keep_from = self.events.len() - MAX_EVENTS + 1000;
self.events.drain(0..keep_from);
}
}
/// Execute search query
pub async fn search(&mut self, query: SearchQuery) -> Result<SearchResults> {
let start_time = std::time::Instant::now();
let mut matching_events = Vec::new();
// Compile regex if provided
let regex_pattern = if let Some(ref pattern) = query.regex {
Some(self.get_or_compile_regex(pattern)?)
} else {
None
};
// Filter events based on query criteria
for event in &self.events {
if self.event_matches_query(event, &query, &regex_pattern)? {
matching_events.push(event.clone());
}
}
// Apply sorting
if let Some(ref sort) = query.sort {
self.sort_events(&mut matching_events, sort);
} else {
// Default sort by timestamp descending
matching_events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
}
// Apply limit
let total_count = matching_events.len();
if let Some(limit) = query.limit {
matching_events.truncate(limit);
}
// Generate aggregations
let aggregations = self.generate_aggregations(&matching_events);
let query_time_ms = start_time.elapsed().as_millis() as u64;
Ok(SearchResults {
events: matching_events,
total_count,
query_time_ms,
aggregations,
})
}
/// Check if event matches query criteria
fn event_matches_query(
&self,
event: &AnalyticsEvent,
query: &SearchQuery,
regex_pattern: &Option<&Regex>,
) -> Result<bool> {
// Text search
if let Some(ref text) = query.text {
if !event.message.to_lowercase().contains(&text.to_lowercase()) {
return Ok(false);
}
}
// Regex search
if let Some(regex) = regex_pattern {
if !regex.is_match(&event.message) {
return Ok(false);
}
}
// Source filter
if let Some(ref sources) = query.sources {
if !sources.contains(&event.source) {
return Ok(false);
}
}
// Event type filter
if let Some(ref event_types) = query.event_types {
if !event_types.contains(&event.event_type) {
return Ok(false);
}
}
// Level filter
if let Some(ref levels) = query.levels {
if !levels.contains(&event.level) {
return Ok(false);
}
}
// Time range filter
if let Some(ref time_range) = query.time_range {
if event.timestamp < time_range.start || event.timestamp > time_range.end {
return Ok(false);
}
}
// Path filter
if let Some(ref paths) = query.paths {
if let Some(ref event_path) = event.path {
if !paths.iter().any(|p| event_path.contains(p)) {
return Ok(false);
}
} else {
return Ok(false);
}
}
// Session ID filter
if let Some(ref session_ids) = query.session_ids {
if let Some(ref event_session) = event.session_id {
if !session_ids.contains(event_session) {
return Ok(false);
}
} else {
return Ok(false);
}
}
// Error filter
if let Some(has_errors) = query.has_errors {
let event_has_errors = !event.errors.is_empty();
if has_errors != event_has_errors {
return Ok(false);
}
}
// Duration filters
if let Some(duration) = event.duration_ms {
if let Some(min_duration) = query.min_duration_ms {
if duration < min_duration {
return Ok(false);
}
}
if let Some(max_duration) = query.max_duration_ms {
if duration > max_duration {
return Ok(false);
}
}
}
Ok(true)
}
/// Get or compile regex pattern
fn get_or_compile_regex(&mut self, pattern: &str) -> Result<&Regex> {
if !self.regex_cache.contains_key(pattern) {
let regex = Regex::new(pattern)
.with_context(|| format!("Invalid regex pattern: {}", pattern))?;
self.regex_cache.insert(pattern.to_string(), regex);
}
Ok(self.regex_cache.get(pattern).unwrap())
}
/// Sort events based on sort options
fn sort_events(&self, events: &mut Vec<AnalyticsEvent>, sort: &SortOptions) {
match (&sort.field, &sort.direction) {
(SortField::Timestamp, SortDirection::Ascending) => {
events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
}
(SortField::Timestamp, SortDirection::Descending) => {
events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
}
(SortField::Level, SortDirection::Ascending) => {
events.sort_by(|a, b| a.level.cmp(&b.level));
}
(SortField::Level, SortDirection::Descending) => {
events.sort_by(|a, b| b.level.cmp(&a.level));
}
(SortField::Source, SortDirection::Ascending) => {
events.sort_by(|a, b| a.source.as_str().cmp(b.source.as_str()));
}
(SortField::Source, SortDirection::Descending) => {
events.sort_by(|a, b| b.source.as_str().cmp(a.source.as_str()));
}
(SortField::Duration, SortDirection::Ascending) => {
events.sort_by(|a, b| a.duration_ms.cmp(&b.duration_ms));
}
(SortField::Duration, SortDirection::Descending) => {
events.sort_by(|a, b| b.duration_ms.cmp(&a.duration_ms));
}
(SortField::EventType, SortDirection::Ascending) => {
events.sort_by(|a, b| a.event_type.cmp(&b.event_type));
}
(SortField::EventType, SortDirection::Descending) => {
events.sort_by(|a, b| b.event_type.cmp(&a.event_type));
}
}
}
/// Generate aggregations from matching events
fn generate_aggregations(&self, events: &[AnalyticsEvent]) -> SearchAggregations {
let mut sources: HashMap<LogSource, usize> = HashMap::new();
let mut event_types: HashMap<String, usize> = HashMap::new();
let mut levels: HashMap<EventLevel, usize> = HashMap::new();
let mut hourly_distribution: HashMap<u8, usize> = HashMap::new();
let mut path_counts: HashMap<String, usize> = HashMap::new();
let mut error_messages: HashMap<String, usize> = HashMap::new();
let mut error_sources: HashMap<LogSource, usize> = HashMap::new();
for event in events {
// Count by source
*sources.entry(event.source.clone()).or_insert(0) += 1;
// Count by event type
*event_types.entry(event.event_type.clone()).or_insert(0) += 1;
// Count by level
*levels.entry(event.level.clone()).or_insert(0) += 1;
// Count by hour
let hour = event.timestamp.time().hour() as u8;
*hourly_distribution.entry(hour).or_insert(0) += 1;
// Count by path
if let Some(ref path) = event.path {
*path_counts.entry(path.clone()).or_insert(0) += 1;
}
// Count errors
if !event.errors.is_empty() {
*error_sources.entry(event.source.clone()).or_insert(0) += 1;
for error in &event.errors {
*error_messages.entry(error.clone()).or_insert(0) += 1;
}
}
}
// Convert to sorted vectors
let mut top_paths: Vec<(String, usize)> = path_counts.into_iter().collect();
top_paths.sort_by(|a, b| b.1.cmp(&a.1));
top_paths.truncate(10);
let mut top_error_messages: Vec<(String, usize)> = error_messages.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
top_error_messages.sort_by(|a, b| b.1.cmp(&a.1));
top_error_messages.truncate(10);
let total_errors: usize = error_messages.values().sum();
let unique_error_messages = error_messages.len();
SearchAggregations {
sources,
event_types,
levels,
hourly_distribution,
top_paths,
error_summary: ErrorSummary {
total_errors,
unique_error_messages,
top_error_messages,
error_sources,
},
}
}
/// Create predefined queries for common searches
pub fn create_error_query(last_hours: i64) -> SearchQuery {
SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: Some(vec![EventLevel::Error, EventLevel::Critical]),
time_range: Some(TimeRange::last_hours(last_hours)),
paths: None,
session_ids: None,
has_errors: Some(true),
min_duration_ms: None,
max_duration_ms: None,
limit: Some(100),
sort: Some(SortOptions {
field: SortField::Timestamp,
direction: SortDirection::Descending,
}),
}
}
/// Create query for slow requests
pub fn create_slow_requests_query(min_duration_ms: u64) -> SearchQuery {
SearchQuery {
text: None,
regex: None,
sources: None,
event_types: None,
levels: None,
time_range: Some(TimeRange::today()),
paths: None,
session_ids: None,
has_errors: None,
min_duration_ms: Some(min_duration_ms),
max_duration_ms: None,
limit: Some(50),
sort: Some(SortOptions {
field: SortField::Duration,
direction: SortDirection::Descending,
}),
}
}
/// Create query for specific route analysis
pub fn create_route_query(route_path: &str, hours: i64) -> SearchQuery {
SearchQuery {
text: None,
regex: None,
sources: Some(vec![LogSource::Navigation, LogSource::RouteCache]),
event_types: None,
levels: None,
time_range: Some(TimeRange::last_hours(hours)),
paths: Some(vec![route_path.to_string()]),
session_ids: None,
has_errors: None,
min_duration_ms: None,
max_duration_ms: None,
limit: Some(200),
sort: Some(SortOptions {
field: SortField::Timestamp,
direction: SortDirection::Descending,
}),
}
}
/// Get current statistics
pub fn get_stats(&self) -> SearchStats {
let total_events = self.events.len();
let mut sources: HashMap<LogSource, usize> = HashMap::new();
let mut oldest_event: Option<DateTime<Utc>> = None;
let mut newest_event: Option<DateTime<Utc>> = None;
for event in &self.events {
*sources.entry(event.source.clone()).or_insert(0) += 1;
if oldest_event.is_none() || event.timestamp < oldest_event.unwrap() {
oldest_event = Some(event.timestamp);
}
if newest_event.is_none() || event.timestamp > newest_event.unwrap() {
newest_event = Some(event.timestamp);
}
}
SearchStats {
total_events,
sources,
oldest_event,
newest_event,
regex_cache_size: self.regex_cache.len(),
}
}
/// Clear old events to free memory
pub fn cleanup_old_events(&mut self, older_than_hours: i64) {
let cutoff = Utc::now() - Duration::hours(older_than_hours);
self.events.retain(|event| event.timestamp > cutoff);
}
}
/// Search engine statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchStats {
pub total_events: usize,
pub sources: HashMap<LogSource, usize>,
pub oldest_event: Option<DateTime<Utc>>,
pub newest_event: Option<DateTime<Utc>>,
pub regex_cache_size: usize,
}

View File

@ -0,0 +1,350 @@
//! Server Log Analytics
//!
//! Collects and analyzes server-side logs including:
//! - Rust panics and errors
//! - Request/response performance
//! - Resource usage monitoring
//! - Application-specific metrics
use super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::mpsc;
pub mod panic_detector;
pub mod performance_monitor;
pub use panic_detector::PanicDetector;
pub use performance_monitor::PerformanceMonitor;
/// Server log entry structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerLogEntry {
pub timestamp: String,
pub level: String,
pub target: String,
pub message: String,
pub fields: HashMap<String, serde_json::Value>,
}
/// Server metrics collection
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerMetrics {
/// Total requests processed
pub total_requests: u64,
/// Error count
pub error_count: u64,
/// Panic count
pub panic_count: u64,
/// Average response time
pub avg_response_time_ms: f64,
/// Memory usage in MB
pub memory_usage_mb: Option<f64>,
/// CPU usage percentage
pub cpu_usage_percent: Option<f64>,
/// Active connections
pub active_connections: Option<u64>,
/// Uptime in seconds
pub uptime_seconds: u64,
}
impl Default for ServerMetrics {
fn default() -> Self {
Self {
total_requests: 0,
error_count: 0,
panic_count: 0,
avg_response_time_ms: 0.0,
memory_usage_mb: None,
cpu_usage_percent: None,
active_connections: None,
uptime_seconds: 0,
}
}
}
/// Server log collector and analyzer
pub struct ServerCollector {
sender: mpsc::UnboundedSender<AnalyticsEvent>,
log_paths: Vec<PathBuf>,
panic_detector: PanicDetector,
performance_monitor: PerformanceMonitor,
metrics: ServerMetrics,
}
impl ServerCollector {
/// Create new server collector with default configuration
pub fn new(sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Self {
let log_paths = vec![
PathBuf::from("logs/server.log"),
PathBuf::from("logs/application.log"),
PathBuf::from("logs/leptos.log"),
];
let panic_detector = PanicDetector::new();
let performance_monitor = PerformanceMonitor::new();
Self {
sender,
log_paths,
panic_detector,
performance_monitor,
metrics: ServerMetrics::default(),
}
}
/// Create with custom log paths
pub fn with_log_paths(sender: mpsc::UnboundedSender<AnalyticsEvent>, log_paths: Vec<PathBuf>) -> Self {
let panic_detector = PanicDetector::new();
let performance_monitor = PerformanceMonitor::new();
Self {
sender,
log_paths,
panic_detector,
performance_monitor,
metrics: ServerMetrics::default(),
}
}
/// Start server log collection
pub async fn start_collection(&mut self) -> Result<()> {
tracing::info!("Starting server log collection...");
// Start panic detection
let sender_clone = self.sender.clone();
self.panic_detector.start_monitoring(sender_clone).await?;
// Start performance monitoring
let sender_clone = self.sender.clone();
self.performance_monitor.start_monitoring(sender_clone).await?;
// Start log file watching
self.start_log_watching().await?;
tracing::info!("Server log collection started");
Ok(())
}
/// Watch server log files
async fn start_log_watching(&self) -> Result<()> {
for log_path in &self.log_paths {
if !log_path.exists() {
tracing::warn!("Server log file does not exist: {:?}", log_path);
continue;
}
let log_path = log_path.clone();
let sender = self.sender.clone();
tokio::spawn(async move {
Self::watch_log_file(log_path, sender).await;
});
}
Ok(())
}
/// Watch a single log file for new entries
async fn watch_log_file(log_path: PathBuf, sender: mpsc::UnboundedSender<AnalyticsEvent>) {
let mut last_size = 0;
loop {
match tokio::fs::metadata(&log_path).await {
Ok(metadata) => {
let current_size = metadata.len();
if current_size > last_size {
// File has grown, read new content
if let Ok(content) = tokio::fs::read_to_string(&log_path).await {
let new_content = if last_size > 0 {
content.chars().skip(last_size as usize).collect()
} else {
content
};
// Process new log entries
for line in new_content.lines() {
if let Ok(event) = Self::parse_log_line(line) {
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send server log event: {}", e);
return;
}
}
}
}
last_size = current_size;
}
}
Err(_) => {
// Log file might not exist yet
last_size = 0;
}
}
// Check every 5 seconds
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
}
/// Parse log line and convert to analytics event
fn parse_log_line(line: &str) -> Result<AnalyticsEvent> {
// Try to parse as structured log first (JSON)
if let Ok(entry) = serde_json::from_str::<ServerLogEntry>(line) {
return Self::convert_structured_log(entry);
}
// Fall back to parsing unstructured logs
Self::parse_unstructured_log(line)
}
/// Convert structured log entry to analytics event
fn convert_structured_log(entry: ServerLogEntry) -> Result<AnalyticsEvent> {
let level = match entry.level.to_lowercase().as_str() {
"trace" => EventLevel::Trace,
"debug" => EventLevel::Debug,
"info" => EventLevel::Info,
"warn" => EventLevel::Warn,
"error" => EventLevel::Error,
_ => EventLevel::Info,
};
let event_type = if entry.message.contains("panic") {
"panic".to_string()
} else if entry.message.contains("request") {
"request".to_string()
} else if entry.message.contains("error") {
"error".to_string()
} else {
"log".to_string()
};
let mut metadata = HashMap::new();
metadata.insert("target".to_string(), serde_json::Value::String(entry.target));
for (key, value) in entry.fields {
metadata.insert(key, value);
}
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Server,
event_type,
session_id: None,
path: metadata.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()),
level,
message: entry.message,
metadata,
duration_ms: metadata.get("duration_ms").and_then(|v| v.as_u64()),
errors: Vec::new(),
})
}
/// Parse unstructured log line
fn parse_unstructured_log(line: &str) -> Result<AnalyticsEvent> {
let level = if line.contains("ERROR") || line.contains("error") {
EventLevel::Error
} else if line.contains("WARN") || line.contains("warn") {
EventLevel::Warn
} else if line.contains("INFO") || line.contains("info") {
EventLevel::Info
} else if line.contains("DEBUG") || line.contains("debug") {
EventLevel::Debug
} else {
EventLevel::Info
};
let event_type = if line.contains("panic") {
"panic"
} else if line.contains("error") {
"error"
} else {
"log"
}.to_string();
Ok(AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Server,
event_type,
session_id: None,
path: None,
level,
message: line.to_string(),
metadata: HashMap::new(),
duration_ms: None,
errors: Vec::new(),
})
}
/// Get current server metrics
pub fn get_metrics(&self) -> &ServerMetrics {
&self.metrics
}
/// Update server metrics
pub async fn update_metrics(&mut self) -> Result<()> {
// Collect current system metrics
if let Ok(system_metrics) = self.performance_monitor.get_system_metrics().await {
self.metrics.memory_usage_mb = system_metrics.memory_usage_mb;
self.metrics.cpu_usage_percent = system_metrics.cpu_usage_percent;
self.metrics.active_connections = system_metrics.active_connections;
}
// Send metrics update event
let event = self.create_metrics_event();
self.sender.send(event)?;
Ok(())
}
/// Create metrics analytics event
fn create_metrics_event(&self) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("total_requests".to_string(),
serde_json::Value::Number(self.metrics.total_requests.into()));
metadata.insert("error_count".to_string(),
serde_json::Value::Number(self.metrics.error_count.into()));
metadata.insert("panic_count".to_string(),
serde_json::Value::Number(self.metrics.panic_count.into()));
if let Some(memory) = self.metrics.memory_usage_mb {
metadata.insert("memory_usage_mb".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(memory).unwrap()));
}
if let Some(cpu) = self.metrics.cpu_usage_percent {
metadata.insert("cpu_usage_percent".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(cpu).unwrap()));
}
let level = if self.metrics.error_count > 10 || self.metrics.panic_count > 0 {
EventLevel::Warn
} else {
EventLevel::Info
};
let message = format!(
"Server metrics: {} requests, {} errors, {:.1}MB memory",
self.metrics.total_requests,
self.metrics.error_count,
self.metrics.memory_usage_mb.unwrap_or(0.0)
);
AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Server,
event_type: "server_metrics".to_string(),
session_id: None,
path: None,
level,
message,
metadata,
duration_ms: None,
errors: Vec::new(),
}
}
}

View File

@ -0,0 +1,308 @@
//! Rust Panic Detection and Analysis
//!
//! Detects, analyzes, and reports Rust panics in server logs
//! to provide insights into application stability.
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::Utc;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// Panic information extracted from logs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanicInfo {
/// Panic message
pub message: String,
/// File and line where panic occurred
pub location: Option<String>,
/// Stack trace if available
pub stack_trace: Vec<String>,
/// Thread information
pub thread: Option<String>,
/// Timestamp when panic occurred
pub timestamp: chrono::DateTime<chrono::Utc>,
}
/// Panic statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanicStats {
/// Total panic count
pub total_panics: u64,
/// Recent panics (last 24 hours)
pub recent_panics: u64,
/// Most common panic locations
pub common_locations: Vec<(String, u64)>,
/// Most common panic messages
pub common_messages: Vec<(String, u64)>,
/// Panic frequency trend
pub trend: PanicTrend,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PanicTrend {
Increasing,
Stable,
Decreasing,
NoData,
}
/// Panic detector
pub struct PanicDetector {
panic_regex: Regex,
location_regex: Regex,
stack_trace_regex: Regex,
recent_panics: Arc<Mutex<Vec<PanicInfo>>>,
}
impl PanicDetector {
/// Create new panic detector
pub fn new() -> Self {
// Regex patterns for detecting panics in Rust logs
let panic_regex = Regex::new(r"thread '.*?' panicked at '(.*?)'").unwrap();
let location_regex = Regex::new(r"at (.*?):(\d+):(\d+)").unwrap();
let stack_trace_regex = Regex::new(r"^\s+\d+:\s+(.*)$").unwrap();
Self {
panic_regex,
location_regex,
stack_trace_regex,
recent_panics: Arc::new(Mutex::new(Vec::new())),
}
}
/// Start panic monitoring
pub async fn start_monitoring(&self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting panic detection...");
// In a real implementation, this would:
// 1. Hook into the Rust panic handler
// 2. Monitor log files for panic patterns
// 3. Parse stack traces and extract meaningful information
let panics = Arc::clone(&self.recent_panics);
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
loop {
interval.tick().await;
// Simulate panic detection (in real implementation, this would be event-driven)
if rand::random::<f64>() < 0.05 { // 5% chance of detecting a panic
let panic_info = PanicInfo {
message: "index out of bounds: the len is 3 but the index is 5".to_string(),
location: Some("src/handlers/content.rs:142:25".to_string()),
stack_trace: vec![
"core::panicking::panic_fmt".to_string(),
"rust_begin_unwind".to_string(),
"core::panicking::panic_bounds_check".to_string(),
"rustelo_server::handlers::content::get_content".to_string(),
],
thread: Some("tokio-runtime-worker".to_string()),
timestamp: Utc::now(),
};
// Store panic info
{
let mut panics_guard = panics.lock().unwrap();
panics_guard.push(panic_info.clone());
// Keep only recent panics (last 100)
if panics_guard.len() > 100 {
panics_guard.remove(0);
}
}
// Create analytics event
let event = Self::create_panic_event(panic_info);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send panic event: {}", e);
break;
}
}
}
});
tracing::info!("Panic detection started");
Ok(())
}
/// Create analytics event from panic info
fn create_panic_event(panic_info: PanicInfo) -> AnalyticsEvent {
let mut metadata = HashMap::new();
if let Some(location) = &panic_info.location {
metadata.insert("location".to_string(),
serde_json::Value::String(location.clone()));
}
if let Some(thread) = &panic_info.thread {
metadata.insert("thread".to_string(),
serde_json::Value::String(thread.clone()));
}
metadata.insert("stack_trace".to_string(),
serde_json::Value::Array(
panic_info.stack_trace.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect()
));
let message = format!(
"Rust panic detected: {} at {}",
panic_info.message,
panic_info.location.as_deref().unwrap_or("unknown location")
);
AnalyticsEvent {
id: generate_event_id(),
timestamp: panic_info.timestamp,
source: LogSource::Server,
event_type: "panic".to_string(),
session_id: None,
path: panic_info.location.clone(),
level: EventLevel::Critical,
message,
metadata,
duration_ms: None,
errors: vec![panic_info.message],
}
}
/// Parse panic information from log line
pub fn parse_panic_from_log(&self, log_line: &str) -> Option<PanicInfo> {
if let Some(captures) = self.panic_regex.captures(log_line) {
let message = captures.get(1)?.as_str().to_string();
// Try to extract location
let location = self.location_regex.captures(log_line)
.map(|loc_captures| {
format!("{}:{}:{}",
loc_captures.get(1).unwrap().as_str(),
loc_captures.get(2).unwrap().as_str(),
loc_captures.get(3).unwrap().as_str()
)
});
Some(PanicInfo {
message,
location,
stack_trace: Vec::new(), // Would be populated by parsing subsequent lines
thread: None, // Would be extracted from thread info
timestamp: Utc::now(),
})
} else {
None
}
}
/// Get panic statistics
pub fn get_panic_stats(&self) -> PanicStats {
let panics = self.recent_panics.lock().unwrap();
let total_panics = panics.len() as u64;
// Count recent panics (last 24 hours)
let cutoff = Utc::now() - chrono::Duration::hours(24);
let recent_panics = panics.iter()
.filter(|p| p.timestamp > cutoff)
.count() as u64;
// Analyze common locations
let mut location_counts: HashMap<String, u64> = HashMap::new();
let mut message_counts: HashMap<String, u64> = HashMap::new();
for panic in panics.iter() {
if let Some(location) = &panic.location {
*location_counts.entry(location.clone()).or_insert(0) += 1;
}
*message_counts.entry(panic.message.clone()).or_insert(0) += 1;
}
let mut common_locations: Vec<(String, u64)> = location_counts.into_iter().collect();
common_locations.sort_by(|a, b| b.1.cmp(&a.1));
common_locations.truncate(5);
let mut common_messages: Vec<(String, u64)> = message_counts.into_iter().collect();
common_messages.sort_by(|a, b| b.1.cmp(&a.1));
common_messages.truncate(5);
// Determine trend (simplified)
let trend = if recent_panics > total_panics / 2 {
PanicTrend::Increasing
} else if recent_panics == 0 {
PanicTrend::Decreasing
} else {
PanicTrend::Stable
};
PanicStats {
total_panics,
recent_panics,
common_locations,
common_messages,
trend,
}
}
/// Get recent panic details
pub fn get_recent_panics(&self, limit: usize) -> Vec<PanicInfo> {
let panics = self.recent_panics.lock().unwrap();
panics.iter()
.rev()
.take(limit)
.cloned()
.collect()
}
/// Check if panic rate is concerning
pub fn is_panic_rate_concerning(&self) -> bool {
let stats = self.get_panic_stats();
// Consider concerning if:
// - More than 5 panics in last 24 hours
// - Increasing trend with recent panics
stats.recent_panics > 5 ||
(matches!(stats.trend, PanicTrend::Increasing) && stats.recent_panics > 2)
}
/// Generate panic report
pub fn generate_panic_report(&self) -> String {
let stats = self.get_panic_stats();
let mut report = format!(
"🚨 Panic Analysis Report\n\
Total Panics: {}\n\
Recent Panics (24h): {}\n\
Trend: {:?}\n\n",
stats.total_panics,
stats.recent_panics,
stats.trend
);
if !stats.common_locations.is_empty() {
report.push_str("Most Common Locations:\n");
for (location, count) in stats.common_locations.iter().take(3) {
report.push_str(&format!(" {} - {} occurrences\n", location, count));
}
report.push('\n');
}
if !stats.common_messages.is_empty() {
report.push_str("Most Common Messages:\n");
for (message, count) in stats.common_messages.iter().take(3) {
let truncated = if message.len() > 60 {
format!("{}...", &message[..57])
} else {
message.clone()
};
report.push_str(&format!(" {} - {} occurrences\n", truncated, count));
}
}
report
}
}

View File

@ -0,0 +1,610 @@
//! Server Performance Monitoring
//!
//! Monitors server performance metrics including CPU usage, memory consumption,
//! request throughput, and system resource utilization.
use super::super::{AnalyticsEvent, EventLevel, LogSource, generate_event_id};
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
/// System performance metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
/// CPU usage percentage
pub cpu_usage_percent: Option<f64>,
/// Memory usage in MB
pub memory_usage_mb: Option<f64>,
/// Total memory available in MB
pub total_memory_mb: Option<f64>,
/// Number of active connections
pub active_connections: Option<u64>,
/// Disk usage percentage
pub disk_usage_percent: Option<f64>,
/// Load average (1 min, 5 min, 15 min)
pub load_average: Option<(f64, f64, f64)>,
/// Network bytes in/out
pub network_io: Option<(u64, u64)>,
}
impl Default for SystemMetrics {
fn default() -> Self {
Self {
cpu_usage_percent: None,
memory_usage_mb: None,
total_memory_mb: None,
active_connections: None,
disk_usage_percent: None,
load_average: None,
network_io: None,
}
}
}
/// Request performance tracking
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestMetrics {
/// Requests per second
pub requests_per_second: f64,
/// Average response time in milliseconds
pub avg_response_time_ms: f64,
/// 95th percentile response time
pub p95_response_time_ms: f64,
/// 99th percentile response time
pub p99_response_time_ms: f64,
/// Error rate percentage
pub error_rate_percent: f64,
/// Timeout rate percentage
pub timeout_rate_percent: f64,
}
impl Default for RequestMetrics {
fn default() -> Self {
Self {
requests_per_second: 0.0,
avg_response_time_ms: 0.0,
p95_response_time_ms: 0.0,
p99_response_time_ms: 0.0,
error_rate_percent: 0.0,
timeout_rate_percent: 0.0,
}
}
}
/// Performance thresholds for alerting
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceThresholds {
pub max_cpu_percent: f64,
pub max_memory_percent: f64,
pub max_response_time_ms: f64,
pub max_error_rate_percent: f64,
pub min_requests_per_second: f64,
}
impl Default for PerformanceThresholds {
fn default() -> Self {
Self {
max_cpu_percent: 80.0,
max_memory_percent: 85.0,
max_response_time_ms: 500.0,
max_error_rate_percent: 5.0,
min_requests_per_second: 10.0,
}
}
}
/// Performance monitor
pub struct PerformanceMonitor {
thresholds: PerformanceThresholds,
system_metrics: Arc<Mutex<SystemMetrics>>,
request_metrics: Arc<Mutex<RequestMetrics>>,
monitoring_interval: u64,
}
impl PerformanceMonitor {
/// Create new performance monitor
pub fn new() -> Self {
Self {
thresholds: PerformanceThresholds::default(),
system_metrics: Arc::new(Mutex::new(SystemMetrics::default())),
request_metrics: Arc::new(Mutex::new(RequestMetrics::default())),
monitoring_interval: 30, // 30 seconds
}
}
/// Create with custom thresholds
pub fn with_thresholds(thresholds: PerformanceThresholds) -> Self {
Self {
thresholds,
system_metrics: Arc::new(Mutex::new(SystemMetrics::default())),
request_metrics: Arc::new(Mutex::new(RequestMetrics::default())),
monitoring_interval: 30,
}
}
/// Start performance monitoring
pub async fn start_monitoring(&self, sender: mpsc::UnboundedSender<AnalyticsEvent>) -> Result<()> {
tracing::info!("Starting performance monitoring...");
let system_metrics = Arc::clone(&self.system_metrics);
let request_metrics = Arc::clone(&self.request_metrics);
let thresholds = self.thresholds.clone();
let interval = self.monitoring_interval;
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(
tokio::time::Duration::from_secs(interval)
);
loop {
interval_timer.tick().await;
// Collect system metrics
if let Ok(sys_metrics) = Self::collect_system_metrics().await {
{
let mut metrics_guard = system_metrics.lock().unwrap();
*metrics_guard = sys_metrics.clone();
}
// Generate system metrics event
let event = Self::create_system_metrics_event(sys_metrics, &thresholds);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send system metrics event: {}", e);
break;
}
}
// Collect request metrics
if let Ok(req_metrics) = Self::collect_request_metrics().await {
{
let mut metrics_guard = request_metrics.lock().unwrap();
*metrics_guard = req_metrics.clone();
}
// Generate request metrics event
let event = Self::create_request_metrics_event(req_metrics, &thresholds);
if let Err(e) = sender.send(event) {
tracing::error!("Failed to send request metrics event: {}", e);
break;
}
}
}
});
tracing::info!("Performance monitoring started");
Ok(())
}
/// Collect system performance metrics
async fn collect_system_metrics() -> Result<SystemMetrics> {
let mut metrics = SystemMetrics::default();
// Try to collect CPU usage (cross-platform)
if let Ok(cpu_usage) = Self::get_cpu_usage().await {
metrics.cpu_usage_percent = Some(cpu_usage);
}
// Try to collect memory usage
if let Ok((used_memory, total_memory)) = Self::get_memory_usage().await {
metrics.memory_usage_mb = Some(used_memory);
metrics.total_memory_mb = Some(total_memory);
}
// Try to collect load average (Unix-like systems)
if let Ok(load_avg) = Self::get_load_average().await {
metrics.load_average = Some(load_avg);
}
// Try to collect network I/O
if let Ok(network_io) = Self::get_network_io().await {
metrics.network_io = Some(network_io);
}
// Simulate active connections (would integrate with actual server)
metrics.active_connections = Some(50 + rand::random::<u64>() % 100);
Ok(metrics)
}
/// Get CPU usage percentage
async fn get_cpu_usage() -> Result<f64> {
// Cross-platform CPU usage detection
#[cfg(target_os = "linux")]
{
// Use /proc/stat on Linux
if let Ok(output) = tokio::fs::read_to_string("/proc/stat").await {
if let Some(line) = output.lines().next() {
// Parse CPU line: cpu user nice system idle iowait irq softirq
let values: Vec<u64> = line
.split_whitespace()
.skip(1)
.filter_map(|s| s.parse().ok())
.collect();
if values.len() >= 4 {
let idle = values[3];
let total: u64 = values.iter().sum();
let cpu_usage = 100.0 - (idle as f64 / total as f64 * 100.0);
return Ok(cpu_usage);
}
}
}
}
#[cfg(target_os = "macos")]
{
// Use top command on macOS
if let Ok(output) = Command::new("top")
.args(&["-l", "1", "-n", "0"])
.output()
{
let output_str = String::from_utf8_lossy(&output.stdout);
for line in output_str.lines() {
if line.contains("CPU usage") {
// Parse: "CPU usage: 15.2% user, 8.1% sys, 76.7% idle"
if let Some(idle_part) = line.split("idle").next() {
if let Some(idle_str) = idle_part.split_whitespace().last() {
if let Ok(idle_percent) = idle_str.trim_end_matches('%').parse::<f64>() {
return Ok(100.0 - idle_percent);
}
}
}
}
}
}
}
// Fallback: simulate CPU usage
Ok(15.0 + rand::random::<f64>() * 30.0)
}
/// Get memory usage in MB
async fn get_memory_usage() -> Result<(f64, f64)> {
#[cfg(target_os = "linux")]
{
if let Ok(content) = tokio::fs::read_to_string("/proc/meminfo").await {
let mut total_kb = 0u64;
let mut available_kb = 0u64;
for line in content.lines() {
if line.starts_with("MemTotal:") {
total_kb = line.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
} else if line.starts_with("MemAvailable:") {
available_kb = line.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
}
}
if total_kb > 0 && available_kb > 0 {
let total_mb = total_kb as f64 / 1024.0;
let used_mb = (total_kb - available_kb) as f64 / 1024.0;
return Ok((used_mb, total_mb));
}
}
}
#[cfg(target_os = "macos")]
{
if let Ok(output) = Command::new("vm_stat").output() {
let output_str = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output to get memory information
// This is simplified - would need more robust parsing
return Ok((2048.0, 8192.0)); // Placeholder values
}
}
// Fallback: simulate memory usage
let total_mb = 8192.0;
let used_mb = 1500.0 + rand::random::<f64>() * 2000.0;
Ok((used_mb, total_mb))
}
/// Get load average (Unix-like systems)
async fn get_load_average() -> Result<(f64, f64, f64)> {
#[cfg(unix)]
{
if let Ok(content) = tokio::fs::read_to_string("/proc/loadavg").await {
let values: Vec<f64> = content
.split_whitespace()
.take(3)
.filter_map(|s| s.parse().ok())
.collect();
if values.len() == 3 {
return Ok((values[0], values[1], values[2]));
}
}
}
// Fallback: simulate load average
let base_load = 0.5 + rand::random::<f64>() * 1.0;
Ok((base_load, base_load * 0.8, base_load * 0.6))
}
/// Get network I/O statistics
async fn get_network_io() -> Result<(u64, u64)> {
// Simplified network I/O collection
// In a real implementation, this would parse /proc/net/dev on Linux
// or use system APIs on other platforms
let bytes_in = 1024 * 1024 + rand::random::<u64>() % (10 * 1024 * 1024);
let bytes_out = 512 * 1024 + rand::random::<u64>() % (5 * 1024 * 1024);
Ok((bytes_in, bytes_out))
}
/// Collect request performance metrics
async fn collect_request_metrics() -> Result<RequestMetrics> {
// In a real implementation, this would integrate with the actual server
// to collect request timing and error statistics
let mut metrics = RequestMetrics::default();
// Simulate request metrics based on system load
let base_rps = 25.0 + rand::random::<f64>() * 50.0;
let base_response_time = 50.0 + rand::random::<f64>() * 100.0;
metrics.requests_per_second = base_rps;
metrics.avg_response_time_ms = base_response_time;
metrics.p95_response_time_ms = base_response_time * 2.0;
metrics.p99_response_time_ms = base_response_time * 3.5;
metrics.error_rate_percent = rand::random::<f64>() * 2.0; // 0-2%
metrics.timeout_rate_percent = rand::random::<f64>() * 0.5; // 0-0.5%
Ok(metrics)
}
/// Create system metrics analytics event
fn create_system_metrics_event(metrics: SystemMetrics, thresholds: &PerformanceThresholds) -> AnalyticsEvent {
let mut metadata = HashMap::new();
if let Some(cpu) = metrics.cpu_usage_percent {
metadata.insert("cpu_usage_percent".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(cpu).unwrap()));
}
if let Some(memory) = metrics.memory_usage_mb {
metadata.insert("memory_usage_mb".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(memory).unwrap()));
}
if let Some(total_memory) = metrics.total_memory_mb {
metadata.insert("total_memory_mb".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(total_memory).unwrap()));
// Calculate memory usage percentage
if let Some(used_memory) = metrics.memory_usage_mb {
let memory_percent = (used_memory / total_memory) * 100.0;
metadata.insert("memory_usage_percent".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(memory_percent).unwrap()));
}
}
if let Some(connections) = metrics.active_connections {
metadata.insert("active_connections".to_string(),
serde_json::Value::Number(connections.into()));
}
if let Some((load1, load5, load15)) = metrics.load_average {
metadata.insert("load_1min".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(load1).unwrap()));
metadata.insert("load_5min".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(load5).unwrap()));
metadata.insert("load_15min".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(load15).unwrap()));
}
// Determine alert level based on thresholds
let level = if Self::exceeds_thresholds(&metrics, thresholds) {
EventLevel::Warn
} else {
EventLevel::Info
};
let message = Self::format_system_metrics_message(&metrics);
AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Server,
event_type: "system_performance".to_string(),
session_id: None,
path: None,
level,
message,
metadata,
duration_ms: None,
errors: Vec::new(),
}
}
/// Create request metrics analytics event
fn create_request_metrics_event(metrics: RequestMetrics, thresholds: &PerformanceThresholds) -> AnalyticsEvent {
let mut metadata = HashMap::new();
metadata.insert("requests_per_second".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.requests_per_second).unwrap()));
metadata.insert("avg_response_time_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.avg_response_time_ms).unwrap()));
metadata.insert("p95_response_time_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.p95_response_time_ms).unwrap()));
metadata.insert("p99_response_time_ms".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.p99_response_time_ms).unwrap()));
metadata.insert("error_rate_percent".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.error_rate_percent).unwrap()));
metadata.insert("timeout_rate_percent".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(metrics.timeout_rate_percent).unwrap()));
// Determine alert level
let level = if metrics.avg_response_time_ms > thresholds.max_response_time_ms ||
metrics.error_rate_percent > thresholds.max_error_rate_percent ||
metrics.requests_per_second < thresholds.min_requests_per_second {
EventLevel::Warn
} else {
EventLevel::Info
};
let message = format!(
"Request Performance: {:.1} RPS, {:.1}ms avg response, {:.2}% errors",
metrics.requests_per_second,
metrics.avg_response_time_ms,
metrics.error_rate_percent
);
AnalyticsEvent {
id: generate_event_id(),
timestamp: Utc::now(),
source: LogSource::Server,
event_type: "request_performance".to_string(),
session_id: None,
path: None,
level,
message,
metadata,
duration_ms: Some(metrics.avg_response_time_ms as u64),
errors: Vec::new(),
}
}
/// Check if system metrics exceed thresholds
fn exceeds_thresholds(metrics: &SystemMetrics, thresholds: &PerformanceThresholds) -> bool {
if let Some(cpu) = metrics.cpu_usage_percent {
if cpu > thresholds.max_cpu_percent {
return true;
}
}
if let (Some(used), Some(total)) = (metrics.memory_usage_mb, metrics.total_memory_mb) {
let memory_percent = (used / total) * 100.0;
if memory_percent > thresholds.max_memory_percent {
return true;
}
}
false
}
/// Format system metrics message
fn format_system_metrics_message(metrics: &SystemMetrics) -> String {
let mut parts = Vec::new();
if let Some(cpu) = metrics.cpu_usage_percent {
parts.push(format!("{:.1}% CPU", cpu));
}
if let (Some(used), Some(total)) = (metrics.memory_usage_mb, metrics.total_memory_mb) {
let percent = (used / total) * 100.0;
parts.push(format!("{:.1}% memory ({:.0}MB/{:.0}MB)", percent, used, total));
}
if let Some(connections) = metrics.active_connections {
parts.push(format!("{} connections", connections));
}
if let Some((load1, _, _)) = metrics.load_average {
parts.push(format!("{:.2} load", load1));
}
if parts.is_empty() {
"System performance metrics".to_string()
} else {
format!("System Performance: {}", parts.join(", "))
}
}
/// Get current system metrics
pub async fn get_system_metrics(&self) -> Result<SystemMetrics> {
Self::collect_system_metrics().await
}
/// Get current request metrics
pub async fn get_request_metrics(&self) -> Result<RequestMetrics> {
Self::collect_request_metrics().await
}
/// Generate performance report
pub async fn generate_performance_report(&self) -> Result<String> {
let sys_metrics = self.get_system_metrics().await?;
let req_metrics = self.get_request_metrics().await?;
let mut report = String::from("📊 Server Performance Report\n\n");
// System metrics section
report.push_str("🖥️ System Metrics:\n");
if let Some(cpu) = sys_metrics.cpu_usage_percent {
report.push_str(&format!(" CPU Usage: {:.1}%\n", cpu));
}
if let (Some(used), Some(total)) = (sys_metrics.memory_usage_mb, sys_metrics.total_memory_mb) {
let percent = (used / total) * 100.0;
report.push_str(&format!(" Memory Usage: {:.1}% ({:.0}MB / {:.0}MB)\n", percent, used, total));
}
if let Some(connections) = sys_metrics.active_connections {
report.push_str(&format!(" Active Connections: {}\n", connections));
}
if let Some((load1, load5, load15)) = sys_metrics.load_average {
report.push_str(&format!(" Load Average: {:.2}, {:.2}, {:.2}\n", load1, load5, load15));
}
// Request metrics section
report.push_str("\n🌐 Request Metrics:\n");
report.push_str(&format!(" Requests per Second: {:.1}\n", req_metrics.requests_per_second));
report.push_str(&format!(" Average Response Time: {:.1}ms\n", req_metrics.avg_response_time_ms));
report.push_str(&format!(" 95th Percentile: {:.1}ms\n", req_metrics.p95_response_time_ms));
report.push_str(&format!(" 99th Percentile: {:.1}ms\n", req_metrics.p99_response_time_ms));
report.push_str(&format!(" Error Rate: {:.2}%\n", req_metrics.error_rate_percent));
report.push_str(&format!(" Timeout Rate: {:.2}%\n", req_metrics.timeout_rate_percent));
// Health assessment
report.push_str("\n🏥 Health Assessment:\n");
let health_issues = self.assess_health(&sys_metrics, &req_metrics);
if health_issues.is_empty() {
report.push_str(" ✅ All systems nominal\n");
} else {
for issue in health_issues {
report.push_str(&format!(" ⚠️ {}\n", issue));
}
}
Ok(report)
}
/// Assess system health based on thresholds
fn assess_health(&self, sys_metrics: &SystemMetrics, req_metrics: &RequestMetrics) -> Vec<String> {
let mut issues = Vec::new();
if let Some(cpu) = sys_metrics.cpu_usage_percent {
if cpu > self.thresholds.max_cpu_percent {
issues.push(format!("High CPU usage: {:.1}%", cpu));
}
}
if let (Some(used), Some(total)) = (sys_metrics.memory_usage_mb, sys_metrics.total_memory_mb) {
let percent = (used / total) * 100.0;
if percent > self.thresholds.max_memory_percent {
issues.push(format!("High memory usage: {:.1}%", percent));
}
}
if req_metrics.avg_response_time_ms > self.thresholds.max_response_time_ms {
issues.push(format!("Slow response time: {:.1}ms", req_metrics.avg_response_time_ms));
}
if req_metrics.error_rate_percent > self.thresholds.max_error_rate_percent {
issues.push(format!("High error rate: {:.2}%", req_metrics.error_rate_percent));
}
if req_metrics.requests_per_second < self.thresholds.min_requests_per_second {
issues.push(format!("Low traffic: {:.1} RPS", req_metrics.requests_per_second));
}
issues
}
}

Some files were not shown because too many files have changed in this diff Show More