feat: Implement blog post management including database schema, models, handlers, and UI.
This commit is contained in:
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod posts;
|
||||
|
||||
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
@@ -11,6 +12,8 @@ pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.merge(admin::router())
|
||||
// Auth routes under /__dungeon
|
||||
.merge(auth::router())
|
||||
// Posts routes under /__dungeon/posts
|
||||
.nest("/posts", posts::router())
|
||||
// Apply middleware to all /__dungeon routes
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
228
src/handlers/posts.rs
Normal file
228
src/handlers/posts.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Extension, Form, Path, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::models::{BlogPost, User};
|
||||
|
||||
use crate::utils::HtmlTemplate;
|
||||
use crate::AppState;
|
||||
use crate::error::AppError;
|
||||
use anyhow::Context;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "posts_list.html")]
|
||||
pub struct PostsListTemplate<'a> {
|
||||
pub current_user: &'a User,
|
||||
pub posts: Vec<BlogPost>,
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "post_edit.html")]
|
||||
pub struct PostEditTemplate<'a> {
|
||||
pub current_user: &'a User,
|
||||
pub post: Option<&'a BlogPost>,
|
||||
pub error: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/", get(list_posts))
|
||||
.route("/new", get(new_post).post(create_post))
|
||||
.route("/edit/{id}", get(edit_post).post(update_post))
|
||||
.route("/delete/{id}", post(delete_post))
|
||||
}
|
||||
|
||||
fn get_current_time() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
pub async fn list_posts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<User>,
|
||||
) -> Result<Response, AppError> {
|
||||
render_posts_list(&state, ¤t_user, None).await
|
||||
}
|
||||
|
||||
async fn render_posts_list(
|
||||
state: &AppState,
|
||||
current_user: &User,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response, AppError> {
|
||||
let posts: Vec<BlogPost> = sqlx::query_as("SELECT * FROM posts ORDER BY created_at DESC")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.context("Failed to fetch posts from database")?;
|
||||
|
||||
Ok(HtmlTemplate(PostsListTemplate {
|
||||
current_user,
|
||||
posts,
|
||||
error,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn new_post(
|
||||
Extension(current_user): Extension<User>,
|
||||
) -> Result<Response, AppError> {
|
||||
Ok(HtmlTemplate(PostEditTemplate {
|
||||
current_user: ¤t_user,
|
||||
post: None,
|
||||
error: None,
|
||||
})
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostPayload {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub visibility: String,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<User>,
|
||||
Form(payload): Form<PostPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
let now = get_current_time();
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let categories = payload.categories.unwrap_or_default();
|
||||
let password = if payload.visibility == "password_protected" {
|
||||
payload.password.filter(|p| !p.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let hashed_password = match password {
|
||||
Some(p) => Some(bcrypt::hash(p, bcrypt::DEFAULT_COST).context("Failed to hash password")?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO posts (author_id, title, content, tags, categories, visibility, password, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(current_user.id)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.content)
|
||||
.bind(&tags)
|
||||
.bind(&categories)
|
||||
.bind(&payload.visibility)
|
||||
.bind(&hashed_password)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.context("Failed to insert new post into database")?;
|
||||
|
||||
Ok(Redirect::to("/__dungeon/posts").into_response())
|
||||
}
|
||||
|
||||
pub async fn edit_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(current_user): Extension<User>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
let post: Option<BlogPost> = sqlx::query_as("SELECT * FROM posts WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.context("Failed to fetch post from database")?;
|
||||
|
||||
match post {
|
||||
Some(post) => Ok(HtmlTemplate(PostEditTemplate {
|
||||
current_user: ¤t_user,
|
||||
post: Some(&post),
|
||||
error: None,
|
||||
})
|
||||
.into_response()),
|
||||
None => render_posts_list(&state, ¤t_user, Some("Post not found")).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Extension(_current_user): Extension<User>,
|
||||
|
||||
Path(id): Path<i64>,
|
||||
Form(payload): Form<PostPayload>,
|
||||
) -> Result<Response, AppError> {
|
||||
let now = get_current_time();
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
let categories = payload.categories.unwrap_or_default();
|
||||
|
||||
let mut query_str = r#"
|
||||
UPDATE posts
|
||||
SET title = ?, content = ?, tags = ?, categories = ?, visibility = ?, updated_at = ?
|
||||
"#.to_string();
|
||||
|
||||
let password = if payload.visibility == "password_protected" {
|
||||
payload.password.filter(|p| !p.is_empty())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let hashed_password = match password {
|
||||
Some(p) => {
|
||||
query_str.push_str(", password = ?");
|
||||
Some(bcrypt::hash(p, bcrypt::DEFAULT_COST).context("Failed to hash password")?)
|
||||
},
|
||||
None => {
|
||||
if payload.visibility != "password_protected" {
|
||||
query_str.push_str(", password = NULL");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
query_str.push_str(" WHERE id = ?");
|
||||
|
||||
let mut query = sqlx::query(&query_str)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.content)
|
||||
.bind(&tags)
|
||||
.bind(&categories)
|
||||
.bind(&payload.visibility)
|
||||
.bind(now);
|
||||
|
||||
if let Some(h) = hashed_password {
|
||||
query = query.bind(h);
|
||||
}
|
||||
|
||||
query = query.bind(id);
|
||||
|
||||
query.execute(&state.db)
|
||||
.await
|
||||
.context("Failed to update post in database")?;
|
||||
|
||||
Ok(Redirect::to("/__dungeon/posts").into_response())
|
||||
}
|
||||
|
||||
pub async fn delete_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
sqlx::query("DELETE FROM posts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.context(format!("Failed to delete post with id {}", id))?;
|
||||
|
||||
Ok(Redirect::to("/__dungeon/posts").into_response())
|
||||
}
|
||||
22
src/main.rs
22
src/main.rs
@@ -90,6 +90,28 @@ async fn main() {
|
||||
.await
|
||||
.expect("Failed to create sessions table");
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
categories TEXT NOT NULL DEFAULT '',
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
password TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(author_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&db_pool)
|
||||
.await
|
||||
.expect("Failed to create posts table");
|
||||
|
||||
|
||||
let app_state = Arc::new(AppState { db: db_pool });
|
||||
|
||||
let app = Router::new()
|
||||
|
||||
@@ -47,3 +47,52 @@ pub struct Session {
|
||||
pub user_id: i64,
|
||||
pub expires_at: i64, // Unix timestamp in seconds
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum Visibility {
|
||||
Public,
|
||||
Private,
|
||||
PasswordProtected,
|
||||
}
|
||||
|
||||
impl ToString for Visibility {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
Visibility::Public => "public".to_string(),
|
||||
Visibility::Private => "private".to_string(),
|
||||
Visibility::PasswordProtected => "password_protected".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Visibility {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"public" => Ok(Visibility::Public),
|
||||
"private" => Ok(Visibility::Private),
|
||||
"password_protected" => Ok(Visibility::PasswordProtected),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct BlogPost {
|
||||
pub id: i64,
|
||||
pub author_id: i64,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: String,
|
||||
pub categories: String,
|
||||
pub visibility: String,
|
||||
pub password: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl BlogPost {
|
||||
pub fn visibility(&self) -> Visibility {
|
||||
self.visibility.parse().unwrap_or(Visibility::Public)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
</form>
|
||||
<br><br>
|
||||
|
||||
<b>Features</b><br>
|
||||
<ul>
|
||||
<li><a href="/__dungeon/posts">Manage Blog Posts</a></li>
|
||||
</ul>
|
||||
<br>
|
||||
|
||||
<b>System Users</b><br>
|
||||
{% if let Some(err) = error %}
|
||||
<font color="red"><b>{{ err }}</b></font><br>
|
||||
|
||||
65
templates/post_edit.html
Normal file
65
templates/post_edit.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{% if post.is_some() %}Edit Post{% else %}New Post{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>{% if post.is_some() %}Edit Post{% else %}New Post{% endif %}</h2>
|
||||
|
||||
<a href="/__dungeon/posts">Back to Posts</a>
|
||||
<br><br>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<font color="red"><b>{{ err }}</b></font><br><br>
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(p) = post %}
|
||||
<form method="POST" action="/__dungeon/posts/edit/{{ p.id }}">
|
||||
{% else %}
|
||||
<form method="POST" action="/__dungeon/posts/new">
|
||||
{% endif %}
|
||||
|
||||
<b>Title:</b><br>
|
||||
<input type="text" name="title" size="50" required
|
||||
value="{% if let Some(p) = post %}{{ p.title }}{% endif %}"><br><br>
|
||||
|
||||
<b>Content (Plain Text):</b><br>
|
||||
<textarea name="content" rows="15" cols="80"
|
||||
required>{% if let Some(p) = post %}{{ p.content }}{% endif %}</textarea><br><br>
|
||||
|
||||
<b>Tags (comma separated):</b><br>
|
||||
<input type="text" name="tags" size="50" value="{% if let Some(p) = post %}{{ p.tags }}{% endif %}"><br><br>
|
||||
|
||||
<b>Categories (comma separated):</b><br>
|
||||
<input type="text" name="categories" size="50"
|
||||
value="{% if let Some(p) = post %}{{ p.categories }}{% endif %}"><br><br>
|
||||
|
||||
<b>Visibility:</b><br>
|
||||
<select name="visibility">
|
||||
<option value="public" {% if let Some(p)=post %}{% if p.visibility=="public" %}selected{% endif %}{% endif
|
||||
%}>
|
||||
Public
|
||||
</option>
|
||||
<option value="private" {% if let Some(p)=post %}{% if p.visibility=="private" %}selected{% endif %}{% endif
|
||||
%}>
|
||||
Private (Requires Login)
|
||||
</option>
|
||||
<option value="password_protected" {% if let Some(p)=post %}{% if p.visibility=="password_protected"
|
||||
%}selected{% endif %}{% endif %}>
|
||||
Password Protected
|
||||
</option>
|
||||
</select><br><br>
|
||||
|
||||
<b>Password (only used if Visibility is Password Protected):</b><br>
|
||||
<input type="text" name="password" size="20" placeholder="Optional">
|
||||
{% if let Some(p) = post %}
|
||||
{% if p.visibility == "password_protected" %}
|
||||
<i>(Leave blank to keep existing password)</i>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<br><br>
|
||||
|
||||
<input type="submit" value="Save Post">
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
42
templates/posts_list.html
Normal file
42
templates/posts_list.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Manage Blog Posts{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Manage Blog Posts</h2>
|
||||
|
||||
<a href="/__dungeon">Back to Dashboard</a> | <a href="/__dungeon/posts/new">Create New Post</a>
|
||||
<br><br>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<font color="red"><b>{{ err }}</b></font><br><br>
|
||||
{% endif %}
|
||||
|
||||
{% if posts.is_empty() %}
|
||||
<i>No posts found.</i>
|
||||
{% else %}
|
||||
<table border="1" cellpadding="5" cellspacing="0">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Visibility</th>
|
||||
<th>Created At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td>{{ post.id }}</td>
|
||||
<td>{{ post.title }}</td>
|
||||
<td>{{ post.visibility }}</td>
|
||||
<td>{{ post.created_at }}</td>
|
||||
<td>
|
||||
<a href="/__dungeon/posts/edit/{{ post.id }}">Edit</a> |
|
||||
<form method="POST" action="/__dungeon/posts/delete/{{ post.id }}" style="display:inline;">
|
||||
<input type="submit" value="Delete">
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user