feat: added file uploads to posts
44b87023
11 file(s) · +350 −12
| 3 | 3 | *.db |
|
| 4 | 4 | .env |
|
| 5 | 5 | .DS_Store |
|
| 6 | + | apps/posts/uploads |
| 1 | 1 | POSTS_PASSWORD=changeme |
|
| 2 | 2 | POSTS_DB_PATH=posts.sqlite |
|
| 3 | + | UPLOADS_DIR=uploads |
|
| 3 | 4 | COOKIE_SECURE=false |
|
| 4 | 5 | HOST=127.0.0.1 |
|
| 5 | 6 | PORT=3000 |
| 8 | 8 | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | 9 | ||
| 10 | 10 | [dependencies] |
|
| 11 | - | axum = { workspace = true } |
|
| 11 | + | axum = { workspace = true, features = ["multipart"] } |
|
| 12 | 12 | tokio = { workspace = true } |
|
| 13 | 13 | serde = { workspace = true } |
|
| 14 | 14 | serde_json = { workspace = true } |
| 8 | 8 | environment: |
|
| 9 | 9 | - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme} |
|
| 10 | 10 | - POSTS_DB_PATH=/data/posts.sqlite |
|
| 11 | + | - UPLOADS_DIR=/data/uploads |
|
| 12 | + | - SITE_URL=${SITE_URL:-http://localhost:3000} |
|
| 11 | 13 | - COOKIE_SECURE=false |
|
| 12 | 14 | - HOST=0.0.0.0 |
|
| 13 | 15 | - PORT=${PORT:-3000} |
| 61 | 61 | pub updated_at: String, |
|
| 62 | 62 | } |
|
| 63 | 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 | + | ||
| 64 | 75 | pub fn init_db() -> Db { |
|
| 65 | 76 | let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string()); |
|
| 66 | 77 | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 105 | 116 | CREATE TABLE IF NOT EXISTS settings ( |
|
| 106 | 117 | key TEXT PRIMARY KEY, |
|
| 107 | 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')) |
|
| 108 | 129 | );" |
|
| 109 | 130 | ) |
|
| 110 | 131 | .expect("Failed to create tables"); |
|
| 505 | 526 | )?; |
|
| 506 | 527 | Ok(()) |
|
| 507 | 528 | } |
|
| 529 | + | ||
| 530 | + | // --- File CRUD --- |
|
| 531 | + | ||
| 532 | + | fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result<UploadedFile> { |
|
| 533 | + | Ok(UploadedFile { |
|
| 534 | + | id: row.get(0)?, |
|
| 535 | + | short_id: row.get(1)?, |
|
| 536 | + | filename: row.get(2)?, |
|
| 537 | + | original_name: row.get(3)?, |
|
| 538 | + | content_type: row.get(4)?, |
|
| 539 | + | size: row.get(5)?, |
|
| 540 | + | created_at: row.get(6)?, |
|
| 541 | + | }) |
|
| 542 | + | } |
|
| 543 | + | ||
| 544 | + | const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at"; |
|
| 545 | + | ||
| 546 | + | pub fn create_file( |
|
| 547 | + | db: &Db, |
|
| 548 | + | filename: &str, |
|
| 549 | + | original_name: &str, |
|
| 550 | + | content_type: &str, |
|
| 551 | + | size: i64, |
|
| 552 | + | ) -> Result<UploadedFile, DbError> { |
|
| 553 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 554 | + | let short_id = nanoid!(10); |
|
| 555 | + | conn.execute( |
|
| 556 | + | "INSERT INTO files (short_id, filename, original_name, content_type, size) VALUES (?1, ?2, ?3, ?4, ?5)", |
|
| 557 | + | params![short_id, filename, original_name, content_type, size], |
|
| 558 | + | )?; |
|
| 559 | + | let id = conn.last_insert_rowid(); |
|
| 560 | + | let file = conn.query_row( |
|
| 561 | + | &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS), |
|
| 562 | + | params![id], |
|
| 563 | + | row_to_file, |
|
| 564 | + | )?; |
|
| 565 | + | Ok(file) |
|
| 566 | + | } |
|
| 567 | + | ||
| 568 | + | pub fn get_all_files(db: &Db) -> Result<Vec<UploadedFile>, DbError> { |
|
| 569 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 570 | + | let mut stmt = conn.prepare( |
|
| 571 | + | &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS), |
|
| 572 | + | )?; |
|
| 573 | + | let files = stmt |
|
| 574 | + | .query_map([], row_to_file)? |
|
| 575 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 576 | + | Ok(files) |
|
| 577 | + | } |
|
| 578 | + | ||
| 579 | + | pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> { |
|
| 580 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 581 | + | let file = match conn.query_row( |
|
| 582 | + | &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS), |
|
| 583 | + | params![short_id], |
|
| 584 | + | row_to_file, |
|
| 585 | + | ) { |
|
| 586 | + | Ok(f) => f, |
|
| 587 | + | Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), |
|
| 588 | + | Err(e) => return Err(DbError::Sqlite(e)), |
|
| 589 | + | }; |
|
| 590 | + | conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?; |
|
| 591 | + | Ok(Some(file)) |
|
| 592 | + | } |
|
| 1 | 1 | use askama::Template; |
|
| 2 | 2 | use askama_web::WebTemplate; |
|
| 3 | 3 | use axum::{ |
|
| 4 | - | extract::{Form, Path, Query, State}, |
|
| 4 | + | extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State}, |
|
| 5 | 5 | http::{HeaderValue, StatusCode, Uri}, |
|
| 6 | 6 | response::{Html, IntoResponse, Redirect, Response}, |
|
| 7 | 7 | routing::{get, post}, |
|
| 12 | 12 | use std::sync::Arc; |
|
| 13 | 13 | ||
| 14 | 14 | use crate::auth; |
|
| 15 | - | use crate::db::{self, Db, Page, Post}; |
|
| 15 | + | use crate::db::{self, Db, Page, Post, UploadedFile}; |
|
| 16 | 16 | ||
| 17 | 17 | #[derive(Clone)] |
|
| 18 | 18 | pub struct AppState { |
|
| 19 | 19 | pub db: Db, |
|
| 20 | 20 | pub app_password: String, |
|
| 21 | 21 | pub cookie_secure: bool, |
|
| 22 | + | pub uploads_dir: String, |
|
| 23 | + | pub site_url: String, |
|
| 22 | 24 | } |
|
| 23 | 25 | ||
| 24 | 26 | #[derive(Embed)] |
|
| 107 | 109 | success: bool, |
|
| 108 | 110 | } |
|
| 109 | 111 | ||
| 112 | + | #[derive(Template)] |
|
| 113 | + | #[template(path = "admin_files.html")] |
|
| 114 | + | struct AdminFilesTemplate { |
|
| 115 | + | files: Vec<UploadedFile>, |
|
| 116 | + | site_url: String, |
|
| 117 | + | error: Option<String>, |
|
| 118 | + | success: bool, |
|
| 119 | + | } |
|
| 120 | + | ||
| 110 | 121 | // --- Query/Form structs --- |
|
| 111 | 122 | ||
| 112 | 123 | #[derive(serde::Deserialize, Default)] |
|
| 160 | 171 | "slug" => attrs.slug = value, |
|
| 161 | 172 | "alias" => attrs.alias = value, |
|
| 162 | 173 | "published_date" => attrs.published_date = value, |
|
| 163 | - | "meta_description" => attrs.meta_description = value, |
|
| 174 | + | "description" | "meta_description" => attrs.meta_description = value, |
|
| 164 | 175 | "meta_image" => attrs.meta_image = value, |
|
| 165 | 176 | "lang" => attrs.lang = value, |
|
| 166 | 177 | "tags" => attrs.tags = value, |
|
| 197 | 208 | "js" => "application/javascript", |
|
| 198 | 209 | "html" => "text/html", |
|
| 199 | 210 | "png" => "image/png", |
|
| 211 | + | "jpg" | "jpeg" => "image/jpeg", |
|
| 212 | + | "gif" => "image/gif", |
|
| 213 | + | "webp" => "image/webp", |
|
| 200 | 214 | "ico" => "image/x-icon", |
|
| 201 | 215 | "svg" => "image/svg+xml", |
|
| 202 | 216 | "woff" | "woff2" => "font/woff2", |
|
| 203 | 217 | "ttf" => "font/ttf", |
|
| 204 | 218 | "otf" => "font/otf", |
|
| 205 | 219 | "json" | "webmanifest" => "application/json", |
|
| 220 | + | "pdf" => "application/pdf", |
|
| 221 | + | "mp4" => "video/mp4", |
|
| 222 | + | "webm" => "video/webm", |
|
| 206 | 223 | _ => "application/octet-stream", |
|
| 207 | 224 | } |
|
| 208 | 225 | } |
|
| 745 | 762 | Redirect::to("/admin/settings?success=true").into_response() |
|
| 746 | 763 | } |
|
| 747 | 764 | ||
| 765 | + | // --- Admin file handlers --- |
|
| 766 | + | ||
| 767 | + | async fn admin_files( |
|
| 768 | + | _session: auth::AuthSession, |
|
| 769 | + | State(state): State<Arc<AppState>>, |
|
| 770 | + | Query(q): Query<FlashQuery>, |
|
| 771 | + | ) -> Response { |
|
| 772 | + | match db::get_all_files(&state.db) { |
|
| 773 | + | Ok(files) => WebTemplate(AdminFilesTemplate { |
|
| 774 | + | files, |
|
| 775 | + | site_url: state.site_url.clone(), |
|
| 776 | + | error: q.error, |
|
| 777 | + | success: q.success, |
|
| 778 | + | }) |
|
| 779 | + | .into_response(), |
|
| 780 | + | Err(e) => { |
|
| 781 | + | tracing::error!("Failed to list files: {}", e); |
|
| 782 | + | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 783 | + | } |
|
| 784 | + | } |
|
| 785 | + | } |
|
| 786 | + | ||
| 787 | + | async fn admin_upload_file( |
|
| 788 | + | _session: auth::AuthSession, |
|
| 789 | + | State(state): State<Arc<AppState>>, |
|
| 790 | + | mut multipart: Multipart, |
|
| 791 | + | ) -> Response { |
|
| 792 | + | let mut file_data: Option<(String, String, Vec<u8>)> = None; |
|
| 793 | + | ||
| 794 | + | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 795 | + | if field.name() == Some("file") { |
|
| 796 | + | let original_name = field |
|
| 797 | + | .file_name() |
|
| 798 | + | .unwrap_or("upload") |
|
| 799 | + | .to_string(); |
|
| 800 | + | let content_type = field |
|
| 801 | + | .content_type() |
|
| 802 | + | .unwrap_or("application/octet-stream") |
|
| 803 | + | .to_string(); |
|
| 804 | + | match field.bytes().await { |
|
| 805 | + | Ok(bytes) => { |
|
| 806 | + | file_data = Some((original_name, content_type, bytes.to_vec())); |
|
| 807 | + | } |
|
| 808 | + | Err(e) => { |
|
| 809 | + | tracing::error!("Failed to read upload: {}", e); |
|
| 810 | + | return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response(); |
|
| 811 | + | } |
|
| 812 | + | } |
|
| 813 | + | } |
|
| 814 | + | } |
|
| 815 | + | ||
| 816 | + | let (original_name, content_type, bytes) = match file_data { |
|
| 817 | + | Some(d) => d, |
|
| 818 | + | None => return Redirect::to("/admin/files?error=No+file+provided").into_response(), |
|
| 819 | + | }; |
|
| 820 | + | ||
| 821 | + | let max_size: usize = 10 * 1024 * 1024; |
|
| 822 | + | if bytes.len() > max_size { |
|
| 823 | + | return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response(); |
|
| 824 | + | } |
|
| 825 | + | ||
| 826 | + | let ext = original_name |
|
| 827 | + | .rsplit('.') |
|
| 828 | + | .next() |
|
| 829 | + | .filter(|e| !e.is_empty() && *e != original_name) |
|
| 830 | + | .unwrap_or(""); |
|
| 831 | + | let id = nanoid::nanoid!(10); |
|
| 832 | + | let stored_name = if ext.is_empty() { |
|
| 833 | + | id |
|
| 834 | + | } else { |
|
| 835 | + | format!("{}.{}", id, ext) |
|
| 836 | + | }; |
|
| 837 | + | ||
| 838 | + | let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name); |
|
| 839 | + | if let Err(e) = tokio::fs::write(&path, &bytes).await { |
|
| 840 | + | tracing::error!("Failed to write file: {}", e); |
|
| 841 | + | return Redirect::to("/admin/files?error=Failed+to+save+file").into_response(); |
|
| 842 | + | } |
|
| 843 | + | ||
| 844 | + | match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) { |
|
| 845 | + | Ok(_) => Redirect::to("/admin/files?success=true").into_response(), |
|
| 846 | + | Err(e) => { |
|
| 847 | + | tracing::error!("Failed to record file: {}", e); |
|
| 848 | + | let _ = tokio::fs::remove_file(&path).await; |
|
| 849 | + | Redirect::to("/admin/files?error=Failed+to+record+file").into_response() |
|
| 850 | + | } |
|
| 851 | + | } |
|
| 852 | + | } |
|
| 853 | + | ||
| 854 | + | async fn admin_delete_file( |
|
| 855 | + | _session: auth::AuthSession, |
|
| 856 | + | State(state): State<Arc<AppState>>, |
|
| 857 | + | Path(short_id): Path<String>, |
|
| 858 | + | ) -> Response { |
|
| 859 | + | match db::delete_file(&state.db, &short_id) { |
|
| 860 | + | Ok(Some(file)) => { |
|
| 861 | + | let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename); |
|
| 862 | + | if let Err(e) = tokio::fs::remove_file(&path).await { |
|
| 863 | + | tracing::warn!("Failed to delete file from disk: {}", e); |
|
| 864 | + | } |
|
| 865 | + | Redirect::to("/admin/files").into_response() |
|
| 866 | + | } |
|
| 867 | + | Ok(None) => Redirect::to("/admin/files").into_response(), |
|
| 868 | + | Err(e) => { |
|
| 869 | + | tracing::error!("Failed to delete file: {}", e); |
|
| 870 | + | Redirect::to("/admin/files").into_response() |
|
| 871 | + | } |
|
| 872 | + | } |
|
| 873 | + | } |
|
| 874 | + | ||
| 875 | + | async fn serve_uploaded_file( |
|
| 876 | + | State(state): State<Arc<AppState>>, |
|
| 877 | + | Path(filename): Path<String>, |
|
| 878 | + | ) -> Response { |
|
| 879 | + | if filename.contains("..") || filename.contains('/') || filename.contains('\\') { |
|
| 880 | + | return StatusCode::NOT_FOUND.into_response(); |
|
| 881 | + | } |
|
| 882 | + | ||
| 883 | + | let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename); |
|
| 884 | + | match tokio::fs::read(&path).await { |
|
| 885 | + | Ok(bytes) => { |
|
| 886 | + | let mime = mime_from_path(&filename); |
|
| 887 | + | ( |
|
| 888 | + | StatusCode::OK, |
|
| 889 | + | [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))], |
|
| 890 | + | bytes, |
|
| 891 | + | ) |
|
| 892 | + | .into_response() |
|
| 893 | + | } |
|
| 894 | + | Err(_) => StatusCode::NOT_FOUND.into_response(), |
|
| 895 | + | } |
|
| 896 | + | } |
|
| 897 | + | ||
| 748 | 898 | // --- RSS feed handler --- |
|
| 749 | 899 | ||
| 750 | 900 | fn xml_escape(s: &str) -> String { |
|
| 761 | 911 | .ok() |
|
| 762 | 912 | .flatten() |
|
| 763 | 913 | .unwrap_or_default(); |
|
| 764 | - | let site_url = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); |
|
| 765 | - | let site_url = site_url.trim_end_matches('/'); |
|
| 914 | + | let site_url = &state.site_url; |
|
| 766 | 915 | ||
| 767 | 916 | let posts = match db::get_published_posts(&state.db) { |
|
| 768 | 917 | Ok(posts) => posts, |
|
| 861 | 1010 | .map(|v| v == "true") |
|
| 862 | 1011 | .unwrap_or(false); |
|
| 863 | 1012 | ||
| 1013 | + | let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string()); |
|
| 1014 | + | tokio::fs::create_dir_all(&uploads_dir) |
|
| 1015 | + | .await |
|
| 1016 | + | .expect("Failed to create uploads directory"); |
|
| 1017 | + | ||
| 1018 | + | let site_url = std::env::var("SITE_URL") |
|
| 1019 | + | .unwrap_or_else(|_| "http://localhost:3000".to_string()) |
|
| 1020 | + | .trim_end_matches('/') |
|
| 1021 | + | .to_string(); |
|
| 1022 | + | ||
| 864 | 1023 | let state = Arc::new(AppState { |
|
| 865 | 1024 | db, |
|
| 866 | 1025 | app_password, |
|
| 867 | 1026 | cookie_secure, |
|
| 1027 | + | uploads_dir, |
|
| 1028 | + | site_url, |
|
| 868 | 1029 | }); |
|
| 869 | 1030 | ||
| 870 | 1031 | let app = Router::new() |
|
| 893 | 1054 | .route("/admin/pages/{id}/delete", post(admin_delete_page)) |
|
| 894 | 1055 | // Admin settings |
|
| 895 | 1056 | .route("/admin/settings", get(admin_get_settings).post(admin_post_settings)) |
|
| 1057 | + | // Admin files |
|
| 1058 | + | .route("/admin/files", get(admin_files)) |
|
| 1059 | + | .route("/admin/files/upload", post(admin_upload_file)) |
|
| 1060 | + | .route("/admin/files/{id}/delete", post(admin_delete_file)) |
|
| 1061 | + | // Public files |
|
| 1062 | + | .route("/files/{filename}", get(serve_uploaded_file)) |
|
| 896 | 1063 | // Static assets |
|
| 897 | 1064 | .route("/static/{*path}", get(serve_static)) |
|
| 898 | 1065 | // Fallback |
|
| 899 | 1066 | .fallback(get(fallback_handler)) |
|
| 900 | - | .with_state(state); |
|
| 1067 | + | .with_state(state) |
|
| 1068 | + | .layer(DefaultBodyLimit::max(11 * 1024 * 1024)); |
|
| 901 | 1069 | ||
| 902 | 1070 | let addr = format!("{}:{}", host, port); |
|
| 903 | 1071 | tracing::info!("Listening on http://{}", addr); |
|
| 41 | 41 | min-height: 100vh; |
|
| 42 | 42 | max-width: 700px; |
|
| 43 | 43 | margin: auto; |
|
| 44 | - | padding: 0 1rem; |
|
| 44 | + | padding: 0 1rem 4rem; |
|
| 45 | 45 | } |
|
| 46 | 46 | ||
| 47 | 47 | @media (max-width: 480px) { |
|
| 221 | 221 | ||
| 222 | 222 | .post-title { |
|
| 223 | 223 | font-size: 16px; |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | .post-description { |
|
| 227 | + | font-style: italic; |
|
| 228 | + | opacity: 0.7; |
|
| 224 | 229 | } |
|
| 225 | 230 | ||
| 226 | 231 | .post-date { |
|
| 585 | 590 | color: white; |
|
| 586 | 591 | line-height: 1; |
|
| 587 | 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 | + | } |
|
| 10 | 10 | </head> |
|
| 11 | 11 | <body> |
|
| 12 | 12 | <header class="header"> |
|
| 13 | - | <a href="/admin" class="logo">Admin</a> |
|
| 13 | + | <a href="/admin" class="logo">POSTS</a> |
|
| 14 | 14 | <nav class="links"> |
|
| 15 | 15 | <a href="/admin">posts</a> |
|
| 16 | 16 | <a href="/admin/pages">pages</a> |
|
| 17 | + | <a href="/admin/files">files</a> |
|
| 17 | 18 | <a href="/admin/settings">settings</a> |
|
| 18 | 19 | <a href="/" target="_blank">view site</a> |
|
| 19 | 20 | <a href="/admin/logout">logout</a> |
| 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('');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 %} |
| 26 | 26 | meta_image: {{ p.meta_image.as_deref().unwrap_or_default() }} |
|
| 27 | 27 | {%- endif %} |
|
| 28 | 28 | {%- if p.meta_description.is_some() %} |
|
| 29 | - | meta_description: {{ p.meta_description.as_deref().unwrap_or_default() }} |
|
| 29 | + | description: {{ p.meta_description.as_deref().unwrap_or_default() }} |
|
| 30 | 30 | {%- endif %}</textarea> |
|
| 31 | 31 | <details class="available-fields"> |
|
| 32 | 32 | <summary>available fields</summary> |
|
| 38 | 38 | <span>tags: rust, web, tutorial</span> |
|
| 39 | 39 | <span>alias: /old/path</span> |
|
| 40 | 40 | <span>meta_image: https://example.com/image.jpg</span> |
|
| 41 | - | <span>meta_description: A short summary of the post</span> |
|
| 41 | + | <span>description: A short summary of the post</span> |
|
| 42 | 42 | </div> |
|
| 43 | 43 | </details> |
|
| 44 | 44 | <label for="content">content</label> |
|
| 61 | 61 | <span>tags: rust, web, tutorial</span> |
|
| 62 | 62 | <span>alias: /old/path</span> |
|
| 63 | 63 | <span>meta_image: https://example.com/image.jpg</span> |
|
| 64 | - | <span>meta_description: A short summary of the post</span> |
|
| 64 | + | <span>description: A short summary of the post</span> |
|
| 65 | 65 | </div> |
|
| 66 | 66 | </details> |
|
| 67 | 67 | <label for="content">content</label> |
|
| 18 | 18 | {% block content %} |
|
| 19 | 19 | <div class="post-header"> |
|
| 20 | 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 %} |
|
| 21 | 24 | {% if post.published_date.is_some() %} |
|
| 22 | 25 | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |
|
| 23 | 26 | {% endif %} |