feat: init posts app 044d0b97
Steve · 2026-04-07 18:01 25 file(s) · +2529 −1
Cargo.lock +23 −1
1267 1267
1268 1268
[[package]]
1269 1269
name = "feeds"
1270 -
version = "0.1.2"
1270 +
version = "0.1.3"
1271 1271
dependencies = [
1272 1272
 "andromeda-auth",
1273 1273
 "askama 0.13.1",
2970 2970
version = "1.13.1"
2971 2971
source = "registry+https://github.com/rust-lang/crates.io-index"
2972 2972
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
2973 +
2974 +
[[package]]
2975 +
name = "posts"
2976 +
version = "0.1.0"
2977 +
dependencies = [
2978 +
 "andromeda-auth",
2979 +
 "askama 0.15.6",
2980 +
 "askama_web",
2981 +
 "axum",
2982 +
 "dotenvy",
2983 +
 "nanoid",
2984 +
 "pulldown-cmark",
2985 +
 "rand 0.8.5",
2986 +
 "rusqlite",
2987 +
 "rust-embed",
2988 +
 "serde",
2989 +
 "serde_json",
2990 +
 "subtle",
2991 +
 "tokio",
2992 +
 "tracing",
2993 +
 "tracing-subscriber",
2994 +
]
2973 2995
2974 2996
[[package]]
2975 2997
name = "potential_utf"
Cargo.toml +1 −0
7 7
    "apps/og",
8 8
    "apps/shrink",
9 9
    "apps/cellar",
10 +
    "apps/posts",
10 11
    "crates/auth",
11 12
]
12 13
resolver = "3"
apps/posts/.env.example (added) +5 −0
1 +
POSTS_PASSWORD=changeme
2 +
POSTS_DB_PATH=posts.sqlite
3 +
COOKIE_SECURE=false
4 +
HOST=127.0.0.1
5 +
PORT=3000
apps/posts/Cargo.toml (added) +26 −0
1 +
[package]
2 +
name = "posts"
3 +
version = "0.1.0"
4 +
edition = "2024"
5 +
description = "CMS blog with admin interface"
6 +
license = "MIT"
7 +
repository = "https://github.com/stevedylandev/andromeda"
8 +
homepage = "https://github.com/stevedylandev/andromeda"
9 +
10 +
[dependencies]
11 +
axum = { workspace = true }
12 +
tokio = { workspace = true }
13 +
serde = { workspace = true }
14 +
serde_json = { workspace = true }
15 +
rusqlite = { workspace = true }
16 +
nanoid = { workspace = true }
17 +
rust-embed = { workspace = true }
18 +
dotenvy = { workspace = true }
19 +
subtle = { workspace = true }
20 +
rand = { workspace = true }
21 +
tracing = { workspace = true }
22 +
tracing-subscriber = { workspace = true }
23 +
andromeda-auth = { workspace = true }
24 +
askama = "0.15"
25 +
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 +
pulldown-cmark = "0.12"
apps/posts/Dockerfile (added) +40 −0
1 +
# Build from repo root: docker build -t posts -f apps/posts/Dockerfile .
2 +
FROM rust:1-slim-bookworm AS builder
3 +
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
4 +
WORKDIR /app
5 +
6 +
# Copy workspace manifests
7 +
COPY Cargo.toml Cargo.lock .
8 +
COPY crates/auth/Cargo.toml crates/auth/
9 +
COPY apps/sipp/Cargo.toml apps/sipp/
10 +
COPY apps/feeds/Cargo.toml apps/feeds/
11 +
COPY apps/parcels/Cargo.toml apps/parcels/
12 +
COPY apps/jotts/Cargo.toml apps/jotts/
13 +
COPY apps/og/Cargo.toml apps/og/
14 +
COPY apps/shrink/Cargo.toml apps/shrink/
15 +
COPY apps/cellar/Cargo.toml apps/cellar/
16 +
COPY apps/posts/Cargo.toml apps/posts/
17 +
18 +
# Create stubs for dependency caching
19 +
RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \
20 +
    && for app in sipp feeds parcels jotts og shrink cellar posts; do \
21 +
         mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \
22 +
       done
