Merge pull request #13 from stevedylandev/feat/posts-init 68d1e9a0
Init Posts App
Steve Simkins · 2026-04-07 20:04 35 file(s) · +3332 −4
.gitignore +1 −0
3 3
*.db
4 4
.env
5 5
.DS_Store
6 +
apps/posts/uploads
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"
README.md +1 −0
15 15
| [**OG**](apps/og) | Open Graph tag inspector | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/OdXBt_?referralCode=JGcIp6) |
16 16
| [**Shrink**](apps/shrink) | Image compression and resizing | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/enYUFb?referralCode=JGcIp6) |
17 17
| [**Cellar**](apps/cellar) | Minimal wine collection tracker | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/MNprVh?referralCode=JGcIp6) |
18 +
| [**Posts**](apps/posts) | Minimal CMS blog with admin interface | |
18 19
19 20
## Shared Crates
20 21
apps/backup/.env.example +1 −0
9 9
# JOTTS_VOLUME=jotts_jotts-data
10 10
# SIPP_VOLUME=sipp_sipp-data
11 11
# CELLAR_VOLUME=cellar_cellar-data
12 +
# POSTS_VOLUME=posts_posts-data
12 13
13 14
# Optional: days to keep backups (default: 30)
14 15
# RETENTION_DAYS=30
apps/backup/README.md +4 −1
1 1
# Backup
2 2
3 -
Automated SQLite backups for Jotts, Sipp, and Cellar to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
3 +
Automated SQLite backups for Jotts, Sipp, Cellar, and Posts to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
4 4
5 5
## Setup
6 6
44 44
JOTTS_VOLUME=jotts_jotts-data
45 45
SIPP_VOLUME=sipp_sipp-data
46 46
CELLAR_VOLUME=cellar_cellar-data
47 +
POSTS_VOLUME=posts_posts-data
47 48
```
48 49
49 50
Run `docker volume ls` to check the actual names on your host.
81 82
  -v jotts_jotts-data:/data/jotts:ro \
82 83
  -v sipp_sipp-data:/data/sipp:ro \
83 84
  -v cellar_cellar-data:/data/cellar:ro \
85 +
  -v posts_posts-data:/data/posts:ro \
84 86
  ghcr.io/stevedylandev/andromeda-backup:latest
85 87
```
86 88
148 150
| `JOTTS_VOLUME` | `jotts_jotts-data` | Docker volume name for Jotts data |
149 151
| `SIPP_VOLUME` | `sipp_sipp-data` | Docker volume name for Sipp data |
150 152
| `CELLAR_VOLUME` | `cellar_cellar-data` | Docker volume name for Cellar data |
153 +
| `POSTS_VOLUME` | `posts_posts-data` | Docker volume name for Posts data |
apps/backup/backup.sh +2 −2
5 5
BUCKET="${R2_BUCKET:-andromeda-backups}"
6 6
RETENTION_DAYS="${RETENTION_DAYS:-30}"
7 7
8 -
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite"
8 +
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite"
9 9
10 10
for entry in $DBS; do
11 11
  name="${entry%%:*}"
28 28
29 29
# Prune old backups
30 30
cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || date -u -v-${RETENTION_DAYS}d +%Y-%m-%d)
31 -
for name in jotts sipp cellar; do
31 +
for name in jotts sipp cellar posts; do
32 32
  aws s3 ls "s3://${BUCKET}/${name}/" --endpoint-url "${R2_ENDPOINT}" 2>/dev/null | while read -r line; do
33 33
    filedate=$(echo "$line" | awk '{print $1}')
34 34
    filename=$(echo "$line" | awk '{print $4}')
apps/backup/docker-compose.yml +4 −0
5 5
      - jotts-data:/data/jotts:ro
6 6
      - sipp-data:/data/sipp:ro
7 7
      - cellar-data:/data/cellar:ro
8 +
      - posts-data:/data/posts:ro
8 9
    env_file: .env
9 10
    restart: unless-stopped
10 11
18 19
  cellar-data:
19 20
    external: true
20 21
    name: ${CELLAR_VOLUME:-cellar_cellar-data}
22 +
  posts-data:
23 +
    external: true
24 +
    name: ${POSTS_VOLUME:-posts_posts-data}
apps/posts/.env.example (added) +7 −0
1 +
POSTS_PASSWORD=changeme
2 +
POSTS_DB_PATH=posts.sqlite
3 +
UPLOADS_DIR=uploads
4 +
COOKIE_SECURE=false
5 +
HOST=127.0.0.1
6 +
PORT=3000
7 +
SITE_URL=http://localhost: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, features = ["multipart"] }
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/LICENSE (added) +22 −0
1 +
MIT License
2 +
3 +
Copyright (c) 2026 Steve Simkins
4 +
5 +
Permission is hereby granted, free of charge, to any person obtaining a copy
6 +
of this software and associated documentation files (the "Software"), to deal
7 +
in the Software without restriction, including without limitation the rights
8 +
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +
copies of the Software, and to permit persons to whom the Software is
10 +
furnished to do so, subject to the following conditions:
11 +
12 +
The above copyright notice and this permission notice shall be included in all
13 +
copies or substantial portions of the Software.
14 +
15 +
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +
SOFTWARE.
22 +
apps/posts/README.md (added) +94 −0
1 +
# Posts
2 +
3 +
![cover](https://assets.andromeda.build/posts-demo.png)
4 +
5 +
A minimal CMS blog with admin interface
6 +
7 +
## Quickstart
8 +
9 +
```bash
10 +
cd apps/posts
11 +
cp .env.example .env
12 +
# Edit .env with your password
13 +
cargo build --release
14 +
./target/release/posts
15 +
```
16 +
17 +
### Environment Variables
18 +
19 +
| Variable | Description | Default |
20 +
|---|---|---|
21 +
| `POSTS_PASSWORD` | Password for admin login | `changeme` |
22 +
| `POSTS_DB_PATH` | SQLite database file path | `posts.sqlite` |
23 +
| `UPLOADS_DIR` | Directory for uploaded files | `uploads` |
24 +
| `SITE_URL` | Public URL for RSS feed and links | `http://localhost:3000` |
25 +
| `HOST` | Server bind address | `127.0.0.1` |
26 +
| `PORT` | Server port | `3000` |
27 +
| `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` |
28 +
29 +
## Overview
30 +
31 +
A self-hosted blog CMS built with Rust. Here's a few highlights:
32 +
- Single Rust binary with embedded assets
33 +
- Password authentication with session cookies
34 +
- Create, edit, publish, and delete blog posts with markdown
35 +
- Static pages with custom navigation links
36 +
- File uploads with admin management
37 +
- Custom CSS support from the admin panel
38 +
- RSS feed at `/feed.xml`
39 +
- Dark themed UI with Commit Mono font
40 +
- SQLite for persistent storage
41 +
42 +
## Structure
43 +
44 +
```
45 +
posts/
46 +
├── src/
47 +
│   ├── main.rs        # App entrypoint, env vars, starts server
48 +
│   ├── server.rs      # Axum router, HTTP handlers, and templates
49 +
│   ├── auth.rs        # Password verification and session management
50 +
│   └── db.rs          # SQLite database layer (posts, pages, files, settings, sessions)
51 +
├── templates/         # Askama HTML templates
52 +
│   ├── base.html            # Public base layout
53 +
│   ├── index.html           # Blog home page
54 +
│   ├── post.html            # Single post view
55 +
│   ├── posts.html           # Post listing
56 +
│   ├── page.html            # Static page view
57 +
│   ├── login.html           # Login page
58 +
│   ├── admin_base.html      # Admin layout
59 +
│   ├── admin_index.html     # Admin dashboard
60 +
│   ├── admin_post_form.html # Create/edit post form
61 +
│   ├── admin_pages.html     # Admin page listing
62 +
│   ├── admin_page_form.html # Create/edit page form
63 +
│   ├── admin_files.html     # File upload management
64 +
│   └── admin_settings.html  # Blog settings
65 +
├── static/            # Favicons, fonts, and styles
66 +
├── uploads/           # Uploaded files directory
67 +
├── Dockerfile
68 +
└── docker-compose.yml
69 +
```
70 +
71 +
## Deployment
72 +
73 +
### Docker (recommended)
74 +
75 +
```bash
76 +
cd apps/posts
77 +
cp .env.example .env
78 +
# Edit .env with your password
79 +
docker compose up -d
80 +
```
81 +
82 +
This will start Posts on port `3000` with a persistent volume for the SQLite database and uploads.
83 +
84 +
### Binary
85 +
86 +
```bash
87 +
cargo build --release
88 +
```
89 +
90 +
The resulting binary at `./target/release/posts` is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly.
91 +
92 +
## License
93 +
94 +
[MIT](LICENSE)
apps/posts/docker-compose.yml (added) +21 −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 +
      - UPLOADS_DIR=/data/uploads
12 +
      - SITE_URL=${SITE_URL:-http://localhost:3000}
13 +
      - COOKIE_SECURE=false
14 +
      - HOST=0.0.0.0
15 +
      - PORT=${PORT:-3000}
16 +
    volumes:
17 +
      - posts-data:/data
18 +
    restart: unless-stopped
19 +
20 +
volumes:
21 +
  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) +602 −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 +
#[derive(Debug, Serialize, Deserialize, Clone)]
65 +
pub struct UploadedFile {
66 +
    pub id: i64,
67 +
    pub short_id: String,
68 +
    pub filename: String,
69 +
    pub original_name: String,
70 +
    pub content_type: String,
71 +
    pub size: i64,
72 +
    pub created_at: String,
73 +
}
74 +
75 +
pub fn init_db() -> Db {
76 +
    let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string());
77 +
    let conn = Connection::open(&path).expect("Failed to open database");
78 +
79 +
    conn.execute_batch(
80 +
        "CREATE TABLE IF NOT EXISTS posts (
81 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
82 +
            short_id        TEXT NOT NULL UNIQUE,
83 +
            title           TEXT NOT NULL,
84 +
            slug            TEXT NOT NULL UNIQUE,
85 +
            alias           TEXT,
86 +
            canonical_url   TEXT,
87 +
            published_date  TEXT,
88 +
            meta_description TEXT,
89 +
            meta_image      TEXT,
90 +
            lang            TEXT NOT NULL DEFAULT 'en',
91 +
            tags            TEXT,
92 +
            content         TEXT NOT NULL,
93 +
            status          TEXT NOT NULL DEFAULT 'draft',
94 +
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
95 +
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
96 +
        );
97 +
98 +
        CREATE TABLE IF NOT EXISTS pages (
99 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
100 +
            short_id        TEXT NOT NULL UNIQUE,
101 +
            title           TEXT NOT NULL,
102 +
            slug            TEXT NOT NULL UNIQUE,
103 +
            content         TEXT NOT NULL,
104 +
            is_published    INTEGER NOT NULL DEFAULT 0,
105 +
            nav_order       INTEGER NOT NULL DEFAULT 0,
106 +
            created_at      TEXT NOT NULL DEFAULT (datetime('now')),
107 +
            updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
108 +
        );
109 +
110 +
        CREATE TABLE IF NOT EXISTS sessions (
111 +
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
112 +
            token           TEXT NOT NULL UNIQUE,
113 +
            expires_at      TEXT NOT NULL
114 +
        );
115 +
116 +
        CREATE TABLE IF NOT EXISTS settings (
117 +
            key   TEXT PRIMARY KEY,
118 +
            value TEXT NOT NULL
119 +
        );
120 +
121 +
        CREATE TABLE IF NOT EXISTS files (
122 +
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
123 +
            short_id      TEXT NOT NULL UNIQUE,
124 +
            filename      TEXT NOT NULL UNIQUE,
125 +
            original_name TEXT NOT NULL,
126 +
            content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
127 +
            size          INTEGER NOT NULL,
128 +
            created_at    TEXT NOT NULL DEFAULT (datetime('now'))
129 +
        );"
130 +
    )
