feat(blog): add markdown blog sourced from a gitea repo
posts are markdown files with yaml frontmatter (title, slug, date;
optional draft/public) in the grenade/blog repo. the worker's new
BlogSource polls the repo — one branch-tip request when nothing
changed — and upserts posts into events with source='blog' and
occurred_at from the frontmatter date, so imported posts keep their
original publish dates and backfill the contribution graph.
- new /v1/blog and /v1/blog/{slug} endpoints over the existing
EventReader port; drafts stay hidden via the public gate
- new /blog and /blog/:slug routes, nav link, activity-feed entry
with post icon and filter toggle; relative image srcs resolve to
gitea raw urls
- shared Markdown component extracted from ProjectPage
- vite proxy target overridable via API_PROXY_TARGET for local dev
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{DailyCount, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use moments_entities::{BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
@@ -56,6 +56,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
.route("/v1/events", get(list_events))
|
||||
.route("/v1/sources", get(list_sources))
|
||||
.route("/v1/projects", get(list_projects))
|
||||
.route("/v1/blog", get(list_blog_posts))
|
||||
.route("/v1/blog/{slug}", get(get_blog_post))
|
||||
.route("/v1/activity/daily", get(daily_counts))
|
||||
.route("/v1/activity/hourly", get(hourly_avgs))
|
||||
.route("/v1/languages/daily", get(language_daily_counts))
|
||||
@@ -144,6 +146,61 @@ async fn list_projects(
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
/// All public blog events, newest first. Blog posts live in the events
|
||||
/// table (`source = 'blog'`); the payload carries the frontmatter fields
|
||||
/// and the full markdown body.
|
||||
async fn blog_events(state: &AppState) -> Result<Vec<Event>, ApiError> {
|
||||
let query = EventQuery {
|
||||
sources: Some(vec![Source::Blog]),
|
||||
// Drafts are stored with public = false and stay invisible here.
|
||||
include_private: false,
|
||||
limit: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
state.store.list_events(&query).await.map_err(internal)
|
||||
}
|
||||
|
||||
fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str {
|
||||
event.payload.get(key).and_then(|v| v.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
async fn list_blog_posts(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<BlogPostSummary>>, ApiError> {
|
||||
let posts = blog_events(&state)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|ev| BlogPostSummary {
|
||||
slug: payload_str(ev, "slug").to_string(),
|
||||
title: payload_str(ev, "title").to_string(),
|
||||
published_at: ev.occurred_at,
|
||||
excerpt: moments_core::presentation::blog::excerpt(payload_str(ev, "markdown")),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(posts))
|
||||
}
|
||||
|
||||
async fn get_blog_post(
|
||||
State(state): State<AppState>,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<BlogPost>, ApiError> {
|
||||
let id = format!("blog:{slug}");
|
||||
let events = blog_events(&state).await?;
|
||||
let ev = events.iter().find(|ev| ev.id == id).ok_or(ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: "no such post".into(),
|
||||
})?;
|
||||
Ok(Json(BlogPost {
|
||||
slug: payload_str(ev, "slug").to_string(),
|
||||
title: payload_str(ev, "title").to_string(),
|
||||
published_at: ev.occurred_at,
|
||||
markdown: payload_str(ev, "markdown").to_string(),
|
||||
host: payload_str(ev, "_host").to_string(),
|
||||
repo: payload_str(ev, "_repo").to_string(),
|
||||
branch: payload_str(ev, "_branch").to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DailyCountsParams {
|
||||
from: Option<NaiveDate>,
|
||||
|
||||
Reference in New Issue
Block a user