23 +
24 +
RUN cargo build --release -p posts
25 +
26 +
# Copy real source
27 +
COPY crates/auth/src crates/auth/src
28 +
COPY apps/posts/src apps/posts/src
29 +
COPY apps/posts/static apps/posts/static
30 +
COPY apps/posts/templates apps/posts/templates
31 +
32 +
RUN touch apps/posts/src/*.rs crates/auth/src/*.rs && cargo build --release -p posts
33 +
34 +
FROM debian:bookworm-slim
35 +
COPY --from=builder /app/target/release/posts /usr/local/bin/posts
36 +
WORKDIR /data
37 +
EXPOSE 3000
38 +
ENV HOST=0.0.0.0
39 +
ENV PORT=3000
40 +
CMD ["posts"]
apps/posts/docker-compose.yml (added) +19 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/posts/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme}
10 +
      - POSTS_DB_PATH=/data/posts.sqlite
11 +
      - COOKIE_SECURE=false
12 +
      - HOST=0.0.0.0
13 +
      - PORT=${PORT:-3000}
14 +
    volumes:
15 +
      - posts-data:/data
16 +
    restart: unless-stopped
17 +
18 +
volumes:
19 +
  posts-data:
apps/posts/src/auth.rs (added) +75 −0
1 +
use axum::{
2 +
    extract::FromRequestParts,
3 +
    http::request::Parts,
4 +
    response::{IntoResponse, Redirect, Response},
5 +
};
6 +
use std::sync::Arc;
7 +
8 +
use crate::db;
9 +
use crate::server::AppState;
10 +
11 +
pub use andromeda_auth::{
12 +
    build_session_cookie, clear_session_cookie, generate_session_token, verify_password,
13 +
};
14 +
15 +
pub struct AuthSession;
16 +
17 +
impl FromRequestParts<Arc<AppState>> for AuthSession {
18 +
    type Rejection = Response;
19 +
20 +
    async fn from_request_parts(
21 +
        parts: &mut Parts,
22 +
        state: &Arc<AppState>,
23 +
    ) -> Result<Self, Self::Rejection> {
24 +
        let token = andromeda_auth::extract_session_cookie(&parts.headers);
25 +
        if let Some(token) = token {
26 +
            if is_valid_session(state, &token) {
27 +
                return Ok(AuthSession);
28 +
            }
29 +
        }
30 +
        Err(Redirect::to("/admin/login").into_response())
31 +
    }
32 +
}
33 +
34 +
fn is_valid_session(state: &AppState, token: &str) -> bool {
35 +
    match db::get_session_expiry(&state.db, token) {
36 +
        Ok(Some(expires_at)) => {
37 +
            let now = chrono_now();
38 +
            expires_at > now
39 +
        }
40 +
        _ => false,
41 +
    }
42 +
}
43 +
44 +
fn chrono_now() -> String {
45 +
    use std::time::{SystemTime, UNIX_EPOCH};
46 +
    let secs = SystemTime::now()
47 +
        .duration_since(UNIX_EPOCH)
48 +
        .unwrap()
49 +
        .as_secs();
50 +
    let days_since_epoch = secs / 86400;
51 +
    let time_of_day = secs % 86400;
52 +
    let hours = time_of_day / 3600;
53 +
    let minutes = (time_of_day % 3600) / 60;
54 +
    let seconds = time_of_day % 60;
55 +
56 +
    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
57 +
    format!(
58 +
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
59 +
        year, month, day, hours, minutes, seconds
60 +
    )
61 +
}
62 +
63 +
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
64 +
    days += 719468;
65 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
66 +
    let doe = (days - era * 146097) as u32;
67 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
68 +
    let y = yoe as i64 + era * 400;
69 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
70 +
    let mp = (5 * doy + 2) / 153;
71 +
    let d = doy - (153 * mp + 2) / 5 + 1;
72 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
73 +
    let y = if m <= 2 { y + 1 } else { y };
74 +
    (y, m as i64, d as i64)
75 +
}
apps/posts/src/db.rs (added) +507 −0
1 +
use nanoid::nanoid;
2 +
use rusqlite::{Connection, params};
3 +
use serde::{Deserialize, Serialize};
4 +
use std::fmt;
5 +
use std::sync::{Arc, Mutex};
6 +
7 +
pub type Db = Arc<Mutex<Connection>>;
8 +
9 +
#[derive(Debug)]
10 +
pub enum DbError {
11 +
    Sqlite(rusqlite::Error),
12 +
    LockPoisoned,
13 +
}
14 +
15 +
impl fmt::Display for DbError {
16 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17 +
        match self {
18 +
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
19 +
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
20 +
        }
21 +
    }
22 +
}
23 +
24 +
impl std::error::Error for DbError {}
25 +
26 +
impl From<rusqlite::Error> for DbError {
27 +
    fn from(e: rusqlite::Error) -> Self {
28 +
        DbError::Sqlite(e)
29 +
    }
30 +
}
31 +
32 +
#[derive(Debug, Serialize, Deserialize, Clone)]
33 +
pub struct Post {
34 +
    pub id: i64,
35 +
    pub short_id: String,
36 +
    pub title: String,
37 +
    pub slug: String,
38 +
    pub alias: Option<String>,
39 +
    pub canonical_url: Option<String>,
40 +
    pub published_date: Option<String>,
41 +
    pub meta_description: Option<String>,
42 +
    pub meta_image: Option<String>,
43 +
    pub lang: String,
44 +
    pub tags: Option<String>,
45 +
    pub content: String,
46 +
    pub status: String,
47 +
    pub created_at: String,
48 +
    pub updated_at: String,
49 +
}
50 +
51 +
#[derive(Debug, Serialize, Deserialize, Clone)]
52 +
pub struct Page {
53 +
    pub id: i64,
54 +
    pub short_id: String,
55 +
    pub title: String,
56 +
    pub slug: String,
57 +
    pub content: String,
58 +
    pub is_published: bool,
59 +
    pub nav_order: i64,
60 +
    pub created_at: String,
61 +
    pub updated_at: String,
62 +
}
63 +
64 +
pub fn init_db() -> Db {
65 +
    let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string());
66 +
    let conn = Connection::open(&path).expect("Failed to open database");
67 +
68 +
    conn.execute_batch(
69 +
        "CREATE TABLE IF NOT EXISTS posts (
70 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
71 +
            short_id        TEXT NOT NULL UNIQUE,
72 +
            title           TEXT NOT NULL,
73 +
            slug            TEXT NOT NULL UNIQUE,
74 +
            alias           TEXT,
75 +
            canonical_url   TEXT,
76 +
            published_date  TEXT,
77 +
            meta_description TEXT,
78 +
            meta_image      TEXT,
79 +
            lang            TEXT NOT NULL DEFAULT 'en',
80 +
            tags            TEXT,
81 +
            content         TEXT NOT NULL,
82 +
            status          TEXT NOT NULL DEFAULT 'draft',
83 +
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
84 +
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
85 +
        );
86 +
87 +
        CREATE TABLE IF NOT EXISTS pages (
88 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
89 +
            short_id        TEXT NOT NULL UNIQUE,
90 +
            title           TEXT NOT NULL,
91 +
            slug            TEXT NOT NULL UNIQUE,
92 +
            content         TEXT NOT NULL,
93 +
            is_published    INTEGER NOT NULL DEFAULT 0,
94 +
            nav_order       INTEGER NOT NULL DEFAULT 0,
95 +
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
96 +
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
97 +
        );
98 +
99 +
        CREATE TABLE IF NOT EXISTS sessions (
100 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
101 +
            token           TEXT NOT NULL UNIQUE,
102 +
            expires_at      TEXT NOT NULL
103 +
        );
104 +
105 +
        CREATE TABLE IF NOT EXISTS settings (
106 +
            key   TEXT PRIMARY KEY,
107 +
            value TEXT NOT NULL
108 +
        );"
109 +
    )
110 +
    .expect("Failed to create tables");
111 +
112 +
    // Seed default settings
113 +
    conn.execute(
114 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_title', 'My Blog')",
115 +
        [],
116 +
    )
117 +
    .ok();
118 +
    conn.execute(
119 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_description', 'A simple blog')",
120 +
        [],
121 +
    )
122 +
    .ok();
123 +
    conn.execute(
124 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('intro_content', '')",
125 +
        [],
126 +
    )
127 +
    .ok();
128 +
129 +
    Arc::new(Mutex::new(conn))
130 +
}
131 +
132 +
// --- Post CRUD ---
133 +
134 +
fn row_to_post(row: &rusqlite::Row) -> rusqlite::Result<Post> {
135 +
    Ok(Post {
136 +
        id: row.get(0)?,
137 +
        short_id: row.get(1)?,
138 +
        title: row.get(2)?,
139 +
        slug: row.get(3)?,
140 +
        alias: row.get(4)?,
141 +
        canonical_url: row.get(5)?,
142 +
        published_date: row.get(6)?,
143 +
        meta_description: row.get(7)?,
144 +
        meta_image: row.get(8)?,
145 +
        lang: row.get(9)?,
146 +
        tags: row.get(10)?,
147 +
        content: row.get(11)?,
148 +
        status: row.get(12)?,
149 +
        created_at: row.get(13)?,
150 +
        updated_at: row.get(14)?,
151 +
    })
152 +
}
153 +
154 +
const POST_COLS: &str = "id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at";
155 +
156 +
pub fn create_post(
157 +
    db: &Db,
158 +
    title: &str,
159 +
    slug: &str,
160 +
    content: &str,
161 +
    status: &str,
162 +
    alias: Option<&str>,
163 +
    canonical_url: Option<&str>,
164 +
    published_date: Option<&str>,
165 +
    meta_description: Option<&str>,
166 +
    meta_image: Option<&str>,
167 +
    lang: &str,
168 +
    tags: Option<&str>,
169 +
) -> Result<Post, DbError> {
170 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
171 +
    let short_id = nanoid!(10);
172 +
    conn.execute(
173 +
        "INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
174 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
175 +
        params![short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags],
176 +
    )?;
177 +
    let id = conn.last_insert_rowid();
178 +
    let post = conn.query_row(
179 +
        &format!("SELECT {} FROM posts WHERE id = ?1", POST_COLS),
180 +
        params![id],
181 +
        row_to_post,
182 +
    )?;
183 +
    Ok(post)
184 +
}
185 +
186 +
pub fn get_post_by_short_id(db: &Db, short_id: &str) -> Result<Option<Post>, DbError> {
187 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
188 +
    match conn.query_row(
189 +
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
190 +
        params![short_id],
191 +
        row_to_post,
192 +
    ) {
193 +
        Ok(post) => Ok(Some(post)),
194 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
195 +
        Err(e) => Err(DbError::Sqlite(e)),
196 +
    }
197 +
}
198 +
199 +
pub fn get_post_by_slug(db: &Db, slug: &str) -> Result<Option<Post>, DbError> {
200 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
201 +
    match conn.query_row(
202 +
        &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS),
203 +
        params![slug],
204 +
        row_to_post,
205 +
    ) {
206 +
        Ok(post) => Ok(Some(post)),
207 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
208 +
        Err(e) => Err(DbError::Sqlite(e)),
209 +
    }
210 +
}
211 +
212 +
pub fn get_all_posts(db: &Db) -> Result<Vec<Post>, DbError> {
213 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
214 +
    let mut stmt = conn.prepare(
215 +
        &format!("SELECT {} FROM posts ORDER BY id DESC", POST_COLS),
216 +
    )?;
217 +
    let posts = stmt
218 +
        .query_map([], row_to_post)?
219 +
        .collect::<Result<Vec<_>, _>>()?;
220 +
    Ok(posts)
221 +
}
222 +
223 +
pub fn get_published_posts(db: &Db) -> Result<Vec<Post>, DbError> {
224 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
225 +
    let mut stmt = conn.prepare(
226 +
        &format!("SELECT {} FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC", POST_COLS),
227 +
    )?;
228 +
    let posts = stmt
229 +
        .query_map([], row_to_post)?
230 +
        .collect::<Result<Vec<_>, _>>()?;
231 +
    Ok(posts)
232 +
}
233 +
234 +
pub fn update_post(
235 +
    db: &Db,
236 +
    short_id: &str,
237 +
    title: &str,
238 +
    slug: &str,
239 +
    content: &str,
240 +
    alias: Option<&str>,
241 +
    canonical_url: Option<&str>,
242 +
    published_date: Option<&str>,
243 +
    meta_description: Option<&str>,
244 +
    meta_image: Option<&str>,
245 +
    lang: &str,
246 +
    tags: Option<&str>,
247 +
) -> Result<Option<Post>, DbError> {
248 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
249 +
    let rows = conn.execute(
250 +
        "UPDATE posts SET title = ?1, slug = ?2, content = ?3, alias = ?4, canonical_url = ?5,
251 +
         published_date = ?6, meta_description = ?7, meta_image = ?8, lang = ?9, tags = ?10,
252 +
         updated_at = datetime('now') WHERE short_id = ?11",
253 +
        params![title, slug, content, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, short_id],
254 +
    )?;
255 +
    if rows == 0 {
256 +
        return Ok(None);
257 +
    }
258 +
    match conn.query_row(
259 +
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
260 +
        params![short_id],
261 +
        row_to_post,
262 +
    ) {
263 +
        Ok(post) => Ok(Some(post)),
264 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
265 +
        Err(e) => Err(DbError::Sqlite(e)),
266 +
    }
267 +
}
268 +
269 +
pub fn delete_post(db: &Db, short_id: &str) -> Result<bool, DbError> {
270 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
271 +
    let rows = conn.execute("DELETE FROM posts WHERE short_id = ?1", params![short_id])?;
272 +
    Ok(rows > 0)
273 +
}
274 +
275 +
pub fn toggle_post_status(db: &Db, short_id: &str) -> Result<Option<String>, DbError> {
276 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
277 +
    let current: String = match conn.query_row(
278 +
        "SELECT status FROM posts WHERE short_id = ?1",
279 +
        params![short_id],
280 +
        |row| row.get(0),
281 +
    ) {
282 +
        Ok(s) => s,
283 +
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
284 +
        Err(e) => return Err(DbError::Sqlite(e)),
285 +
    };
286 +
    let new_status = if current == "published" { "draft" } else { "published" };
287 +
    if new_status == "published" {
288 +
        conn.execute(
289 +
            "UPDATE posts SET status = ?1, published_date = COALESCE(published_date, datetime('now')), updated_at = datetime('now') WHERE short_id = ?2",
290 +
            params![new_status, short_id],
291 +
        )?;
292 +
    } else {
293 +
        conn.execute(
294 +
            "UPDATE posts SET status = ?1, updated_at = datetime('now') WHERE short_id = ?2",
295 +
            params![new_status, short_id],
296 +
        )?;
297 +
    }
298 +
    Ok(Some(new_status.to_string()))
299 +
}
300 +
301 +
pub fn find_alias_redirect(db: &Db, alias: &str) -> Result<Option<String>, DbError> {
302 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
303 +
    match conn.query_row(
304 +
        "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'",
305 +
        params![alias],
306 +
        |row| row.get::<_, String>(0),
307 +
    ) {
308 +
        Ok(slug) => Ok(Some(format!("/posts/{}", slug))),
309 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
310 +
        Err(e) => Err(DbError::Sqlite(e)),
311 +
    }
312 +
}
313 +
314 +
// --- Page CRUD ---
315 +
316 +
fn row_to_page(row: &rusqlite::Row) -> rusqlite::Result<Page> {
317 +
    Ok(Page {
318 +
        id: row.get(0)?,
319 +
        short_id: row.get(1)?,
320 +
        title: row.get(2)?,
321 +
        slug: row.get(3)?,
322 +
        content: row.get(4)?,
323 +
        is_published: row.get::<_, i64>(5)? != 0,
324 +
        nav_order: row.get(6)?,
325 +
        created_at: row.get(7)?,
326 +
        updated_at: row.get(8)?,
327 +
    })
328 +
}
329 +
330 +
const PAGE_COLS: &str = "id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at";
331 +
332 +
pub fn create_page(
333 +
    db: &Db,
334 +
    title: &str,
335 +
    slug: &str,
336 +
    content: &str,
337 +
    is_published: bool,
338 +
    nav_order: i64,
339 +
) -> Result<Page, DbError> {
340 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
341 +
    let short_id = nanoid!(10);
342 +
    conn.execute(
343 +
        "INSERT INTO pages (short_id, title, slug, content, is_published, nav_order) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
344 +
        params![short_id, title, slug, content, is_published as i64, nav_order],
345 +
    )?;
346 +
    let id = conn.last_insert_rowid();
347 +
    let page = conn.query_row(
348 +
        &format!("SELECT {} FROM pages WHERE id = ?1", PAGE_COLS),
349 +
        params![id],
350 +
        row_to_page,
351 +
    )?;
352 +
    Ok(page)
353 +
}
354 +
355 +
pub fn get_page_by_short_id(db: &Db, short_id: &str) -> Result<Option<Page>, DbError> {
356 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
357 +
    match conn.query_row(
358 +
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
359 +
        params![short_id],
360 +
        row_to_page,
361 +
    ) {
362 +
        Ok(page) => Ok(Some(page)),
363 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
364 +
        Err(e) => Err(DbError::Sqlite(e)),
365 +
    }
366 +
}
367 +
368 +
pub fn get_page_by_slug(db: &Db, slug: &str) -> Result<Option<Page>, DbError> {
369 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
370 +
    match conn.query_row(
371 +
        &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS),
372 +
        params![slug],
373 +
        row_to_page,
374 +
    ) {
375 +
        Ok(page) => Ok(Some(page)),
376 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
377 +
        Err(e) => Err(DbError::Sqlite(e)),
378 +
    }
379 +
}
380 +
381 +
pub fn get_all_pages(db: &Db) -> Result<Vec<Page>, DbError> {
382 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
383 +
    let mut stmt = conn.prepare(
384 +
        &format!("SELECT {} FROM pages ORDER BY nav_order ASC, id ASC", PAGE_COLS),
385 +
    )?;
386 +
    let pages = stmt
387 +
        .query_map([], row_to_page)?
388 +
        .collect::<Result<Vec<_>, _>>()?;
389 +
    Ok(pages)
390 +
}
391 +
392 +
pub fn get_published_pages(db: &Db) -> Result<Vec<Page>, DbError> {
393 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
394 +
    let mut stmt = conn.prepare(
395 +
        &format!("SELECT {} FROM pages WHERE is_published = 1 ORDER BY nav_order ASC, id ASC", PAGE_COLS),
396 +
    )?;
397 +
    let pages = stmt
398 +
        .query_map([], row_to_page)?
399 +
        .collect::<Result<Vec<_>, _>>()?;
400 +
    Ok(pages)
401 +
}
402 +
403 +
pub fn update_page(
404 +
    db: &Db,
405 +
    short_id: &str,
406 +
    title: &str,
407 +
    slug: &str,
408 +
    content: &str,
409 +
    is_published: bool,
410 +
    nav_order: i64,
411 +
) -> Result<Option<Page>, DbError> {
412 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
413 +
    let rows = conn.execute(
414 +
        "UPDATE pages SET title = ?1, slug = ?2, content = ?3, is_published = ?4, nav_order = ?5, updated_at = datetime('now') WHERE short_id = ?6",
415 +
        params![title, slug, content, is_published as i64, nav_order, short_id],
416 +
    )?;
417 +
    if rows == 0 {
418 +
        return Ok(None);
419 +
    }
420 +
    match conn.query_row(
421 +
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
422 +
        params![short_id],
423 +
        row_to_page,
424 +
    ) {
425 +
        Ok(page) => Ok(Some(page)),
426 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
427 +
        Err(e) => Err(DbError::Sqlite(e)),
428 +
    }
429 +
}
430 +
431 +
pub fn delete_page(db: &Db, short_id: &str) -> Result<bool, DbError> {
432 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
433 +
    let rows = conn.execute("DELETE FROM pages WHERE short_id = ?1", params![short_id])?;
434 +
    Ok(rows > 0)
435 +
}
436 +
437 +
// --- Settings ---
438 +
439 +
pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> {
440 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
441 +
    match conn.query_row(
442 +
        "SELECT value FROM settings WHERE key = ?1",
443 +
        params![key],
444 +
        |row| row.get(0),
445 +
    ) {
446 +
        Ok(val) => Ok(Some(val)),
447 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
448 +
        Err(e) => Err(DbError::Sqlite(e)),
449 +
    }
450 +
}
451 +
452 +
pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> {
453 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
454 +
    conn.execute(
455 +
        "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2",
456 +
        params![key, value],
457 +
    )?;
458 +
    Ok(())
459 +
}
460 +
461 +
pub fn get_all_settings(db: &Db) -> Result<Vec<(String, String)>, DbError> {
462 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
463 +
    let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
464 +
    let settings = stmt
465 +
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
466 +
        .collect::<Result<Vec<_>, _>>()?;
467 +
    Ok(settings)
468 +
}
469 +
470 +
// --- Session functions ---
471 +
472 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
473 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
474 +
    conn.execute(
475 +
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
476 +
        params![token, expires_at],
477 +
    )?;
478 +
    Ok(())
479 +
}
480 +
481 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
482 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
483 +
    match conn.query_row(
484 +
        "SELECT expires_at FROM sessions WHERE token = ?1",
485 +
        params![token],
486 +
        |row| row.get(0),
487 +
    ) {
488 +
        Ok(val) => Ok(Some(val)),
489 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
490 +
        Err(e) => Err(DbError::Sqlite(e)),
491 +
    }
492 +
}
493 +
494 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
495 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
496 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
497 +
    Ok(())
498 +
}
499 +
500 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
501 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
502 +
    conn.execute(
503 +
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
504 +
        [],
505 +
    )?;
506 +
    Ok(())
507 +
}
apps/posts/src/main.rs (added) +14 −0
1 +
mod auth;
2 +
mod db;
3 +
mod server;
4 +
5 +
#[tokio::main]
6 +
async fn main() {
7 +
    tracing_subscriber::fmt::init();
8 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
9 +
    let port: u16 = std::env::var("PORT")
10 +
        .ok()
11 +
        .and_then(|v| v.parse().ok())
12 +
        .unwrap_or(3000);
13 +
    server::run(host, port).await;
14 +
}
apps/posts/src/server.rs (added) +826 −0
1 +
use askama::Template;
2 +
use askama_web::WebTemplate;
3 +
use axum::{
4 +
    extract::{Form, Path, Query, State},
5 +
    http::{HeaderValue, StatusCode, Uri},
6 +
    response::{Html, IntoResponse, Redirect, Response},
7 +
    routing::{get, post},
8 +
    Router,
9 +
};
10 +
use pulldown_cmark::{Options, Parser, html};
11 +
use rust_embed::Embed;
12 +
use std::sync::Arc;
13 +
14 +
use crate::auth;
15 +
use crate::db::{self, Db, Page, Post};
16 +
17 +
#[derive(Clone)]
18 +
pub struct AppState {
19 +
    pub db: Db,
20 +
    pub app_password: String,
21 +
    pub cookie_secure: bool,
22 +
}
23 +
24 +
#[derive(Embed)]
25 +
#[folder = "static/"]
26 +
struct Static;
27 +
28 +
// --- Templates ---
29 +
30 +
#[derive(Template)]
31 +
#[template(path = "base.html")]
32 +
struct BaseTemplate {
33 +
    blog_title: String,
34 +
    nav_pages: Vec<Page>,
35 +
}
36 +
37 +
#[derive(Template)]
38 +
#[template(path = "admin_base.html")]
39 +
struct AdminBaseTemplate;
40 +
41 +
#[derive(Template)]
42 +
#[template(path = "login.html")]
43 +
struct LoginTemplate {
44 +
    error: Option<String>,
45 +
}
46 +
47 +
#[derive(Template)]
48 +
#[template(path = "index.html")]
49 +
struct IndexTemplate {
50 +
    blog_title: String,
51 +
    blog_description: String,
52 +
    intro_html: String,
53 +
    posts: Vec<Post>,
54 +
    nav_pages: Vec<Page>,
55 +
}
56 +
57 +
#[derive(Template)]
58 +
#[template(path = "post.html")]
59 +
struct PostTemplate {
60 +
    blog_title: String,
61 +
    nav_pages: Vec<Page>,
62 +
    post: Post,
63 +
    rendered_content: String,
64 +
}
65 +
66 +
#[derive(Template)]
67 +
#[template(path = "page.html")]
68 +
struct PageTemplate {
69 +
    blog_title: String,
70 +
    nav_pages: Vec<Page>,
71 +
    page: Page,
72 +
    rendered_content: String,
73 +
}
74 +
75 +
#[derive(Template)]
76 +
#[template(path = "admin_index.html")]
77 +
struct AdminIndexTemplate {
78 +
    posts: Vec<Post>,
79 +
}
80 +
81 +
#[derive(Template)]
82 +
#[template(path = "admin_post_form.html")]
83 +
struct AdminPostFormTemplate {
84 +
    post: Option<Post>,
85 +
    error: Option<String>,
86 +
}
87 +
88 +
#[derive(Template)]
89 +
#[template(path = "admin_pages.html")]
90 +
struct AdminPagesTemplate {
91 +
    pages: Vec<Page>,
92 +
}
93 +
94 +
#[derive(Template)]
95 +
#[template(path = "admin_page_form.html")]
96 +
struct AdminPageFormTemplate {
97 +
    page: Option<Page>,
98 +
    error: Option<String>,
99 +
}
100 +
101 +
#[derive(Template)]
102 +
#[template(path = "admin_settings.html")]
103 +
struct AdminSettingsTemplate {
104 +
    blog_title: String,
105 +
    blog_description: String,
106 +
    intro_content: String,
107 +
    success: bool,
108 +
}
109 +
110 +
// --- Query/Form structs ---
111 +
112 +
#[derive(serde::Deserialize, Default)]
113 +
pub struct FlashQuery {
114 +
    pub error: Option<String>,
115 +
    #[serde(default)]
116 +
    pub success: bool,
117 +
}
118 +
119 +
#[derive(serde::Deserialize)]
120 +
struct LoginForm {
121 +
    password: String,
122 +
}
123 +
124 +
#[derive(serde::Deserialize)]
125 +
struct PostForm {
126 +
    attributes: String,
127 +
    content: String,
128 +
    #[serde(default)]
129 +
    action: String,
130 +
}
131 +
132 +
struct ParsedAttributes {
133 +
    title: String,
134 +
    slug: String,
135 +
    alias: String,
136 +
    published_date: String,
137 +
    meta_description: String,
138 +
    meta_image: String,
139 +
    lang: String,
140 +
    tags: String,
141 +
}
142 +
143 +
fn parse_attributes(text: &str) -> ParsedAttributes {
144 +
    let mut attrs = ParsedAttributes {
145 +
        title: String::new(),
146 +
        slug: String::new(),
147 +
        alias: String::new(),
148 +
        published_date: String::new(),
149 +
        meta_description: String::new(),
150 +
        meta_image: String::new(),
151 +
        lang: String::new(),
152 +
        tags: String::new(),
153 +
    };
154 +
    for line in text.lines() {
155 +
        if let Some((key, value)) = line.split_once(':') {
156 +
            let key = key.trim().to_lowercase();
157 +
            let value = value.trim().to_string();
158 +
            match key.as_str() {
159 +
                "title" => attrs.title = value,
160 +
                "slug" => attrs.slug = value,
161 +
                "alias" => attrs.alias = value,
162 +
                "published_date" => attrs.published_date = value,
163 +
                "meta_description" => attrs.meta_description = value,
164 +
                "meta_image" => attrs.meta_image = value,
165 +
                "lang" => attrs.lang = value,
166 +
                "tags" => attrs.tags = value,
167 +
                _ => {} // ignore unknown keys (including canonical_url)
168 +
            }
169 +
        }
170 +
    }
171 +
    attrs
172 +
}
173 +
174 +
#[derive(serde::Deserialize)]
175 +
struct PageForm {
176 +
    title: String,
177 +
    slug: String,
178 +
    content: String,
179 +
    #[serde(default)]
180 +
    is_published: Option<String>,
181 +
    #[serde(default)]
182 +
    nav_order: i64,
183 +
}
184 +
185 +
#[derive(serde::Deserialize)]
186 +
struct SettingsForm {
187 +
    blog_title: String,
188 +
    blog_description: String,
189 +
    intro_content: String,
190 +
}
191 +
192 +
// --- Helpers ---
193 +
194 +
fn mime_from_path(path: &str) -> &'static str {
195 +
    match path.rsplit('.').next().unwrap_or("") {
196 +
        "css" => "text/css",
197 +
        "js" => "application/javascript",
198 +
        "html" => "text/html",
199 +
        "png" => "image/png",
200 +
        "ico" => "image/x-icon",
201 +
        "svg" => "image/svg+xml",
202 +
        "woff" | "woff2" => "font/woff2",
203 +
        "ttf" => "font/ttf",
204 +
        "otf" => "font/otf",
205 +
        "json" | "webmanifest" => "application/json",
206 +
        _ => "application/octet-stream",
207 +
    }
208 +
}
209 +
210 +
fn render_markdown(content: &str) -> String {
211 +
    let mut options = Options::empty();
212 +
    options.insert(Options::ENABLE_STRIKETHROUGH);
213 +
    options.insert(Options::ENABLE_TABLES);
214 +
    options.insert(Options::ENABLE_TASKLISTS);
215 +
    let parser = Parser::new_ext(content, options);
216 +
    let mut html_output = String::new();
217 +
    html::push_html(&mut html_output, parser);
218 +
    html_output
219 +
}
220 +
221 +
fn now_datetime() -> String {
222 +
    use std::time::{SystemTime, UNIX_EPOCH};
223 +
    let secs = SystemTime::now()
224 +
        .duration_since(UNIX_EPOCH)
225 +
        .unwrap()
226 +
        .as_secs();
227 +
    let days = secs / 86400;
228 +
    let tod = secs % 86400;
229 +
    let (y, m, d) = days_to_ymd(days as i64);
230 +
    format!(
231 +
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
232 +
        y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
233 +
    )
234 +
}
235 +
236 +
fn slugify(s: &str) -> String {
237 +
    s.to_lowercase()
238 +
        .chars()
239 +
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
240 +
        .collect::<String>()
241 +
        .split('-')
242 +
        .filter(|s| !s.is_empty())
243 +
        .collect::<Vec<_>>()
244 +
        .join("-")
245 +
}
246 +
247 +
fn opt_str(s: &str) -> Option<&str> {
248 +
    let trimmed = s.trim();
249 +
    if trimmed.is_empty() { None } else { Some(trimmed) }
250 +
}
251 +
252 +
fn get_blog_title(db: &Db) -> String {
253 +
    db::get_setting(db, "blog_title")
254 +
        .ok()
255 +
        .flatten()
256 +
        .unwrap_or_else(|| "My Blog".to_string())
257 +
}
258 +
259 +
fn get_nav_pages(db: &Db) -> Vec<Page> {
260 +
    db::get_published_pages(db).unwrap_or_default()
261 +
}
262 +
263 +
// --- Static file handler ---
264 +
265 +
async fn serve_static(Path(path): Path<String>) -> Response {
266 +
    match Static::get(&path) {
267 +
        Some(file) => {
268 +
            let mime = mime_from_path(&path);
269 +
            (
270 +
                StatusCode::OK,
271 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
272 +
                file.data.to_vec(),
273 +
            )
274 +
                .into_response()
275 +
        }
276 +
        None => StatusCode::NOT_FOUND.into_response(),
277 +
    }
278 +
}
279 +
280 +
// --- Auth handlers ---
281 +
282 +
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
283 +
    WebTemplate(LoginTemplate { error: q.error }).into_response()
284 +
}
285 +
286 +
async fn post_login(
287 +
    State(state): State<Arc<AppState>>,
288 +
    Form(form): Form<LoginForm>,
289 +
) -> Response {
290 +
    if !auth::verify_password(&form.password, &state.app_password) {
291 +
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
292 +
    }
293 +
294 +
    let token = auth::generate_session_token();
295 +
296 +
    let expires_at = {
297 +
        use std::time::{SystemTime, UNIX_EPOCH};
298 +
        let secs = SystemTime::now()
299 +
            .duration_since(UNIX_EPOCH)
300 +
            .unwrap()
301 +
            .as_secs()
302 +
            + 7 * 24 * 3600;
303 +
        let days = secs / 86400;
304 +
        let tod = secs % 86400;
305 +
        let (y, m, d) = days_to_ymd(days as i64);
306 +
        format!(
307 +
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
308 +
            y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
309 +
        )
310 +
    };
311 +
312 +
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
313 +
        tracing::error!("Failed to create session: {}", e);
314 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
315 +
    }
316 +
317 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
318 +
    let mut resp = Redirect::to("/admin").into_response();
319 +
    resp.headers_mut().insert(
320 +
        axum::http::header::SET_COOKIE,
321 +
        HeaderValue::from_str(&cookie).unwrap(),
322 +
    );
323 +
    resp
324 +
}
325 +
326 +
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
327 +
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
328 +
        for part in cookie_header.split(';') {
329 +
            let part = part.trim();
330 +
            if let Some(val) = part.strip_prefix("session=") {
331 +
                let val = val.trim();
332 +
                if !val.is_empty() {
333 +
                    let _ = db::delete_session(&state.db, val);
334 +
                }
335 +
            }
336 +
        }
337 +
    }
338 +
339 +
    let cookie = auth::clear_session_cookie();
340 +
    let mut resp = Redirect::to("/admin/login").into_response();
341 +
    resp.headers_mut().insert(
342 +
        axum::http::header::SET_COOKIE,
343 +
        HeaderValue::from_str(&cookie).unwrap(),
344 +
    );
345 +
    resp
346 +
}
347 +
348 +
// --- Public handlers ---
349 +
350 +
async fn public_index(State(state): State<Arc<AppState>>) -> Response {
351 +
    let blog_title = get_blog_title(&state.db);
352 +
    let blog_description = db::get_setting(&state.db, "blog_description")
353 +
        .ok()
354 +
        .flatten()
355 +
        .unwrap_or_default();
356 +
    let intro_content = db::get_setting(&state.db, "intro_content")
357 +
        .ok()
358 +
        .flatten()
359 +
        .unwrap_or_default();
360 +
    let intro_html = render_markdown(&intro_content);
361 +
    let nav_pages = get_nav_pages(&state.db);
362 +
363 +
    match db::get_published_posts(&state.db) {
364 +
        Ok(posts) => WebTemplate(IndexTemplate {
365 +
            blog_title,
366 +
            blog_description,
367 +
            intro_html,
368 +
            posts,
369 +
            nav_pages,
370 +
        })
371 +
        .into_response(),
372 +
        Err(e) => {
373 +
            tracing::error!("Failed to list posts: {}", e);
374 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
375 +
        }
376 +
    }
377 +
}
378 +
379 +
async fn public_post(
380 +
    State(state): State<Arc<AppState>>,
381 +
    Path(slug): Path<String>,
382 +
) -> Response {
383 +
    match db::get_post_by_slug(&state.db, &slug) {
384 +
        Ok(Some(post)) if post.status == "published" => {
385 +
            let rendered_content = render_markdown(&post.content);
386 +
            let blog_title = get_blog_title(&state.db);
387 +
            let nav_pages = get_nav_pages(&state.db);
388 +
            WebTemplate(PostTemplate {
389 +
                blog_title,
390 +
                nav_pages,
391 +
                post,
392 +
                rendered_content,
393 +
            })
394 +
            .into_response()
395 +
        }
396 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
397 +
        Err(e) => {
398 +
            tracing::error!("Failed to get post: {}", e);
399 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
400 +
        }
401 +
    }
402 +
}
403 +
404 +
async fn public_page(
405 +
    State(state): State<Arc<AppState>>,
406 +
    Path(slug): Path<String>,
407 +
) -> Response {
408 +
    match db::get_page_by_slug(&state.db, &slug) {
409 +
        Ok(Some(page)) if page.is_published => {
410 +
            let rendered_content = render_markdown(&page.content);
411 +
            let blog_title = get_blog_title(&state.db);
412 +
            let nav_pages = get_nav_pages(&state.db);
413 +
            WebTemplate(PageTemplate {
414 +
                blog_title,
415 +
                nav_pages,
416 +
                page,
417 +
                rendered_content,
418 +
            })
419 +
            .into_response()
420 +
        }
421 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
422 +
        Err(e) => {
423 +
            tracing::error!("Failed to get page: {}", e);
424 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
425 +
        }
426 +
    }
427 +
}
428 +
429 +
async fn fallback_handler(
430 +
    State(state): State<Arc<AppState>>,
431 +
    uri: Uri,
432 +
) -> Response {
433 +
    let path = uri.path().trim_start_matches('/');
434 +
    if let Ok(Some(redirect_to)) = db::find_alias_redirect(&state.db, path) {
435 +
        return Redirect::permanent(&redirect_to).into_response();
436 +
    }
437 +
    (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response()
438 +
}
439 +
440 +
// --- Admin post handlers ---
441 +
442 +
async fn admin_index(
443 +
    _session: auth::AuthSession,
444 +
    State(state): State<Arc<AppState>>,
445 +
) -> Response {
446 +
    match db::get_all_posts(&state.db) {
447 +
        Ok(posts) => WebTemplate(AdminIndexTemplate { posts }).into_response(),
448 +
        Err(e) => {
449 +
            tracing::error!("Failed to list posts: {}", e);
450 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
451 +
        }
452 +
    }
453 +
}
454 +
455 +
async fn admin_new_post(
456 +
    _session: auth::AuthSession,
457 +
    Query(q): Query<FlashQuery>,
458 +
) -> Response {
459 +
    WebTemplate(AdminPostFormTemplate {
460 +
        post: None,
461 +
        error: q.error,
462 +
    })
463 +
    .into_response()
464 +
}
465 +
466 +
async fn admin_create_post(
467 +
    _session: auth::AuthSession,
468 +
    State(state): State<Arc<AppState>>,
469 +
    Form(form): Form<PostForm>,
470 +
) -> Response {
471 +
    let attrs = parse_attributes(&form.attributes);
472 +
    let title = attrs.title.trim();
473 +
    if title.is_empty() {
474 +
        return Redirect::to("/admin/posts/new?error=Title+is+required").into_response();
475 +
    }
476 +
    let slug = if attrs.slug.trim().is_empty() {
477 +
        slugify(title)
478 +
    } else {
479 +
        attrs.slug.trim().to_string()
480 +
    };
481 +
482 +
    let status = if form.action == "publish" { "published" } else { "draft" };
483 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
484 +
    let published_date = if attrs.published_date.trim().is_empty() {
485 +
        now_datetime()
486 +
    } else {
487 +
        attrs.published_date.trim().to_string()
488 +
    };
489 +
490 +
    match db::create_post(
491 +
        &state.db,
492 +
        title,
493 +
        &slug,
494 +
        &form.content,
495 +
        status,
496 +
        opt_str(&attrs.alias),
497 +
        None,
498 +
        Some(&published_date),
499 +
        opt_str(&attrs.meta_description),
500 +
        opt_str(&attrs.meta_image),
501 +
        lang,
502 +
        opt_str(&attrs.tags),
503 +
    ) {
504 +
        Ok(_) => Redirect::to("/admin").into_response(),
505 +
        Err(e) => {
506 +
            tracing::error!("Failed to create post: {}", e);
507 +
            Redirect::to("/admin/posts/new?error=Failed+to+create+post").into_response()
508 +
        }
509 +
    }
510 +
}
511 +
512 +
async fn admin_edit_post(
513 +
    _session: auth::AuthSession,
514 +
    State(state): State<Arc<AppState>>,
515 +
    Path(short_id): Path<String>,
516 +
    Query(q): Query<FlashQuery>,
517 +
) -> Response {
518 +
    match db::get_post_by_short_id(&state.db, &short_id) {
519 +
        Ok(Some(post)) => WebTemplate(AdminPostFormTemplate {
520 +
            post: Some(post),
521 +
            error: q.error,
522 +
        })
523 +
        .into_response(),
524 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
525 +
        Err(e) => {
526 +
            tracing::error!("Failed to get post: {}", e);
527 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
528 +
        }
529 +
    }
530 +
}
531 +
532 +
async fn admin_update_post(
533 +
    _session: auth::AuthSession,
534 +
    State(state): State<Arc<AppState>>,
535 +
    Path(short_id): Path<String>,
536 +
    Form(form): Form<PostForm>,
537 +
) -> Response {
538 +
    let attrs = parse_attributes(&form.attributes);
539 +
    let title = attrs.title.trim();
540 +
    if title.is_empty() {
541 +
        return Redirect::to(&format!("/admin/posts/{}/edit?error=Title+is+required", short_id))
542 +
            .into_response();
543 +
    }
544 +
    let slug = if attrs.slug.trim().is_empty() {
545 +
        slugify(title)
546 +
    } else {
547 +
        attrs.slug.trim().to_string()
548 +
    };
549 +
550 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
551 +
    let published_date = if attrs.published_date.trim().is_empty() {
552 +
        now_datetime()
553 +
    } else {
554 +
        attrs.published_date.trim().to_string()
555 +
    };
556 +
557 +
    match db::update_post(
558 +
        &state.db,
559 +
        &short_id,
560 +
        title,
561 +
        &slug,
562 +
        &form.content,
563 +
        opt_str(&attrs.alias),
564 +
        None,
565 +
        Some(&published_date),
566 +
        opt_str(&attrs.meta_description),
567 +
        opt_str(&attrs.meta_image),
568 +
        lang,
569 +
        opt_str(&attrs.tags),
570 +
    ) {
571 +
        Ok(Some(_)) => Redirect::to("/admin").into_response(),
572 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
573 +
        Err(e) => {
574 +
            tracing::error!("Failed to update post: {}", e);
575 +
            Redirect::to(&format!("/admin/posts/{}/edit?error=Failed+to+update", short_id))
576 +
                .into_response()
577 +
        }
578 +
    }
579 +
}
580 +
581 +
async fn admin_delete_post(
582 +
    _session: auth::AuthSession,
583 +
    State(state): State<Arc<AppState>>,
584 +
    Path(short_id): Path<String>,
585 +
) -> Response {
586 +
    match db::delete_post(&state.db, &short_id) {
587 +
        Ok(_) => Redirect::to("/admin").into_response(),
588 +
        Err(e) => {
589 +
            tracing::error!("Failed to delete post: {}", e);
590 +
            Redirect::to("/admin").into_response()
591 +
        }
592 +
    }
593 +
}
594 +
595 +
async fn admin_toggle_publish(
596 +
    _session: auth::AuthSession,
597 +
    State(state): State<Arc<AppState>>,
598 +
    Path(short_id): Path<String>,
599 +
) -> Response {
600 +
    match db::toggle_post_status(&state.db, &short_id) {
601 +
        Ok(_) => Redirect::to("/admin").into_response(),
602 +
        Err(e) => {
603 +
            tracing::error!("Failed to toggle post status: {}", e);
604 +
            Redirect::to("/admin").into_response()
605 +
        }
606 +
    }
607 +
}
608 +
609 +
// --- Admin page handlers ---
610 +
611 +
async fn admin_pages(
612 +
    _session: auth::AuthSession,
613 +
    State(state): State<Arc<AppState>>,
614 +
) -> Response {
615 +
    match db::get_all_pages(&state.db) {
616 +
        Ok(pages) => WebTemplate(AdminPagesTemplate { pages }).into_response(),
617 +
        Err(e) => {
618 +
            tracing::error!("Failed to list pages: {}", e);
619 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
620 +
        }
621 +
    }
622 +
}
623 +
624 +
async fn admin_new_page(
625 +
    _session: auth::AuthSession,
626 +
    Query(q): Query<FlashQuery>,
627 +
) -> Response {
628 +
    WebTemplate(AdminPageFormTemplate {
629 +
        page: None,
630 +
        error: q.error,
631 +
    })
632 +
    .into_response()
633 +
}
634 +
635 +
async fn admin_create_page(
636 +
    _session: auth::AuthSession,
637 +
    State(state): State<Arc<AppState>>,
638 +
    Form(form): Form<PageForm>,
639 +
) -> Response {
640 +
    let title = form.title.trim();
641 +
    let slug = form.slug.trim();
642 +
    if title.is_empty() || slug.is_empty() {
643 +
        return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response();
644 +
    }
645 +
646 +
    let is_published = form.is_published.as_deref() == Some("on");
647 +
648 +
    match db::create_page(&state.db, title, slug, &form.content, is_published, form.nav_order) {
649 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
650 +
        Err(e) => {
651 +
            tracing::error!("Failed to create page: {}", e);
652 +
            Redirect::to("/admin/pages/new?error=Failed+to+create+page").into_response()
653 +
        }
654 +
    }
655 +
}
656 +
657 +
async fn admin_edit_page(
658 +
    _session: auth::AuthSession,
659 +
    State(state): State<Arc<AppState>>,
660 +
    Path(short_id): Path<String>,
661 +
    Query(q): Query<FlashQuery>,
662 +
) -> Response {
663 +
    match db::get_page_by_short_id(&state.db, &short_id) {
664 +
        Ok(Some(page)) => WebTemplate(AdminPageFormTemplate {
665 +
            page: Some(page),
666 +
            error: q.error,
667 +
        })
668 +
        .into_response(),
669 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
670 +
        Err(e) => {
671 +
            tracing::error!("Failed to get page: {}", e);
672 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
673 +
        }
674 +
    }
675 +
}
676 +
677 +
async fn admin_update_page(
678 +
    _session: auth::AuthSession,
679 +
    State(state): State<Arc<AppState>>,
680 +
    Path(short_id): Path<String>,
681 +
    Form(form): Form<PageForm>,
682 +
) -> Response {
683 +
    let title = form.title.trim();
684 +
    let slug = form.slug.trim();
685 +
    if title.is_empty() || slug.is_empty() {
686 +
        return Redirect::to(&format!("/admin/pages/{}/edit?error=Title+and+slug+are+required", short_id))
687 +
            .into_response();
688 +
    }
689 +
690 +
    let is_published = form.is_published.as_deref() == Some("on");
691 +
692 +
    match db::update_page(&state.db, &short_id, title, slug, &form.content, is_published, form.nav_order) {
693 +
        Ok(Some(_)) => Redirect::to("/admin/pages").into_response(),
694 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
695 +
        Err(e) => {
696 +
            tracing::error!("Failed to update page: {}", e);
697 +
            Redirect::to(&format!("/admin/pages/{}/edit?error=Failed+to+update", short_id))
698 +
                .into_response()
699 +
        }
700 +
    }
701 +
}
702 +
703 +
async fn admin_delete_page(
704 +
    _session: auth::AuthSession,
705 +
    State(state): State<Arc<AppState>>,
706 +
    Path(short_id): Path<String>,
707 +
) -> Response {
708 +
    match db::delete_page(&state.db, &short_id) {
709 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
710 +
        Err(e) => {
711 +
            tracing::error!("Failed to delete page: {}", e);
712 +
            Redirect::to("/admin/pages").into_response()
713 +
        }
714 +
    }
715 +
}
716 +
717 +
// --- Admin settings handlers ---
718 +
719 +
async fn admin_get_settings(
720 +
    _session: auth::AuthSession,
721 +
    State(state): State<Arc<AppState>>,
722 +
    Query(q): Query<FlashQuery>,
723 +
) -> Response {
724 +
    let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default();
725 +
    let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default();
726 +
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
727 +
728 +
    WebTemplate(AdminSettingsTemplate {
729 +
        blog_title,
730 +
        blog_description,
731 +
        intro_content,
732 +
        success: q.success,
733 +
    })
734 +
    .into_response()
735 +
}
736 +
737 +
async fn admin_post_settings(
738 +
    _session: auth::AuthSession,
739 +
    State(state): State<Arc<AppState>>,
740 +
    Form(form): Form<SettingsForm>,
741 +
) -> Response {
742 +
    let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim());
743 +
    let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim());
744 +
    let _ = db::set_setting(&state.db, "intro_content", &form.intro_content);
745 +
    Redirect::to("/admin/settings?success=true").into_response()
746 +
}
747 +
748 +
// --- Date helper ---
749 +
750 +
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
751 +
    days += 719468;
752 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
753 +
    let doe = (days - era * 146097) as u32;
754 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
755 +
    let y = yoe as i64 + era * 400;
756 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
757 +
    let mp = (5 * doy + 2) / 153;
758 +
    let d = doy - (153 * mp + 2) / 5 + 1;
759 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
760 +
    let y = if m <= 2 { y + 1 } else { y };
761 +
    (y, m as i64, d as i64)
762 +
}
763 +
764 +
// --- Router ---
765 +
766 +
pub async fn run(host: String, port: u16) {
767 +
    dotenvy::dotenv().ok();
768 +
769 +
    let db = db::init_db();
770 +
771 +
    if let Err(e) = db::prune_expired_sessions(&db) {
772 +
        tracing::warn!("Failed to prune sessions: {}", e);
773 +
    }
774 +
775 +
    let app_password = std::env::var("POSTS_PASSWORD").unwrap_or_else(|_| {
776 +
        tracing::warn!("POSTS_PASSWORD not set, using default 'changeme'");
777 +
        "changeme".to_string()
778 +
    });
779 +
780 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
781 +
        .map(|v| v == "true")
782 +
        .unwrap_or(false);
783 +
784 +
    let state = Arc::new(AppState {
785 +
        db,
786 +
        app_password,
787 +
        cookie_secure,
788 +
    });
789 +
790 +
    let app = Router::new()
791 +
        // Public routes
792 +
        .route("/", get(public_index))
793 +
        .route("/posts/{slug}", get(public_post))
794 +
        .route("/pages/{slug}", get(public_page))
795 +
        // Admin auth
796 +
        .route("/admin/login", get(get_login).post(post_login))
797 +
        .route("/admin/logout", get(get_logout))
798 +
        // Admin posts
799 +
        .route("/admin", get(admin_index))
800 +
        .route("/admin/posts/new", get(admin_new_post))
801 +
        .route("/admin/posts", post(admin_create_post))
802 +
        .route("/admin/posts/{id}/edit", get(admin_edit_post))
803 +
        .route("/admin/posts/{id}", post(admin_update_post))
804 +
        .route("/admin/posts/{id}/delete", post(admin_delete_post))
805 +
        .route("/admin/posts/{id}/publish", post(admin_toggle_publish))
806 +
        // Admin pages
807 +
        .route("/admin/pages", get(admin_pages))
808 +
        .route("/admin/pages/new", get(admin_new_page))
809 +
        .route("/admin/pages/create", post(admin_create_page))
810 +
        .route("/admin/pages/{id}/edit", get(admin_edit_page))
811 +
        .route("/admin/pages/{id}", post(admin_update_page))
812 +
        .route("/admin/pages/{id}/delete", post(admin_delete_page))
813 +
        // Admin settings
814 +
        .route("/admin/settings", get(admin_get_settings).post(admin_post_settings))
815 +
        // Static assets
816 +
        .route("/static/{*path}", get(serve_static))
817 +
        // Fallback
818 +
        .fallback(get(fallback_handler))
819 +
        .with_state(state);
820 +
821 +
    let addr = format!("{}:{}", host, port);
822 +
    tracing::info!("Listening on http://{}", addr);
823 +
824 +
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
825 +
    axum::serve(listener, app).await.unwrap();
826 +
}
apps/posts/static/favicons/favicon.ico (added) +0 −0

Binary file — no preview.

apps/posts/static/fonts/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

apps/posts/static/fonts/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

apps/posts/static/styles.css (added) +587 −0
1 +
@font-face {
2 +
  font-family: "Commit Mono";
3 +
  src: url("/static/fonts/CommitMono-400-Regular.otf") format("opentype");
4 +
  font-weight: 400;
5 +
  font-style: normal;
6 +
}
7 +
8 +
@font-face {
9 +
  font-family: "Commit Mono";
10 +
  src: url("/static/fonts/CommitMono-700-Regular.otf") format("opentype");
11 +
  font-weight: 700;
12 +
  font-style: normal;
13 +
}
14 +
15 +
* {
16 +
  padding: 0;
17 +
  margin: 0;
18 +
  box-sizing: border-box;
19 +
  font-family: "Commit Mono", monospace, sans-serif;
20 +
  scrollbar-width: none;
21 +
  -ms-overflow-style: none;
22 +
}
23 +
24 +
html {
25 +
  background: #121113;
26 +
  color: #ffffff;
27 +
  font-size: 14px;
28 +
  line-height: 1.6;
29 +
}
30 +
31 +
html::-webkit-scrollbar {
32 +
  display: none;
33 +
}
34 +
35 +
body {
36 +
  display: flex;
37 +
  flex-direction: column;
38 +
  justify-content: start;
39 +
  align-items: start;
40 +
  gap: 1.5rem;
41 +
  min-height: 100vh;
42 +
  max-width: 700px;
43 +
  margin: auto;
44 +
  padding: 0 1rem;
45 +
}
46 +
47 +
@media (max-width: 480px) {
48 +
  body {
49 +
    padding: 1rem;
50 +
    gap: 1rem;
51 +
  }
52 +
}
53 +
54 +
a {
55 +
  color: #ffffff;
56 +
  text-decoration: none;
57 +
}
58 +
59 +
a:hover {
60 +
  opacity: 0.7;
61 +
}
62 +
63 +
/* Header */
64 +
65 +
.header {
66 +
  display: flex;
67 +
  flex-direction: column;
68 +
  gap: 0.5rem;
69 +
  width: 100%;
70 +
  margin-top: 2rem;
71 +
  border-bottom: 1px solid #333;
72 +
  padding-bottom: 1rem;
73 +
}
74 +
75 +
.logo {
76 +
  font-size: 28px;
77 +
  font-weight: 700;
78 +
  text-decoration: none;
79 +
  text-transform: uppercase;
80 +
}
81 +
82 +
.links {
83 +
  display: flex;
84 +
  align-items: center;
85 +
  gap: 0.75rem;
86 +
  font-size: 12px;
87 +
}
88 +
89 +
/* Main content */
90 +
91 +
main {
92 +
  width: 100%;
93 +
  display: flex;
94 +
  flex-direction: column;
95 +
  gap: 1rem;
96 +
}
97 +
98 +
/* Forms */
99 +
100 +
.form {
101 +
  display: flex;
102 +
  flex-direction: column;
103 +
  gap: 0.5rem;
104 +
  width: 100%;
105 +
}
106 +
107 +
label {
108 +
  font-size: 12px;
109 +
  opacity: 0.7;
110 +
}
111 +
112 +
input, textarea, select {
113 +
  background: #121113;
114 +
  color: #ffffff;
115 +
  border: 1px solid white;
116 +
  padding: 0.4rem 0.75rem;
117 +
  font-size: 16px;
118 +
  width: 100%;
119 +
  border-radius: 0;
120 +
}
121 +
122 +
textarea {
123 +
  min-height: 400px;
124 +
  resize: vertical;
125 +
}
126 +
127 +
textarea.post-content {
128 +
  min-height: 500px;
129 +
}
130 +
131 +
textarea.attributes-textarea {
132 +
  min-height: 80px;
133 +
}
134 +
135 +
.available-fields {
136 +
  margin-top: 0.5rem;
137 +
}
138 +
139 +
.available-fields > summary {
140 +
  cursor: pointer;
141 +
  user-select: none;
142 +
  font-size: 0.85rem;
143 +
  opacity: 0.6;
144 +
}
145 +
146 +
.fields-list {
147 +
  display: flex;
148 +
  flex-direction: column;
149 +
  gap: 0.15rem;
150 +
  margin-top: 0.25rem;
151 +
  font-size: 0.85rem;
152 +
  opacity: 0.6;
153 +
}
154 +
155 +
button, .btn {
156 +
  background: #121113;
157 +
  color: #ffffff;
158 +
  padding: 0.4rem 0.75rem;
159 +
  border: 1px solid white;
160 +
  cursor: pointer;
161 +
  width: fit-content;
162 +
  font-size: 14px;
163 +
  border-radius: 0;
164 +
  text-decoration: none;
165 +
  display: inline-block;
166 +
}
167 +
168 +
button:hover, .btn:hover {
169 +
  opacity: 0.7;
170 +
}
171 +
172 +
/* Error / Success */
173 +
174 +
.error {
175 +
  color: #ffffff;
176 +
  border-left: 2px solid #ffffff;
177 +
  padding-left: 0.5rem;
178 +
  font-size: 13px;
179 +
  opacity: 0.8;
180 +
}
181 +
182 +
.success {
183 +
  color: #ffffff;
184 +
  border-left: 2px solid #555;
185 +
  padding-left: 0.5rem;
186 +
  font-size: 13px;
187 +
  opacity: 0.7;
188 +
}
189 +
190 +
.empty {
191 +
  opacity: 0.5;
192 +
  font-size: 12px;
193 +
}
194 +
195 +
/* Post list (public) */
196 +
197 +
.post-list {
198 +
  display: flex;
199 +
  flex-direction: column;
200 +
  width: 100%;
201 +
}
202 +
203 +
.post-item {
204 +
  display: flex;
205 +
  justify-content: space-between;
206 +
  align-items: center;
207 +
  padding: 8px 0;
208 +
  border-bottom: 1px solid #333;
209 +
  text-decoration: none;
210 +
}
211 +
212 +
.post-item:hover {
213 +
  opacity: 0.7;
214 +
}
215 +
216 +
.post-item-info {
217 +
  display: flex;
218 +
  flex-direction: column;
219 +
  gap: 0.25rem;
220 +
}
221 +
222 +
.post-title {
223 +
  font-size: 16px;
224 +
}
225 +
226 +
.post-date {
227 +
  font-size: 12px;
228 +
  opacity: 0.5;
229 +
}
230 +
231 +
/* Tags */
232 +
233 +
.post-tags {
234 +
  display: flex;
235 +
  gap: 0.4rem;
236 +
  flex-wrap: wrap;
237 +
}
238 +
239 +
.tag {
240 +
  font-size: 11px;
241 +
  opacity: 0.5;
242 +
  background: #1e1c1f;
243 +
  padding: 1px 6px;
244 +
  border: 1px solid #333;
245 +
}
246 +
247 +
/* Post header (public single) */
248 +
249 +
.post-header {
250 +
  display: flex;
251 +
  flex-direction: column;
252 +
  gap: 0.25rem;
253 +
}
254 +
255 +
.post-header h1 {
256 +
  font-size: 24px;
257 +
  font-weight: 700;
258 +
  letter-spacing: -0.5px;
259 +
}
260 +
261 +
.page-header {
262 +
  display: flex;
263 +
  flex-direction: column;
264 +
  gap: 0.25rem;
265 +
}
266 +
267 +
.page-header h1 {
268 +
  font-size: 24px;
269 +
  font-weight: 700;
270 +
  letter-spacing: -0.5px;
271 +
}
272 +
273 +
/* Intro */
274 +
275 +
.intro {
276 +
  padding-bottom: 1rem;
277 +
  border-bottom: 1px solid #333;
278 +
}
279 +
280 +
/* Inline form */
281 +
282 +
.inline-form {
283 +
  display: inline;
284 +
}
285 +
286 +
.link-button {
287 +
  background: none;
288 +
  border: none;
289 +
  color: #ffffff;
290 +
  cursor: pointer;
291 +
  font-size: 12px;
292 +
  padding: 0;
293 +
}
294 +
295 +
.link-button:hover {
296 +
  opacity: 0.7;
297 +
}
298 +
299 +
.link-button.danger {
300 +
  opacity: 0.5;
301 +
}
302 +
303 +
.link-button.danger:hover {
304 +
  opacity: 0.3;
305 +
}
306 +
307 +
/* Admin toolbar */
308 +
309 +
.admin-toolbar {
310 +
  display: flex;
311 +
  justify-content: space-between;
312 +
  align-items: center;
313 +
  width: 100%;
314 +
}
315 +
316 +
.admin-toolbar h2 {
317 +
  font-size: 18px;
318 +
  font-weight: 700;
319 +
}
320 +
321 +
/* Admin list */
322 +
323 +
.admin-list {
324 +
  display: flex;
325 +
  flex-direction: column;
326 +
  width: 100%;
327 +
}
328 +
329 +
.admin-list-item {
330 +
  display: flex;
331 +
  justify-content: space-between;
332 +
  align-items: center;
333 +
  padding: 8px 0;
334 +
  border-bottom: 1px solid #333;
335 +
  gap: 1rem;
336 +
}
337 +
338 +
.admin-list-info {
339 +
  display: flex;
340 +
  flex-direction: column;
341 +
  gap: 0.2rem;
342 +
  min-width: 0;
343 +
}
344 +
345 +
.admin-list-title {
346 +
  font-size: 15px;
347 +
  white-space: nowrap;
348 +
  overflow: hidden;
349 +
  text-overflow: ellipsis;
350 +
}
351 +
352 +
.admin-list-meta {
353 +
  display: flex;
354 +
  gap: 0.75rem;
355 +
  align-items: center;
356 +
}
357 +
358 +
.admin-list-date {
359 +
  font-size: 11px;
360 +
  opacity: 0.4;
361 +
}
362 +
363 +
.admin-list-actions {
364 +
  display: flex;
365 +
  gap: 1rem;
366 +
  font-size: 12px;
367 +
  flex-shrink: 0;
368 +
}
369 +
370 +
/* Status badges */
371 +
372 +
.status-badge {
373 +
  font-size: 11px;
374 +
  padding: 1px 6px;
375 +
  border: 1px solid #333;
376 +
}
377 +
378 +
.status-published {
379 +
  opacity: 1;
380 +
  border-color: #555;
381 +
}
382 +
383 +
.status-draft {
384 +
  opacity: 0.4;
385 +
}
386 +
387 +
/* Form layout */
388 +
389 +
.form-row {
390 +
  display: flex;
391 +
  gap: 0.5rem;
392 +
  width: 100%;
393 +
}
394 +
395 +
.form-row .form-field {
396 +
  flex: 1;
397 +
}
398 +
399 +
.form-field {
400 +
  display: flex;
401 +
  flex-direction: column;
402 +
  gap: 0.25rem;
403 +
}
404 +
405 +
.checkbox-field {
406 +
  justify-content: flex-end;
407 +
}
408 +
409 +
.checkbox-field label {
410 +
  display: flex;
411 +
  align-items: center;
412 +
  gap: 0.5rem;
413 +
  font-size: 14px;
414 +
  opacity: 1;
415 +
  cursor: pointer;
416 +
}
417 +
418 +
.checkbox-field input[type="checkbox"] {
419 +
  width: 16px;
420 +
  height: 16px;
421 +
  -webkit-appearance: none;
422 +
  appearance: none;
423 +
  background: transparent;
424 +
  border: 1px solid white;
425 +
  border-radius: 0;
426 +
  cursor: pointer;
427 +
  position: relative;
428 +
}
429 +
430 +
.checkbox-field input[type="checkbox"]:checked::after {
431 +
  content: '✔︎';
432 +
  position: absolute;
433 +
  top: 50%;
434 +
  left: 50%;
435 +
  transform: translate(-50%, -50%);
436 +
  font-size: 12px;
437 +
  color: white;
438 +
  line-height: 1;
439 +
}
440 +
441 +
.form-actions {
442 +
  display: flex;
443 +
  gap: 0.5rem;
444 +
}
445 +
446 +
@media (max-width: 480px) {
447 +
  .form-row {
448 +
    flex-direction: column;
449 +
  }
450 +
451 +
  .admin-list-item {
452 +
    flex-direction: column;
453 +
    align-items: flex-start;
454 +
    gap: 0.5rem;
455 +
  }
456 +
}
457 +
458 +
/* Markdown rendered content */
459 +
460 +
.markdown-body {
461 +
  width: 100%;
462 +
  line-height: 1.6;
463 +
}
464 +
465 +
.markdown-body h1,
466 +
.markdown-body h2,
467 +
.markdown-body h3,
468 +
.markdown-body h4,
469 +
.markdown-body h5,
470 +
.markdown-body h6 {
471 +
  margin-top: 1.5rem;
472 +
  margin-bottom: 0.5rem;
473 +
  font-weight: 700;
474 +
}
475 +
476 +
.markdown-body h1 { font-size: 18px; }
477 +
.markdown-body h2 { font-size: 16px; }
478 +
.markdown-body h3 { font-size: 15px; }
479 +
.markdown-body h4,
480 +
.markdown-body h5,
481 +
.markdown-body h6 { font-size: 14px; }
482 +
483 +
.markdown-body p {
484 +
  margin-bottom: 0.75rem;
485 +
}
486 +
487 +
.markdown-body ul,
488 +
.markdown-body ol {
489 +
  margin-left: 1.5rem;
490 +
  margin-bottom: 0.75rem;
491 +
}
492 +
493 +
.markdown-body li {
494 +
  margin-bottom: 0.25rem;
495 +
}
496 +
497 +
.markdown-body code {
498 +
  background: #1e1c1f;
499 +
  padding: 2px 4px;
500 +
  font-size: 13px;
501 +
}
502 +
503 +
.markdown-body pre {
504 +
  background: #1e1c1f;
505 +
  padding: 12px;
506 +
  overflow-x: auto;
507 +
  margin-bottom: 0.75rem;
508 +
  border: 1px solid #333;
509 +
}
510 +
511 +
.markdown-body pre code {
512 +
  background: none;
513 +
  padding: 0;
514 +
}
515 +
516 +
.markdown-body blockquote {
517 +
  border-left: 2px solid #555;
518 +
  padding-left: 12px;
519 +
  opacity: 0.7;
520 +
  margin-bottom: 0.75rem;
521 +
}
522 +
523 +
.markdown-body table {
524 +
  width: 100%;
525 +
  border-collapse: collapse;
526 +
  margin-bottom: 0.75rem;
527 +
}
528 +
529 +
.markdown-body th,
530 +
.markdown-body td {
531 +
  border: 1px solid #333;
532 +
  padding: 6px;
533 +
  text-align: left;
534 +
}
535 +
536 +
.markdown-body th {
537 +
  font-weight: 700;
538 +
  text-transform: uppercase;
539 +
  opacity: 0.5;
540 +
  font-size: 12px;
541 +
}
542 +
543 +
.markdown-body hr {
544 +
  border: none;
545 +
  border-top: 1px solid #333;
546 +
  margin: 1rem 0;
547 +
}
548 +
549 +
.markdown-body a {
550 +
  text-decoration: underline;
551 +
}
552 +
553 +
.markdown-body img {
554 +
  max-width: 100%;
555 +
}
556 +
557 +
.markdown-body li:has(> input[type="checkbox"]) {
558 +
  list-style: none;
559 +
  margin-left: -1.5rem;
560 +
}
561 +
562 +
.markdown-body input[type="checkbox"] {
563 +
  -webkit-appearance: none;
564 +
  appearance: none;
565 +
  width: 14px;
566 +
  height: 14px;
567 +
  background: transparent;
568 +
  border: 1px solid white;
569 +
  border-radius: 0;
570 +
  padding: 0;
571 +
  margin-right: 6px;
572 +
  vertical-align: middle;
573 +
  position: relative;
574 +
  top: -1px;
575 +
  cursor: pointer;
576 +
}
577 +
578 +
.markdown-body input[type="checkbox"]:checked::after {
579 +
  content: '✔︎';
580 +
  position: absolute;
581 +
  top: 50%;
582 +
  left: 50%;
583 +
  transform: translate(-50%, -50%);
584 +
  font-size: 12px;
585 +
  color: white;
586 +
  line-height: 1;
587 +
}
apps/posts/templates/admin_base.html (added) +26 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>{% block title %}Admin{% endblock %}</title>
7 +
  <link rel="icon" href="/static/favicons/favicon.ico">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/static/styles.css">