131 +
    .expect("Failed to create tables");
132 +
133 +
    // Seed default settings
134 +
    conn.execute(
135 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_title', 'My Blog')",
136 +
        [],
137 +
    )
138 +
    .ok();
139 +
    conn.execute(
140 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_description', 'A simple blog')",
141 +
        [],
142 +
    )
143 +
    .ok();
144 +
    conn.execute(
145 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('intro_content', '')",
146 +
        [],
147 +
    )
148 +
    .ok();
149 +
    conn.execute(
150 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('nav_links', '[blog](/) [posts](/posts)')",
151 +
        [],
152 +
    )
153 +
    .ok();
154 +
    conn.execute(
155 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_css', '')",
156 +
        [],
157 +
    )
158 +
    .ok();
159 +
160 +
    Arc::new(Mutex::new(conn))
161 +
}
162 +
163 +
// --- Post CRUD ---
164 +
165 +
fn row_to_post(row: &rusqlite::Row) -> rusqlite::Result<Post> {
166 +
    Ok(Post {
167 +
        id: row.get(0)?,
168 +
        short_id: row.get(1)?,
169 +
        title: row.get(2)?,
170 +
        slug: row.get(3)?,
171 +
        alias: row.get(4)?,
172 +
        canonical_url: row.get(5)?,
173 +
        published_date: row.get(6)?,
174 +
        meta_description: row.get(7)?,
175 +
        meta_image: row.get(8)?,
176 +
        lang: row.get(9)?,
177 +
        tags: row.get(10)?,
178 +
        content: row.get(11)?,
179 +
        status: row.get(12)?,
180 +
        created_at: row.get(13)?,
181 +
        updated_at: row.get(14)?,
182 +
    })
183 +
}
184 +
185 +
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";
186 +
187 +
pub fn create_post(
188 +
    db: &Db,
189 +
    title: &str,
190 +
    slug: &str,
191 +
    content: &str,
192 +
    status: &str,
193 +
    alias: Option<&str>,
194 +
    canonical_url: Option<&str>,
195 +
    published_date: Option<&str>,
196 +
    meta_description: Option<&str>,
197 +
    meta_image: Option<&str>,
198 +
    lang: &str,
199 +
    tags: Option<&str>,
200 +
) -> Result<Post, DbError> {
201 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
202 +
    let short_id = nanoid!(10);
203 +
    conn.execute(
204 +
        "INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags)
205 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
206 +
        params![short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags],
207 +
    )?;
208 +
    let id = conn.last_insert_rowid();
209 +
    let post = conn.query_row(
210 +
        &format!("SELECT {} FROM posts WHERE id = ?1", POST_COLS),
211 +
        params![id],
212 +
        row_to_post,
213 +
    )?;
214 +
    Ok(post)
215 +
}
216 +
217 +
pub fn get_post_by_short_id(db: &Db, short_id: &str) -> Result<Option<Post>, DbError> {
218 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
219 +
    match conn.query_row(
220 +
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
221 +
        params![short_id],
222 +
        row_to_post,
223 +
    ) {
224 +
        Ok(post) => Ok(Some(post)),
225 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
226 +
        Err(e) => Err(DbError::Sqlite(e)),
227 +
    }
228 +
}
229 +
230 +
pub fn get_post_by_slug(db: &Db, slug: &str) -> Result<Option<Post>, DbError> {
231 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
232 +
    match conn.query_row(
233 +
        &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS),
234 +
        params![slug],
235 +
        row_to_post,
236 +
    ) {
237 +
        Ok(post) => Ok(Some(post)),
238 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
239 +
        Err(e) => Err(DbError::Sqlite(e)),
240 +
    }
241 +
}
242 +
243 +
pub fn get_all_posts(db: &Db) -> Result<Vec<Post>, DbError> {
244 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
245 +
    let mut stmt = conn.prepare(
246 +
        &format!("SELECT {} FROM posts ORDER BY id DESC", POST_COLS),
247 +
    )?;
248 +
    let posts = stmt
249 +
        .query_map([], row_to_post)?
250 +
        .collect::<Result<Vec<_>, _>>()?;
251 +
    Ok(posts)
252 +
}
253 +
254 +
pub fn get_published_posts(db: &Db) -> Result<Vec<Post>, DbError> {
255 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
256 +
    let mut stmt = conn.prepare(
257 +
        &format!("SELECT {} FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC", POST_COLS),
258 +
    )?;
259 +
    let posts = stmt
260 +
        .query_map([], row_to_post)?
261 +
        .collect::<Result<Vec<_>, _>>()?;
262 +
    Ok(posts)
263 +
}
264 +
265 +
pub fn update_post(
266 +
    db: &Db,
267 +
    short_id: &str,
268 +
    title: &str,
269 +
    slug: &str,
270 +
    content: &str,
271 +
    alias: Option<&str>,
272 +
    canonical_url: Option<&str>,
273 +
    published_date: Option<&str>,
274 +
    meta_description: Option<&str>,
275 +
    meta_image: Option<&str>,
276 +
    lang: &str,
277 +
    tags: Option<&str>,
278 +
) -> Result<Option<Post>, DbError> {
279 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
280 +
    let rows = conn.execute(
281 +
        "UPDATE posts SET title = ?1, slug = ?2, content = ?3, alias = ?4, canonical_url = ?5,
282 +
         published_date = ?6, meta_description = ?7, meta_image = ?8, lang = ?9, tags = ?10,
283 +
         updated_at = datetime('now') WHERE short_id = ?11",
284 +
        params![title, slug, content, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, short_id],
285 +
    )?;
286 +
    if rows == 0 {
287 +
        return Ok(None);
288 +
    }
289 +
    match conn.query_row(
290 +
        &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS),
291 +
        params![short_id],
292 +
        row_to_post,
293 +
    ) {
294 +
        Ok(post) => Ok(Some(post)),
295 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
296 +
        Err(e) => Err(DbError::Sqlite(e)),
297 +
    }
298 +
}
299 +
300 +
pub fn delete_post(db: &Db, short_id: &str) -> Result<bool, DbError> {
301 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
302 +
    let rows = conn.execute("DELETE FROM posts WHERE short_id = ?1", params![short_id])?;
303 +
    Ok(rows > 0)
304 +
}
305 +
306 +
pub fn toggle_post_status(db: &Db, short_id: &str) -> Result<Option<String>, DbError> {
307 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
308 +
    let current: String = match conn.query_row(
309 +
        "SELECT status FROM posts WHERE short_id = ?1",
310 +
        params![short_id],
311 +
        |row| row.get(0),
312 +
    ) {
313 +
        Ok(s) => s,
314 +
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
315 +
        Err(e) => return Err(DbError::Sqlite(e)),
316 +
    };
317 +
    let new_status = if current == "published" { "draft" } else { "published" };
318 +
    if new_status == "published" {
319 +
        conn.execute(
320 +
            "UPDATE posts SET status = ?1, published_date = COALESCE(published_date, datetime('now')), updated_at = datetime('now') WHERE short_id = ?2",
321 +
            params![new_status, short_id],
322 +
        )?;
323 +
    } else {
324 +
        conn.execute(
325 +
            "UPDATE posts SET status = ?1, updated_at = datetime('now') WHERE short_id = ?2",
326 +
            params![new_status, short_id],
327 +
        )?;
328 +
    }
329 +
    Ok(Some(new_status.to_string()))
330 +
}
331 +
332 +
pub fn find_alias_redirect(db: &Db, alias: &str) -> Result<Option<String>, DbError> {
333 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
334 +
    match conn.query_row(
335 +
        "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'",
336 +
        params![alias],
337 +
        |row| row.get::<_, String>(0),
338 +
    ) {
339 +
        Ok(slug) => Ok(Some(format!("/posts/{}", slug))),
340 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
341 +
        Err(e) => Err(DbError::Sqlite(e)),
342 +
    }
343 +
}
344 +
345 +
// --- Page CRUD ---
346 +
347 +
fn row_to_page(row: &rusqlite::Row) -> rusqlite::Result<Page> {
348 +
    Ok(Page {
349 +
        id: row.get(0)?,
350 +
        short_id: row.get(1)?,
351 +
        title: row.get(2)?,
352 +
        slug: row.get(3)?,
353 +
        content: row.get(4)?,
354 +
        is_published: row.get::<_, i64>(5)? != 0,
355 +
        nav_order: row.get(6)?,
356 +
        created_at: row.get(7)?,
357 +
        updated_at: row.get(8)?,
358 +
    })
359 +
}
360 +
361 +
const PAGE_COLS: &str = "id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at";
362 +
363 +
pub fn create_page(
364 +
    db: &Db,
365 +
    title: &str,
366 +
    slug: &str,
367 +
    content: &str,
368 +
    is_published: bool,
369 +
    nav_order: i64,
370 +
) -> Result<Page, DbError> {
371 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
372 +
    let short_id = nanoid!(10);
373 +
    conn.execute(
374 +
        "INSERT INTO pages (short_id, title, slug, content, is_published, nav_order) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
375 +
        params![short_id, title, slug, content, is_published as i64, nav_order],
376 +
    )?;
377 +
    let id = conn.last_insert_rowid();
378 +
    let page = conn.query_row(
379 +
        &format!("SELECT {} FROM pages WHERE id = ?1", PAGE_COLS),
380 +
        params![id],
381 +
        row_to_page,
382 +
    )?;
383 +
    Ok(page)
384 +
}
385 +
386 +
pub fn get_page_by_short_id(db: &Db, short_id: &str) -> Result<Option<Page>, DbError> {
387 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
388 +
    match conn.query_row(
389 +
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
390 +
        params![short_id],
391 +
        row_to_page,
392 +
    ) {
393 +
        Ok(page) => Ok(Some(page)),
394 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
395 +
        Err(e) => Err(DbError::Sqlite(e)),
396 +
    }
397 +
}
398 +
399 +
pub fn get_page_by_slug(db: &Db, slug: &str) -> Result<Option<Page>, DbError> {
400 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
401 +
    match conn.query_row(
402 +
        &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS),
403 +
        params![slug],
404 +
        row_to_page,
405 +
    ) {
406 +
        Ok(page) => Ok(Some(page)),
407 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
408 +
        Err(e) => Err(DbError::Sqlite(e)),
409 +
    }
410 +
}
411 +
412 +
pub fn get_all_pages(db: &Db) -> Result<Vec<Page>, DbError> {
413 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
414 +
    let mut stmt = conn.prepare(
415 +
        &format!("SELECT {} FROM pages ORDER BY nav_order ASC, id ASC", PAGE_COLS),
416 +
    )?;
417 +
    let pages = stmt
418 +
        .query_map([], row_to_page)?
419 +
        .collect::<Result<Vec<_>, _>>()?;
420 +
    Ok(pages)
421 +
}
422 +
423 +
pub fn get_published_pages(db: &Db) -> Result<Vec<Page>, DbError> {
424 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
425 +
    let mut stmt = conn.prepare(
426 +
        &format!("SELECT {} FROM pages WHERE is_published = 1 ORDER BY nav_order ASC, id ASC", PAGE_COLS),
427 +
    )?;
428 +
    let pages = stmt
429 +
        .query_map([], row_to_page)?
430 +
        .collect::<Result<Vec<_>, _>>()?;
431 +
    Ok(pages)
432 +
}
433 +
434 +
pub fn update_page(
435 +
    db: &Db,
436 +
    short_id: &str,
437 +
    title: &str,
438 +
    slug: &str,
439 +
    content: &str,
440 +
    is_published: bool,
441 +
    nav_order: i64,
442 +
) -> Result<Option<Page>, DbError> {
443 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
444 +
    let rows = conn.execute(
445 +
        "UPDATE pages SET title = ?1, slug = ?2, content = ?3, is_published = ?4, nav_order = ?5, updated_at = datetime('now') WHERE short_id = ?6",
446 +
        params![title, slug, content, is_published as i64, nav_order, short_id],
447 +
    )?;
448 +
    if rows == 0 {
449 +
        return Ok(None);
450 +
    }
451 +
    match conn.query_row(
452 +
        &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS),
453 +
        params![short_id],
454 +
        row_to_page,
455 +
    ) {
456 +
        Ok(page) => Ok(Some(page)),
457 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
458 +
        Err(e) => Err(DbError::Sqlite(e)),
459 +
    }
