feat: added custom css and navigation
430ce096
7 file(s) · +352 −97
| 146 | 146 | [], |
|
| 147 | 147 | ) |
|
| 148 | 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(); |
|
| 149 | 159 | ||
| 150 | 160 | Arc::new(Mutex::new(conn)) |
|
| 151 | 161 | } |
| 14 | 14 | use crate::auth; |
|
| 15 | 15 | use crate::db::{self, Db, Page, Post, UploadedFile}; |
|
| 16 | 16 | ||
| 17 | + | #[derive(Debug, Clone)] |
|
| 18 | + | pub struct NavLink { |
|
| 19 | + | pub label: String, |
|
| 20 | + | pub url: String, |
|
| 21 | + | } |
|
| 22 | + | ||
| 17 | 23 | #[derive(Clone)] |
|
| 18 | 24 | pub struct AppState { |
|
| 19 | 25 | pub db: Db, |
|
| 33 | 39 | #[template(path = "base.html")] |
|
| 34 | 40 | struct BaseTemplate { |
|
| 35 | 41 | blog_title: String, |
|
| 36 | - | nav_pages: Vec<Page>, |
|
| 42 | + | nav_links: Vec<NavLink>, |
|
| 37 | 43 | } |
|
| 38 | 44 | ||
| 39 | 45 | #[derive(Template)] |
|
| 53 | 59 | blog_description: String, |
|
| 54 | 60 | intro_html: String, |
|
| 55 | 61 | posts: Vec<Post>, |
|
| 56 | - | nav_pages: Vec<Page>, |
|
| 62 | + | nav_links: Vec<NavLink>, |
|
| 57 | 63 | } |
|
| 58 | 64 | ||
| 59 | 65 | #[derive(Template)] |
|
| 60 | 66 | #[template(path = "post.html")] |
|
| 61 | 67 | struct PostTemplate { |
|
| 62 | 68 | blog_title: String, |
|
| 63 | - | nav_pages: Vec<Page>, |
|
| 69 | + | nav_links: Vec<NavLink>, |
|
| 64 | 70 | post: Post, |
|
| 65 | 71 | rendered_content: String, |
|
| 66 | 72 | } |
|
| 69 | 75 | #[template(path = "page.html")] |
|
| 70 | 76 | struct PageTemplate { |
|
| 71 | 77 | blog_title: String, |
|
| 72 | - | nav_pages: Vec<Page>, |
|
| 78 | + | nav_links: Vec<NavLink>, |
|
| 73 | 79 | page: Page, |
|
| 74 | 80 | rendered_content: String, |
|
| 75 | 81 | } |
|
| 106 | 112 | blog_title: String, |
|
| 107 | 113 | blog_description: String, |
|
| 108 | 114 | intro_content: String, |
|
| 115 | + | nav_links: String, |
|
| 116 | + | custom_css: String, |
|
| 117 | + | default_css: String, |
|
| 109 | 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>, |
|
| 110 | 127 | } |
|
| 111 | 128 | ||
| 112 | 129 | #[derive(Template)] |
|
| 184 | 201 | ||
| 185 | 202 | #[derive(serde::Deserialize)] |
|
| 186 | 203 | struct PageForm { |
|
| 204 | + | attributes: String, |
|
| 205 | + | content: String, |
|
| 206 | + | } |
|
| 207 | + | ||
| 208 | + | struct ParsedPageAttributes { |
|
| 187 | 209 | title: String, |
|
| 188 | 210 | slug: String, |
|
| 189 | - | content: String, |
|
| 190 | - | #[serde(default)] |
|
| 191 | - | is_published: Option<String>, |
|
| 192 | - | #[serde(default)] |
|
| 193 | - | nav_order: i64, |
|
| 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 |
|
| 194 | 233 | } |
|
| 195 | 234 | ||
| 196 | 235 | #[derive(serde::Deserialize)] |
|
| 198 | 237 | blog_title: String, |
|
| 199 | 238 | blog_description: String, |
|
| 200 | 239 | intro_content: String, |
|
| 240 | + | nav_links: String, |
|
| 241 | + | custom_css: String, |
|
| 201 | 242 | } |
|
| 202 | 243 | ||
| 203 | 244 | // --- Helpers --- |
|
| 273 | 314 | .unwrap_or_else(|| "My Blog".to_string()) |
|
| 274 | 315 | } |
|
| 275 | 316 | ||
| 276 | - | fn get_nav_pages(db: &Db) -> Vec<Page> { |
|
| 277 | - | db::get_published_pages(db).unwrap_or_default() |
|
| 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 |
|
| 278 | 371 | } |
|
| 279 | 372 | ||
| 280 | 373 | // --- Static file handler --- |
|
| 374 | 467 | .ok() |
|
| 375 | 468 | .flatten() |
|
| 376 | 469 | .unwrap_or_default(); |
|
| 377 | - | let intro_html = render_markdown(&intro_content); |
|
| 378 | - | let nav_pages = get_nav_pages(&state.db); |
|
| 470 | + | let nav_links = get_nav_links(&state.db); |
|
| 379 | 471 | ||
| 380 | 472 | match db::get_published_posts(&state.db) { |
|
| 381 | - | Ok(posts) => WebTemplate(IndexTemplate { |
|
| 382 | - | blog_title, |
|
| 383 | - | blog_description, |
|
| 384 | - | intro_html, |
|
| 385 | - | posts, |
|
| 386 | - | nav_pages, |
|
| 387 | - | }) |
|
| 388 | - | .into_response(), |
|
| 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 | + | } |
|
| 389 | 492 | Err(e) => { |
|
| 390 | 493 | tracing::error!("Failed to list posts: {}", e); |
|
| 391 | 494 | (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response() |
|
| 401 | 504 | Ok(Some(post)) if post.status == "published" => { |
|
| 402 | 505 | let rendered_content = render_markdown(&post.content); |
|
| 403 | 506 | let blog_title = get_blog_title(&state.db); |
|
| 404 | - | let nav_pages = get_nav_pages(&state.db); |
|
| 507 | + | let nav_links = get_nav_links(&state.db); |
|
| 405 | 508 | WebTemplate(PostTemplate { |
|
| 406 | 509 | blog_title, |
|
| 407 | - | nav_pages, |
|
| 510 | + | nav_links, |
|
| 408 | 511 | post, |
|
| 409 | 512 | rendered_content, |
|
| 410 | 513 | }) |
|
| 426 | 529 | Ok(Some(page)) if page.is_published => { |
|
| 427 | 530 | let rendered_content = render_markdown(&page.content); |
|
| 428 | 531 | let blog_title = get_blog_title(&state.db); |
|
| 429 | - | let nav_pages = get_nav_pages(&state.db); |
|
| 532 | + | let nav_links = get_nav_links(&state.db); |
|
| 430 | 533 | WebTemplate(PageTemplate { |
|
| 431 | 534 | blog_title, |
|
| 432 | - | nav_pages, |
|
| 535 | + | nav_links, |
|
| 433 | 536 | page, |
|
| 434 | 537 | rendered_content, |
|
| 435 | 538 | }) |
|
| 443 | 546 | } |
|
| 444 | 547 | } |
|
| 445 | 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 | + | ||
| 446 | 580 | async fn fallback_handler( |
|
| 447 | 581 | State(state): State<Arc<AppState>>, |
|
| 448 | 582 | uri: Uri, |
|
| 654 | 788 | State(state): State<Arc<AppState>>, |
|
| 655 | 789 | Form(form): Form<PageForm>, |
|
| 656 | 790 | ) -> Response { |
|
| 657 | - | let title = form.title.trim(); |
|
| 658 | - | let slug = form.slug.trim(); |
|
| 791 | + | let attrs = parse_page_attributes(&form.attributes); |
|
| 792 | + | let title = attrs.title.trim().to_string(); |
|
| 793 | + | let slug = attrs.slug.trim().to_string(); |
|
| 659 | 794 | if title.is_empty() || slug.is_empty() { |
|
| 660 | 795 | return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response(); |
|
| 661 | 796 | } |
|
| 662 | 797 | ||
| 663 | - | let is_published = form.is_published.as_deref() == Some("on"); |
|
| 664 | - | ||
| 665 | - | match db::create_page(&state.db, title, slug, &form.content, is_published, form.nav_order) { |
|
| 798 | + | match db::create_page(&state.db, &title, &slug, &form.content, attrs.is_published, 0) { |
|
| 666 | 799 | Ok(_) => Redirect::to("/admin/pages").into_response(), |
|
| 667 | 800 | Err(e) => { |
|
| 668 | 801 | tracing::error!("Failed to create page: {}", e); |
|
| 697 | 830 | Path(short_id): Path<String>, |
|
| 698 | 831 | Form(form): Form<PageForm>, |
|
| 699 | 832 | ) -> Response { |
|
| 700 | - | let title = form.title.trim(); |
|
| 701 | - | let slug = form.slug.trim(); |
|
| 833 | + | let attrs = parse_page_attributes(&form.attributes); |
|
| 834 | + | let title = attrs.title.trim().to_string(); |
|
| 835 | + | let slug = attrs.slug.trim().to_string(); |
|
| 702 | 836 | if title.is_empty() || slug.is_empty() { |
|
| 703 | 837 | return Redirect::to(&format!("/admin/pages/{}/edit?error=Title+and+slug+are+required", short_id)) |
|
| 704 | 838 | .into_response(); |
|
| 705 | 839 | } |
|
| 706 | 840 | ||
| 707 | - | let is_published = form.is_published.as_deref() == Some("on"); |
|
| 708 | - | ||
| 709 | - | match db::update_page(&state.db, &short_id, title, slug, &form.content, is_published, form.nav_order) { |
|
| 841 | + | match db::update_page(&state.db, &short_id, &title, &slug, &form.content, attrs.is_published, 0) { |
|
| 710 | 842 | Ok(Some(_)) => Redirect::to("/admin/pages").into_response(), |
|
| 711 | 843 | Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(), |
|
| 712 | 844 | Err(e) => { |
|
| 741 | 873 | let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default(); |
|
| 742 | 874 | let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default(); |
|
| 743 | 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(); |
|
| 744 | 881 | ||
| 745 | 882 | WebTemplate(AdminSettingsTemplate { |
|
| 746 | 883 | blog_title, |
|
| 747 | 884 | blog_description, |
|
| 748 | 885 | intro_content, |
|
| 886 | + | nav_links, |
|
| 887 | + | custom_css, |
|
| 888 | + | default_css, |
|
| 749 | 889 | success: q.success, |
|
| 750 | 890 | }) |
|
| 751 | 891 | .into_response() |
|
| 759 | 899 | let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim()); |
|
| 760 | 900 | let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim()); |
|
| 761 | 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); |
|
| 762 | 904 | Redirect::to("/admin/settings?success=true").into_response() |
|
| 763 | 905 | } |
|
| 764 | 906 | ||
| 1031 | 1173 | let app = Router::new() |
|
| 1032 | 1174 | // Public routes |
|
| 1033 | 1175 | .route("/", get(public_index)) |
|
| 1176 | + | .route("/posts", get(public_posts_list)) |
|
| 1034 | 1177 | .route("/posts/{slug}", get(public_post)) |
|
| 1178 | + | .route("/custom-styles.css", get(serve_custom_css)) |
|
| 1035 | 1179 | .route("/pages/{slug}", get(public_page)) |
|
| 1036 | 1180 | .route("/feed.xml", get(rss_feed)) |
|
| 1037 | 1181 | // Admin auth |
|
| 613 | 613 | font-size: 12px; |
|
| 614 | 614 | margin-right: 0.5rem; |
|
| 615 | 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 | + | } |
| 7 | 7 | {% endif %} |
|
| 8 | 8 | {% match page %} |
|
| 9 | 9 | {% when Some with (p) %} |
|
| 10 | - | <form method="POST" action="/admin/pages/{{ p.short_id }}" class="form"> |
|
| 11 | - | <div class="form-row"> |
|
| 12 | - | <div class="form-field"> |
|
| 13 | - | <label for="title">title</label> |
|
| 14 | - | <input type="text" id="title" name="title" value="{{ p.title }}" required> |
|
| 15 | - | </div> |
|
| 16 | - | <div class="form-field"> |
|
| 17 | - | <label for="slug">slug</label> |
|
| 18 | - | <input type="text" id="slug" name="slug" value="{{ p.slug }}" required> |
|
| 19 | - | </div> |
|
| 20 | - | </div> |
|
| 21 | - | <div class="form-row"> |
|
| 22 | - | <div class="form-field"> |
|
| 23 | - | <label for="nav_order">nav order</label> |
|
| 24 | - | <input type="number" id="nav_order" name="nav_order" value="{{ p.nav_order }}"> |
|
| 25 | - | </div> |
|
| 26 | - | <div class="form-field checkbox-field"> |
|
| 27 | - | <label> |
|
| 28 | - | <input type="checkbox" id="is_published" name="is_published" {% if p.is_published %}checked{% endif %}> |
|
| 29 | - | published |
|
| 30 | - | </label> |
|
| 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> |
|
| 31 | 20 | </div> |
|
| 32 | - | </div> |
|
| 21 | + | </details> |
|
| 33 | 22 | <label for="content">content</label> |
|
| 34 | 23 | <textarea id="content" name="content" class="post-content">{{ p.content }}</textarea> |
|
| 35 | 24 | <button type="submit">save</button> |
|
| 36 | 25 | </form> |
|
| 37 | 26 | {% when None %} |
|
| 38 | - | <form method="POST" action="/admin/pages/create" class="form"> |
|
| 39 | - | <div class="form-row"> |
|
| 40 | - | <div class="form-field"> |
|
| 41 | - | <label for="title">title</label> |
|
| 42 | - | <input type="text" id="title" name="title" required autofocus> |
|
| 43 | - | </div> |
|
| 44 | - | <div class="form-field"> |
|
| 45 | - | <label for="slug">slug</label> |
|
| 46 | - | <input type="text" id="slug" name="slug" required> |
|
| 47 | - | </div> |
|
| 48 | - | </div> |
|
| 49 | - | <div class="form-row"> |
|
| 50 | - | <div class="form-field"> |
|
| 51 | - | <label for="nav_order">nav order</label> |
|
| 52 | - | <input type="number" id="nav_order" name="nav_order" value="0"> |
|
| 53 | - | </div> |
|
| 54 | - | <div class="form-field checkbox-field"> |
|
| 55 | - | <label> |
|
| 56 | - | <input type="checkbox" id="is_published" name="is_published"> |
|
| 57 | - | published |
|
| 58 | - | </label> |
|
| 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> |
|
| 59 | 37 | </div> |
|
| 60 | - | </div> |
|
| 38 | + | </details> |
|
| 61 | 39 | <label for="content">content</label> |
|
| 62 | 40 | <textarea id="content" name="content" class="post-content" placeholder="write markdown here..."></textarea> |
|
| 63 | 41 | <button type="submit">save</button> |
|
| 64 | 42 | </form> |
|
| 65 | 43 | {% endmatch %} |
|
| 66 | - | <script> |
|
| 67 | - | const titleInput = document.getElementById('title'); |
|
| 68 | - | const slugInput = document.getElementById('slug'); |
|
| 69 | - | let slugEdited = slugInput.value !== ''; |
|
| 70 | - | slugInput.addEventListener('input', () => { slugEdited = true; }); |
|
| 71 | - | titleInput.addEventListener('input', () => { |
|
| 72 | - | if (!slugEdited) { |
|
| 73 | - | slugInput.value = titleInput.value |
|
| 74 | - | .toLowerCase() |
|
| 75 | - | .replace(/[^a-z0-9]+/g, '-') |
|
| 76 | - | .replace(/^-|-$/g, ''); |
|
| 77 | - | } |
|
| 78 | - | }); |
|
| 79 | - | </script> |
|
| 80 | 44 | {% endblock %} |
| 10 | 10 | <input type="text" id="blog_title" name="blog_title" value="{{ blog_title }}" required> |
|
| 11 | 11 | <label for="blog_description">blog description</label> |
|
| 12 | 12 | <input type="text" id="blog_description" name="blog_description" value="{{ blog_description }}"> |
|
| 13 | - | <label for="intro_content">intro content (markdown, shown on homepage)</label> |
|
| 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 {{latest_posts}} to embed recent posts)</label> |
|
| 14 | 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> |
|
| 15 | 28 | <button type="submit">save</button> |
|
| 16 | 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> |
|
| 17 | 43 | {% endblock %} |
| 8 | 8 | <meta name="theme-color" content="#121113" /> |
|
| 9 | 9 | {% block meta %}{% endblock %} |
|
| 10 | 10 | <link rel="stylesheet" href="/static/styles.css"> |
|
| 11 | + | <link rel="stylesheet" href="/custom-styles.css"> |
|
| 11 | 12 | </head> |
|
| 12 | 13 | <body> |
|
| 13 | 14 | <header class="header"> |
|
| 14 | 15 | <a href="/" class="logo">{{ blog_title }}</a> |
|
| 15 | 16 | <nav class="links"> |
|
| 16 | - | <a href="/">blog</a> |
|
| 17 | - | {% for page in nav_pages %} |
|
| 18 | - | <a href="/pages/{{ page.slug }}">{{ page.title }}</a> |
|
| 17 | + | {% for link in nav_links %} |
|
| 18 | + | <a href="{{ link.url }}">{{ link.label }}</a> |
|
| 19 | 19 | {% endfor %} |
|
| 20 | 20 | </nav> |
|
| 21 | 21 | </header> |
| 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 %} |