Merge pull request #16 from stevedylandev/feat/posts-enhancements 579c3624
feat: added posts enhancements
Steve Simkins · 2026-04-08 15:05 10 file(s) · +487 −9
Cargo.lock +236 −0
9 9
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 10
11 11
[[package]]
12 +
name = "aes"
13 +
version = "0.8.4"
14 +
source = "registry+https://github.com/rust-lang/crates.io-index"
15 +
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
16 +
dependencies = [
17 +
 "cfg-if",
18 +
 "cipher",
19 +
 "cpufeatures",
20 +
]
21 +
22 +
[[package]]
12 23
name = "aho-corasick"
13 24
version = "1.1.4"
14 25
source = "registry+https://github.com/rust-lang/crates.io-index"
120 131
version = "1.4.2"
121 132
source = "registry+https://github.com/rust-lang/crates.io-index"
122 133
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
134 +
dependencies = [
135 +
 "derive_arbitrary",
136 +
]
123 137
124 138
[[package]]
125 139
name = "arboard"
622 636
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
623 637
624 638
[[package]]
639 +
name = "bzip2"
640 +
version = "0.5.2"
641 +
source = "registry+https://github.com/rust-lang/crates.io-index"
642 +
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
643 +
dependencies = [
644 +
 "bzip2-sys",
645 +
]
646 +
647 +
[[package]]
648 +
name = "bzip2-sys"
649 +
version = "0.1.13+1.0.8"
650 +
source = "registry+https://github.com/rust-lang/crates.io-index"
651 +
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
652 +
dependencies = [
653 +
 "cc",
654 +
 "pkg-config",
655 +
]
656 +
657 +
[[package]]
625 658
name = "castaway"
626 659
version = "0.2.4"
627 660
source = "registry+https://github.com/rust-lang/crates.io-index"
699 732
]
700 733
701 734
[[package]]
735 +
name = "cipher"
736 +
version = "0.4.4"
737 +
source = "registry+https://github.com/rust-lang/crates.io-index"
738 +
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
739 +
dependencies = [
740 +
 "crypto-common",
741 +
 "inout",
742 +
]
743 +
744 +
[[package]]
702 745
name = "clap"
703 746
version = "4.6.0"
704 747
source = "registry+https://github.com/rust-lang/crates.io-index"
793 836
]
794 837
795 838
[[package]]
839 +
name = "constant_time_eq"
840 +
version = "0.3.1"
841 +
source = "registry+https://github.com/rust-lang/crates.io-index"
842 +
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
843 +
844 +
[[package]]
796 845
name = "convert_case"
797 846
version = "0.10.0"
798 847
source = "registry+https://github.com/rust-lang/crates.io-index"
846 895
]
847 896
848 897
[[package]]
898 +
name = "crc"
899 +
version = "3.4.0"
900 +
source = "registry+https://github.com/rust-lang/crates.io-index"
901 +
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
902 +
dependencies = [
903 +
 "crc-catalog",
904 +
]
905 +
906 +
[[package]]
907 +
name = "crc-catalog"
908 +
version = "2.4.0"
909 +
source = "registry+https://github.com/rust-lang/crates.io-index"
910 +
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
911 +
912 +
[[package]]
849 913
name = "crc32fast"
850 914
version = "1.5.0"
851 915
source = "registry+https://github.com/rust-lang/crates.io-index"
990 1054
]
991 1055
992 1056
[[package]]
1057 +
name = "deflate64"
1058 +
version = "0.1.12"
1059 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1060 +
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
1061 +
1062 +
[[package]]
993 1063
name = "deltae"
994 1064
version = "0.3.2"
995 1065
source = "registry+https://github.com/rust-lang/crates.io-index"
1002 1072
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
1003 1073
dependencies = [
1004 1074
 "powerfmt",
1075 +
]
1076 +
1077 +
[[package]]
1078 +
name = "derive_arbitrary"
1079 +
version = "1.4.2"
1080 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1081 +
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
1082 +
dependencies = [
1083 +
 "proc-macro2",
1084 +
 "quote",
1085 +
 "syn 2.0.117",
1005 1086
]
1006 1087
1007 1088
[[package]]
1045 1126
dependencies = [
1046 1127
 "block-buffer",
1047 1128
 "crypto-common",
1129 +
 "subtle",
1048 1130
]
1049 1131
1050 1132
[[package]]
1593 1675
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1594 1676
1595 1677
[[package]]
1678 +
name = "hmac"
1679 +
version = "0.12.1"
1680 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1681 +
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1682 +
dependencies = [
1683 +
 "digest",
1684 +
]
1685 +
1686 +
[[package]]
1596 1687
name = "html5ever"
1597 1688
version = "0.29.1"
1598 1689
source = "registry+https://github.com/rust-lang/crates.io-index"
1956 2047
]
1957 2048
1958 2049
[[package]]
2050 +
name = "inout"
2051 +
version = "0.1.4"
2052 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2053 +
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
2054 +
dependencies = [
2055 +
 "generic-array",
2056 +
]
2057 +
2058 +
[[package]]
1959 2059
name = "instability"
1960 2060
version = "0.3.12"
1961 2061
source = "registry+https://github.com/rust-lang/crates.io-index"
2264 2364
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
2265 2365
2266 2366
[[package]]
2367 +
name = "lzma-rs"
2368 +
version = "0.3.0"
2369 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2370 +
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
2371 +
dependencies = [
2372 +
 "byteorder",
2373 +
 "crc",
2374 +
]
2375 +
2376 +
[[package]]
2377 +
name = "lzma-sys"
2378 +
version = "0.1.20"
2379 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2380 +
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
2381 +
dependencies = [
2382 +
 "cc",
2383 +
 "libc",
2384 +
 "pkg-config",
2385 +
]
2386 +
2387 +
[[package]]
2267 2388
name = "mac"
2268 2389
version = "0.1.1"
2269 2390
source = "registry+https://github.com/rust-lang/crates.io-index"
2827 2948
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
2828 2949
2829 2950
[[package]]
2951 +
name = "pbkdf2"
2952 +
version = "0.12.2"
2953 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2954 +
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
2955 +
dependencies = [
2956 +
 "digest",
2957 +
 "hmac",
2958 +
]
2959 +
2960 +
[[package]]
2830 2961
name = "percent-encoding"
2831 2962
version = "2.3.2"
2832 2963
source = "registry+https://github.com/rust-lang/crates.io-index"
2991 3122
 "tokio",