460 +
}
461 +
462 +
pub fn delete_page(db: &Db, short_id: &str) -> Result<bool, DbError> {
463 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
464 +
    let rows = conn.execute("DELETE FROM pages WHERE short_id = ?1", params![short_id])?;
465 +
    Ok(rows > 0)
466 +
}
467 +
468 +
// --- Settings ---
469 +
470 +
pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> {
471 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
472 +
    match conn.query_row(
473 +
        "SELECT value FROM settings WHERE key = ?1",
474 +
        params![key],
475 +
        |row| row.get(0),
476 +
    ) {
477 +
        Ok(val) => Ok(Some(val)),
478 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
479 +
        Err(e) => Err(DbError::Sqlite(e)),
480 +
    }
481 +
}
482 +
483 +
pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> {
484 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
485 +
    conn.execute(
486 +
        "INSERT INTO settings (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2",
487 +
        params![key, value],
488 +
    )?;
489 +
    Ok(())
490 +
}
491 +
492 +
pub fn get_all_settings(db: &Db) -> Result<Vec<(String, String)>, DbError> {
493 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
494 +
    let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
495 +
    let settings = stmt
496 +
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
497 +
        .collect::<Result<Vec<_>, _>>()?;
498 +
    Ok(settings)
499 +
}
500 +
501 +
// --- Session functions ---
502 +
503 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
504 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
505 +
    conn.execute(
506 +
        "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)",
507 +
        params![token, expires_at],
508 +
    )?;
509 +
    Ok(())
510 +
}
511 +
512 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
513 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
514 +
    match conn.query_row(
515 +
        "SELECT expires_at FROM sessions WHERE token = ?1",
516 +
        params![token],
517 +
        |row| row.get(0),
518 +
    ) {
519 +
        Ok(val) => Ok(Some(val)),
520 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
521 +
        Err(e) => Err(DbError::Sqlite(e)),
522 +
    }
523 +
}
524 +
525 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
526 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
527 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
528 +
    Ok(())
529 +
}
530 +
531 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
532 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
533 +
    conn.execute(
534 +
        "DELETE FROM sessions WHERE expires_at < datetime('now')",
535 +
        [],
536 +
    )?;
537 +
    Ok(())
538 +
}
539 +
540 +
// --- File CRUD ---
541 +
542 +
fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result<UploadedFile> {
543 +
    Ok(UploadedFile {
544 +
        id: row.get(0)?,
545 +
        short_id: row.get(1)?,
546 +
        filename: row.get(2)?,
547 +
        original_name: row.get(3)?,
548 +
        content_type: row.get(4)?,
549 +
        size: row.get(5)?,
550 +
        created_at: row.get(6)?,
551 +
    })
552 +
}
553 +
554 +
const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at";
555 +
556 +
pub fn create_file(
557 +
    db: &Db,
558 +
    filename: &str,
559 +
    original_name: &str,
560 +
    content_type: &str,
561 +
    size: i64,
562 +
) -> Result<UploadedFile, DbError> {
563 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
564 +
    let short_id = nanoid!(10);
565 +
    conn.execute(
566 +
        "INSERT INTO files (short_id, filename, original_name, content_type, size) VALUES (?1, ?2, ?3, ?4, ?5)",
567 +
        params![short_id, filename, original_name, content_type, size],
568 +
    )?;
569 +
    let id = conn.last_insert_rowid();
570 +
    let file = conn.query_row(
571 +
        &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS),
572 +
        params![id],
573 +
        row_to_file,
574 +
    )?;
575 +
    Ok(file)
576 +
}
577 +
578 +
pub fn get_all_files(db: &Db) -> Result<Vec<UploadedFile>, DbError> {
579 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
580 +
    let mut stmt = conn.prepare(
581 +
        &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS),
582 +
    )?;
583 +
    let files = stmt
584 +
        .query_map([], row_to_file)?
585 +
        .collect::<Result<Vec<_>, _>>()?;
586 +
    Ok(files)
587 +
}
588 +
589 +
pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> {
590 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
591 +
    let file = match conn.query_row(
592 +
        &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS),
593 +
        params![short_id],
594 +
        row_to_file,
595 +
    ) {
596 +
        Ok(f) => f,
597 +
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
598 +
        Err(e) => return Err(DbError::Sqlite(e)),
599 +
    };
600 +
    conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?;
601 +
    Ok(Some(file))
