feat: Implement blog post management including database schema, models, handlers, and UI.

This commit is contained in:
2026-03-03 16:34:38 +00:00
parent aee36fa70d
commit ef068f7dfa
7 changed files with 415 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
pub mod admin; pub mod admin;
pub mod auth; pub mod auth;
pub mod posts;
pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> { pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
Router::new() Router::new()
@@ -11,6 +12,8 @@ pub fn router(state: &Arc<AppState>) -> Router<Arc<AppState>> {
.merge(admin::router()) .merge(admin::router())
// Auth routes under /__dungeon // Auth routes under /__dungeon
.merge(auth::router()) .merge(auth::router())
// Posts routes under /__dungeon/posts
.nest("/posts", posts::router())
// Apply middleware to all /__dungeon routes // Apply middleware to all /__dungeon routes
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),

228
src/handlers/posts.rs Normal file
View 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, &current_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: &current_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: &current_user,
post: Some(&post),
error: None,
})
.into_response()),
None => render_posts_list(&state, &current_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())
}

View File

@@ -90,6 +90,28 @@ async fn main() {
.await .await
.expect("Failed to create sessions table"); .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_state = Arc::new(AppState { db: db_pool });
let app = Router::new() let app = Router::new()

View File

@@ -47,3 +47,52 @@ pub struct Session {
pub user_id: i64, pub user_id: i64,
pub expires_at: i64, // Unix timestamp in seconds 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)
}
}

View File

@@ -9,6 +9,12 @@
</form> </form>
<br><br> <br><br>
<b>Features</b><br>
<ul>
<li><a href="/__dungeon/posts">Manage Blog Posts</a></li>
</ul>
<br>
<b>System Users</b><br> <b>System Users</b><br>
{% if let Some(err) = error %} {% if let Some(err) = error %}
<font color="red"><b>{{ err }}</b></font><br> <font color="red"><b>{{ err }}</b></font><br>

65
templates/post_edit.html Normal file
View 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
View 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 %}