10 +
</head>
11 +
<body>
12 +
  <header class="header">
13 +
    <a href="/admin" class="logo">Admin</a>
14 +
    <nav class="links">
15 +
      <a href="/admin">posts</a>
16 +
      <a href="/admin/pages">pages</a>
17 +
      <a href="/admin/settings">settings</a>
18 +
      <a href="/" target="_blank">view site</a>
19 +
      <a href="/admin/logout">logout</a>
20 +
    </nav>
21 +
  </header>
22 +
  <main>
23 +
    {% block content %}{% endblock %}
24 +
  </main>
25 +
</body>
26 +
</html>
apps/posts/templates/admin_index.html (added) +36 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — Posts{% endblock %}
3 +
{% block content %}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Posts</h2>
6 +
    <a href="/admin/posts/new" class="btn">new post</a>
7 +
  </div>
8 +
  {% if posts.is_empty() %}
9 +
    <p class="empty">no posts yet</p>
10 +
  {% else %}
11 +
    <div class="admin-list">
12 +
      {% for post in posts %}
13 +
        <div class="admin-list-item">
14 +
          <div class="admin-list-info">
15 +
            <a href="/admin/posts/{{ post.short_id }}/edit" class="admin-list-title">{{ post.title }}</a>
16 +
            <div class="admin-list-meta">