602 +
}
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) +1219 −0
1 +
use askama::Template;
2 +
use askama_web::WebTemplate;
3 +
use axum::{
4 +
    extract::{DefaultBodyLimit, Form, Multipart, 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, UploadedFile};
16 +
17 +
#[derive(Debug, Clone)]
18 +
pub struct NavLink {
19 +
    pub label: String,
20 +
    pub url: String,
21 +
}
22 +
23 +
#[derive(Clone)]
24 +
pub struct AppState {
25 +
    pub db: Db,
26 +
    pub app_password: String,
27 +
    pub cookie_secure: bool,
28 +
    pub uploads_dir: String,
29 +
    pub site_url: String,
30 +
}
31 +
32 +
#[derive(Embed)]
33 +
#[folder = "static/"]
34 +
struct Static;
35 +
36 +
// --- Templates ---
37 +
38 +
#[derive(Template)]
39 +
#[template(path = "base.html")]
40 +
struct BaseTemplate {
41 +
    blog_title: String,
42 +
    nav_links: Vec<NavLink>,
43 +
}
44 +
45 +
#[derive(Template)]
46 +
#[template(path = "admin_base.html")]
47 +
struct AdminBaseTemplate;
48 +
49 +
#[derive(Template)]
50 +
#[template(path = "login.html")]
51 +
struct LoginTemplate {
52 +
    error: Option<String>,
53 +
}
54 +
55 +
#[derive(Template)]
56 +
#[template(path = "index.html")]
57 +
struct IndexTemplate {
58 +
    blog_title: String,
59 +
    blog_description: String,
60 +
    intro_html: String,
61 +
    posts: Vec<Post>,
62 +
    nav_links: Vec<NavLink>,
63 +
}
64 +
65 +
#[derive(Template)]
66 +
#[template(path = "post.html")]
67 +
struct PostTemplate {
68 +
    blog_title: String,
69 +
    nav_links: Vec<NavLink>,
70 +
    post: Post,
71 +
    rendered_content: String,
72 +
}
73 +
74 +
#[derive(Template)]
75 +
#[template(path = "page.html")]
76 +
struct PageTemplate {
77 +
    blog_title: String,
78 +
    nav_links: Vec<NavLink>,
79 +
    page: Page,
80 +
    rendered_content: String,
81 +
}
82 +
83 +
#[derive(Template)]
84 +
#[template(path = "admin_index.html")]
85 +
struct AdminIndexTemplate {
86 +
    posts: Vec<Post>,
87 +
}
88 +
89 +
#[derive(Template)]
90 +
#[template(path = "admin_post_form.html")]
91 +
struct AdminPostFormTemplate {
92 +
    post: Option<Post>,
93 +
    error: Option<String>,
94 +
}
95 +
96 +
#[derive(Template)]
97 +
#[template(path = "admin_pages.html")]
98 +
struct AdminPagesTemplate {
99 +
    pages: Vec<Page>,
100 +
}
101 +
102 +
#[derive(Template)]
103 +
#[template(path = "admin_page_form.html")]
104 +
struct AdminPageFormTemplate {
105 +
    page: Option<Page>,
106 +
    error: Option<String>,
107 +
}
108 +
109 +
#[derive(Template)]
110 +
#[template(path = "admin_settings.html")]
111 +
struct AdminSettingsTemplate {
112 +
    blog_title: String,
113 +
    blog_description: String,
114 +
    intro_content: String,
115 +
    nav_links: String,
116 +
    custom_css: String,
117 +
    default_css: String,
118 +
    success: bool,
119 +
}
120 +
121 +
#[derive(Template)]
122 +
#[template(path = "posts.html")]
123 +
struct PostsListTemplate {
124 +
    blog_title: String,
125 +
    nav_links: Vec<NavLink>,
126 +
    posts: Vec<Post>,
127 +
}
128 +
129 +
#[derive(Template)]
130 +
#[template(path = "admin_files.html")]
131 +
struct AdminFilesTemplate {
132 +
    files: Vec<UploadedFile>,
133 +
    site_url: String,
134 +
    error: Option<String>,
135 +
    success: bool,
136 +
}
137 +
138 +
// --- Query/Form structs ---
139 +
140 +
#[derive(serde::Deserialize, Default)]
141 +
pub struct FlashQuery {
142 +
    pub error: Option<String>,
143 +
    #[serde(default)]
144 +
    pub success: bool,
145 +
}
146 +
147 +
#[derive(serde::Deserialize)]
148 +
struct LoginForm {
149 +
    password: String,
150 +
}
151 +
152 +
#[derive(serde::Deserialize)]
153 +
struct PostForm {
154 +
    attributes: String,
155 +
    content: String,
156 +
    #[serde(default)]
157 +
    action: String,
158 +
}
159 +
160 +
struct ParsedAttributes {
161 +
    title: String,
162 +
    slug: String,
163 +
    alias: String,
164 +
    published_date: String,
165 +
    meta_description: String,
166 +
    meta_image: String,
167 +
    lang: String,
168 +
    tags: String,
169 +
}
170 +
171 +
fn parse_attributes(text: &str) -> ParsedAttributes {
172 +
    let mut attrs = ParsedAttributes {
173 +
        title: String::new(),
174 +
        slug: String::new(),
175 +
        alias: String::new(),
176 +
        published_date: String::new(),
177 +
        meta_description: String::new(),
178 +
        meta_image: String::new(),
179 +
        lang: String::new(),
180 +
        tags: String::new(),
181 +
    };
182 +
    for line in text.lines() {
183 +
        if let Some((key, value)) = line.split_once(':') {
184 +
            let key = key.trim().to_lowercase();
185 +
            let value = value.trim().to_string();
186 +
            match key.as_str() {
187 +
                "title" => attrs.title = value,
188 +
                "slug" => attrs.slug = value,
189 +
                "alias" => attrs.alias = value,
190 +
                "published_date" => attrs.published_date = value,
191 +
                "description" | "meta_description" => attrs.meta_description = value,
192 +
                "meta_image" => attrs.meta_image = value,
193 +
                "lang" => attrs.lang = value,
194 +
                "tags" => attrs.tags = value,
195 +
                _ => {} // ignore unknown keys (including canonical_url)
196 +
            }
197 +
        }
198 +
    }
199 +
    attrs
200 +
}
201 +
202 +
#[derive(serde::Deserialize)]
203 +
struct PageForm {
204 +
    attributes: String,
205 +
    content: String,
206 +
}
207 +
208 +
struct ParsedPageAttributes {
209 +
    title: String,
210 +
    slug: String,
211 +
    is_published: bool,
212 +
}
213 +
214 +
fn parse_page_attributes(text: &str) -> ParsedPageAttributes {
215 +
    let mut attrs = ParsedPageAttributes {
216 +
        title: String::new(),
217 +
        slug: String::new(),
218 +
        is_published: false,
219 +
    };
220 +
    for line in text.lines() {
221 +
        if let Some((key, value)) = line.split_once(':') {
222 +
            let key = key.trim().to_lowercase();
223 +
            let value = value.trim().to_string();
224 +
            match key.as_str() {
225 +
                "title" => attrs.title = value,
226 +
                "slug" => attrs.slug = value,
227 +
                "published" => attrs.is_published = value == "true",
228 +
                _ => {}
229 +
            }
230 +
        }
231 +
    }
232 +
    attrs
233 +
}
234 +
235 +
#[derive(serde::Deserialize)]
236 +
struct SettingsForm {
237 +
    blog_title: String,
238 +
    blog_description: String,
239 +
    intro_content: String,
240 +
    nav_links: String,
241 +
    custom_css: String,
242 +
}
243 +
244 +
// --- Helpers ---
245 +
246 +
fn mime_from_path(path: &str) -> &'static str {
247 +
    match path.rsplit('.').next().unwrap_or("") {
248 +
        "css" => "text/css",
249 +
        "js" => "application/javascript",
250 +
        "html" => "text/html",
251 +
        "png" => "image/png",
252 +
        "jpg" | "jpeg" => "image/jpeg",
253 +
        "gif" => "image/gif",
254 +
        "webp" => "image/webp",
255 +
        "ico" => "image/x-icon",
256 +
        "svg" => "image/svg+xml",
257 +
        "woff" | "woff2" => "font/woff2",
258 +
        "ttf" => "font/ttf",
259 +
        "otf" => "font/otf",
260 +
        "json" | "webmanifest" => "application/json",
261 +
        "pdf" => "application/pdf",
262 +
        "mp4" => "video/mp4",
263 +
        "webm" => "video/webm",
264 +
        _ => "application/octet-stream",
265 +
    }
266 +
}
267 +
268 +
fn render_markdown(content: &str) -> String {
269 +
    let mut options = Options::empty();
270 +
    options.insert(Options::ENABLE_STRIKETHROUGH);
271 +
    options.insert(Options::ENABLE_TABLES);
272 +
    options.insert(Options::ENABLE_TASKLISTS);
273 +
    let parser = Parser::new_ext(content, options);
274 +
    let mut html_output = String::new();
275 +
    html::push_html(&mut html_output, parser);
276 +
    html_output
277 +
}
278 +
279 +
fn now_datetime() -> String {
280 +
    use std::time::{SystemTime, UNIX_EPOCH};
281 +
    let secs = SystemTime::now()
282 +
        .duration_since(UNIX_EPOCH)
283 +
        .unwrap()
284 +
        .as_secs();
285 +
    let days = secs / 86400;
286 +
    let tod = secs % 86400;
287 +
    let (y, m, d) = days_to_ymd(days as i64);
288 +
    format!(
289 +
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
290 +
        y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
291 +
    )
292 +
}
293 +
294 +
fn slugify(s: &str) -> String {
295 +
    s.to_lowercase()
296 +
        .chars()
297 +
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
298 +
        .collect::<String>()
299 +
        .split('-')
300 +
        .filter(|s| !s.is_empty())
301 +
        .collect::<Vec<_>>()
302 +
        .join("-")
303 +
}
304 +
305 +
fn opt_str(s: &str) -> Option<&str> {
306 +
    let trimmed = s.trim();
307 +
    if trimmed.is_empty() { None } else { Some(trimmed) }
308 +
}
309 +
310 +
fn get_blog_title(db: &Db) -> String {
311 +
    db::get_setting(db, "blog_title")
312 +
        .ok()
313 +
        .flatten()
314 +
        .unwrap_or_else(|| "My Blog".to_string())
315 +
}
316 +
317 +
fn parse_nav_links(input: &str) -> Vec<NavLink> {
318 +
    let mut links = Vec::new();
319 +
    let mut chars = input.chars().peekable();
320 +
    while let Some(c) = chars.next() {
321 +
        if c == '[' {
322 +
            let label: String = chars.by_ref().take_while(|&ch| ch != ']').collect();
323 +
            if chars.peek() == Some(&'(') {
324 +
                chars.next();
325 +
                let url: String = chars.by_ref().take_while(|&ch| ch != ')').collect();
326 +
                if !label.is_empty() && !url.is_empty() {
327 +
                    links.push(NavLink { label, url });
328 +
                }
329 +
            }
330 +
        }
331 +
    }
332 +
    links
333 +
}
334 +
335 +
fn get_nav_links(db: &Db) -> Vec<NavLink> {
336 +
    let raw = db::get_setting(db, "nav_links")
337 +
        .ok()
338 +
        .flatten()
339 +
        .unwrap_or_default();
340 +
    parse_nav_links(&raw)
341 +
}
342 +
343 +
fn render_latest_posts_embed(posts: &[&Post]) -> String {
344 +
    let mut html = String::from("<div class=\"post-list\">");
345 +
    for post in posts {
346 +
        html.push_str(&format!(
347 +
            r#"<a href="/posts/{slug}" class="post-item"><div class="post-item-info"><span class="post-title">{title}</span>"#,
348 +
            slug = post.slug,
349 +
            title = post.title,
350 +
        ));
351 +
        if let Some(ref tags) = post.tags {
352 +
            if !tags.is_empty() {
353 +
                html.push_str(r#"<span class="post-tags">"#);
354 +
                for tag in tags.split(',') {
355 +
                    let tag = tag.trim();
356 +
                    if !tag.is_empty() {
357 +
                        html.push_str(&format!(r#"<span class="tag">{}</span>"#, tag));
358 +
                    }
359 +
                }
360 +
                html.push_str("</span>");
361 +
            }
362 +
        }
363 +
        html.push_str("</div>");
364 +
        if let Some(ref date) = post.published_date {
365 +
            html.push_str(&format!(r#"<time class="post-date">{}</time>"#, date));
366 +
        }
367 +
        html.push_str("</a>");
368 +
    }
369 +
    html.push_str("</div>");
370 +
    html
371 +
}
372 +
373 +
// --- Static file handler ---
374 +
375 +
async fn serve_static(Path(path): Path<String>) -> Response {
376 +
    match Static::get(&path) {
377 +
        Some(file) => {
378 +
            let mime = mime_from_path(&path);
379 +
            (
380 +
                StatusCode::OK,
381 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
382 +
                file.data.to_vec(),
383 +
            )
384 +
                .into_response()
385 +
        }
386 +
        None => StatusCode::NOT_FOUND.into_response(),
387 +
    }
388 +
}
389 +
390 +
// --- Auth handlers ---
391 +
392 +
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
393 +
    WebTemplate(LoginTemplate { error: q.error }).into_response()
394 +
}
395 +
396 +
async fn post_login(
397 +
    State(state): State<Arc<AppState>>,
398 +
    Form(form): Form<LoginForm>,
399 +
) -> Response {
400 +
    if !auth::verify_password(&form.password, &state.app_password) {
401 +
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
402 +
    }
403 +
404 +
    let token = auth::generate_session_token();
405 +
406 +
    let expires_at = {
407 +
        use std::time::{SystemTime, UNIX_EPOCH};
408 +
        let secs = SystemTime::now()
409 +
            .duration_since(UNIX_EPOCH)
410 +
            .unwrap()
411 +
            .as_secs()
412 +
            + 7 * 24 * 3600;
413 +
        let days = secs / 86400;
414 +
        let tod = secs % 86400;
415 +
        let (y, m, d) = days_to_ymd(days as i64);
416 +
        format!(
417 +
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
418 +
            y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
419 +
        )
420 +
    };
421 +
422 +
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
423 +
        tracing::error!("Failed to create session: {}", e);
424 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
425 +
    }
426 +
427 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
428 +
    let mut resp = Redirect::to("/admin").into_response();
429 +
    resp.headers_mut().insert(
430 +
        axum::http::header::SET_COOKIE,
431 +
        HeaderValue::from_str(&cookie).unwrap(),
432 +
    );
433 +
    resp
434 +
}
435 +
436 +
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
437 +
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
438 +
        for part in cookie_header.split(';') {
439 +
            let part = part.trim();
440 +
            if let Some(val) = part.strip_prefix("session=") {
441 +
                let val = val.trim();
442 +
                if !val.is_empty() {
443 +
                    let _ = db::delete_session(&state.db, val);
444 +
                }
445 +
            }
446 +
        }
447 +
    }
448 +
449 +
    let cookie = auth::clear_session_cookie();
450 +
    let mut resp = Redirect::to("/admin/login").into_response();
451 +
    resp.headers_mut().insert(
452 +
        axum::http::header::SET_COOKIE,
453 +
        HeaderValue::from_str(&cookie).unwrap(),
454 +
    );
455 +
    resp
456 +
}
457 +
458 +
// --- Public handlers ---
459 +
460 +
async fn public_index(State(state): State<Arc<AppState>>) -> Response {
461 +
    let blog_title = get_blog_title(&state.db);
462 +
    let blog_description = db::get_setting(&state.db, "blog_description")
463 +
        .ok()
464 +
        .flatten()
465 +
        .unwrap_or_default();
466 +
    let intro_content = db::get_setting(&state.db, "intro_content")
467 +
        .ok()
468 +
        .flatten()
469 +
        .unwrap_or_default();
470 +
    let nav_links = get_nav_links(&state.db);
471 +
472 +
    match db::get_published_posts(&state.db) {
473 +
        Ok(posts) => {
474 +
            let mut intro_html = render_markdown(&intro_content);
475 +
476 +
            if intro_content.contains("{{latest_posts}}") {
477 +
                let latest: Vec<&Post> = posts.iter().take(5).collect();
478 +
                let embed_html = render_latest_posts_embed(&latest);
479 +
                intro_html = intro_html.replace("<p>{{latest_posts}}</p>", &embed_html);
480 +
                intro_html = intro_html.replace("{{latest_posts}}", &embed_html);
481 +
            }
482 +
483 +
            WebTemplate(IndexTemplate {
484 +
                blog_title,
485 +
                blog_description,
486 +
                intro_html,
487 +
                posts,
488 +
                nav_links,
489 +
            })
490 +
            .into_response()
491 +
        }
492 +
        Err(e) => {
493 +
            tracing::error!("Failed to list posts: {}", e);
494 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
495 +
        }
496 +
    }
497 +
}
498 +
499 +
async fn public_post(
500 +
    State(state): State<Arc<AppState>>,
501 +
    Path(slug): Path<String>,
502 +
) -> Response {
503 +
    match db::get_post_by_slug(&state.db, &slug) {
504 +
        Ok(Some(post)) if post.status == "published" => {
505 +
            let rendered_content = render_markdown(&post.content);
506 +
            let blog_title = get_blog_title(&state.db);
507 +
            let nav_links = get_nav_links(&state.db);
508 +
            WebTemplate(PostTemplate {
509 +
                blog_title,
510 +
                nav_links,
511 +
                post,
512 +
                rendered_content,
513 +
            })
514 +
            .into_response()
515 +
        }
516 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
517 +
        Err(e) => {
518 +
            tracing::error!("Failed to get post: {}", e);
519 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
520 +
        }
521 +
    }
522 +
}
523 +
524 +
async fn public_page(
525 +
    State(state): State<Arc<AppState>>,
526 +
    Path(slug): Path<String>,
527 +
) -> Response {
528 +
    match db::get_page_by_slug(&state.db, &slug) {
529 +
        Ok(Some(page)) if page.is_published => {
530 +
            let rendered_content = render_markdown(&page.content);
531 +
            let blog_title = get_blog_title(&state.db);
532 +
            let nav_links = get_nav_links(&state.db);
533 +
            WebTemplate(PageTemplate {
534 +
                blog_title,
535 +
                nav_links,
536 +
                page,
537 +
                rendered_content,
538 +
            })
539 +
            .into_response()
540 +
        }
541 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
542 +
        Err(e) => {
543 +
            tracing::error!("Failed to get page: {}", e);
544 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
545 +
        }
546 +
    }
547 +
}
548 +
549 +
async fn public_posts_list(State(state): State<Arc<AppState>>) -> Response {
550 +
    let blog_title = get_blog_title(&state.db);
551 +
    let nav_links = get_nav_links(&state.db);
552 +
553 +
    match db::get_published_posts(&state.db) {
554 +
        Ok(posts) => WebTemplate(PostsListTemplate {
555 +
            blog_title,
556 +
            nav_links,
557 +
            posts,
558 +
        })
559 +
        .into_response(),
560 +
        Err(e) => {
561 +
            tracing::error!("Failed to list posts: {}", e);
562 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
563 +
        }
564 +
    }
565 +
}
566 +
567 +
async fn serve_custom_css(State(state): State<Arc<AppState>>) -> Response {
568 +
    let css = db::get_setting(&state.db, "custom_css")
569 +
        .ok()
570 +
        .flatten()
571 +
        .unwrap_or_default();
572 +
    (
573 +
        StatusCode::OK,
574 +
        [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
575 +
        css,
576 +
    )
577 +
        .into_response()
578 +
}
579 +
580 +
async fn fallback_handler(
581 +
    State(state): State<Arc<AppState>>,
582 +
    uri: Uri,
583 +
) -> Response {
584 +
    let path = uri.path().trim_start_matches('/');
585 +
    if let Ok(Some(redirect_to)) = db::find_alias_redirect(&state.db, path) {
586 +
        return Redirect::permanent(&redirect_to).into_response();
587 +
    }
588 +
    (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response()
589 +
}
590 +
591 +
// --- Admin post handlers ---
592 +
593 +
async fn admin_index(
594 +
    _session: auth::AuthSession,
595 +
    State(state): State<Arc<AppState>>,
596 +
) -> Response {
597 +
    match db::get_all_posts(&state.db) {
598 +
        Ok(posts) => WebTemplate(AdminIndexTemplate { posts }).into_response(),
599 +
        Err(e) => {
600 +
            tracing::error!("Failed to list posts: {}", e);
601 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
602 +
        }
603 +
    }
604 +
}
605 +
606 +
async fn admin_new_post(
607 +
    _session: auth::AuthSession,
608 +
    Query(q): Query<FlashQuery>,
609 +
) -> Response {
610 +
    WebTemplate(AdminPostFormTemplate {
611 +
        post: None,
612 +
        error: q.error,
613 +
    })
614 +
    .into_response()
615 +
}
616 +
617 +
async fn admin_create_post(
618 +
    _session: auth::AuthSession,
619 +
    State(state): State<Arc<AppState>>,
620 +
    Form(form): Form<PostForm>,
621 +
) -> Response {
622 +
    let attrs = parse_attributes(&form.attributes);
623 +
    let title = attrs.title.trim();
624 +
    if title.is_empty() {
625 +
        return Redirect::to("/admin/posts/new?error=Title+is+required").into_response();
626 +
    }
627 +
    let slug = if attrs.slug.trim().is_empty() {
628 +
        slugify(title)
629 +
    } else {
630 +
        attrs.slug.trim().to_string()
631 +
    };
632 +
633 +
    let status = if form.action == "publish" { "published" } else { "draft" };
634 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
635 +
    let published_date = if attrs.published_date.trim().is_empty() {
636 +
        now_datetime()
637 +
    } else {
638 +
        attrs.published_date.trim().to_string()
639 +
    };
640 +
641 +
    match db::create_post(
642 +
        &state.db,
643 +
        title,
644 +
        &slug,
645 +
        &form.content,
646 +
        status,
647 +
        opt_str(&attrs.alias),
648 +
        None,
649 +
        Some(&published_date),
650 +
        opt_str(&attrs.meta_description),
651 +
        opt_str(&attrs.meta_image),
652 +
        lang,
653 +
        opt_str(&attrs.tags),
654 +
    ) {
655 +
        Ok(_) => Redirect::to("/admin").into_response(),
656 +
        Err(e) => {
657 +
            tracing::error!("Failed to create post: {}", e);
658 +
            Redirect::to("/admin/posts/new?error=Failed+to+create+post").into_response()
659 +
        }
660 +
    }
661 +
}
662 +
663 +
async fn admin_edit_post(
664 +
    _session: auth::AuthSession,
665 +
    State(state): State<Arc<AppState>>,
666 +
    Path(short_id): Path<String>,
667 +
    Query(q): Query<FlashQuery>,
668 +
) -> Response {
669 +
    match db::get_post_by_short_id(&state.db, &short_id) {
670 +
        Ok(Some(post)) => WebTemplate(AdminPostFormTemplate {
671 +
            post: Some(post),
672 +
            error: q.error,
673 +
        })
674 +
        .into_response(),
675 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
676 +
        Err(e) => {
677 +
            tracing::error!("Failed to get post: {}", e);
678 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
679 +
        }
680 +
    }
681 +
}
682 +
683 +
async fn admin_update_post(
684 +
    _session: auth::AuthSession,
685 +
    State(state): State<Arc<AppState>>,
686 +
    Path(short_id): Path<String>,
687 +
    Form(form): Form<PostForm>,
688 +
) -> Response {
689 +
    let attrs = parse_attributes(&form.attributes);
690 +
    let title = attrs.title.trim();
691 +
    if title.is_empty() {
692 +
        return Redirect::to(&format!("/admin/posts/{}/edit?error=Title+is+required", short_id))
693 +
            .into_response();
694 +
    }
695 +
    let slug = if attrs.slug.trim().is_empty() {
696 +
        slugify(title)
697 +
    } else {
698 +
        attrs.slug.trim().to_string()
699 +
    };
700 +
701 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
702 +
    let published_date = if attrs.published_date.trim().is_empty() {
703 +
        now_datetime()
704 +
    } else {
705 +
        attrs.published_date.trim().to_string()
706 +
    };
707 +
708 +
    match db::update_post(
709 +
        &state.db,
710 +
        &short_id,
711 +
        title,
712 +
        &slug,
713 +
        &form.content,
714 +
        opt_str(&attrs.alias),
715 +
        None,
716 +
        Some(&published_date),
717 +
        opt_str(&attrs.meta_description),
718 +
        opt_str(&attrs.meta_image),
719 +
        lang,
720 +
        opt_str(&attrs.tags),
721 +
    ) {
722 +
        Ok(Some(_)) => Redirect::to("/admin").into_response(),
723 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
724 +
        Err(e) => {
725 +
            tracing::error!("Failed to update post: {}", e);
726 +
            Redirect::to(&format!("/admin/posts/{}/edit?error=Failed+to+update", short_id))
727 +
                .into_response()
728 +
        }
729 +
    }
730 +
}
731 +
732 +
async fn admin_delete_post(
733 +
    _session: auth::AuthSession,
734 +
    State(state): State<Arc<AppState>>,
735 +
    Path(short_id): Path<String>,
736 +
) -> Response {
737 +
    match db::delete_post(&state.db, &short_id) {
738 +
        Ok(_) => Redirect::to("/admin").into_response(),
739 +
        Err(e) => {
740 +
            tracing::error!("Failed to delete post: {}", e);
741 +
            Redirect::to("/admin").into_response()
742 +
        }
743 +
    }
744 +
}
745 +
746 +
async fn admin_toggle_publish(
747 +
    _session: auth::AuthSession,
748 +
    State(state): State<Arc<AppState>>,
749 +
    Path(short_id): Path<String>,
750 +
) -> Response {
751 +
    match db::toggle_post_status(&state.db, &short_id) {
752 +
        Ok(_) => Redirect::to("/admin").into_response(),
753 +
        Err(e) => {
754 +
            tracing::error!("Failed to toggle post status: {}", e);
755 +
            Redirect::to("/admin").into_response()
756 +
        }
757 +
    }
758 +
}
759 +
760 +
// --- Admin page handlers ---
761 +
762 +
async fn admin_pages(
763 +
    _session: auth::AuthSession,
764 +
    State(state): State<Arc<AppState>>,
765 +
) -> Response {
766 +
    match db::get_all_pages(&state.db) {
767 +
        Ok(pages) => WebTemplate(AdminPagesTemplate { pages }).into_response(),
768 +
        Err(e) => {
769 +
            tracing::error!("Failed to list pages: {}", e);
770 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
771 +
        }
772 +
    }
773 +
}
774 +
775 +
async fn admin_new_page(
776 +
    _session: auth::AuthSession,
777 +
    Query(q): Query<FlashQuery>,
778 +
) -> Response {
779 +
    WebTemplate(AdminPageFormTemplate {
780 +
        page: None,
781 +
        error: q.error,
782 +
    })
783 +
    .into_response()
784 +
}
785 +
786 +
async fn admin_create_page(
787 +
    _session: auth::AuthSession,
788 +
    State(state): State<Arc<AppState>>,
789 +
    Form(form): Form<PageForm>,
790 +
) -> Response {
791 +
    let attrs = parse_page_attributes(&form.attributes);
792 +
    let title = attrs.title.trim().to_string();
793 +
    let slug = attrs.slug.trim().to_string();
794 +
    if title.is_empty() || slug.is_empty() {
795 +
        return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response();
796 +
    }
797 +
798 +
    match db::create_page(&state.db, &title, &slug, &form.content, attrs.is_published, 0) {
799 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
800 +
        Err(e) => {
801 +
            tracing::error!("Failed to create page: {}", e);
802 +
            Redirect::to("/admin/pages/new?error=Failed+to+create+page").into_response()
803 +
        }
804 +
    }
805 +
}
806 +
807 +
async fn admin_edit_page(
808 +
    _session: auth::AuthSession,
809 +
    State(state): State<Arc<AppState>>,
810 +
    Path(short_id): Path<String>,
811 +
    Query(q): Query<FlashQuery>,
812 +
) -> Response {
813 +
    match db::get_page_by_short_id(&state.db, &short_id) {
814 +
        Ok(Some(page)) => WebTemplate(AdminPageFormTemplate {
815 +
            page: Some(page),
816 +
            error: q.error,
817 +
        })
818 +
        .into_response(),
819 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
820 +
        Err(e) => {
821 +
            tracing::error!("Failed to get page: {}", e);
822 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
823 +
        }
824 +
    }
825 +
}
826 +
827 +
async fn admin_update_page(
828 +
    _session: auth::AuthSession,
829 +
    State(state): State<Arc<AppState>>,
830 +
    Path(short_id): Path<String>,
831 +
    Form(form): Form<PageForm>,
832 +
) -> Response {
833 +
    let attrs = parse_page_attributes(&form.attributes);
834 +
    let title = attrs.title.trim().to_string();
835 +
    let slug = attrs.slug.trim().to_string();
836 +
    if title.is_empty() || slug.is_empty() {
837 +
        return Redirect::to(&format!("/admin/pages/{}/edit?error=Title+and+slug+are+required", short_id))
838 +
            .into_response();
839 +
    }
840 +
841 +
    match db::update_page(&state.db, &short_id, &title, &slug, &form.content, attrs.is_published, 0) {
842 +
        Ok(Some(_)) => Redirect::to("/admin/pages").into_response(),
843 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
844 +
        Err(e) => {
845 +
            tracing::error!("Failed to update page: {}", e);
846 +
            Redirect::to(&format!("/admin/pages/{}/edit?error=Failed+to+update", short_id))
847 +
                .into_response()
848 +
        }
849 +
    }
850 +
}
851 +
852 +
async fn admin_delete_page(
853 +
    _session: auth::AuthSession,
854 +
    State(state): State<Arc<AppState>>,
855 +
    Path(short_id): Path<String>,
856 +
) -> Response {
857 +
    match db::delete_page(&state.db, &short_id) {
858 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
859 +
        Err(e) => {
860 +
            tracing::error!("Failed to delete page: {}", e);
861 +
            Redirect::to("/admin/pages").into_response()
862 +
        }
863 +
    }
864 +
}
865 +
866 +
// --- Admin settings handlers ---
867 +
868 +
async fn admin_get_settings(
869 +
    _session: auth::AuthSession,
870 +
    State(state): State<Arc<AppState>>,
871 +
    Query(q): Query<FlashQuery>,
872 +
) -> Response {
873 +
    let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default();
874 +
    let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default();
875 +
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
876 +
    let nav_links = db::get_setting(&state.db, "nav_links").ok().flatten().unwrap_or_default();
877 +
    let custom_css = db::get_setting(&state.db, "custom_css").ok().flatten().unwrap_or_default();
878 +
    let default_css = Static::get("styles.css")
879 +
        .map(|f| String::from_utf8_lossy(&f.data).into_owned())
880 +
        .unwrap_or_default();
881 +
882 +
    WebTemplate(AdminSettingsTemplate {
883 +
        blog_title,
884 +
        blog_description,
885 +
        intro_content,
886 +
        nav_links,
887 +
        custom_css,
888 +
        default_css,
889 +
        success: q.success,
890 +
    })
891 +
    .into_response()
892 +
}
893 +
894 +
async fn admin_post_settings(
895 +
    _session: auth::AuthSession,
896 +
    State(state): State<Arc<AppState>>,
897 +
    Form(form): Form<SettingsForm>,
898 +
) -> Response {
899 +
    let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim());
900 +
    let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim());
901 +
    let _ = db::set_setting(&state.db, "intro_content", &form.intro_content);
902 +
    let _ = db::set_setting(&state.db, "nav_links", &form.nav_links);
903 +
    let _ = db::set_setting(&state.db, "custom_css", &form.custom_css);
904 +
    Redirect::to("/admin/settings?success=true").into_response()
905 +
}
906 +
907 +
// --- Admin file handlers ---
908 +
909 +
async fn admin_files(
910 +
    _session: auth::AuthSession,
911 +
    State(state): State<Arc<AppState>>,
912 +
    Query(q): Query<FlashQuery>,
913 +
) -> Response {
914 +
    match db::get_all_files(&state.db) {
915 +
        Ok(files) => WebTemplate(AdminFilesTemplate {
916 +
            files,
917 +
            site_url: state.site_url.clone(),
918 +
            error: q.error,
919 +
            success: q.success,
920 +
        })
921 +
        .into_response(),
922 +
        Err(e) => {
923 +
            tracing::error!("Failed to list files: {}", e);
924 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
925 +
        }
926 +
    }
927 +
}
928 +
929 +
async fn admin_upload_file(
930 +
    _session: auth::AuthSession,
931 +
    State(state): State<Arc<AppState>>,
932 +
    mut multipart: Multipart,
933 +
) -> Response {
934 +
    let mut file_data: Option<(String, String, Vec<u8>)> = None;
935 +
936 +
    while let Ok(Some(field)) = multipart.next_field().await {
937 +
        if field.name() == Some("file") {
938 +
            let original_name = field
939 +
                .file_name()
940 +
                .unwrap_or("upload")
941 +
                .to_string();
942 +
            let content_type = field
943 +
                .content_type()
944 +
                .unwrap_or("application/octet-stream")
945 +
                .to_string();
946 +
            match field.bytes().await {
947 +
                Ok(bytes) => {
948 +
                    file_data = Some((original_name, content_type, bytes.to_vec()));
949 +
                }
950 +
                Err(e) => {
951 +
                    tracing::error!("Failed to read upload: {}", e);
952 +
                    return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response();
953 +
                }
954 +
            }
955 +
        }
956 +
    }
957 +
958 +
    let (original_name, content_type, bytes) = match file_data {
959 +
        Some(d) => d,
960 +
        None => return Redirect::to("/admin/files?error=No+file+provided").into_response(),
961 +
    };
962 +
963 +
    let max_size: usize = 10 * 1024 * 1024;
964 +
    if bytes.len() > max_size {
965 +
        return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response();
966 +
    }
967 +
968 +
    let ext = original_name
969 +
        .rsplit('.')
970 +
        .next()
971 +
        .filter(|e| !e.is_empty() && *e != original_name)
972 +
        .unwrap_or("");
973 +
    let id = nanoid::nanoid!(10);
974 +
    let stored_name = if ext.is_empty() {
975 +
        id
976 +
    } else {
977 +
        format!("{}.{}", id, ext)
978 +
    };
979 +
980 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
981 +
    if let Err(e) = tokio::fs::write(&path, &bytes).await {
982 +
        tracing::error!("Failed to write file: {}", e);
983 +
        return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
984 +
    }
985 +
986 +
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) {
987 +
        Ok(_) => Redirect::to("/admin/files?success=true").into_response(),
988 +
        Err(e) => {
989 +
            tracing::error!("Failed to record file: {}", e);
990 +
            let _ = tokio::fs::remove_file(&path).await;
991 +
            Redirect::to("/admin/files?error=Failed+to+record+file").into_response()
992 +
        }
993 +
    }
994 +
}
995 +
996 +
async fn admin_delete_file(
997 +
    _session: auth::AuthSession,
998 +
    State(state): State<Arc<AppState>>,
999 +
    Path(short_id): Path<String>,
1000 +
) -> Response {
1001 +
    match db::delete_file(&state.db, &short_id) {
1002 +
        Ok(Some(file)) => {
1003 +
            let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
1004 +
            if let Err(e) = tokio::fs::remove_file(&path).await {
1005 +
                tracing::warn!("Failed to delete file from disk: {}", e);
1006 +
            }
1007 +
            Redirect::to("/admin/files").into_response()
1008 +
        }
1009 +
        Ok(None) => Redirect::to("/admin/files").into_response(),
1010 +
        Err(e) => {
1011 +
            tracing::error!("Failed to delete file: {}", e);
1012 +
            Redirect::to("/admin/files").into_response()
1013 +
        }
1014 +
    }
1015 +
}
1016 +
1017 +
async fn serve_uploaded_file(
1018 +
    State(state): State<Arc<AppState>>,
1019 +
    Path(filename): Path<String>,
1020 +
) -> Response {
1021 +
    if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
1022 +
        return StatusCode::NOT_FOUND.into_response();
1023 +
    }
