added docker, ci/cd, threads, posts, profiles
This commit is contained in:
parent
8f2cb6eda6
commit
e036304b78
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/target
|
||||||
|
.git
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.dockerignore
|
||||||
93
.gitea/workflows/ci.yml
Normal file
93
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Build & Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.toasterdragon.com
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.toasterdragon.com/butter/sarmentine
|
||||||
|
tags: |
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Rocky
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: docker
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
username: butter
|
||||||
|
key: ${{ secrets.DEPLOY_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/sarmentine
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --force-recreate
|
||||||
|
docker image prune -f
|
||||||
50
.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json
generated
Normal file
50
.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json
generated
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, body, author_id, thread_id, created_at, updated_at\n FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_id",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "thread_id",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98"
|
||||||
|
}
|
||||||
50
.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json
generated
Normal file
50
.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json
generated
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_id",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879"
|
||||||
|
}
|
||||||
50
.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json
generated
Normal file
50
.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json
generated
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads ORDER BY updated_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_id",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67"
|
||||||
|
}
|
||||||
50
.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json
generated
Normal file
50
.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json
generated
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE author_id = ? ORDER BY updated_at DESC",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "title",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "author_id",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Datetime"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872"
|
||||||
|
}
|
||||||
1475
Cargo.lock
generated
1475
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,3 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
cargo-generate-rpm = "0.20"
|
|
||||||
|
|||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
FROM rust:1.85 AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV SQLX_OFFLINE=true
|
||||||
|
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/sarmentine /usr/local/bin/sarmentine
|
||||||
|
COPY --from=builder /app/templates /app/templates
|
||||||
|
COPY --from=builder /app/static /app/static
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV SARMENTINE_BIND_ADDR=0.0.0.0:3000
|
||||||
|
ENV SARMENTINE_STATIC_DIR=/app/static
|
||||||
|
|
||||||
|
CMD ["sarmentine"]
|
||||||
8
migrations/0002_threads.sql
Normal file
8
migrations/0002_threads.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
8
migrations/0003_posts.sql
Normal file
8
migrations/0003_posts.sql
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@ -1,4 +1,4 @@
|
|||||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||||
|
|
||||||
pub async fn connect(database_url: &str) -> SqlitePool {
|
pub async fn connect(database_url: &str) -> SqlitePool {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
use argon2::{
|
use argon2::{
|
||||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
|
||||||
Argon2,
|
Argon2,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
@ -33,10 +33,14 @@ pub struct LoginTemplate {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render<T: Template>(t: T) -> Response {
|
pub fn render<T: Template>(t: T) -> Response {
|
||||||
match t.render() {
|
match t.render() {
|
||||||
Ok(html) => Html(html).into_response(),
|
Ok(html) => Html(html).into_response(),
|
||||||
Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(),
|
Err(_) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"render error",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod thread;
|
||||||
|
|||||||
301
src/handlers/thread.rs
Normal file
301
src/handlers/thread.rs
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, Path, State},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use crate::CurrentUser;
|
||||||
|
use crate::handlers::auth::{get_current_user, render};
|
||||||
|
use crate::models::{post::Post, thread::Thread, user::User};
|
||||||
|
|
||||||
|
// ~~ Templates
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "threads/list.html")]
|
||||||
|
pub struct ThreadListTemplate {
|
||||||
|
pub title: String,
|
||||||
|
pub current_user: Option<CurrentUser>,
|
||||||
|
pub threads: Vec<ThreadWithAuthor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "threads/new.html")]
|
||||||
|
pub struct NewThreadTemplate {
|
||||||
|
pub title: String,
|
||||||
|
pub current_user: Option<CurrentUser>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "threads/view.html")]
|
||||||
|
pub struct ThreadViewTemplate {
|
||||||
|
pub title: String,
|
||||||
|
pub current_user: Option<CurrentUser>,
|
||||||
|
pub thread: ThreadWithAuthor,
|
||||||
|
pub posts: Vec<PostWithAuthor>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "profile.html")]
|
||||||
|
pub struct ProfileTemplate {
|
||||||
|
pub title: String,
|
||||||
|
pub current_user: Option<CurrentUser>,
|
||||||
|
pub profile_user: User,
|
||||||
|
pub threads: Vec<ThreadWithAuthor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~~ Data wrappers
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ThreadWithAuthor {
|
||||||
|
pub thread: Thread,
|
||||||
|
pub author: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PostWithAuthor {
|
||||||
|
pub post: Post,
|
||||||
|
pub author: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~~ Form types
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewThreadForm {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewPostForm {
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~~ Handlers
|
||||||
|
|
||||||
|
pub async fn list_threads(State(pool): State<SqlitePool>, session: Session) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
let current_user = user.map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let threads = Thread::list_all(&pool).await.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut threads_with_authors = Vec::new();
|
||||||
|
for thread in threads {
|
||||||
|
let author = User::find_by_id(&pool, thread.author_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
if let Some(a) = author {
|
||||||
|
threads_with_authors.push(ThreadWithAuthor { thread, author: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ThreadListTemplate {
|
||||||
|
title: "threads".into(),
|
||||||
|
current_user,
|
||||||
|
threads: threads_with_authors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn new_thread_page(session: Session, State(pool): State<SqlitePool>) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
if user.is_none() {
|
||||||
|
return Redirect::to("/auth/login").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_user = user.map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(NewThreadTemplate {
|
||||||
|
title: "new thread".into(),
|
||||||
|
current_user,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_thread(
|
||||||
|
session: Session,
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
Form(form): Form<NewThreadForm>,
|
||||||
|
) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
let current_user = user.clone().map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Redirect::to("/auth/login").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if form.title.trim().is_empty() || form.body.trim().is_empty() {
|
||||||
|
return render(NewThreadTemplate {
|
||||||
|
title: "new thread".into(),
|
||||||
|
current_user,
|
||||||
|
error: Some("title and body cannot be empty".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let thread_id = Thread::insert(&pool, &form.title, &form.body, user.id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create thread");
|
||||||
|
|
||||||
|
Redirect::to(&format!("/threads/{}", thread_id)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn view_thread(
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
session: Session,
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
let current_user = user.map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return (axum::http::StatusCode::NOT_FOUND, "thread not found").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let author = match User::find_by_id(&pool, thread.author_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
Some(a) => a,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"author not found",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let posts = Post::by_thread(&pool, id).await.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut posts_with_authors = Vec::new();
|
||||||
|
for post in posts {
|
||||||
|
let author = User::find_by_id(&pool, post.author_id).await.ok().flatten();
|
||||||
|
if let Some(a) = author {
|
||||||
|
posts_with_authors.push(PostWithAuthor { post, author: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ThreadViewTemplate {
|
||||||
|
title: thread.title.clone(),
|
||||||
|
current_user,
|
||||||
|
thread: ThreadWithAuthor { thread, author },
|
||||||
|
posts: posts_with_authors,
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_post(
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
session: Session,
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
Form(form): Form<NewPostForm>,
|
||||||
|
) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
let current_user = user.clone().map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let user = match user {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Redirect::to("/auth/login").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Redirect::to("/threads").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if form.body.trim().is_empty() {
|
||||||
|
let author = User::find_by_id(&pool, thread.author_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| User {
|
||||||
|
id: 0,
|
||||||
|
username: "unknown".into(),
|
||||||
|
email: "".into(),
|
||||||
|
password: "".into(),
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
role: "member".into(),
|
||||||
|
created_at: chrono::NaiveDateTime::MIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
let posts = Post::by_thread(&pool, id).await.unwrap_or_default();
|
||||||
|
let mut posts_with_authors = Vec::new();
|
||||||
|
for post in posts {
|
||||||
|
let author = User::find_by_id(&pool, post.author_id).await.ok().flatten();
|
||||||
|
if let Some(a) = author {
|
||||||
|
posts_with_authors.push(PostWithAuthor { post, author: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(ThreadViewTemplate {
|
||||||
|
title: thread.title.clone(),
|
||||||
|
current_user,
|
||||||
|
thread: ThreadWithAuthor { thread, author },
|
||||||
|
posts: posts_with_authors,
|
||||||
|
error: Some("reply cannot be empty".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Post::insert(&pool, &form.body, user.id, thread.id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create post");
|
||||||
|
|
||||||
|
Redirect::to(&format!("/threads/{}", thread.id)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn view_profile(
|
||||||
|
Path(username): Path<String>,
|
||||||
|
session: Session,
|
||||||
|
State(pool): State<SqlitePool>,
|
||||||
|
) -> Response {
|
||||||
|
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||||
|
let current_user = user.map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
let profile_user = match User::find_by_username(&pool, &username)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
Some(u) => u,
|
||||||
|
None => return (axum::http::StatusCode::NOT_FOUND, "user not found").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let threads = Thread::by_author(&pool, profile_user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut threads_with_authors = Vec::new();
|
||||||
|
for thread in threads {
|
||||||
|
threads_with_authors.push(ThreadWithAuthor {
|
||||||
|
thread,
|
||||||
|
author: profile_user.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(ProfileTemplate {
|
||||||
|
title: format!("profile: {}", profile_user.username),
|
||||||
|
current_user,
|
||||||
|
profile_user,
|
||||||
|
threads: threads_with_authors,
|
||||||
|
})
|
||||||
|
}
|
||||||
49
src/main.rs
49
src/main.rs
@ -4,19 +4,22 @@ mod models;
|
|||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Router,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tower_sessions::{cookie::SameSite, SessionManagerLayer};
|
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
|
||||||
use tower_sessions_sqlx_store::SqliteStore;
|
use tower_sessions_sqlx_store::SqliteStore;
|
||||||
|
|
||||||
use handlers::auth::{
|
use handlers::auth::{
|
||||||
get_current_user, login_page, login_submit, logout, register_page, register_submit,
|
get_current_user, login_page, login_submit, logout, register_page, register_submit,
|
||||||
};
|
};
|
||||||
|
use handlers::thread::{
|
||||||
|
create_post, create_thread, list_threads, new_thread_page, view_profile, view_thread,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct CurrentUser {
|
pub struct CurrentUser {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@ -29,12 +32,11 @@ struct IndexTemplate {
|
|||||||
current_user: Option<CurrentUser>,
|
current_user: Option<CurrentUser>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index(
|
async fn index(State(pool): State<SqlitePool>, session: tower_sessions::Session) -> Response {
|
||||||
State(pool): State<SqlitePool>,
|
|
||||||
session: tower_sessions::Session,
|
|
||||||
) -> Response {
|
|
||||||
let user: Option<models::user::User> = get_current_user(&session, &pool).await;
|
let user: Option<models::user::User> = get_current_user(&session, &pool).await;
|
||||||
let current_user = user.map(|u| CurrentUser { username: u.username });
|
let current_user = user.map(|u| CurrentUser {
|
||||||
|
username: u.username,
|
||||||
|
});
|
||||||
|
|
||||||
let tmpl = IndexTemplate {
|
let tmpl = IndexTemplate {
|
||||||
title: "home".into(),
|
title: "home".into(),
|
||||||
@ -43,7 +45,11 @@ async fn index(
|
|||||||
|
|
||||||
match tmpl.render() {
|
match tmpl.render() {
|
||||||
Ok(html) => Html(html).into_response(),
|
Ok(html) => Html(html).into_response(),
|
||||||
Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(),
|
Err(_) => (
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"render error",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +63,10 @@ async fn main() {
|
|||||||
let pool: SqlitePool = db::connect(&database_url).await;
|
let pool: SqlitePool = db::connect(&database_url).await;
|
||||||
|
|
||||||
let session_store = SqliteStore::new(pool.clone());
|
let session_store = SqliteStore::new(pool.clone());
|
||||||
session_store.migrate().await.expect("session store migration failed");
|
session_store
|
||||||
|
.migrate()
|
||||||
|
.await
|
||||||
|
.expect("session store migration failed");
|
||||||
|
|
||||||
let session_secret = std::env::var("SARMENTINE_SESSION_SECRET")
|
let session_secret = std::env::var("SARMENTINE_SESSION_SECRET")
|
||||||
.unwrap_or_else(|_| "change-me-in-production-use-a-long-random-string!!".into());
|
.unwrap_or_else(|_| "change-me-in-production-use-a-long-random-string!!".into());
|
||||||
@ -65,15 +74,21 @@ async fn main() {
|
|||||||
let session_layer = SessionManagerLayer::new(session_store)
|
let session_layer = SessionManagerLayer::new(session_store)
|
||||||
.with_secure(false)
|
.with_secure(false)
|
||||||
.with_same_site(SameSite::Lax)
|
.with_same_site(SameSite::Lax)
|
||||||
.with_signed(tower_sessions::cookie::Key::from(
|
.with_signed(tower_sessions::cookie::Key::from(session_secret.as_bytes()));
|
||||||
session_secret.as_bytes(),
|
|
||||||
));
|
|
||||||
|
|
||||||
let static_dir = std::env::var("SARMENTINE_STATIC_DIR")
|
let static_dir = std::env::var("SARMENTINE_STATIC_DIR").unwrap_or_else(|_| "static".into());
|
||||||
.unwrap_or_else(|_| "static".into());
|
|
||||||
|
let bind_addr =
|
||||||
|
std::env::var("SARMENTINE_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
|
.route("/threads", get(list_threads))
|
||||||
|
.route("/threads/new", get(new_thread_page))
|
||||||
|
.route("/threads", post(create_thread))
|
||||||
|
.route("/threads/:id", get(view_thread))
|
||||||
|
.route("/threads/:id/posts", post(create_post))
|
||||||
|
.route("/profile/:username", get(view_profile))
|
||||||
.route("/auth/register", get(register_page))
|
.route("/auth/register", get(register_page))
|
||||||
.route("/auth/register", post(register_submit))
|
.route("/auth/register", post(register_submit))
|
||||||
.route("/auth/login", get(login_page))
|
.route("/auth/login", get(login_page))
|
||||||
@ -83,10 +98,8 @@ async fn main() {
|
|||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
.with_state(pool);
|
.with_state(pool);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
println!("listening on http://127.0.0.1:3000");
|
println!("listening on http://{}", bind_addr);
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
|
pub mod post;
|
||||||
|
pub mod thread;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
42
src/models/post.rs
Normal file
42
src/models/post.rs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Post {
|
||||||
|
pub id: i64,
|
||||||
|
pub body: String,
|
||||||
|
pub author_id: i64,
|
||||||
|
pub thread_id: i64,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Post {
|
||||||
|
pub async fn by_thread(pool: &SqlitePool, thread_id: i64) -> sqlx::Result<Vec<Post>> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Post,
|
||||||
|
r#"SELECT id, body, author_id, thread_id, created_at, updated_at
|
||||||
|
FROM posts WHERE thread_id = ? ORDER BY created_at ASC"#,
|
||||||
|
thread_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
body: &str,
|
||||||
|
author_id: i64,
|
||||||
|
thread_id: i64,
|
||||||
|
) -> sqlx::Result<i64> {
|
||||||
|
let result = sqlx::query("INSERT INTO posts (body, author_id, thread_id) VALUES (?, ?, ?)")
|
||||||
|
.bind(body)
|
||||||
|
.bind(author_id)
|
||||||
|
.bind(thread_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid())
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/models/thread.rs
Normal file
63
src/models/thread.rs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
use chrono::NaiveDateTime;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Thread {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub author_id: i64,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Thread {
|
||||||
|
pub async fn list_all(pool: &SqlitePool) -> sqlx::Result<Vec<Thread>> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Thread,
|
||||||
|
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||||
|
FROM threads ORDER BY updated_at DESC"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<Thread>> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Thread,
|
||||||
|
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||||
|
FROM threads WHERE id = ?"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn by_author(pool: &SqlitePool, author_id: i64) -> sqlx::Result<Vec<Thread>> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Thread,
|
||||||
|
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||||
|
FROM threads WHERE author_id = ? ORDER BY updated_at DESC"#,
|
||||||
|
author_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
author_id: i64,
|
||||||
|
) -> sqlx::Result<i64> {
|
||||||
|
let result = sqlx::query("INSERT INTO threads (title, body, author_id) VALUES (?, ?, ?)")
|
||||||
|
.bind(title)
|
||||||
|
.bind(body)
|
||||||
|
.bind(author_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.last_insert_rowid())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -55,19 +55,13 @@ impl User {
|
|||||||
email: &str,
|
email: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
) -> sqlx::Result<i64> {
|
) -> sqlx::Result<i64> {
|
||||||
let result = sqlx::query(
|
let result = sqlx::query("INSERT INTO users (username, email, password) VALUES (?, ?, ?)")
|
||||||
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)"
|
.bind(username)
|
||||||
)
|
.bind(email)
|
||||||
.bind(username)
|
.bind(password_hash)
|
||||||
.bind(email)
|
.execute(pool)
|
||||||
.bind(password_hash)
|
.await?;
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(result.last_insert_rowid())
|
Ok(result.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_admin(&self) -> bool {
|
|
||||||
self.role == "admin"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
templates/profile.html
Normal file
27
templates/profile.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">profile: {{ profile_user.username }}</div>
|
||||||
|
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-title">{{ profile_user.username }}</div>
|
||||||
|
{% if let Some(bio) = profile_user.bio %}
|
||||||
|
<div class="post-body">{{ bio }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="post-body" style="color:#888; font-size:11px;">joined {{ profile_user.created_at }} · role: {{ profile_user.role }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if !threads.is_empty() %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#888; margin:16px 0 8px; text-transform:uppercase;">threads by {{ profile_user.username }}</div>
|
||||||
|
{% for twa in threads %}
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-title"><a href="/threads/{{ twa.thread.id }}">{{ twa.thread.title }}</a></div>
|
||||||
|
<div class="post-body" style="color:#888; font-size:11px;">{{ twa.thread.created_at }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-body" style="color:#888;">no threads yet</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
23
templates/threads/list.html
Normal file
23
templates/threads/list.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">threads</div>
|
||||||
|
|
||||||
|
{% if let Some(user) = current_user %}
|
||||||
|
<a href="/threads/new" class="nav-link" style="margin-bottom:16px; display:inline-block;">+ new thread</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if threads.is_empty() %}
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-title">no threads yet</div>
|
||||||
|
<div class="post-body">be the first to start a thread!</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% for twa in threads %}
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-title"><a href="/threads/{{ twa.thread.id }}">{{ twa.thread.title }}</a></div>
|
||||||
|
<div class="post-body" style="color:#888; font-size:11px;">by <a href="/profile/{{ twa.author.username }}">{{ twa.author.username }}</a> · {{ twa.thread.created_at }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
23
templates/threads/new.html
Normal file
23
templates/threads/new.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">new thread</div>
|
||||||
|
|
||||||
|
{% if let Some(error) = error %}
|
||||||
|
<div class="post" style="border-color:#ff6b6b;">
|
||||||
|
<div class="post-body" style="color:#ff6b6b;">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/threads">
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">TITLE</label>
|
||||||
|
<input type="text" name="title" required maxlength="200" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;">
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">BODY</label>
|
||||||
|
<textarea name="body" required rows="10" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="margin-top:8px;">create thread</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
44
templates/threads/view.html
Normal file
44
templates/threads/view.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px;">
|
||||||
|
<a href="/threads" style="color:#888;">threads</a> / {{ thread.thread.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-title">{{ thread.thread.title }}</div>
|
||||||
|
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ thread.author.username }}">{{ thread.author.username }}</a> · {{ thread.thread.created_at }}</div>
|
||||||
|
<div class="post-body" style="white-space:pre-wrap;">{{ thread.thread.body }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if let Some(error) = error %}
|
||||||
|
<div class="post" style="border-color:#ff6b6b;">
|
||||||
|
<div class="post-body" style="color:#ff6b6b;">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if !posts.is_empty() %}
|
||||||
|
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#888; margin:16px 0 8px; text-transform:uppercase;">replies</div>
|
||||||
|
{% for pwa in posts %}
|
||||||
|
<div class="post">
|
||||||
|
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ pwa.author.username }}">{{ pwa.author.username }}</a> · {{ pwa.post.created_at }}</div>
|
||||||
|
<div class="post-body" style="white-space:pre-wrap;">{{ pwa.post.body }}</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if let Some(user) = current_user %}
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
<form method="post" action="/threads/{{ thread.thread.id }}/posts">
|
||||||
|
<div style="margin-bottom:8px;">
|
||||||
|
<textarea name="body" required rows="4" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">reply</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="margin-top:16px; color:#888; font-size:11px;">
|
||||||
|
<a href="/auth/login">login</a> to reply
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user