17 +
              <span class="status-badge {% if post.status == "published" %}status-published{% else %}status-draft{% endif %}">{{ post.status }}</span>
18 +
              <span class="admin-list-date">{{ post.updated_at }}</span>
19 +
            </div>
20 +
          </div>
21 +
          <div class="admin-list-actions">
22 +
            <a href="/admin/posts/{{ post.short_id }}/edit">edit</a>
23 +
            <form method="POST" action="/admin/posts/{{ post.short_id }}/publish" class="inline-form">
24 +
              <button type="submit" class="link-button">
25 +
                {% if post.status == "published" %}unpublish{% else %}publish{% endif %}
26 +
              </button>
27 +
            </form>
28 +
            <form method="POST" action="/admin/posts/{{ post.short_id }}/delete" class="inline-form">
29 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this post?')">delete</button>
30 +
            </form>
31 +
          </div>
32 +
        </div>
33 +
      {% endfor %}
34 +
    </div>
35 +
  {% endif %}
36 +
{% endblock %}
apps/posts/templates/admin_page_form.html (added) +80 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — {% if page.is_some() %}Edit Page{% else %}New Page{% endif %}{% endblock %}
3 +
{% block content %}
4 +
  <h2>{% if page.is_some() %}Edit Page{% else %}New Page{% endif %}</h2>