1024 +
1025 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename);
1026 +
    match tokio::fs::read(&path).await {
1027 +
        Ok(bytes) => {
1028 +
            let mime = mime_from_path(&filename);
1029 +
            (
1030 +
                StatusCode::OK,
1031 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
1032 +
                bytes,
1033 +
            )
1034 +
                .into_response()
1035 +
        }
1036 +
        Err(_) => StatusCode::NOT_FOUND.into_response(),
1037 +
    }
1038 +
}
1039 +
1040 +
// --- RSS feed handler ---
1041 +
1042 +
fn xml_escape(s: &str) -> String {
1043 +
    s.replace('&', "&amp;")
1044 +
        .replace('<', "&lt;")
1045 +
        .replace('>', "&gt;")
1046 +
        .replace('"', "&quot;")
1047 +
        .replace('\'', "&apos;")
1048 +
}
1049 +
1050 +
async fn rss_feed(State(state): State<Arc<AppState>>) -> Response {
1051 +
    let blog_title = get_blog_title(&state.db);
1052 +
    let blog_description = db::get_setting(&state.db, "blog_description")
1053 +
        .ok()
1054 +
        .flatten()
1055 +
        .unwrap_or_default();
1056 +
    let site_url = &state.site_url;
1057 +
1058 +
    let posts = match db::get_published_posts(&state.db) {
1059 +
        Ok(posts) => posts,
1060 +
        Err(e) => {
1061 +
            tracing::error!("Failed to get posts for RSS: {}", e);
1062 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1063 +
        }
1064 +
    };
1065 +
1066 +
    let mut items = String::new();
1067 +
    for post in &posts {
1068 +
        let link = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
1069 +
        let title = xml_escape(&post.title);
1070 +
        let description = match &post.meta_description {
1071 +
            Some(d) if !d.is_empty() => xml_escape(d),
1072 +
            _ => {
1073 +
                let plain: String = post.content.chars().take(200).collect();
1074 +
                xml_escape(&plain)
1075 +
            }
1076 +
        };
1077 +
        let pub_date = post.published_date.as_deref().unwrap_or(&post.created_at);
1078 +
        let guid = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
1079 +
1080 +
        items.push_str(&format!(
1081 +
            "    <item>\n      <title>{title}</title>\n      <link>{link}</link>\n      <guid>{guid}</guid>\n      <description>{description}</description>\n      <pubDate>{pub_date}</pubDate>\n    </item>\n"
1082 +
        ));
1083 +
    }
1084 +
1085 +
    let last_build = posts
1086 +
        .first()
1087 +
        .and_then(|p| p.published_date.as_deref())
1088 +
        .unwrap_or("");
1089 +
1090 +
    let xml = format!(
1091 +
        r#"<?xml version="1.0" encoding="UTF-8"?>
1092 +
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
1093 +
  <channel>
1094 +
    <title>{title}</title>
1095 +
    <link>{site_url}</link>
1096 +
    <description>{desc}</description>
1097 +
    <lastBuildDate>{last_build}</lastBuildDate>
1098 +
    <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/>
1099 +
{items}  </channel>
1100 +
</rss>"#,
1101 +
        title = xml_escape(&blog_title),
1102 +
        site_url = site_url,
1103 +
        desc = xml_escape(&blog_description),
1104 +
        last_build = last_build,
1105 +
        items = items,
1106 +
    );
1107 +
1108 +
    (
1109 +
        StatusCode::OK,
1110 +
        [(
1111 +
            axum::http::header::CONTENT_TYPE,
1112 +
            HeaderValue::from_static("application/rss+xml; charset=utf-8"),
1113 +
        )],
1114 +
        xml,
1115 +
    )
1116 +
        .into_response()
1117 +
}
1118 +
1119 +
// --- Date helper ---
1120 +
1121 +
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
1122 +
    days += 719468;
