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
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
232
Cargo.toml
@ -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"
|
||||
|
||||
339
DOCUMENTATION.md
@ -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/
|
||||
568
INSTALL.md
@ -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! 🚀
|
||||
442
QUICK_START.md
@ -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! 🦀✨
|
||||
77
README.md
@ -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
|
||||
|
||||
```
|
||||
|
||||
8
TODO.md
@ -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)?
|
||||
32
assets/example-init-config.toml
Normal 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
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
BIN
assets/logos/src/github-img.psd
Normal file
66
assets/rustelo-init-config-example.toml
Normal 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"
|
||||
@ -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"]
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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};
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
/>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
#[allow(non_snake_case)]
|
||||
pub mod AdminLayout;
|
||||
pub use AdminLayout::*;
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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 /> });
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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(())
|
||||
}
|
||||
@ -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>,
|
||||
}
|
||||
@ -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::*;
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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::*;
|
||||
@ -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()}</>
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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>) {}
|
||||
@ -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()],
|
||||
});
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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).*
|
||||
@ -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
|
||||
248
content/en.ftl
@ -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
|
||||
248
content/es.ftl
@ -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
|
||||
@ -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"
|
||||
@ -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:
|
||||
|
||||

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

|
||||
|
||||
## 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
|
||||
85
content/public/.gitignore
vendored
@ -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
|
||||
@ -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.*
|
||||
@ -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
@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
playwright-report
|
||||
test-results
|
||||
167
end2end/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
// },
|
||||
});
|
||||
@ -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!");
|
||||
});
|
||||
@ -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. */
|
||||
}
|
||||
}
|
||||
67
features/analytics/feature.toml
Normal 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"
|
||||
411
features/analytics/src/browser/console_collector.rs
Normal 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>,
|
||||
}
|
||||
345
features/analytics/src/browser/error_detector.rs
Normal 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,
|
||||
}
|
||||
444
features/analytics/src/browser/interaction_tracker.rs
Normal 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,
|
||||
}
|
||||
585
features/analytics/src/browser/mod.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
955
features/analytics/src/cli.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
475
features/analytics/src/collector.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
344
features/analytics/src/mod.rs
Normal 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())
|
||||
})
|
||||
}
|
||||
300
features/analytics/src/navigation/cache_monitor.rs
Normal 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 => "🔴",
|
||||
}
|
||||
}
|
||||
}
|
||||
325
features/analytics/src/navigation/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
412
features/analytics/src/navigation/route_analytics.rs
Normal 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 => "🔒",
|
||||
}
|
||||
}
|
||||
}
|
||||
84
features/analytics/src/navigation/tracker_integration.rs
Normal 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
|
||||
}
|
||||
}
|
||||
525
features/analytics/src/search.rs
Normal 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, ®ex_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,
|
||||
}
|
||||
350
features/analytics/src/server/mod.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
308
features/analytics/src/server/panic_detector.rs
Normal 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
|
||||
}
|
||||
}
|
||||
610
features/analytics/src/server/performance_monitor.rs
Normal 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
|
||||
}
|
||||
}
|
||||