5 +
  {% if let Some(error) = error %}
6 +
    <p class="error">{{ error }}</p>
7 +
  {% endif %}
8 +
  {% match page %}
9 +
    {% when Some with (p) %}
10 +
      <form method="POST" action="/admin/pages/{{ p.short_id }}" class="form">
11 +
        <div class="form-row">
12 +
          <div class="form-field">
13 +
            <label for="title">title</label>
14 +
            <input type="text" id="title" name="title" value="{{ p.title }}" required>
15 +
          </div>
16 +
          <div class="form-field">
17 +
            <label for="slug">slug</label>
18 +
            <input type="text" id="slug" name="slug" value="{{ p.slug }}" required>
19 +
          </div>
20 +
        </div>
21 +
        <div class="form-row">
22 +
          <div class="form-field">
23 +
            <label for="nav_order">nav order</label>
24 +
            <input type="number" id="nav_order" name="nav_order" value="{{ p.nav_order }}">
25 +
          </div>
26 +
          <div class="form-field checkbox-field">
27 +
            <label>
28 +
              <input type="checkbox" id="is_published" name="is_published" {% if p.is_published %}checked{% endif %}>
29 +
              published
30 +
            </label>
31 +
          </div>
32 +
        </div>
33 +
        <label for="content">content</label>