1123 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
1124 +
    let doe = (days - era * 146097) as u32;
1125 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1126 +
    let y = yoe as i64 + era * 400;
1127 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1128 +
    let mp = (5 * doy + 2) / 153;
1129 +
    let d = doy - (153 * mp + 2) / 5 + 1;
1130 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1131 +
    let y = if m <= 2 { y + 1 } else { y };
1132 +
    (y, m as i64, d as i64)
1133 +
}
1134 +
1135 +
// --- Router ---
1136 +
1137 +
pub async fn run(host: String, port: u16) {
1138 +
    dotenvy::dotenv().ok();
1139 +
1140 +
    let db = db::init_db();
1141 +
1142 +
    if let Err(e) = db::prune_expired_sessions(&db) {
1143 +
        tracing::warn!("Failed to prune sessions: {}", e);
1144 +
    }
1145 +
1146 +
    let app_password = std::env::var("POSTS_PASSWORD").unwrap_or_else(|_| {
1147 +
        tracing::warn!("POSTS_PASSWORD not set, using default 'changeme'");
1148 +
        "changeme".to_string()
1149 +
    });
1150 +
1151 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
1152 +
        .map(|v| v == "true")
1153 +
        .unwrap_or(false);
1154 +
1155 +
    let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string());
1156 +
    tokio::fs::create_dir_all(&uploads_dir)
1157 +
        .await
1158 +
        .expect("Failed to create uploads directory");
1159 +
1160 +
    let site_url = std::env::var("SITE_URL")
1161 +
        .unwrap_or_else(|_| "http://localhost:3000".to_string())
1162 +
        .trim_end_matches('/')
1163 +
        .to_string();
1164 +
1165 +
    let state = Arc::new(AppState {
1166 +
        db,
1167 +
        app_password,
1168 +
        cookie_secure,
1169 +
        uploads_dir,
1170 +
        site_url,
1171 +
    });
1172 +
1173 +
    let app = Router::new()
1174 +
        // Public routes
1175 +
        .route("/", get(public_index))
1176 +
        .route("/posts", get(public_posts_list))
1177 +
        .route("/posts/{slug}", get(public_post))
1178 +
        .route("/custom-styles.css", get(serve_custom_css))
1179 +
        .route("/pages/{slug}", get(public_page))
1180 +
        .route("/feed.xml", get(rss_feed))
1181 +
        // Admin auth
1182 +
        .route("/admin/login", get(get_login).post(post_login))