2992 3123
 "tracing",
2993 3124
 "tracing-subscriber",
3125 +
 "zip",
2994 3126
]
2995 3127
2996 3128
[[package]]
3924 4056
]
3925 4057
3926 4058
[[package]]
4059 +
name = "sha1"
4060 +
version = "0.10.6"
4061 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4062 +
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
4063 +
dependencies = [
4064 +
 "cfg-if",
4065 +
 "cpufeatures",
4066 +
 "digest",
4067 +
]
4068 +
4069 +
[[package]]
3927 4070
name = "sha2"
3928 4071
version = "0.10.9"
3929 4072
source = "registry+https://github.com/rust-lang/crates.io-index"
5525 5668
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
5526 5669
5527 5670
[[package]]
5671 +
name = "xz2"
5672 +
version = "0.1.7"
5673 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5674 +
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
5675 +
dependencies = [
5676 +
 "lzma-sys",
5677 +
]
5678 +
5679 +
[[package]]
5528 5680
name = "y4m"
5529 5681
version = "0.8.0"
5530 5682
source = "registry+https://github.com/rust-lang/crates.io-index"
5608 5760
version = "1.8.2"
5609 5761
source = "registry+https://github.com/rust-lang/crates.io-index"
5610 5762
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
5763 +
dependencies = [
5764 +
 "zeroize_derive",
5765 +
]
5766 +
5767 +
[[package]]
5768 +
name = "zeroize_derive"
5769 +
version = "1.4.3"
5770 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5771 +
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
5772 +
dependencies = [
5773 +
 "proc-macro2",
5774 +
 "quote",
5775 +
 "syn 2.0.117",
5776 +
]
5611 5777
5612 5778
[[package]]
5613 5779
name = "zerotrie"
5643 5809
]
5644 5810
5645 5811
[[package]]
5812 +
name = "zip"
5813 +
version = "2.4.2"
5814 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5815 +
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
5816 +
dependencies = [
5817 +
 "aes",
5818 +
 "arbitrary",
5819 +
 "bzip2",
5820 +
 "constant_time_eq",
5821 +
 "crc32fast",
5822 +
 "crossbeam-utils",
5823 +
 "deflate64",
5824 +
 "displaydoc",
5825 +
 "flate2",
5826 +
 "getrandom 0.3.4",
5827 +
 "hmac",
5828 +
 "indexmap",
5829 +
 "lzma-rs",
5830 +
 "memchr",
5831 +
 "pbkdf2",
5832 +
 "sha1",
5833 +
 "thiserror 2.0.18",
5834 +
 "time",
5835 +
 "xz2",
5836 +
 "zeroize",
5837 +
 "zopfli",
5838 +
 "zstd",
5839 +
]
5840 +
5841 +
[[package]]
5646 5842
name = "zmij"
5647 5843
version = "1.0.21"
5648 5844
source = "registry+https://github.com/rust-lang/crates.io-index"
5649 5845
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
5846 +
5847 +
[[package]]
5848 +
name = "zopfli"
5849 +
version = "0.8.3"
5850 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5851 +
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
5852 +
dependencies = [
5853 +
 "bumpalo",
5854 +
 "crc32fast",
5855 +
 "log",
5856 +
 "simd-adler32",
5857 +
]
5858 +
5859 +
[[package]]
5860 +
name = "zstd"
5861 +
version = "0.13.3"
5862 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5863 +
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
5864 +
dependencies = [
5865 +
 "zstd-safe",
5866 +
]
5867 +
5868 +
[[package]]
5869 +
name = "zstd-safe"
5870 +
version = "7.2.4"
5871 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5872 +
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
5873 +
dependencies = [
5874 +
 "zstd-sys",
5875 +
]
5876 +
5877 +
[[package]]
5878 +
name = "zstd-sys"
5879 +
version = "2.0.16+zstd.1.5.7"
5880 +
source = "registry+https://github.com/rust-lang/crates.io-index"
5881 +
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
5882 +
dependencies = [
5883 +
 "cc",
5884 +
 "pkg-config",
5885 +
]
5650 5886
5651 5887
[[package]]
5652 5888
name = "zune-core"
Cargo.toml +3 −0
42 42
tracing = "0.1"
43 43
tracing-subscriber = "0.3"
44 44
45 +
# Archive
46 +
zip = "2"
47 +
45 48
# Workspace crates
46 49
andromeda-auth = { path = "crates/auth" }
apps/posts/Cargo.toml +1 −0
24 24
askama = "0.15"
25 25
askama_web = { version = "0.15", features = ["axum-0.8"] }
26 26
pulldown-cmark = "0.12"
27 +
zip = { workspace = true }
apps/posts/src/db.rs +11 −4
278 278
    title: &str,