34 +
        <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea>
35 +
        <button type="submit">save</button>
36 +
      </form>
37 +
    {% when None %}
38 +
      <form method="POST" action="/admin/pages/create" class="form">
39 +
        <div class="form-row">
40 +
          <div class="form-field">
41 +
            <label for="title">title</label>
42 +
            <input type="text" id="title" name="title" required autofocus>
43 +
          </div>
44 +
          <div class="form-field">
45 +
            <label for="slug">slug</label>
46 +
            <input type="text" id="slug" name="slug" required>
47 +
          </div>
48 +
        </div>
49 +
        <div class="form-row">
50 +
          <div class="form-field">
51 +
            <label for="nav_order">nav order</label>
52 +
            <input type="number" id="nav_order" name="nav_order" value="0">
53 +
          </div>
54 +
          <div class="form-field checkbox-field">
55 +
            <label>
56 +
              <input type="checkbox" id="is_published" name="is_published">
57 +
              published
58 +
            </label>
59 +
          </div>
60 +
        </div>
61 +
        <label for="content">content</label>
62 +
        <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea>
63 +
        <button type="submit">save</button>
64 +
      </form>
65 +
  {% endmatch %}
66 +
  <script>
67 +
    const titleInput = document.getElementById('title');
68 +
    const slugInput = document.getElementById('slug');