1183 +
        .route("/admin/logout", get(get_logout))
1184 +
        // Admin posts
1185 +
        .route("/admin", get(admin_index))
1186 +
        .route("/admin/posts/new", get(admin_new_post))
1187 +
        .route("/admin/posts", post(admin_create_post))
1188 +
        .route("/admin/posts/{id}/edit", get(admin_edit_post))
1189 +
        .route("/admin/posts/{id}", post(admin_update_post))
1190 +
        .route("/admin/posts/{id}/delete", post(admin_delete_post))
1191 +
        .route("/admin/posts/{id}/publish", post(admin_toggle_publish))
1192 +
        // Admin pages
1193 +
        .route("/admin/pages", get(admin_pages))
1194 +
        .route("/admin/pages/new", get(admin_new_page))
1195 +
        .route("/admin/pages/create", post(admin_create_page))
1196 +
        .route("/admin/pages/{id}/edit", get(admin_edit_page))
1197 +
        .route("/admin/pages/{id}", post(admin_update_page))
1198 +
        .route("/admin/pages/{id}/delete", post(admin_delete_page))
1199 +
        // Admin settings
1200 +
        .route("/admin/settings", get(admin_get_settings).post(admin_post_settings))
1201 +
        // Admin files
1202 +
        .route("/admin/files", get(admin_files))
1203 +
        .route("/admin/files/upload", post(admin_upload_file))
1204 +
        .route("/admin/files/{id}/delete", post(admin_delete_file))
1205 +
        // Public files
1206 +
        .route("/files/{filename}", get(serve_uploaded_file))
1207 +
        // Static assets
1208 +
        .route("/static/{*path}", get(serve_static))
1209 +
        // Fallback
1210 +
        .fallback(get(fallback_handler))
1211 +
        .with_state(state)
1212 +
        .layer(DefaultBodyLimit::max(11 * 1024 * 1024));
1213 +
1214 +
    let addr = format!("{}:{}", host, port);
1215 +
    tracing::info!("Listening on http://{}", addr);
1216 +
1217 +
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
1218 +
    axum::serve(listener, app).await.unwrap();
1219 +
}
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) +691 −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 4rem;
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-description {
227 +
  font-style: italic;
228 +
  opacity: 0.7;
229 +
}
230 +
231 +
.post-date {
232 +
  font-size: 12px;
233 +
  opacity: 0.5;
234 +
}
235 +
236 +
/* Tags */
237 +
238 +
.post-tags {
239 +
  display: flex;
240 +
  gap: 0.4rem;
241 +
  flex-wrap: wrap;
242 +
}
243 +
244 +
.tag {
245 +
  font-size: 11px;
246 +
  opacity: 0.5;
247 +
  background: #1e1c1f;
248 +
  padding: 1px 6px;
249 +
  border: 1px solid #333;
250 +
}
251 +
252 +
/* Post header (public single) */
253 +
254 +
.post-header {
255 +
  display: flex;
256 +
  flex-direction: column;
257 +
  gap: 0.25rem;
258 +
}
259 +
260 +
.post-header h1 {
261 +
  font-size: 24px;
262 +
  font-weight: 700;
263 +
  letter-spacing: -0.5px;
264 +
}
265 +
266 +
.page-header {
267 +
  display: flex;
268 +
  flex-direction: column;
269 +
  gap: 0.25rem;
270 +
}
271 +
272 +
.page-header h1 {
273 +
  font-size: 24px;
274 +
  font-weight: 700;
275 +
  letter-spacing: -0.5px;
276 +
}
277 +
278 +
/* Intro */
279 +
280 +
.intro {
281 +
  padding-bottom: 1rem;
282 +
  border-bottom: 1px solid #333;
283 +
}
284 +
285 +
/* Inline form */
286 +
287 +
.inline-form {
288 +
  display: inline;
289 +
}
290 +
291 +
.link-button {
292 +
  background: none;
293 +
  border: none;
294 +
  color: #ffffff;
295 +
  cursor: pointer;
296 +
  font-size: 12px;
297 +
  padding: 0;
298 +
}
299 +
300 +
.link-button:hover {
301 +
  opacity: 0.7;
302 +
}
303 +
304 +
.link-button.danger {
305 +
  opacity: 0.5;
306 +
}
307 +
308 +
.link-button.danger:hover {
309 +
  opacity: 0.3;
310 +
}
311 +
312 +
/* Admin toolbar */
313 +
314 +
.admin-toolbar {
315 +
  display: flex;
316 +
  justify-content: space-between;
317 +
  align-items: center;
318 +
  width: 100%;
319 +
}
320 +
321 +
.admin-toolbar h2 {
322 +
  font-size: 18px;
323 +
  font-weight: 700;
324 +
}
325 +
326 +
/* Admin list */
327 +
328 +
.admin-list {
329 +
  display: flex;
330 +
  flex-direction: column;
331 +
  width: 100%;
332 +
}
333 +
334 +
.admin-list-item {
335 +
  display: flex;
336 +
  justify-content: space-between;
337 +
  align-items: center;
338 +
  padding: 8px 0;
339 +
  border-bottom: 1px solid #333;
340 +
  gap: 1rem;
341 +
}
342 +
343 +
.admin-list-info {
344 +
  display: flex;
345 +
  flex-direction: column;
346 +
  gap: 0.2rem;
347 +
  min-width: 0;
348 +
}
349 +
350 +
.admin-list-title {
351 +
  font-size: 15px;
352 +
  white-space: nowrap;
353 +
  overflow: hidden;
354 +
  text-overflow: ellipsis;
355 +
}
356 +
357 +
.admin-list-meta {
358 +
  display: flex;
359 +
  gap: 0.75rem;
360 +
  align-items: center;
361 +
}
362 +
363 +
.admin-list-date {
364 +
  font-size: 11px;
365 +
  opacity: 0.4;
366 +
}
367 +
368 +
.admin-list-actions {
369 +
  display: flex;
370 +
  gap: 1rem;
371 +
  font-size: 12px;
372 +
  flex-shrink: 0;
373 +
}
374 +
375 +
/* Status badges */
376 +
377 +
.status-badge {
378 +
  font-size: 11px;
379 +
  padding: 1px 6px;
380 +
  border: 1px solid #333;
381 +
}
382 +
383 +
.status-published {
384 +
  opacity: 1;
385 +
  border-color: #555;
386 +
}
387 +
388 +
.status-draft {
389 +
  opacity: 0.4;
390 +
}
391 +
392 +
/* Form layout */
393 +
394 +
.form-row {
395 +
  display: flex;
396 +
  gap: 0.5rem;
397 +
  width: 100%;
398 +
}
399 +
400 +
.form-row .form-field {
401 +
  flex: 1;
402 +
}
403 +
404 +
.form-field {
405 +
  display: flex;
406 +
  flex-direction: column;
407 +
  gap: 0.25rem;
408 +
}
409 +
410 +
.checkbox-field {
411 +
  justify-content: flex-end;
412 +
}
413 +
414 +
.checkbox-field label {
415 +
  display: flex;
416 +
  align-items: center;
417 +
  gap: 0.5rem;
418 +
  font-size: 14px;
419 +
  opacity: 1;
420 +
  cursor: pointer;
421 +
}
422 +
423 +
.checkbox-field input[type="checkbox"] {
424 +
  width: 16px;
425 +
  height: 16px;
426 +
  -webkit-appearance: none;
427 +
  appearance: none;
428 +
  background: transparent;
429 +
  border: 1px solid white;
430 +
  border-radius: 0;
431 +
  cursor: pointer;
432 +
  position: relative;
433 +
}
434 +
435 +
.checkbox-field input[type="checkbox"]:checked::after {
436 +
  content: '✔︎';
437 +
  position: absolute;
438 +
  top: 50%;
439 +
  left: 50%;
440 +
  transform: translate(-50%, -50%);
441 +
  font-size: 12px;
442 +
  color: white;
443 +
  line-height: 1;
444 +
}
445 +
446 +
.form-actions {
447 +
  display: flex;
448 +
  gap: 0.5rem;
449 +
}
450 +
451 +
@media (max-width: 480px) {
452 +
  .form-row {
453 +
    flex-direction: column;
454 +
  }
455 +
456 +
  .admin-list-item {
457 +
    flex-direction: column;
458 +
    align-items: flex-start;
459 +
    gap: 0.5rem;
460 +
  }
461 +
}
462 +
463 +
/* Markdown rendered content */
464 +
465 +
.markdown-body {
466 +
  width: 100%;
467 +
  line-height: 1.6;
468 +
}
469 +
470 +
.markdown-body h1,
471 +
.markdown-body h2,
472 +
.markdown-body h3,
473 +
.markdown-body h4,
474 +
.markdown-body h5,
475 +
.markdown-body h6 {
476 +
  margin-top: 1.5rem;
477 +
  margin-bottom: 0.5rem;
478 +
  font-weight: 700;
479 +
}
480 +
481 +
.markdown-body h1 { font-size: 18px; }
482 +
.markdown-body h2 { font-size: 16px; }
483 +
.markdown-body h3 { font-size: 15px; }
484 +
.markdown-body h4,
485 +
.markdown-body h5,
486 +
.markdown-body h6 { font-size: 14px; }
487 +
488 +
.markdown-body p {
489 +
  margin-bottom: 0.75rem;
490 +
}
491 +
492 +
.markdown-body ul,
493 +
.markdown-body ol {
494 +
  margin-left: 1.5rem;
495 +
  margin-bottom: 0.75rem;
496 +
}
497 +
498 +
.markdown-body li {
499 +
  margin-bottom: 0.25rem;
500 +
}
501 +
502 +
.markdown-body code {
503 +
  background: #1e1c1f;
504 +
  padding: 2px 4px;
505 +
  font-size: 13px;
506 +
}
507 +
508 +
.markdown-body pre {
509 +
  background: #1e1c1f;
510 +
  padding: 12px;
511 +
  overflow-x: auto;
512 +
  margin-bottom: 0.75rem;
513 +
  border: 1px solid #333;
514 +
}
515 +
516 +
.markdown-body pre code {
517 +
  background: none;
518 +
  padding: 0;
519 +
}
520 +
521 +
.markdown-body blockquote {
522 +
  border-left: 2px solid #555;
523 +
  padding-left: 12px;
524 +
  opacity: 0.7;
525 +
  margin-bottom: 0.75rem;
526 +
}
527 +
528 +
.markdown-body table {
529 +
  width: 100%;
530 +
  border-collapse: collapse;
531 +
  margin-bottom: 0.75rem;
532 +
}
533 +
534 +
.markdown-body th,
535 +
.markdown-body td {
536 +
  border: 1px solid #333;
537 +
  padding: 6px;
538 +
  text-align: left;
539 +
}
540 +
541 +
.markdown-body th {
542 +
  font-weight: 700;
543 +
  text-transform: uppercase;
544 +
  opacity: 0.5;
545 +
  font-size: 12px;
546 +
}
547 +
548 +
.markdown-body hr {
549 +
  border: none;
550 +
  border-top: 1px solid #333;
551 +
  margin: 1rem 0;
552 +
}
553 +
554 +
.markdown-body a {
555 +
  text-decoration: underline;
556 +
}
557 +
558 +
.markdown-body img {
559 +
  max-width: 100%;
560 +
}
561 +
562 +
.markdown-body li:has(> input[type="checkbox"]) {
563 +
  list-style: none;
564 +
  margin-left: -1.5rem;
565 +
}
566 +
567 +
.markdown-body input[type="checkbox"] {
568 +
  -webkit-appearance: none;
569 +
  appearance: none;
570 +
  width: 14px;
571 +
  height: 14px;
572 +
  background: transparent;
573 +
  border: 1px solid white;
574 +
  border-radius: 0;
575 +
  padding: 0;
576 +
  margin-right: 6px;
577 +
  vertical-align: middle;
578 +
  position: relative;
579 +
  top: -1px;
580 +
  cursor: pointer;
581 +
}
582 +
583 +
.markdown-body input[type="checkbox"]:checked::after {
584 +
  content: '✔︎';
585 +
  position: absolute;
586 +
  top: 50%;
587 +
  left: 50%;
588 +
  transform: translate(-50%, -50%);
589 +
  font-size: 12px;
590 +
  color: white;
591 +
  line-height: 1;
592 +
}
593 +
594 +
/* File upload input */
595 +
596 +
input[type="file"] {
597 +
  background: #121113;
598 +
  color: #ffffff;
599 +
  border: 1px solid white;
600 +
  padding: 0.4rem 0.75rem;
601 +
  font-size: 14px;
602 +
  width: 100%;
603 +
  cursor: pointer;
604 +
}
605 +
606 +
input[type="file"]::file-selector-button {
607 +
  background: #121113;
608 +
  color: #ffffff;
609 +
  border: 1px solid #555;
610 +
  padding: 0.25rem 0.5rem;
611 +
  cursor: pointer;
612 +
  font-family: "Commit Mono", monospace;
613 +
  font-size: 12px;
614 +
  margin-right: 0.5rem;
615 +
}
616 +
617 +
.post-excerpt {
618 +
  font-size: 12px;
619 +
  opacity: 0.6;
620 +
  line-height: 1.4;
621 +
}
622 +
623 +
.post-item-enhanced .post-item-info {
624 +
  gap: 0.25rem;
625 +
}
626 +
627 +
.nav-links-input {
628 +
  min-height: 40px;
629 +
  height: 40px;
630 +
}
631 +
632 +
.switch-row {
633 +
  display: flex;
634 +
  align-items: center;
635 +
  gap: 0.5rem;
636 +
}
637 +
638 +
.switch-label {
639 +
  font-size: 14px;
640 +
}
641 +
642 +
.switch {
643 +
  position: relative;
644 +
  display: inline-block;
645 +
  width: 36px;
646 +
  height: 20px;
647 +
  flex-shrink: 0;
648 +
}
649 +
650 +
.switch input {
651 +
  opacity: 0;
652 +
  width: 0;
653 +
  height: 0;
654 +
}
655 +
656 +
.switch-slider {
657 +
  position: absolute;
658 +
  cursor: pointer;
659 +
  top: 0;
660 +
  left: 0;
661 +
  right: 0;
662 +
  bottom: 0;
663 +
  background: #333;
664 +
  border-radius: 20px;
665 +
  transition: background 0.2s;
666 +
}
667 +
668 +
.switch-slider::before {
669 +
  content: "";
670 +
  position: absolute;
671 +
  height: 14px;
672 +
  width: 14px;
673 +
  left: 3px;
674 +
  bottom: 3px;
675 +
  background: #888;
676 +
  border-radius: 50%;
677 +
  transition: transform 0.2s, background 0.2s;
678 +
}
679 +
680 +
.switch input:checked + .switch-slider {
681 +
  background: #555;
682 +
}
683 +
684 +
.switch input:checked + .switch-slider::before {
685 +
  transform: translateX(16px);
686 +
  background: #ffffff;
687 +
}
688 +
689 +
.hidden {
690 +
  display: none;
691 +
}
apps/posts/templates/admin_base.html (added) +27 −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">POSTS</a>
14 +
    <nav class="links">