279 279
    slug: &str,
280 280
    content: &str,
281 +
    status: &str,
281 282
    alias: Option<&str>,
282 283
    canonical_url: Option<&str>,
283 284
    published_date: Option<&str>,
287 288
    tags: Option<&str>,
288 289
) -> Result<Option<Post>, DbError> {
289 290
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
291 +
    let effective_published_date = if status == "published" {
292 +
        Some(published_date.unwrap_or(""))
293 +
    } else {
294 +
        published_date
295 +
    };
290 296
    let rows = conn.execute(
291 -
        "UPDATE posts SET title = ?1, slug = ?2, content = ?3, alias = ?4, canonical_url = ?5,
292 -
         published_date = ?6, meta_description = ?7, meta_image = ?8, lang = ?9, tags = ?10,
293 -
         updated_at = datetime('now') WHERE short_id = ?11",
294 -
        params![title, slug, content, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, short_id],
297 +
        "UPDATE posts SET title = ?1, slug = ?2, content = ?3, status = ?4, alias = ?5, canonical_url = ?6,
298 +
         published_date = CASE WHEN ?4 = 'published' THEN COALESCE(?7, published_date, datetime('now')) ELSE ?7 END,
299 +
         meta_description = ?8, meta_image = ?9, lang = ?10, tags = ?11,
300 +
         updated_at = datetime('now') WHERE short_id = ?12",
301 +
        params![title, slug, content, status, alias, canonical_url, effective_published_date, meta_description, meta_image, lang, tags, short_id],
295 302
    )?;
296 303
    if rows == 0 {
297 304
        return Ok(None);
apps/posts/src/server.rs +160 −3
750 750
        attrs.slug.trim().to_string()
751 751
    };
752 752
753 +
    let status = if form.action == "publish" { "published" } else { "draft" };
753 754
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
754 755
    let published_date = if attrs.published_date.trim().is_empty() {
755 -
        now_datetime()
756 +
        None
756 757
    } else {
757 -
        attrs.published_date.trim().to_string()
758 +
        Some(attrs.published_date.trim().to_string())
758 759
    };
759 760
760 761
    match db::update_post(
763 764
        title,
764 765
        &slug,
765 766
        &form.content,
767 +
        status,
766 768
        opt_str(&attrs.alias),
767 769
        None,
768 -
        Some(&published_date),
770 +
        published_date.as_deref(),
769 771
        opt_str(&attrs.meta_description),
770 772
        opt_str(&attrs.meta_image),
771 773
        lang,
1189 1191
        .into_response()
1190 1192
}
1191 1193
1194 +
// --- Download/export handlers ---
1195 +
1196 +
async fn admin_download_posts(
1197 +
    _session: auth::AuthSession,
1198 +
    State(state): State<Arc<AppState>>,
1199 +
) -> Response {
1200 +
    let posts = match db::get_all_posts(&state.db) {
1201 +
        Ok(posts) => posts,
1202 +
        Err(e) => {
1203 +
            tracing::error!("Failed to get posts for export: {}", e);
1204 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1205 +
        }
1206 +
    };
1207 +
1208 +
    let result = tokio::task::spawn_blocking(move || {
1209 +
        let mut buf = std::io::Cursor::new(Vec::new());
1210 +
        {
1211 +
            let mut zip = zip::ZipWriter::new(&mut buf);
1212 +
            let options = zip::write::SimpleFileOptions::default()
1213 +
                .compression_method(zip::CompressionMethod::Deflated);
1214 +
            for post in &posts {
1215 +
                let filename = format!("{}.md", post.slug);
1216 +
                let mut frontmatter = format!(
1217 +
                    "---\ntitle: {}\nslug: {}\nstatus: {}",
1218 +
                    post.title, post.slug, post.status
1219 +
                );
1220 +
                if let Some(ref pd) = post.published_date {
1221 +
                    frontmatter.push_str(&format!("\npublished_date: {}", pd));
1222 +
                }
1223 +
                if let Some(ref tags) = post.tags {
1224 +
                    frontmatter.push_str(&format!("\ntags: {}", tags));
1225 +
                }
1226 +
                frontmatter.push_str(&format!("\nlang: {}", post.lang));
1227 +
                if let Some(ref alias) = post.alias {
1228 +
                    frontmatter.push_str(&format!("\nalias: {}", alias));
1229 +
                }
1230 +
                if let Some(ref meta_image) = post.meta_image {
1231 +
                    frontmatter.push_str(&format!("\nmeta_image: {}", meta_image));
1232 +
                }
1233 +
                if let Some(ref meta_desc) = post.meta_description {
1234 +
                    frontmatter.push_str(&format!("\ndescription: {}", meta_desc));
1235 +
                }
1236 +
                frontmatter.push_str("\n---\n\n");
1237 +
                let content = format!("{}{}", frontmatter, post.content);
1238 +
                if let Err(e) = zip.start_file(&filename, options) {
1239 +
                    tracing::warn!("Failed to add {} to zip: {}", filename, e);
1240 +
                    continue;
1241 +
                }
1242 +
                if let Err(e) = std::io::Write::write_all(&mut zip, content.as_bytes()) {
1243 +
                    tracing::warn!("Failed to write {} to zip: {}", filename, e);
1244 +
                }
1245 +
            }
1246 +
            let _ = zip.finish();
1247 +
        }
1248 +
        buf.into_inner()
1249 +
    })
1250 +
    .await;
1251 +
1252 +
    match result {
1253 +
        Ok(bytes) => (
1254 +
            StatusCode::OK,
1255 +
            [
1256 +
                (axum::http::header::CONTENT_TYPE, "application/zip"),
1257 +
                (
1258 +
                    axum::http::header::CONTENT_DISPOSITION,
1259 +
                    "attachment; filename=\"posts.zip\"",
1260 +
                ),
1261 +
            ],
1262 +
            bytes,
1263 +
        )
1264 +
            .into_response(),
1265 +
        Err(e) => {
1266 +
            tracing::error!("Failed to create posts zip: {}", e);
1267 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
1268 +
        }
1269 +
    }
1270 +
}
1271 +
1272 +
async fn admin_download_uploads(
1273 +
    _session: auth::AuthSession,
1274 +
    State(state): State<Arc<AppState>>,
1275 +
) -> Response {
1276 +
    let files = match db::get_all_files(&state.db) {
1277 +
        Ok(files) => files,
1278 +
        Err(e) => {
1279 +
            tracing::error!("Failed to get files for export: {}", e);
1280 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1281 +
        }
1282 +
    };
1283 +
1284 +
    let uploads_dir = state.uploads_dir.clone();
1285 +
    let mut file_data: Vec<(String, Vec<u8>)> = Vec::new();
1286 +
    let mut seen_names = std::collections::HashSet::new();
1287 +
    for file in &files {
1288 +
        let path = std::path::PathBuf::from(&uploads_dir).join(&file.filename);
1289 +
        match tokio::fs::read(&path).await {
1290 +
            Ok(bytes) => {
1291 +
                let name = if seen_names.contains(&file.original_name) {
1292 +
                    format!("{}_{}", file.short_id, file.original_name)
1293 +
                } else {
1294 +
                    file.original_name.clone()
1295 +
                };
1296 +
                seen_names.insert(file.original_name.clone());
1297 +
                file_data.push((name, bytes));
1298 +
            }
1299 +
            Err(e) => {
1300 +
                tracing::warn!("Skipping file {} ({}): {}", file.original_name, file.filename, e);
1301 +
            }
1302 +
        }
1303 +
    }
1304 +
1305 +
    let result = tokio::task::spawn_blocking(move || {
1306 +
        let mut buf = std::io::Cursor::new(Vec::new());
1307 +
        {
1308 +
            let mut zip = zip::ZipWriter::new(&mut buf);
1309 +
            let options = zip::write::SimpleFileOptions::default()
1310 +
                .compression_method(zip::CompressionMethod::Stored);
1311 +
            for (name, bytes) in &file_data {
1312 +
                if let Err(e) = zip.start_file(name, options) {
1313 +
                    tracing::warn!("Failed to add {} to zip: {}", name, e);
1314 +
                    continue;
1315 +
                }
1316 +
                if let Err(e) = std::io::Write::write_all(&mut zip, bytes) {
1317 +
                    tracing::warn!("Failed to write {} to zip: {}", name, e);
1318 +
                }
1319 +
            }
1320 +
            let _ = zip.finish();
1321 +
        }
1322 +
        buf.into_inner()
1323 +
    })
1324 +
    .await;
1325 +
1326 +
    match result {
1327 +
        Ok(bytes) => (
1328 +
            StatusCode::OK,
1329 +
            [
1330 +
                (axum::http::header::CONTENT_TYPE, "application/zip"),
1331 +
                (
1332 +
                    axum::http::header::CONTENT_DISPOSITION,
1333 +
                    "attachment; filename=\"uploads.zip\"",
1334 +
                ),
1335 +
            ],
1336 +
            bytes,
1337 +
        )
1338 +
            .into_response(),
1339 +
        Err(e) => {
1340 +
            tracing::error!("Failed to create uploads zip: {}", e);
1341 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
1342 +
        }
1343 +
    }
1344 +
}
1345 +
1192 1346
// --- Date helper ---
1193 1347
1194 1348
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
1271 1425
        .route("/admin/pages/{id}/delete", post(admin_delete_page))
1272 1426
        // Admin settings
1273 1427
        .route("/admin/settings", get(admin_get_settings).post(admin_post_settings))
1428 +
        // Admin downloads
1429 +
        .route("/admin/downloads/posts", get(admin_download_posts))
1430 +
        .route("/admin/downloads/uploads", get(admin_download_uploads))
1274 1431
        // Admin files
1275 1432
        .route("/admin/files", get(admin_files))
1276 1433
        .route("/admin/files/upload", post(admin_upload_file))
apps/posts/static/styles.css +51 −0
686 686
  background: #ffffff;
687 687
}
688 688
689 +
/* File thumbnails */
690 +
691 +
.file-thumbnail {
692 +
  max-width: 60px;
693 +
  max-height: 60px;
694 +
  object-fit: cover;
695 +
  border: 1px solid #333;
696 +
  flex-shrink: 0;
697 +
}
698 +
699 +
/* Footer */
700 +
701 +
.footer {
702 +
  width: 100%;
703 +
  border-top: 1px solid #333;
704 +
  padding-top: 1rem;
705 +
  margin-top: auto;
706 +
  display: flex;
707 +
  justify-content: center;
708 +
}
709 +
710 +
.rss-link {
711 +
  display: flex;
712 +
  align-items: center;
713 +
  gap: 0.4rem;
714 +
  font-size: 12px;
715 +
  opacity: 0.5;
716 +
}
717 +
718 +
.rss-link:hover {
719 +
  opacity: 0.8;
720 +
}
721 +
722 +
/* Export buttons */
723 +
724 +
a.btn {
725 +
  display: inline-block;
726 +
  background: #121113;
727 +
  color: #ffffff;
728 +
  border: 1px solid white;
729 +
  padding: 0.4rem 0.75rem;
730 +
  font-size: 14px;
731 +
  font-family: "Commit Mono", monospace;
732 +
  text-decoration: none;
733 +
  cursor: pointer;
734 +
}
735 +
736 +
a.btn:hover {
737 +
  opacity: 0.7;
738 +
}
739 +
689 740
.hidden {
690 741
  display: none;
691 742
}
apps/posts/templates/admin_files.html +3 −0
21 21
    <div class="admin-list">