69 +
    let slugEdited = slugInput.value !== '';
70 +
    slugInput.addEventListener('input', () => { slugEdited = true; });
71 +
    titleInput.addEventListener('input', () => {
72 +
      if (!slugEdited) {
73 +
        slugInput.value = titleInput.value
74 +
          .toLowerCase()
75 +
          .replace(/[^a-z0-9]+/g, '-')
76 +
          .replace(/^-|-$/g, '');
77 +
      }
78 +
    });
79 +
  </script>
80 +
{% endblock %}
apps/posts/templates/admin_pages.html (added) +34 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — Pages{% endblock %}
3 +
{% block content %}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Pages</h2>
6 +
    <a href="/admin/pages/new" class="btn">new page</a>
7 +
  </div>
8 +
  {% if pages.is_empty() %}
9 +
    <p class="empty">no pages yet</p>
10 +
  {% else %}
11 +
    <div class="admin-list">
12 +
      {% for page in pages %}
13 +
        <div class="admin-list-item">
14 +
          <div class="admin-list-info">
15 +
            <a href="/admin/pages/{{ page.short_id }}/edit" class="admin-list-title">{{ page.title }}</a>
16 +
            <div class="admin-list-meta">
17 +
              <span class="status-badge {% if page.is_published %}status-published{% else %}status-draft{% endif %}">
