826 lines
27 KiB
Markdown
826 lines
27 KiB
Markdown
|
|
# Building Your First Rustelo Application
|
||
|
|
|
||
|
|
<div align="center">
|
||
|
|
<img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div align="center">
|
||
|
|
<img src="../logos/rustelo_dev-logo-h.svg" alt="RUSTELO" width="300" />
|
||
|
|
</div>
|
||
|
|
|
||
|
|
Welcome to building your first[Rustelo](/)application! 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](./installation.md)
|
||
|
|
- ✅ **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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Build development configuration
|
||
|
|
./config/scripts/build-config.sh dev
|
||
|
|
|
||
|
|
# Verify setup
|
||
|
|
just verify-setup
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🏗️ Step 2: Database Schema
|
||
|
|
|
||
|
|
### Create Database Migration
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# Create migration for tasks table
|
||
|
|
sqlx migrate add create_tasks_table
|
||
|
|
```
|
||
|
|
|
||
|
|
Edit the migration file in `migrations/`:
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- 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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// 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
|