22 22
      {% for file in files %}
23 23
        <div class="admin-list-item">
24 +
          {% if file.content_type.starts_with("image/") %}
25 +
            <img src="/files/{{ file.filename }}" class="file-thumbnail" alt="{{ file.original_name }}">
26 +
          {% endif %}
24 27
          <div class="admin-list-info">
25 28
            <span class="admin-list-title">{{ file.original_name }}</span>
26 29
            <div class="admin-list-meta">
apps/posts/templates/admin_post_form.html +7 −2
44 44
        <label for="content">content</label>
45 45
        <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea>
46 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>
47 +
          {% if p.status == "published" %}
48 +
            <button type="submit" name="action" value="publish">update</button>
49 +
            <button type="submit" name="action" value="draft">unpublish</button>
50 +
          {% else %}
51 +
            <button type="submit" name="action" value="draft">save draft</button>
52 +
            <button type="submit" name="action" value="publish">publish</button>
53 +
          {% endif %}
49 54
        </div>
50 55
      </form>
51 56
    {% when None %}
apps/posts/templates/admin_settings.html +5 −0
31 31
    </div>
32 32
    <button type="submit">save</button>
33 33
  </form>
34 +
  <h3>Data Export</h3>
35 +
  <div class="form-actions">
36 +
    <a href="/admin/downloads/posts" class="btn">download posts</a>
37 +
    <a href="/admin/downloads/uploads" class="btn">download uploads</a>
38 +
  </div>
34 39
  <script>
35 40
    var toggle = document.getElementById('custom_css_toggle');
36 41
    var section = document.getElementById('custom_css_section');
apps/posts/templates/base.html +10 −0
31 31
  <main>
32 32
    {% block content %}{% endblock %}
33 33
  </main>
34 +
  <footer class="footer">
35 +
    <a href="/feed.xml" class="rss-link" title="RSS Feed">
36 +
      <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
37 +
        <circle cx="3.429" cy="20.571" r="3.429"/>
38 +
        <path d="M11.429 24h4.57C15.999 15.179 8.821 8.001 0 8.001v4.57c6.297 0 11.429 5.132 11.429 11.429z"/>
39 +
        <path d="M19.999 24C19.999 10.767 9.233 0 0 0v4.571c10.714 0 15.428 8.714 15.428 19.429h4.571z"/>
40 +
      </svg>
41 +
      RSS
42 +
    </a>
43 +
  </footer>
34 44
</body>
35 45
</html>