18 +
                {% if page.is_published %}published{% else %}draft{% endif %}
19 +
              </span>
20 +
              <span class="admin-list-date">/pages/{{ page.slug }}</span>
21 +
              <span class="admin-list-date">order: {{ page.nav_order }}</span>
22 +
            </div>
23 +
          </div>
24 +
          <div class="admin-list-actions">
25 +
            <a href="/admin/pages/{{ page.short_id }}/edit">edit</a>
26 +
            <form method="POST" action="/admin/pages/{{ page.short_id }}/delete" class="inline-form">
27 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this page?')">delete</button>
28 +
            </form>
29 +
          </div>
30 +
        </div>
31 +
      {% endfor %}
32 +
    </div>
33 +
  {% endif %}
34 +
{% endblock %}
apps/posts/templates/admin_post_form.html (added) +75 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — {% if post.is_some() %}Edit Post{% else %}New Post{% endif %}{% endblock %}
3 +
{% block content %}
4 +
  <h2>{% if post.is_some() %}Edit Post{% else %}New Post{% endif %}</h2>
5 +
  {% if let Some(error) = error %}
6 +
    <p class="error">{{ error }}</p>
7 +
  {% endif %}
8 +
  {% match post %}
9 +
    {% when Some with (p) %}
10 +
      <form method="POST" action="/admin/posts/{{ p.short_id }}" class="form post-form">
11 +
        <textarea name="attributes" class="attributes-textarea">title: {{ p.title }}
12 +
slug: {{ p.slug }}
13 +
{%- if p.published_date.is_some() %}
14 +
published_date: {{ p.published_date.as_deref().unwrap_or_default() }}
15 +
{%- endif %}
16 +
{%- if p.lang != "en" %}
17 +
lang: {{ p.lang }}
18 +
{%- endif %}
19 +
{%- if p.tags.is_some() %}
20 +
tags: {{ p.tags.as_deref().unwrap_or_default() }}
21 +
{%- endif %}
22 +
{%- if p.alias.is_some() %}
23 +
alias: {{ p.alias.as_deref().unwrap_or_default() }}
24 +
{%- endif %}
25 +
{%- if p.meta_image.is_some() %}
26 +
meta_image: {{ p.meta_image.as_deref().unwrap_or_default() }}
27 +
{%- endif %}
28 +
{%- if p.meta_description.is_some() %}
29 +
meta_description: {{ p.meta_description.as_deref().unwrap_or_default() }}
30 +
{%- endif %}</textarea>
31 +
        <details class="available-fields">
32 +
          <summary>available fields</summary>
33 +
          <div class="fields-list">
34 +
            <span>title: My Post Title</span>
35 +
            <span>slug: my-post-title</span>
36 +
            <span>published_date: 2025-01-15 14:30:00</span>
37 +
            <span>lang: en</span>
38 +
            <span>tags: rust, web, tutorial</span>
39 +
            <span>alias: /old/path</span>
40 +
            <span>meta_image: https://example.com/image.jpg</span>
41 +
            <span>meta_description: A short summary of the post</span>
42 +
          </div>
43 +
        </details>
44 +
        <label for="content">content</label>
45 +
        <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea>
46 +
        <div class="form-actions">
47 +
          <button type="submit" name="action" value="draft">save draft</button>
48 +
          <button type="submit" name="action" value="publish">publish</button>
49 +
        </div>
50 +
      </form>
51 +
    {% when None %}
52 +
      <form method="POST" action="/admin/posts" class="form post-form">
53 +
        <textarea name="attributes" class="attributes-textarea">title: </textarea>
54 +
        <details class="available-fields">
55 +
          <summary>available fields</summary>
56 +
          <div class="fields-list">
57 +
            <span>title: My Post Title</span>
58 +
            <span>slug: my-post-title</span>
59 +
            <span>published_date: 2025-01-15 14:30:00</span>
60 +
            <span>lang: en</span>
61 +
            <span>tags: rust, web, tutorial</span>
62 +
            <span>alias: /old/path</span>
63 +
            <span>meta_image: https://example.com/image.jpg</span>
64 +
            <span>meta_description: A short summary of the post</span>
65 +
          </div>
66 +
        </details>
67 +
        <label for="content">content</label>
68 +
        <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea>
69 +
        <div class="form-actions">
70 +
          <button type="submit" name="action" value="draft">save draft</button>
71 +
          <button type="submit" name="action" value="publish">publish</button>
72 +
        </div>
73 +
      </form>
74 +
  {% endmatch %}
75 +
{% endblock %}
apps/posts/templates/admin_settings.html (added) +17 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — Settings{% endblock %}
3 +
{% block content %}
4 +
  <h2>Settings</h2>
5 +
  {% if success %}
6 +
    <p class="success">Settings saved.</p>
7 +
  {% endif %}
8 +
  <form method="POST" action="/admin/settings" class="form">
9 +
    <label for="blog_title">blog title</label>
10 +
    <input type="text" id="blog_title" name="blog_title" value="{{ blog_title }}" required>
11 +
    <label for="blog_description">blog description</label>
12 +
    <input type="text" id="blog_description" name="blog_description" value="{{ blog_description }}">
13 +
    <label for="intro_content">intro content (markdown, shown on homepage)</label>
14 +
    <textarea id="intro_content" name="intro_content" class="post-content">{{ intro_content }}</textarea>
15 +
    <button type="submit">save</button>
16 +
  </form>
17 +
{% endblock %}
apps/posts/templates/base.html (added) +26 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>{% block title %}{{ blog_title }}{% endblock %}</title>
7 +
  <link rel="icon" href="/static/favicons/favicon.ico">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  {% block meta %}{% endblock %}
10 +
  <link rel="stylesheet" href="/static/styles.css">
11 +
</head>
12 +
<body>
13 +
  <header class="header">
14 +
    <a href="/" class="logo">{{ blog_title }}</a>
15 +
    <nav class="links">
16 +
      <a href="/">blog</a>
17 +
      {% for page in nav_pages %}
18 +
        <a href="/pages/{{ page.slug }}">{{ page.title }}</a>
19 +
      {% endfor %}
20 +
    </nav>
21 +
  </header>
22 +
  <main>
23 +
    {% block content %}{% endblock %}
24 +
  </main>
25 +
</body>
26 +
</html>
apps/posts/templates/index.html (added) +39 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ blog_title }}{% endblock %}
3 +
{% block meta %}
4 +
  <meta name="description" content="{{ blog_description }}">
5 +
  <meta property="og:title" content="{{ blog_title }}">
6 +
  <meta property="og:description" content="{{ blog_description }}">
7 +
  <meta property="og:type" content="website">
8 +
{% endblock %}
9 +
{% block content %}
10 +
  {% if !intro_html.is_empty() %}
11 +
    <article class="intro markdown-body">
12 +
      {{ intro_html|safe }}
13 +
    </article>
14 +
  {% endif %}
15 +
  {% if posts.is_empty() %}
16 +
    <p class="empty">no posts yet</p>
17 +
  {% endif %}
18 +
  <div class="post-list">
19 +
    {% for post in posts %}
20 +
      <a href="/posts/{{ post.slug }}" class="post-item">
21 +
        <div class="post-item-info">
22 +
          <span class="post-title">{{ post.title }}</span>
23 +
          {% if post.tags.is_some() %}
24 +
            <span class="post-tags">
25 +
              {% for tag in post.tags.as_deref().unwrap_or_default().split(',') %}
26 +
                {% if !tag.trim().is_empty() %}
27 +
                  <span class="tag">{{ tag.trim() }}</span>
28 +
                {% endif %}
29 +
              {% endfor %}
30 +
            </span>
31 +
          {% endif %}
32 +
        </div>
33 +
        {% if post.published_date.is_some() %}
34 +
          <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time>
35 +
        {% endif %}
36 +
      </a>
37 +
    {% endfor %}
38 +
  </div>
39 +
{% endblock %}
apps/posts/templates/login.html (added) +26 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>Login</title>
7 +
  <link rel="icon" href="/static/favicons/favicon.ico">
8 +
  <meta name="theme-color" content="#121113" />
9 +
  <link rel="stylesheet" href="/static/styles.css">
10 +
</head>
11 +
<body>
12 +
  <header class="header">
13 +
    <span class="logo">POSTS</span>
14 +
  </header>
15 +
  <main>
16 +
    {% if let Some(error) = error %}
17 +
      <p class="error">{{ error }}</p>
18 +
    {% endif %}
19 +
    <form method="POST" action="/admin/login" class="form">
20 +
      <label for="password">password</label>
21 +
      <input type="password" id="password" name="password" autofocus required>
22 +
      <button type="submit">login</button>
23 +
    </form>
24 +
  </main>
25 +
</body>
26 +
</html>
apps/posts/templates/page.html (added) +10 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ page.title }} — {{ blog_title }}{% endblock %}
3 +
{% block content %}
4 +
  <div class="page-header">
5 +
    <h1>{{ page.title }}</h1>
6 +
  </div>
7 +
  <article class="markdown-body">
8 +
    {{ rendered_content|safe }}
9 +
  </article>
10 +
{% endblock %}
apps/posts/templates/post.html (added) +37 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ post.title }} — {{ blog_title }}{% endblock %}
3 +
{% block meta %}
4 +
  {% if post.meta_description.is_some() %}
5 +
    <meta name="description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}">
6 +
    <meta property="og:description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}">
7 +
  {% endif %}
8 +
  {% if post.meta_image.is_some() %}
9 +
    <meta property="og:image" content="{{ post.meta_image.as_deref().unwrap_or_default() }}">
10 +
  {% endif %}
11 +
  {% if post.canonical_url.is_some() %}
12 +
    <link rel="canonical" href="{{ post.canonical_url.as_deref().unwrap_or_default() }}">
13 +
  {% endif %}
14 +
  <meta property="og:title" content="{{ post.title }}">
15 +
  <meta property="og:type" content="article">
16 +
  <meta property="article:published_time" content="{{ post.published_date.as_deref().unwrap_or_default() }}">
17 +
{% endblock %}
18 +
{% block content %}
19 +
  <div class="post-header">
20 +
    <h1>{{ post.title }}</h1>
21 +
    {% if post.published_date.is_some() %}
22 +
      <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time>
23 +
    {% endif %}
24 +
    {% if post.tags.is_some() %}
25 +
      <div class="post-tags">
26 +
        {% for tag in post.tags.as_deref().unwrap_or_default().split(',') %}
27 +
          {% if !tag.trim().is_empty() %}
28 +
            <span class="tag">{{ tag.trim() }}</span>
29 +
          {% endif %}
30 +
        {% endfor %}
31 +
      </div>
32 +
    {% endif %}
33 +
  </div>
34 +
  <article class="markdown-body">
35 +
    {{ rendered_content|safe }}
36 +
  </article>
37 +
{% endblock %}