- Add complete dark mode system with theme context and toggle - Implement dark mode toggle component in navigation menu - Add client-side routing with SSR-safe signal handling - Fix language selector styling for better dark mode compatibility - Add documentation system with mdBook integration - Improve navigation menu with proper external/internal link handling - Add comprehensive project documentation and configuration - Enhance theme system with localStorage persistence - Fix arena panic issues during server-side rendering - Add proper TypeScript configuration and build optimizations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
27 KiB
27 KiB
Building Your First Rustelo Application
Welcome to building your firstRusteloapplication! This guide will walk you through creating a complete web application from scratch, covering both frontend and backend development with Rustelo's powerful features.
🎯 What You'll Build
By the end of this guide, you'll have created a Task Management Application with:
- 📝 Frontend: Interactive task list with Leptos components
- 🔧 Backend: RESTful API with Axum server
- 🗄️ Database: Data persistence with SQLx
- 🔐 Authentication: User login and registration
- 🎨 Styling: Modern UI with Tailwind CSS
- 📱 Responsive: Mobile-friendly design
🚀 Prerequisites
Before starting, ensure you have:
- ✅ Rustelo installed - Follow the Installation Guide
- ✅ Basic Rust knowledge - Variables, structs, functions
- ✅ Web development basics - HTML, CSS, HTTP concepts
- ✅ Development environment - Your favorite code editor
📋 Step 1: Project Setup
Create Your Project
# Clone the[Rustelo](/) template
git clone https://github.com/yourusername/rustelo.git my-task-app
cd my-task-app
# Run the installer
./scripts/install.sh
Configure Your Application
# Set up environment variables
cat > .env << EOF
# Database Configuration
DATABASE_URL=sqlite//:tasks.db
# Security Configuration
SESSION_SECRET=your-development-session-secret-here
JWT_SECRET=your-development-jwt-secret-here
# Application Configuration
RUSTELO_ENV=development
SERVER_HOST=127.0.0.1
SERVER_PORT=3030
LOG_LEVEL=debug
# Features Configuration
ENABLE_AUTH=true
ENABLE_CONTENT_DB=true
ENABLE_EMAIL=false
ENABLE_TLS=false
EOF
Build Your Configuration
# Build development configuration
./config/scripts/build-config.sh dev
# Verify setup
just verify-setup
🏗️ Step 2: Database Schema
Create Database Migration
# Create migration for tasks table
sqlx migrate add create_tasks_table
Edit the migration file in migrations/:
-- migrations/001_create_tasks_table.sql
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
priority INTEGER NOT NULL DEFAULT 1,
due_date TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Index for faster queries
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
Run Migration
# Apply database migrations
just db-migrate
# Verify database structure
just db-status
🔧 Step 3: Shared Types
Create shared data structures in shared/src/types.rs:
// shared/src/types.rs
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: Option<i32>,
pub title: String,
pub description: Option<String>,
pub completed: bool,
pub priority: TaskPriority,
pub due_date: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub user_id: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TaskPriority {
Low = 1,
Medium = 2,
High = 3,
Urgent = 4,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTaskRequest {
pub title: String,
pub description: Option<String>,
pub priority: TaskPriority,
pub due_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateTaskRequest {
pub title: Option<String>,
pub description: Option<String>,
pub completed: Option<bool>,
pub priority: Option<TaskPriority>,
pub due_date: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TasksResponse {
pub tasks: Vec<Task>,
pub total: usize,
pub page: usize,
pub per_page: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
}
Update shared/src/lib.rs:
// shared/src/lib.rs
pub mod types;
pub mod utils;
pub use types::*;
🖥️ Step 4: Backend API
Create Task Repository
Create server/src/repositories/task_repository.rs:
// server/src/repositories/task_repository.rs
use sqlx::{SqlitePool, Row};
use chrono::{DateTime, Utc};
use shared::{Task, CreateTaskRequest, UpdateTaskRequest, TaskPriority};
use anyhow::Result;
pub struct TaskRepository {
pool: SqlitePool,
}
impl TaskRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn create_task(&self, request: CreateTaskRequest, user_id: i32) -> Result<Task> {
let now = Utc::now();
let row = sqlx::query!(
r#"
INSERT INTO tasks (title, description, priority, due_date, created_at, updated_at, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, title, description, completed, priority, due_date, created_at, updated_at, user_id
"#,
request.title,
request.description,
request.priority as i32,
request.due_date.map(|d| d.to_rfc3339()),
now.to_rfc3339(),
now.to_rfc3339(),
user_id
)
.fetch_one(&self.pool)
.await?;
Ok(Task {
id: Some(row.id),
title: row.title,
description: row.description,
completed: row.completed,
priority: match row.priority {
1 => TaskPriority::Low,
2 => TaskPriority::Medium,
3 => TaskPriority::High,
4 => TaskPriority::Urgent,
_ => TaskPriority::Medium,
},
due_date: row.due_date.and_then(|d| DateTime::parse_from_rfc3339(&d).ok())
.map(|d| d.with_timezone(&Utc)),
created_at: DateTime::parse_from_rfc3339(&row.created_at).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.updated_at).unwrap().with_timezone(&Utc),
user_id: Some(row.user_id),
})
}
pub async fn get_tasks_by_user(&self, user_id: i32, page: usize, per_page: usize) -> Result<Vec<Task>> {
let offset = (page - 1) * per_page;
let rows = sqlx::query!(
r#"
SELECT id, title, description, completed, priority, due_date, created_at, updated_at, user_id
FROM tasks
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
"#,
user_id,
per_page as i32,
offset as i32
)
.fetch_all(&self.pool)
.await?;
let tasks = rows.into_iter().map(|row| Task {
id: Some(row.id),
title: row.title,
description: row.description,
completed: row.completed,
priority: match row.priority {
1 => TaskPriority::Low,
2 => TaskPriority::Medium,
3 => TaskPriority::High,
4 => TaskPriority::Urgent,
_ => TaskPriority::Medium,
},
due_date: row.due_date.and_then(|d| DateTime::parse_from_rfc3339(&d).ok())
.map(|d| d.with_timezone(&Utc)),
created_at: DateTime::parse_from_rfc3339(&row.created_at).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.updated_at).unwrap().with_timezone(&Utc),
user_id: Some(row.user_id),
}).collect();
Ok(tasks)
}
pub async fn update_task(&self, task_id: i32, request: UpdateTaskRequest, user_id: i32) -> Result<Task> {
// First check if task exists and belongs to user
let existing = sqlx::query!(
"SELECT id FROM tasks WHERE id = ? AND user_id = ?",
task_id,
user_id
)
.fetch_optional(&self.pool)
.await?;
if existing.is_none() {
return Err(anyhow::anyhow!("Task not found or access denied"));
}
let now = Utc::now();
// Build dynamic update query
let mut update_fields = Vec::new();
let mut params: Vec<&dyn sqlx::Encode<'_, sqlx::Sqlite>> = Vec::new();
if let Some(title) = &request.title {
update_fields.push("title = ?");
params.push(title);
}
if let Some(description) = &request.description {
update_fields.push("description = ?");
params.push(description);
}
if let Some(completed) = &request.completed {
update_fields.push("completed = ?");
params.push(completed);
}
if let Some(priority) = &request.priority {
update_fields.push("priority = ?");
let priority_val = *priority as i32;
params.push(&priority_val);
}
if let Some(due_date) = &request.due_date {
update_fields.push("due_date = ?");
let due_date_str = due_date.to_rfc3339();
params.push(&due_date_str);
}
if update_fields.is_empty() {
return Err(anyhow::anyhow!("No fields to update"));
}
update_fields.push("updated_at = ?");
let now_str = now.to_rfc3339();
params.push(&now_str);
let query = format!(
"UPDATE tasks SET {} WHERE id = ? AND user_id = ?",
update_fields.join(", ")
);
// Execute update
sqlx::query(&query)
.bind(task_id)
.bind(user_id)
.execute(&self.pool)
.await?;
// Return updated task
let row = sqlx::query!(
r#"
SELECT id, title, description, completed, priority, due_date, created_at, updated_at, user_id
FROM tasks
WHERE id = ?
"#,
task_id
)
.fetch_one(&self.pool)
.await?;
Ok(Task {
id: Some(row.id),
title: row.title,
description: row.description,
completed: row.completed,
priority: match row.priority {
1 => TaskPriority::Low,
2 => TaskPriority::Medium,
3 => TaskPriority::High,
4 => TaskPriority::Urgent,
_ => TaskPriority::Medium,
},
due_date: row.due_date.and_then(|d| DateTime::parse_from_rfc3339(&d).ok())
.map(|d| d.with_timezone(&Utc)),
created_at: DateTime::parse_from_rfc3339(&row.created_at).unwrap().with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.updated_at).unwrap().with_timezone(&Utc),
user_id: Some(row.user_id),
})
}
pub async fn delete_task(&self, task_id: i32, user_id: i32) -> Result<bool> {
let result = sqlx::query!(
"DELETE FROM tasks WHERE id = ? AND user_id = ?",
task_id,
user_id
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn count_tasks_by_user(&self, user_id: i32) -> Result<usize> {
let row = sqlx::query!(
"SELECT COUNT(*) as count FROM tasks WHERE user_id = ?",
user_id
)
.fetch_one(&self.pool)
.await?;
Ok(row.count as usize)
}
}
Create API Handlers
Create server/src/handlers/task_handlers.rs:
// server/src/handlers/task_handlers.rs
use axum::{
extract::{Path, Query, State},
response::Json,
http::StatusCode,
};
use serde::{Deserialize, Serialize};
use shared::{CreateTaskRequest, UpdateTaskRequest, TasksResponse, ApiResponse};
use crate::{
repositories::task_repository::TaskRepository,
auth::AuthUser,
AppState,
};
#[derive(Debug, Deserialize)]
pub struct TaskQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
}
pub async fn create_task(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Json(request): Json<CreateTaskRequest>,
) -> Result<Json<ApiResponse<shared::Task>>, StatusCode> {
let task_repo = TaskRepository::new(state.db_pool.clone());
match task_repo.create_task(request, user_id).await {
Ok(task) => Ok(Json(ApiResponse {
success: true,
data: Some(task),
message: Some("Task created successfully".to_string()),
})),
Err(e) => {
tracing::error!("Failed to create task: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn get_tasks(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Query(params): Query<TaskQuery>,
) -> Result<Json<ApiResponse<TasksResponse>>, StatusCode> {
let task_repo = TaskRepository::new(state.db_pool.clone());
let page = params.page.unwrap_or(1);
let per_page = params.per_page.unwrap_or(10).min(100); // Limit to 100 per page
match task_repo.get_tasks_by_user(user_id, page, per_page).await {
Ok(tasks) => {
let total = task_repo.count_tasks_by_user(user_id).await.unwrap_or(0);
let response = TasksResponse {
tasks,
total,
page,
per_page,
};
Ok(Json(ApiResponse {
success: true,
data: Some(response),
message: None,
}))
}
Err(e) => {
tracing::error!("Failed to get tasks: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
pub async fn update_task(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(task_id): Path<i32>,
Json(request): Json<UpdateTaskRequest>,
) -> Result<Json<ApiResponse<shared::Task>>, StatusCode> {
let task_repo = TaskRepository::new(state.db_pool.clone());
match task_repo.update_task(task_id, request, user_id).await {
Ok(task) => Ok(Json(ApiResponse {
success: true,
data: Some(task),
message: Some("Task updated successfully".to_string()),
})),
Err(e) => {
tracing::error!("Failed to update task: {}", e);
if e.to_string().contains("not found") {
Err(StatusCode::NOT_FOUND)
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
}
pub async fn delete_task(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(task_id): Path<i32>,
) -> Result<Json<ApiResponse<()>>, StatusCode> {
let task_repo = TaskRepository::new(state.db_pool.clone());
match task_repo.delete_task(task_id, user_id).await {
Ok(true) => Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("Task deleted successfully".to_string()),
})),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(e) => {
tracing::error!("Failed to delete task: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
Update API Routes
Update server/src/main.rs to include task routes:
// Add to server/src/main.rs
use axum::{
routing::{get, post, put, delete},
Router,
};
mod handlers;
mod repositories;
use handlers::task_handlers::*;
// In your app router setup:
pub fn api_routes() -> Router<AppState> {
Router::new()
.route("/api/tasks", post(create_task))
.route("/api/tasks", get(get_tasks))
.route("/api/tasks/:id", put(update_task))
.route("/api/tasks/:id", delete(delete_task))
// ... other routes
}
🎨 Step 5: Frontend Components
Create Task Components
Create client/src/components/tasks/task_item.rs:
// client/src/components/tasks/task_item.rs
use leptos::prelude::*;
use shared::{Task, TaskPriority};
use chrono::{DateTime, Utc};
#[component]
pub fn TaskItem(
task: Task,
#[prop(into)] on_toggle: Callback<i32>,
#[prop(into)] on_delete: Callback<i32>,
#[prop(into)] on_edit: Callback<Task>,
) -> impl IntoView {
let task_id = task.id.unwrap_or(0);
let is_completed = task.completed;
let is_overdue = task.due_date
.map(|due| due < Utc::now() && !task.completed)
.unwrap_or(false);
let priority_class = match task.priority {
TaskPriority::Low => "border-l-green-400",
TaskPriority::Medium => "border-l-yellow-400",
TaskPriority::High => "border-l-orange-400",
TaskPriority::Urgent => "border-l-red-400",
};
let priority_text = match task.priority {
TaskPriority::Low => "Low",
TaskPriority::Medium => "Medium",
TaskPriority::High => "High",
TaskPriority::Urgent => "Urgent",
};
view! {
<div class=format!(
"bg-white rounded-lg shadow-sm border-l-4 {} p-4 mb-3 transition-all duration-200 hover:shadow-md",
priority_class
)>
<div class="flex items-start justify-between">
<div class="flex items-start space-x-3 flex-1">
<input
type="checkbox"
checked=is_completed
class="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
on:change=move |_| on_toggle(task_id)
/>
<div class="flex-1">
<h3 class=format!(
"text-lg font-medium {} {}",
if is_completed { "line-through text-gray-500" } else { "text-gray-900" },
if is_overdue { "text-red-600" } else { "" }
)>
{task.title.clone()}
</h3>
{task.description.clone().map(|desc| view! {
<p class="mt-1 text-gray-600 text-sm">{desc}</p>
})}
<div class="flex items-center space-x-4 mt-2 text-sm text-gray-500">
<span class=format!(
"px-2 py-1 rounded-full text-xs font-medium {}",
match task.priority {
TaskPriority::Low => "bg-green-100 text-green-800",
TaskPriority::Medium => "bg-yellow-100 text-yellow-800",
TaskPriority::High => "bg-orange-100 text-orange-800",
TaskPriority::Urgent => "bg-red-100 text-red-800",
}
)>
{priority_text}
</span>
{task.due_date.map(|due| {
let due_str = due.format("%Y-%m-%d %H:%M").to_string();
view! {
<span class=format!(
"{}",
if is_overdue { "text-red-600 font-medium" } else { "" }
)>
"Due: " {due_str}
</span>
}
})}
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<button
class="text-gray-400 hover:text-blue-600 transition-colors"
on:click=move |_| on_edit(task.clone())
title="Edit task"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button
class="text-gray-400 hover:text-red-600 transition-colors"
on:click=move |_| on_delete(task_id)
title="Delete task"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
</div>
}
}
Create Task List Component
Create client/src/components/tasks/task_list.rs:
// client/src/components/tasks/task_list.rs
use leptos::prelude::*;
use shared::{Task, TasksResponse, ApiResponse};
use crate::components::tasks::task_item::TaskItem;
use crate::api::tasks::*;
#[component]
pub fn TaskList() -> impl IntoView {
let (tasks, set_tasks) = signal(Vec::<Task>::new());
let (loading, set_loading) = signal(false);
let (error, set_error) = signal(None::<String>);
let (current_page, set_current_page) = signal(1);
let (total_pages, set_total_pages) = signal(1);
// Load tasks on component mount
Effect::new(move |_| {
spawn_local(async move {
set_loading(true);
set_error(None);
match fetch_tasks(current_page.get_untracked(), 10).await {
Ok(ApiResponse { success: true, data: Some(response), .. }) => {
set_tasks(response.tasks);
set_total_pages((response.total + response.per_page - 1) / response.per_page);
}
Ok(ApiResponse { message: Some(msg), .. }) => {
set_error(Some(msg));
}
Err(e) => {
set_error(Some(format!("Failed to load tasks: {}", e)));
}
}
set_loading(false);
});
});
let handle_toggle = Callback::new(move |task_id: i32| {
spawn_local(async move {
// Find the task and toggle its completion status
let current_tasks = tasks.get_untracked();
if let Some(task) = current_tasks.iter().find(|t| t.id == Some(task_id)) {
let update_request = shared::UpdateTaskRequest {
completed: Some(!task.completed),
title: None,
description: None,
priority: None,
due_date: None,
};
match update_task(task_id, update_request).await {
Ok(ApiResponse { success: true, data: Some(updated_task), .. }) => {
let mut new_tasks = current_tasks;
if let Some(index) = new_tasks.iter().position(|t| t.id == Some(task_id)) {
new_tasks[index] = updated_task;
set_tasks(new_tasks);
}
}
Err(e) => {
set_error(Some(format!("Failed to update task: {}", e)));
}
}
}
});
});
let handle_delete = Callback::new(move |task_id: i32| {
spawn_local(async move {
match delete_task(task_id).await {
Ok(ApiResponse { success: true, .. }) => {
let current_tasks = tasks.get_untracked();
let new_tasks: Vec<Task> = current_tasks
.into_iter()
.filter(|t| t.id != Some(task_id))
.collect();
set_tasks(new_tasks);
}
Err(e) => {
set_error(Some(format!("Failed to delete task: {}", e)));
}
}
});
});
let handle_edit = Callback::new(move |task: Task| {
// For now, just log - you can implement an edit modal here
logging::log!("Edit task: {:?}", task);
});
view! {
<div class="max-w-4xl mx-auto p-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">"My Tasks"</h1>
<p class="text-gray-600">"Manage your tasks efficiently"</p>
</div>
{move || {
if loading.get() {
view! {
<div class="text-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">"Loading tasks..."</p>
</div>
}
} else if let Some(error_msg) = error.get() {
view! {
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">"Error"</h3>
<div class="mt-2 text-sm text-red-700">
<p>{error_msg}</p>
</div>
</div>
</div>
</div>
}
} else {
let task_list = tasks.get();
if task_list.is_empty() {
view! {
<div class="text-center py-12">
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2