15 +
      <a href="/admin">posts</a>
16 +
      <a href="/admin/pages">pages</a>
17 +
      <a href="/admin/files">files</a>
18 +
      <a href="/admin/settings">settings</a>
19 +
      <a href="/" target="_blank">view site</a>
20 +
      <a href="/admin/logout">logout</a>
21 +
    </nav>
22 +
  </header>
23 +
  <main>
24 +
    {% block content %}{% endblock %}
25 +
  </main>
26 +
</body>
27 +
</html>
apps/posts/templates/admin_files.html (added) +49 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — Files{% endblock %}
3 +
{% block content %}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Files</h2>
6 +
  </div>
7 +
  {% if let Some(err) = error %}
8 +
    <p class="error">{{ err }}</p>
9 +
  {% endif %}
10 +
  {% if success %}
11 +
    <p class="success">File uploaded.</p>
12 +
  {% endif %}
13 +
  <form method="POST" action="/admin/files/upload" enctype="multipart/form-data" class="form">
14 +
    <label for="file">upload file (max 10MB)</label>
15 +
    <input type="file" id="file" name="file" required>
16 +
    <button type="submit">upload</button>
17 +
  </form>
18 +
  {% if files.is_empty() %}
19 +
    <p class="empty">no files yet</p>
20 +
  {% else %}
21 +
    <div class="admin-list">
22 +
      {% for file in files %}
23 +
        <div class="admin-list-item">
24 +
          <div class="admin-list-info">
25 +
            <span class="admin-list-title">{{ file.original_name }}</span>
26 +
            <div class="admin-list-meta">
27 +
              <span class="admin-list-date">{{ file.content_type }}</span>
28 +
              <span class="admin-list-date">{{ file.size|filesizeformat }}</span>
29 +
              <span class="admin-list-date">{{ file.created_at }}</span>
30 +
            </div>
31 +
          </div>
32 +
          <div class="admin-list-actions">
33 +
            <button type="button" class="link-button"
34 +
              onclick="navigator.clipboard.writeText('{{ site_url }}/files/{{ file.filename }}');this.textContent='copied!'">
35 +
              copy url
36 +
            </button>
37 +
            <button type="button" class="link-button"
38 +
              onclick="navigator.clipboard.writeText('![{{ file.original_name }}]({{ site_url }}/files/{{ file.filename }})');this.textContent='copied!'">
39 +
              copy md
40 +
            </button>
41 +
            <form method="POST" action="/admin/files/{{ file.short_id }}/delete" class="inline-form">
42 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this file?')">delete</button>
43 +
            </form>
44 +
          </div>
45 +
        </div>
46 +
      {% endfor %}
47 +
    </div>
48 +
  {% endif %}
49 +
{% endblock %}
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) +44 −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 post-form">
11 +
        <textarea name="attributes" class="attributes-textarea">title: {{ p.title }}
12 +
slug: {{ p.slug }}
13 +
published: {{ p.is_published }}</textarea>
14 +
        <details class="available-fields">
15 +
          <summary>available fields</summary>
16 +
          <div class="fields-list">
17 +
            <span>title: My Page Title</span>
18 +
            <span>slug: my-page-slug</span>
19 +
            <span>published: true</span>
20 +
          </div>
21 +
        </details>
22 +
        <label for="content">content</label>
23 +
        <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea>
24 +
        <button type="submit">save</button>
25 +
      </form>
26 +
    {% when None %}
27 +
      <form method="POST" action="/admin/pages/create" class="form post-form">
28 +
        <textarea name="attributes" class="attributes-textarea">title:
29 +
slug:
30 +
published: false</textarea>
31 +
        <details class="available-fields">
32 +
          <summary>available fields</summary>
33 +
          <div class="fields-list">
34 +
            <span>title: My Page Title</span>
35 +
            <span>slug: my-page-slug</span>
36 +
            <span>published: true</span>
37 +
          </div>
38 +
        </details>
39 +
        <label for="content">content</label>
40 +
        <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea>
41 +
        <button type="submit">save</button>
42 +
      </form>
43 +
  {% endmatch %}
44 +
{% 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 +
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>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>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) +43 −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="nav_links">navigation links (format: [label](url) [label2](url2))</label>
14 +
    <textarea id="nav_links" name="nav_links" class="nav-links-input">{{ nav_links }}</textarea>
15 +
    <label for="intro_content">intro content (markdown, shown on homepage — use &#123;&#123;latest_posts&#125;&#125; to embed recent posts)</label>
16 +
    <textarea id="intro_content" name="intro_content" class="post-content">{{ intro_content }}</textarea>
17 +
    <div class="switch-row">
18 +
      <label class="switch">
19 +
        <input type="checkbox" id="custom_css_toggle" {% if !custom_css.is_empty() %}checked{% endif %}>
20 +
        <span class="switch-slider"></span>
21 +
      </label>
22 +
      <span class="switch-label">custom stylesheet</span>
23 +
    </div>
24 +
    <div id="custom_css_section" {% if custom_css.is_empty() %}class="hidden"{% endif %}>
25 +
      <label for="custom_css">custom CSS (overrides default styles)</label>
26 +
      <textarea id="custom_css" name="custom_css" class="post-content">{% if custom_css.is_empty() %}{{ default_css }}{% else %}{{ custom_css }}{% endif %}</textarea>
27 +
    </div>
28 +
    <button type="submit">save</button>
29 +
  </form>
30 +
  <script>
31 +
    var toggle = document.getElementById('custom_css_toggle');
32 +
    var section = document.getElementById('custom_css_section');
33 +
    var cssTextarea = document.getElementById('custom_css');
34 +
    toggle.addEventListener('change', function() {
35 +
      section.classList.toggle('hidden', !this.checked);
36 +
    });
37 +
    document.querySelector('form').addEventListener('submit', function() {
38 +
      if (!toggle.checked) {
39 +
        cssTextarea.value = '';
40 +
      }
41 +
    });
42 +
  </script>
43 +
{% 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 +
  <link rel="stylesheet" href="/custom-styles.css">
12 +
</head>
13 +
<body>
14 +
  <header class="header">
15 +
    <a href="/" class="logo">{{ blog_title }}</a>
16 +
    <nav class="links">
17 +
      {% for link in nav_links %}
18 +
        <a href="{{ link.url }}">{{ link.label }}</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) +40 −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.meta_description.is_some() %}
22 +
      <p class="post-description">{{ post.meta_description.as_deref().unwrap_or_default() }}</p>
23 +
    {% endif %}
24 +
    {% if post.published_date.is_some() %}
25 +
      <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time>
26 +
    {% endif %}
27 +
    {% if post.tags.is_some() %}
28 +
      <div class="post-tags">
29 +
        {% for tag in post.tags.as_deref().unwrap_or_default().split(',') %}
30 +
          {% if !tag.trim().is_empty() %}
31 +
            <span class="tag">{{ tag.trim() }}</span>
32 +
          {% endif %}
33 +
        {% endfor %}
34 +
      </div>
35 +
    {% endif %}
36 +
  </div>
37 +
  <article class="markdown-body">
38 +
    {{ rendered_content|safe }}
39 +
  </article>
40 +
{% endblock %}
apps/posts/templates/posts.html (added) +35 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Posts — {{ blog_title }}{% endblock %}
3 +
{% block content %}
4 +
  <h1>Posts</h1>
5 +
  {% if posts.is_empty() %}
6 +
    <p class="empty">no posts yet</p>
7 +
  {% endif %}
8 +
  <div class="post-list">
9 +
    {% for post in posts %}
10 +
      <a href="/posts/{{ post.slug }}" class="post-item post-item-enhanced">
11 +
        <div class="post-item-info">
12 +
          <span class="post-title">{{ post.title }}</span>
13 +
          {% if post.meta_description.is_some() %}
14 +
            {% let desc = post.meta_description.as_deref().unwrap_or_default() %}
15 +
            {% if !desc.is_empty() %}
16 +
              <span class="post-excerpt">{{ desc }}</span>
17 +
            {% endif %}
18 +
          {% endif %}
19 +
          {% if post.tags.is_some() %}
20 +
            <span class="post-tags">
21 +
              {% for tag in post.tags.as_deref().unwrap_or_default().split(',') %}
22 +
                {% if !tag.trim().is_empty() %}
23 +
                  <span class="tag">{{ tag.trim() }}</span>
24 +
                {% endif %}
25 +
              {% endfor %}
26 +
            </span>
27 +
          {% endif %}
28 +
        </div>
29 +
        {% if post.published_date.is_some() %}
30 +
          <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time>
31 +
        {% endif %}
32 +
      </a>
33 +
    {% endfor %}
34 +
  </div>
35 +
{% endblock %}