Merge pull request #14 from stevedylandev/chore/posts-add-dynamic-meta-images c4a30585
chore/posts add dynamic meta images
Steve Simkins · 2026-04-07 21:57 22 file(s) · +98 −5
Cargo.lock +1 −1
2973 2973
2974 2974
[[package]]
2975 2975
name = "posts"
2976 -
version = "0.1.0"
2976 +
version = "0.1.1"
2977 2977
dependencies = [
2978 2978
 "andromeda-auth",
2979 2979
 "askama 0.15.6",
README.md +1 −1
15 15
| [**OG**](apps/og) | Open Graph tag inspector | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/OdXBt_?referralCode=JGcIp6) |
16 16
| [**Shrink**](apps/shrink) | Image compression and resizing | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/enYUFb?referralCode=JGcIp6) |
17 17
| [**Cellar**](apps/cellar) | Minimal wine collection tracker | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/MNprVh?referralCode=JGcIp6) |
18 -
| [**Posts**](apps/posts) | Minimal CMS blog with admin interface | |
18 +
| [**Posts**](apps/posts) | Minimal CMS blog with admin interface | [![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/tYtJYp?referralCode=JGcIp6) |
19 19
20 20
## Shared Crates
21 21
apps/posts/Cargo.toml +1 −1
1 1
[package]
2 2
name = "posts"
3 -
version = "0.1.0"
3 +
version = "0.1.1"
4 4
edition = "2024"
5 5
description = "CMS blog with admin interface"
6 6
license = "MIT"
apps/posts/README.md +4 −0
70 70
71 71
## Deployment
72 72
73 +
### Railway
74 +
75 +
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/tYtJYp?referralCode=JGcIp6)
76 +
73 77
### Docker (recommended)
74 78
75 79
```bash
apps/posts/src/db.rs +10 −0
156 156
        [],
157 157
    )
158 158
    .ok();
159 +
    conn.execute(
160 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('favicon_url', '')",
161 +
        [],
162 +
    )
163 +
    .ok();
164 +
    conn.execute(
165 +
        "INSERT OR IGNORE INTO settings (key, value) VALUES ('og_image_url', '')",
166 +
        [],
167 +
    )
168 +
    .ok();
159 169
160 170
    Arc::new(Mutex::new(conn))
161 171
}
apps/posts/src/server.rs +50 −0
40 40
struct BaseTemplate {
41 41
    blog_title: String,
42 42
    nav_links: Vec<NavLink>,
43 +
    favicon_url: String,
44 +
    og_image_url: String,
43 45
}
44 46
45 47
#[derive(Template)]
60 62
    intro_html: String,
61 63
    posts: Vec<Post>,
62 64
    nav_links: Vec<NavLink>,
65 +
    favicon_url: String,
66 +
    og_image_url: String,
63 67
}
64 68
65 69
#[derive(Template)]
69 73
    nav_links: Vec<NavLink>,
70 74
    post: Post,
71 75
    rendered_content: String,
76 +
    favicon_url: String,
77 +
    og_image_url: String,
72 78
}
73 79
74 80
#[derive(Template)]
78 84
    nav_links: Vec<NavLink>,
79 85
    page: Page,
80 86
    rendered_content: String,
87 +
    favicon_url: String,
88 +
    og_image_url: String,
81 89
}
82 90
83 91
#[derive(Template)]
115 123
    nav_links: String,
116 124
    custom_css: String,
117 125
    default_css: String,
126 +
    favicon_url: String,
127 +
    og_image_url: String,
118 128
    success: bool,
119 129
}
120 130
124 134
    blog_title: String,
125 135
    nav_links: Vec<NavLink>,
126 136
    posts: Vec<Post>,
137 +
    favicon_url: String,
138 +
    og_image_url: String,
127 139
}
128 140
129 141
#[derive(Template)]
239 251
    intro_content: String,
240 252
    nav_links: String,
241 253
    custom_css: String,
254 +
    favicon_url: String,
255 +
    og_image_url: String,
242 256
}
243 257
244 258
// --- Helpers ---
340 354
    parse_nav_links(&raw)
341 355
}
342 356
357 +
fn get_favicon_url(db: &Db) -> String {
358 +
    db::get_setting(db, "favicon_url")
359 +
        .ok()
360 +
        .flatten()
361 +
        .unwrap_or_default()
362 +
}
363 +
364 +
fn get_og_image_url(db: &Db) -> String {
365 +
    db::get_setting(db, "og_image_url")
366 +
        .ok()
367 +
        .flatten()
368 +
        .unwrap_or_default()
369 +
}
370 +
343 371
fn render_latest_posts_embed(posts: &[&Post]) -> String {
344 372
    let mut html = String::from("<div class=\"post-list\">");
345 373
    for post in posts {
480 508
                intro_html = intro_html.replace("{{latest_posts}}", &embed_html);
481 509
            }
482 510
511 +
            let favicon_url = get_favicon_url(&state.db);
512 +
            let og_image_url = get_og_image_url(&state.db);
483 513
            WebTemplate(IndexTemplate {
484 514
                blog_title,
485 515
                blog_description,
486 516
                intro_html,
487 517
                posts,
488 518
                nav_links,
519 +
                favicon_url,
520 +
                og_image_url,
489 521
            })
490 522
            .into_response()
491 523
        }
505 537
            let rendered_content = render_markdown(&post.content);
506 538
            let blog_title = get_blog_title(&state.db);
507 539
            let nav_links = get_nav_links(&state.db);
540 +
            let favicon_url = get_favicon_url(&state.db);
541 +
            let og_image_url = get_og_image_url(&state.db);
508 542
            WebTemplate(PostTemplate {
509 543
                blog_title,
510 544
                nav_links,
511 545
                post,
512 546
                rendered_content,
547 +
                favicon_url,
548 +
                og_image_url,
513 549
            })
514 550
            .into_response()
515 551
        }
530 566
            let rendered_content = render_markdown(&page.content);
531 567
            let blog_title = get_blog_title(&state.db);
532 568
            let nav_links = get_nav_links(&state.db);
569 +
            let favicon_url = get_favicon_url(&state.db);
570 +
            let og_image_url = get_og_image_url(&state.db);
533 571
            WebTemplate(PageTemplate {
534 572
                blog_title,
535 573
                nav_links,
536 574
                page,
537 575
                rendered_content,
576 +
                favicon_url,
577 +
                og_image_url,
538 578
            })
539 579
            .into_response()
540 580
        }
549 589
async fn public_posts_list(State(state): State<Arc<AppState>>) -> Response {
550 590
    let blog_title = get_blog_title(&state.db);
551 591
    let nav_links = get_nav_links(&state.db);
592 +
    let favicon_url = get_favicon_url(&state.db);
593 +
    let og_image_url = get_og_image_url(&state.db);
552 594
553 595
    match db::get_published_posts(&state.db) {
554 596
        Ok(posts) => WebTemplate(PostsListTemplate {
555 597
            blog_title,
556 598
            nav_links,
557 599
            posts,
600 +
            favicon_url,
601 +
            og_image_url,
558 602
        })
559 603
        .into_response(),
560 604
        Err(e) => {
875 919
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
876 920
    let nav_links = db::get_setting(&state.db, "nav_links").ok().flatten().unwrap_or_default();
877 921
    let custom_css = db::get_setting(&state.db, "custom_css").ok().flatten().unwrap_or_default();
922 +
    let favicon_url = db::get_setting(&state.db, "favicon_url").ok().flatten().unwrap_or_default();
923 +
    let og_image_url = db::get_setting(&state.db, "og_image_url").ok().flatten().unwrap_or_default();
878 924
    let default_css = Static::get("styles.css")
879 925
        .map(|f| String::from_utf8_lossy(&f.data).into_owned())
880 926
        .unwrap_or_default();
886 932
        nav_links,
887 933
        custom_css,
888 934
        default_css,
935 +
        favicon_url,
936 +
        og_image_url,
889 937
        success: q.success,
890 938
    })
891 939
    .into_response()
901 949
    let _ = db::set_setting(&state.db, "intro_content", &form.intro_content);
902 950
    let _ = db::set_setting(&state.db, "nav_links", &form.nav_links);
903 951
    let _ = db::set_setting(&state.db, "custom_css", &form.custom_css);
952 +
    let _ = db::set_setting(&state.db, "favicon_url", form.favicon_url.trim());
953 +
    let _ = db::set_setting(&state.db, "og_image_url", form.og_image_url.trim());
904 954
    Redirect::to("/admin/settings?success=true").into_response()
905 955
}
906 956
apps/posts/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/posts/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/posts/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/posts/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/posts/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/posts/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/posts/static/favicons/favicon.ico (deleted) +0 −0

Binary file — no preview.

apps/posts/static/icon.png (added) +0 −0

Binary file — no preview.

apps/posts/static/og.png (added) +0 −0

Binary file — no preview.

apps/posts/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/posts/templates/admin_settings.html +4 −0
12 12
    <input type="text" id="blog_description" name="blog_description" value="{{ blog_description }}">
13 13
    <label for="nav_links">navigation links (format: [label](url) [label2](url2))</label>
14 14
    <textarea id="nav_links" name="nav_links" class="nav-links-input">{{ nav_links }}</textarea>
15 +
    <label for="favicon_url">favicon URL (leave empty for default)</label>
16 +
    <input type="text" id="favicon_url" name="favicon_url" value="{{ favicon_url }}" placeholder="https://example.com/favicon.png">
17 +
    <label for="og_image_url">default OG image URL (used when posts don't have their own)</label>
18 +
    <input type="text" id="og_image_url" name="og_image_url" value="{{ og_image_url }}" placeholder="https://example.com/og.png">
15 19
    <label for="intro_content">intro content (markdown, shown on homepage — use &#123;&#123;latest_posts&#125;&#125; to embed recent posts)</label>
16 20
    <textarea id="intro_content" name="intro_content" class="post-content">{{ intro_content }}</textarea>
17 21
    <div class="switch-row">
apps/posts/templates/base.html +10 −1
4 4
  <meta charset="UTF-8">
5 5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 6
  <title>{% block title %}{{ blog_title }}{% endblock %}</title>
7 -
  <link rel="icon" href="/static/favicons/favicon.ico">
7 +
  {% if !favicon_url.is_empty() %}
8 +
    <link rel="apple-touch-icon" sizes="180x180" href="{{ favicon_url }}">
9 +
    <link rel="icon" type="image/png" sizes="32x32" href="{{ favicon_url }}">
10 +
    <link rel="icon" type="image/png" sizes="16x16" href="{{ favicon_url }}">
11 +
  {% else %}
12 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
13 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
14 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
15 +
    <link rel="manifest" href="/static/site.webmanifest">
16 +
  {% endif %}
8 17
  <meta name="theme-color" content="#121113" />
9 18
  {% block meta %}{% endblock %}
10 19
  <link rel="stylesheet" href="/static/styles.css">
apps/posts/templates/index.html +3 −0
5 5
  <meta property="og:title" content="{{ blog_title }}">
6 6
  <meta property="og:description" content="{{ blog_description }}">
7 7
  <meta property="og:type" content="website">
8 +
  {% if !og_image_url.is_empty() %}
9 +
    <meta property="og:image" content="{{ og_image_url }}">
10 +
  {% endif %}
8 11
{% endblock %}
9 12
{% block content %}
10 13
  {% if !intro_html.is_empty() %}
apps/posts/templates/page.html +5 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}{{ page.title }} — {{ blog_title }}{% endblock %}
3 +
{% block meta %}
4 +
  {% if !og_image_url.is_empty() %}
5 +
    <meta property="og:image" content="{{ og_image_url }}">
6 +
  {% endif %}
7 +
{% endblock %}
3 8
{% block content %}
4 9
  <div class="page-header">
5 10
    <h1>{{ page.title }}</h1>
apps/posts/templates/post.html +3 −1
5 5
    <meta name="description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}">
6 6
    <meta property="og:description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}">
7 7
  {% endif %}
8 -
  {% if post.meta_image.is_some() %}
8 +
  {% if post.meta_image.is_some() && !post.meta_image.as_deref().unwrap_or_default().is_empty() %}
9 9
    <meta property="og:image" content="{{ post.meta_image.as_deref().unwrap_or_default() }}">
10 +
  {% else if !og_image_url.is_empty() %}
11 +
    <meta property="og:image" content="{{ og_image_url }}">
10 12
  {% endif %}
11 13
  {% if post.canonical_url.is_some() %}
12 14
    <link rel="canonical" href="{{ post.canonical_url.as_deref().unwrap_or_default() }}">
apps/posts/templates/posts.html +5 −0
1 1
{% extends "base.html" %}
2 2
{% block title %}Posts — {{ blog_title }}{% endblock %}
3 +
{% block meta %}
4 +
  {% if !og_image_url.is_empty() %}
5 +
    <meta property="og:image" content="{{ og_image_url }}">
6 +
  {% endif %}
7 +
{% endblock %}
3 8
{% block content %}
4 9
  <h1>Posts</h1>
5 10
  {% if posts.is_empty() %}