feat: added custom css and navigation 430ce096
Steve · 2026-04-07 19:58 7 file(s) · +352 −97
apps/posts/src/db.rs +10 −0
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
}
apps/posts/src/server.rs +179 −35
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
apps/posts/static/styles.css +76 −0
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 +
}
apps/posts/templates/admin_page_form.html +22 −58
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 %}
apps/posts/templates/admin_settings.html +27 −1
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 &#123;&#123;latest_posts&#125;&#125; 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 %}
apps/posts/templates/base.html +3 −3
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>
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 %}