23 Commits
Author SHA1 Message Date
lumither b820f050bc feat(global) update multiple components
PBS - feat(global) new main page, tag filter system, docker compose deployment
2025-10-13 20:25:02 -04:00
lumither 3a4af1ae8d feat(migration) add migration 2025-10-13 14:36:21 -04:00
lumither 02e7166fcb feat(frontend) new tag filter, new landing page 2025-10-13 02:59:21 -04:00
lumither 314f5780fd fix(blog::api) fix pagination for GET posts 2025-10-11 11:51:06 -04:00
lumither b1a55c363f feat(backend::api) add post tag filter 2025-10-09 23:26:08 -04:00
lumither f3cc387013 draft(daemon) module init 2025-10-08 12:48:43 -04:00
lumither 2a56263382 draft(gateway) module init 2025-10-08 12:43:47 -04:00
lumither cd39abbd45 ref(frontend) several minor updates 2025-10-08 12:42:54 -04:00
lumither 88c2ad8664 feat(ui) construction banner 2025-08-27 02:57:39 +08:00
lumither 86dc65ab8a feat(frontend) little update that proves the repo is still alive 2025-07-03 01:57:44 +08:00
lumither cf27de9eb4 Merge pull request #3 from Lumither/dev
Refactor Finished
2025-06-03 03:28:43 +08:00
lumither 1f27e0bddc fix(frontend) fix editor warning 2025-06-03 03:23:32 +08:00
lumither 45010f030c feat(global) compatibility milestone 2025-06-03 03:19:49 +08:00
lumither 06a4ab8cf7 fix(git) exclude /client/content 2025-05-14 14:19:31 +08:00
lumither f491bc99a8 fix(git) exclude .DS_Store 2025-05-14 14:18:03 +08:00
lumither d15cdf216a feat(blog::api) impl GET /blog/posts 2025-05-14 01:47:21 +08:00
lumither 2f6cafa543 feat(blog::api) get_identifier.rs 2025-05-13 17:09:55 +08:00
lumither dc2c294617 feat(blog::api) update pagination api 2025-05-12 11:24:22 +08:00
lumither 80a164625b feat(blog::api) pagination 2025-05-12 01:07:23 +08:00
lumither 2c11af4a25 feat(blog::components::check_update) push frontmatter to database (metadata) 2025-04-18 01:18:25 -07:00
lumither e83cea1d42 feat(blog::components::check_update) impl consistency_guard, fix handle_create issues 2025-04-17 23:40:30 -07:00
lumither 71af26a09c feat(cli) template update 2025-04-17 23:35:13 -07:00
lumither 3f02916e8f PBS from Lumither/refactor
Periodic Branch Synchronization
2025-04-13 04:02:54 -07:00
86 changed files with 1820 additions and 504 deletions

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@
.idea .idea
.vscode .vscode
# exclude post repo # macOS system files
.DS_Store
**/.DS_Store
# exclude content repo
/blog_posts_git /blog_posts_git
# rustc ICE dump # rustc ICE dump
+17 -3
View File
@@ -3,7 +3,21 @@ ifeq ($(ARCH), arm64)
ARCH := aarch64 ARCH := aarch64
endif endif
.PHONY: up .PHONY: all
clean: all:up
echo "todo"
.PHONY: build
build:
ARCH=$(ARCH) docker compose -f prod.compose.yaml build
.PHONY: up
up:
ARCH=$(ARCH) docker compose -f prod.compose.yaml up -d
.PHONY: down
down:
ARCH=$(ARCH) docker compose -f prod.compose.yaml down
.PHONY: rm
rm:
ARCH=$(ARCH) docker compose -f prod.compose.yaml rm
+4
View File
@@ -8,6 +8,7 @@ macros = { path = "../macros" }
markdown = { path = "../markdown" } markdown = { path = "../markdown" }
axum = { version = "0.8.1", features = ["macros"] } axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.3", features = ["query"] }
tracing = { version = "0.1.41", features = ["log"] } tracing = { version = "0.1.41", features = ["log"] }
git2 = "0.20.0" git2 = "0.20.0"
chrono = { version = "0.4.39", features = ["serde"] } chrono = { version = "0.4.39", features = ["serde"] }
@@ -25,6 +26,9 @@ dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] } uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31" futures = "0.3.31"
[dev-dependencies]
tempfile = "3.19.1"
[build-dependencies] [build-dependencies]
git2 = "0.20.0" git2 = "0.20.0"
chrono = "0.4.39" chrono = "0.4.39"
+14 -13
View File
@@ -1,16 +1,17 @@
use chrono::Utc; // use chrono::Utc;
use git2::Repository; // use git2::Repository;
fn main() { fn main() {
let repo = Repository::open("../..").expect("failed to open repository"); // let repo = Repository::open("../..").expect("failed to open repository");
let commit = repo // let commit = repo
.head() // .head()
.expect("failed to get HEAD") // .expect("failed to get HEAD")
.peel_to_commit() // .peel_to_commit()
.expect("failed to get HEAD commit"); // .expect("failed to get HEAD commit");
let hash = commit.id().to_string(); // let hash = commit.id().to_string();
let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string(); // let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
let pkg_version = env!("CARGO_PKG_VERSION"); // let pkg_version = env!("CARGO_PKG_VERSION");
let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp); // let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
println!("cargo:rustc-env=BUILD_ID={}", build_id); // println!("cargo:rustc-env=BUILD_ID={}", build_id);
println!("cargo:rustc-env=BUILD_ID=undefined");
} }
@@ -1,25 +0,0 @@
use crate::constants::blog::PAGE_SIZE;
use axum::{Json, extract::State};
use serde_json::{Value, json};
use sqlx::{PgPool, Row, query};
use tracing::error;
const GET_PAGE_COUNT_QUERY: &str = include_str!("post_page_count.sql");
pub async fn handler(State(db_connection): State<PgPool>) -> Result<Json<Value>, Json<Value>> {
match query(GET_PAGE_COUNT_QUERY)
.bind(PAGE_SIZE as i64)
.fetch_one(&db_connection)
.await
{
Ok(res) => {
let res: i32 = res.get("count");
Ok(Json::from(json!({"res": res})))
}
Err(e) => {
error!("failed to get blog page count: {}", e.to_string());
Err(Json::from(json!({"error": e.to_string()})))
}
}
}
@@ -1,2 +0,0 @@
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
FROM posts;
@@ -1 +0,0 @@
SELECT
@@ -1,35 +0,0 @@
use macros::dev_consume;
use axum::{
Json,
extract::{Path, State},
response::IntoResponse,
};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
const GET_POST_BY_UUID: &str = include_str!("get_post_by_uuid.sql");
const GET_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
pub async fn handler(
State(db_connection): State<PgPool>,
Path(identifier): Path<String>,
) -> impl IntoResponse {
if let Ok(id) = identifier.parse::<Uuid>() {
select_post_by_uuid(&db_connection, id).await
} else {
select_post_by_str_id(&db_connection, &identifier).await
};
Json::from(json!({"res": "ok"})).into_response()
}
async fn select_post_by_uuid(db: &PgPool, id: Uuid) {
dev_consume!(db, id);
todo!()
}
async fn select_post_by_str_id(db: &PgPool, id: &str) {
dev_consume!(db, id);
todo!()
}
@@ -1,130 +0,0 @@
use std::error::Error;
use crate::constants::blog::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use futures::future::join_all;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sqlx::{postgres::PgRow, query, PgPool, Row};
use tracing::error;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct Params {
page: Option<u32>,
}
#[axum::debug_handler]
pub async fn get_post_list(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let search_param_page = params.page.unwrap_or(1);
if search_param_page < 1 {
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": "`page` should be greater than 0"})),
));
}
let offset = PAGE_SIZE * (search_param_page - 1);
let post_list = sqlx::query("SELECT * FROM post ORDER BY first_update DESC LIMIT $1 OFFSET $2")
.bind(PAGE_SIZE as i32)
.bind(offset as i32)
.fetch_all(&db_connection)
.await;
let post_list = match post_list {
Ok(post_list) => post_list,
Err(e) => {
error!(
"Database query error on fetching post list: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = Vec::new();
let posts: Vec<_> = post_list
.into_iter()
.map(|post| async { analyze_post(&db_connection, post).await })
.collect();
let posts = join_all(posts).await;
for post in posts {
match post {
Ok(p) => res.push(p),
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!(("error", e.to_string()))),
))
}
}
}
// res.insert("posts".to_string(), Value::from(posts));
Ok(Json::from(Value::from(res)))
}
async fn analyze_post(db_connection: &PgPool, post: PgRow) -> Result<Value, Box<dyn Error + Send>> {
let post_id: Uuid = post.get("post_id");
let tags = fetch_post_tags(db_connection, post_id).await;
let meta = fetch_post_meta(db_connection, post_id).await.unwrap();
let header_img = meta.get("header_img");
let last_update = meta.get("last_update");
Ok(json!({
"post_id": post_id.to_string(),
"title": post.get::<String, _>("title"),
"category": post.get::<String, _>("category"),
"header_img": header_img,
"sub_title": post.get::<String, _>("sub_title"),
"summary": post.get::<String, _>("summary"),
"last_update": last_update,
"first_update": post.get::<DateTime<Utc>, _>("first_update"),
"tags": tags,
}))
}
async fn fetch_post_meta(db_connection: &PgPool, post_id: Uuid) -> Option<Value> {
match query(
r#"
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS meta
FROM meta t
WHERE post_id = $1;
"#,
)
.bind(post_id)
.fetch_one(db_connection)
.await
{
Ok(value) => Some(value.get::<Value, _>("meta")),
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"post meta data",
"meta",
post_id.to_string(),
e.to_string()
);
None
}
}
}
async fn fetch_post_tags(db_connection: &PgPool, post_id: Uuid) -> Vec<String> {
match query("SELECT tag FROM Tag WHERE post_id = $1")
.bind(post_id)
.fetch_all(db_connection)
.await
{
Ok(res) => res.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(_) => vec![],
}
}
+1 -4
View File
@@ -1,6 +1,3 @@
pub mod get_page_count;
// todo: remove exception tag // todo: remove exception tag
#[allow(dead_code)] #[allow(dead_code)]
pub mod get_post; pub mod posts;
// pub mod get_post_list;
@@ -0,0 +1,20 @@
WITH filtered_posts AS
(SELECT p.id, p.date_created
FROM posts p
WHERE $1::text[] IS NULL
OR (
CASE
WHEN $4 THEN (SELECT COUNT(DISTINCT t.name)
FROM post_tag pt
JOIN tags t ON pt.tag = t.id
WHERE pt.post = p.id
AND t.name = ANY ($1::text[])) = cardinality($1)
ELSE EXISTS (SELECT 1
FROM post_tag pt
JOIN tags t ON pt.tag = t.id
WHERE pt.post = p.id
AND t.name = ANY ($1::text[]))
END
))
SELECT COUNT(*) AS total_count
FROM filtered_posts;
@@ -0,0 +1,174 @@
use crate::{
constants::blog::PAGE_SIZE,
err_resp_log,
utils::axum_response::{err_resp, succ_resp},
};
use std::collections::HashSet;
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use axum_extra::extract::Query;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{query_as, FromRow, PgPool};
use uuid::Uuid;
fn tags_deserializer<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let deserialize_each = |input: String| -> Result<Vec<String>, D::Error> {
Ok(input.split(',').map(|s| s.to_string()).collect())
};
#[derive(Deserialize)]
#[serde(untagged)]
enum Cardinality {
One(String),
Many(Vec<String>),
}
let cardinality: Option<Cardinality> = Deserialize::deserialize(deserializer)?;
let mut tags = HashSet::new();
match cardinality {
Some(Cardinality::One(s)) => {
for item in deserialize_each(s)? {
let item = item.trim().to_string();
if !item.is_empty() {
tags.insert(item);
}
}
let ret = tags.into_iter().collect::<Vec<_>>();
Ok(if ret.is_empty() { None } else { Some(ret) })
}
Some(Cardinality::Many(v)) => {
for items in v
.into_iter()
.map(deserialize_each)
.collect::<Result<Vec<_>, _>>()?
{
for item in items {
let item = item.trim().to_string();
if !item.is_empty() {
tags.insert(item);
}
}
}
let ret = tags.into_iter().collect::<Vec<_>>();
Ok(if ret.is_empty() { None } else { Some(ret) })
}
None => Ok(None),
}
}
#[derive(FromRow, Serialize)]
struct PostBriefModel {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
tags: Vec<String>,
category: String,
date_created: NaiveDate,
date_updated: Option<NaiveDate>,
header_img: Option<String>,
}
#[derive(Deserialize, Serialize)]
pub struct SearchParams {
page: Option<u32>,
page_size: Option<u32>,
#[serde(default, deserialize_with = "tags_deserializer")]
tags: Option<Vec<String>>,
#[serde(default)]
match_all: bool,
}
#[derive(Serialize)]
pub struct RespPagination {
page: u32,
page_size: u32,
page_count: u32,
}
#[derive(Serialize)]
struct Response {
posts: Vec<PostBriefModel>,
pagination: RespPagination,
}
#[derive(FromRow)]
struct UuidRow {
id: Uuid,
}
const I32_PLACEHOLDER: i32 = 0;
const COUNT_TAG_FILTERED_POSTS: &str = include_str!("count_tag_filtered_posts.sql");
const SELECT_POST_WITH_TAG_FILTER_PAGINATED: &str =
include_str!("select_post_with_tag_filter_paginated.sql");
const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql");
pub async fn handler(
State(db): State<PgPool>,
Query(search_params): Query<SearchParams>,
) -> impl IntoResponse {
let page = search_params.page.unwrap_or(1);
let page_size = search_params.page_size.unwrap_or(PAGE_SIZE);
let page_count = match sqlx::query_scalar::<_, i64>(COUNT_TAG_FILTERED_POSTS)
.bind(&search_params.tags)
.bind(I32_PLACEHOLDER) // not used, but help sqlx/psql hit cache
.bind(I32_PLACEHOLDER)
.bind(search_params.match_all)
.fetch_one(&db)
.await
{
Ok(post_count) => ((post_count as f64) / (page_size as f64)).ceil() as u32,
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let pagination = RespPagination {
page,
page_size,
page_count,
};
if page > page_count {
return err_resp(
StatusCode::BAD_REQUEST,
"page number should not be greater than page count",
Some(json!({"pagination": pagination})),
);
}
let offset = page_size * (page - 1);
let ids = match query_as::<_, UuidRow>(SELECT_POST_WITH_TAG_FILTER_PAGINATED)
.bind(search_params.tags)
.bind(page_size as i32)
.bind(offset as i32)
.bind(search_params.match_all)
.fetch_all(&db)
.await
{
Ok(res) => res.into_iter().map(|item| item.id).collect::<Vec<_>>(),
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let posts = match query_as::<_, PostBriefModel>(GET_POST_WITH_BRIEF_MODEL)
.bind(&ids)
.fetch_all(&db)
.await
{
Ok(posts) => posts,
Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let resp = Response { posts, pagination };
succ_resp(StatusCode::OK, serde_json::to_value(resp).unwrap())
}
@@ -0,0 +1,132 @@
use crate::{err_resp_log, utils::axum_response::succ_resp};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::NaiveDate;
use futures::future::OptionFuture;
use serde_json::Value;
use sqlx::{FromRow, PgPool, query_as};
use uuid::Uuid;
const SELECT_POST_BY_UUID: &str = include_str!("get_post_by_uuid.sql");
const SELECT_POST_BY_STR_ID: &str = include_str!("get_post_by_str_id.sql");
const SELECT_POST_ADJACENT_IDS: &str = include_str!("get_post_adjacent_ids.sql");
const SELECT_POST_WITH_ADJACENT_MODEL: &str = include_str!("get_post_with_adjacent_model.sql");
#[derive(serde::Serialize, FromRow)]
struct SelectedPost {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
status: String,
date_created: NaiveDate,
date_updated: Option<NaiveDate>,
category: String,
tags: Vec<String>,
content: String,
metadata: Option<Value>,
}
#[derive(serde::Serialize, FromRow)]
struct AdjacentPost {
id: Uuid,
identifier: String,
title: String,
subtitle: String,
date_created: NaiveDate,
category: String,
tags: Vec<String>,
header_img: Option<String>,
}
#[derive(FromRow, Debug)]
struct AdjacentIds {
prev: Option<Uuid>,
next: Option<Uuid>,
}
#[derive(serde::Serialize, Default)]
struct AdjacentPosts {
prev: Option<AdjacentPost>,
next: Option<AdjacentPost>,
}
#[derive(serde::Serialize)]
struct ResponseModel {
post: SelectedPost,
adjacent: AdjacentPosts,
}
pub async fn handler(
State(db_connection): State<PgPool>,
Path(identifier): Path<String>,
) -> impl IntoResponse {
let post = match if let Ok(id) = identifier.parse::<Uuid>() {
select_post_by_uuid(&db_connection, id).await
} else {
select_post_by_str_id(&db_connection, &identifier).await
} {
Ok(post) => post,
Err(e) => {
return match e {
sqlx::Error::RowNotFound => {
err_resp_log!(StatusCode::NOT_FOUND, "no matching post")
}
_ => err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
}
};
let adj_ids = query_as::<_, AdjacentIds>(SELECT_POST_ADJACENT_IDS)
.bind(post.id)
.fetch_one(&db_connection)
.await
.unwrap();
let adjacent_prev = OptionFuture::from(
adj_ids
.prev
.map(|id| select_post_with_adjacent_model(&db_connection, id)),
)
.await
.flatten();
let adjacent_next = OptionFuture::from(
adj_ids
.next
.map(|id| select_post_with_adjacent_model(&db_connection, id)),
)
.await
.flatten();
let adjacent = AdjacentPosts {
prev: adjacent_prev,
next: adjacent_next,
};
let response = ResponseModel { post, adjacent };
succ_resp(StatusCode::OK, serde_json::to_value(response).unwrap())
}
async fn select_post_by_uuid(db: &PgPool, id: Uuid) -> Result<SelectedPost, sqlx::Error> {
query_as::<_, SelectedPost>(SELECT_POST_BY_UUID)
.bind(id)
.fetch_one(db)
.await
}
async fn select_post_by_str_id(db: &PgPool, id: &str) -> Result<SelectedPost, sqlx::Error> {
query_as::<_, SelectedPost>(SELECT_POST_BY_STR_ID)
.bind(id)
.fetch_one(db)
.await
}
async fn select_post_with_adjacent_model(db: &PgPool, id: Uuid) -> Option<AdjacentPost> {
query_as(SELECT_POST_WITH_ADJACENT_MODEL)
.bind(id)
.fetch_optional(db)
.await
.unwrap()
}
@@ -0,0 +1,13 @@
WITH target_create_date AS (SELECT p.date_created AS date FROM posts p WHERE p.id = $1)
SELECT (SELECT p.id
FROM posts p
WHERE p.date_created < t.date
ORDER BY p.date_created DESC
LIMIT 1) AS prev,
(SELECT p.id
FROM posts p
WHERE p.date_created > t.date
ORDER BY p.date_created
LIMIT 1) AS next
FROM target_create_date t
@@ -0,0 +1,19 @@
SELECT p.id,
p.identifier,
p.title,
p.subtitle,
p.status,
p.date_created,
p.date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
p.content,
(SELECT json_object_agg(m.meta_key, m.meta_value)
FROM metadata m
WHERE m.pid = p.id) AS metadata
FROM posts p
JOIN categories c ON c.id = p.category
WHERE p.identifier = $1;
@@ -0,0 +1,19 @@
SELECT p.id,
p.identifier,
p.title,
p.subtitle,
p.status,
p.date_created,
p.date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
p.content,
(SELECT json_object_agg(m.meta_key, m.meta_value)
FROM metadata m
WHERE m.pid = p.id) AS metadata
FROM posts p
JOIN categories c ON c.id = p.category
WHERE p.id = $1;
@@ -0,0 +1,17 @@
SELECT p.id,
identifier,
title,
subtitle,
date_created,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
(SELECT m.meta_value
FROM metadata m
WHERE m.pid = p.id
AND m.meta_key = 'header_img') AS header_img
FROM posts p
JOIN public.categories c on c.id = p.category
WHERE p.id = $1
@@ -0,0 +1,19 @@
SELECT p.id,
identifier,
title,
subtitle,
date_created,
date_updated,
c.name AS category,
(SELECT array_agg(t.name)
FROM tags t
JOIN post_tag pt ON pt.tag = t.id
WHERE pt.post = p.id) AS tags,
(SELECT m.meta_value
FROM metadata m
WHERE m.pid = p.id
AND m.meta_key = 'header_img') AS header_img
FROM posts p
JOIN public.categories c on c.id = p.category
WHERE p.id = ANY ($1)
ORDER BY p.date_created DESC;
@@ -0,0 +1,3 @@
pub mod get;
pub mod get_identifier;
pub mod pagination;
@@ -0,0 +1,52 @@
use crate::{constants::blog::PAGE_SIZE, err_resp_log, utils::axum_response::succ_resp};
use axum::{extract::State, http::StatusCode, response::Response};
use serde_json::{Value, json};
use sqlx::{FromRow, PgPool, query_as};
#[derive(FromRow)]
struct DBPaginationRow {
page_count: i32,
post_count: i64,
}
struct PaginationResponse {
page_count: i32,
post_count: i64,
}
impl From<DBPaginationRow> for PaginationResponse {
fn from(value: DBPaginationRow) -> Self {
Self {
page_count: value.page_count,
post_count: value.post_count,
}
}
}
impl From<PaginationResponse> for Value {
fn from(value: PaginationResponse) -> Self {
json!({
"page_count": value.page_count,
"post_count": value.post_count
})
}
}
pub async fn handler(State(db_connection): State<PgPool>) -> Response<String> {
match query_as::<_, DBPaginationRow>(
r#"
SELECT
cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS page_count,
count(*) AS post_count
FROM posts;
"#,
)
.bind(PAGE_SIZE as i64)
.fetch_one(&db_connection)
.await
{
Ok(res) => succ_resp(StatusCode::OK, PaginationResponse::from(res).into()),
Err(e) => err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
@@ -0,0 +1,22 @@
WITH filtered_posts AS
(SELECT p.id, p.date_created
FROM posts p
WHERE $1::text[] IS NULL
OR (
CASE
WHEN $4 THEN (SELECT COUNT(DISTINCT t.name)
FROM post_tag pt
JOIN tags t ON pt.tag = t.id
WHERE pt.post = p.id
AND t.name = ANY ($1::text[])) = cardinality($1)
ELSE EXISTS (SELECT 1
FROM post_tag pt
JOIN tags t ON pt.tag = t.id
WHERE pt.post = p.id
AND t.name = ANY ($1::text[]))
END
))
SELECT id
FROM filtered_posts
ORDER BY date_created DESC
LIMIT $2 OFFSET $3;
@@ -5,10 +5,10 @@ use crate::modules::blog::components::check_update::{
}; };
use futures::{ use futures::{
future::{join_all, BoxFuture},
FutureExt, FutureExt,
future::{BoxFuture, join_all},
}; };
use sqlx::{query, PgPool, Postgres, Row, Transaction}; use sqlx::{PgPool, Postgres, Row, Transaction, query};
use tracing::error; use tracing::error;
const DELETE_POST_BY_ID: &str = include_str!("sql/delete_post_by_id.sql"); const DELETE_POST_BY_ID: &str = include_str!("sql/delete_post_by_id.sql");
@@ -16,7 +16,9 @@ const GET_TAG_ID: &str = include_str!("sql/get_tag_id.sql");
const GET_CATEGORY_ID: &str = include_str!("sql/get_category_id.sql"); const GET_CATEGORY_ID: &str = include_str!("sql/get_category_id.sql");
const CREATE_RETURN_TAG_ID: &str = include_str!("sql/create_return_tag_id.sql"); const CREATE_RETURN_TAG_ID: &str = include_str!("sql/create_return_tag_id.sql");
const CREATE_RETURN_CATEGORY_ID: &str = include_str!("sql/create_return_category_id.sql"); const CREATE_RETURN_CATEGORY_ID: &str = include_str!("sql/create_return_category_id.sql");
const CREATE_POST: &str = include_str!("sql/create_post.sql"); const CREATE_POST_GENERAL: &str = include_str!("sql/create_post_general.sql");
const CREATE_POST_TAGS: &str = include_str!("sql/create_post_tags.sql");
const CREATE_POST_META: &str = include_str!("sql/create_post_meta.sql");
pub async fn apply_deltas(db_conn: &PgPool, changes: Vec<Change>) { pub async fn apply_deltas(db_conn: &PgPool, changes: Vec<Change>) {
let handlers = changes let handlers = changes
@@ -47,7 +49,7 @@ async fn handle_move(db: &PgPool, mv: Move) {
db, db,
Delete { Delete {
uuid: mv.uuid, uuid: mv.uuid,
path: mv.from, path: Some(mv.from),
}, },
) )
.await; .await;
@@ -67,7 +69,7 @@ async fn handle_update(db: &PgPool, update: Update) {
db, db,
Delete { Delete {
uuid: update.uuid, uuid: update.uuid,
path: update.path.clone(), path: Some(update.path.clone()),
}, },
) )
.await; .await;
@@ -117,6 +119,19 @@ async fn handle_create(db: &PgPool, create: Create) {
} }
}; };
const KEY_TO_SKIP: &[&str] = &[
"category", "status", "id", "date", "update", "tags", "title", "subtitle",
];
let (post_meta_key, post_meta_value): (Vec<String>, Vec<String>) =
if let Some(kv) = create.payload.meta.as_object() {
kv.iter()
.filter(|(k, _)| !KEY_TO_SKIP.contains(&&***k))
.map(|(k, v)| (k.clone(), v.as_str().unwrap().to_string()))
.collect()
} else {
(vec![], vec![])
};
let meta = create.payload.meta; let meta = create.payload.meta;
let post_id = create.uuid; let post_id = create.uuid;
let post_identifier = &identifier; let post_identifier = &identifier;
@@ -143,8 +158,9 @@ async fn handle_create(db: &PgPool, create: Create) {
.unwrap_or(None); .unwrap_or(None);
let post_status = meta.get("status").map(|v| v.as_str()).unwrap_or(None); let post_status = meta.get("status").map(|v| v.as_str()).unwrap_or(None);
let post_content = create.payload.content; let post_content = create.payload.content;
let post_hash = create.payload.hash;
if let Err(e) = query(CREATE_POST) if let Err(e) = query(CREATE_POST_GENERAL)
.bind(post_id) .bind(post_id)
.bind(post_identifier) .bind(post_identifier)
.bind(post_title) .bind(post_title)
@@ -153,7 +169,17 @@ async fn handle_create(db: &PgPool, create: Create) {
.bind(post_date_updated) .bind(post_date_updated)
.bind(post_status) .bind(post_status)
.bind(post_content) .bind(post_content)
.bind(post_hash)
.bind(category_id) .bind(category_id)
.execute(&mut *tx)
.await
{
error!("failed to insert post {}: {}", &identifier, e);
return;
}
if let Err(e) = query(CREATE_POST_TAGS)
.bind(post_id)
.bind(tag_ids) .bind(tag_ids)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
@@ -162,6 +188,17 @@ async fn handle_create(db: &PgPool, create: Create) {
return; return;
} }
if let Err(e) = query(CREATE_POST_META)
.bind(post_id)
.bind(post_meta_key)
.bind(post_meta_value)
.execute(&mut *tx)
.await
{
error!("failed to insert post {}: {}", &identifier, e);
return;
}
tx.commit().await.unwrap(); tx.commit().await.unwrap();
} }
@@ -17,7 +17,7 @@ pub enum Change {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Delete { pub struct Delete {
pub uuid: Uuid, pub uuid: Uuid,
pub path: PathBuf, pub path: Option<PathBuf>,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -56,7 +56,7 @@ impl CreateDelete2UpdateSlot {
match (&self.create, &self.delete) { match (&self.create, &self.delete) {
(Some(create), Some(delete)) => Some(Change::Move(Move { (Some(create), Some(delete)) => Some(Change::Move(Move {
uuid: create.uuid, uuid: create.uuid,
from: delete.path.clone(), from: delete.path.clone()?,
to: create.path.clone(), to: create.path.clone(),
payload: create.payload.clone(), payload: create.payload.clone(),
})), })),
@@ -1,28 +1,23 @@
use std::{ pub mod apply;
env, pub mod changes;
path::{Path, PathBuf}, mod error;
sync::Arc, mod pull;
}; pub mod utils;
use std::{env, path::PathBuf, sync::Arc};
use crate::{ use crate::{
constants::config::general::{default_value, var_name}, constants::config::general::{default_value, var_name},
modules::blog::components::check_update::{ modules::blog::components::{
apply::apply_deltas, pull::fetch_deltas, utils::expand_path, check_update::{apply::apply_deltas, pull::fetch_deltas},
static_rsc::GIT_WORK_DIR,
}, },
scheduler::task_func::AsyncTaskFunc, scheduler::task_func::AsyncTaskFunc,
}; };
use macros::panic_with_log; use macros::panic_with_log;
use sqlx::PgPool; use sqlx::PgPool;
use tracing::{log::error, trace, warn, Level}; use tracing::{Level, log::error, trace};
// mod error;
// mod index;
mod apply;
mod changes;
mod error;
mod pull;
mod utils;
#[derive(Debug)] #[derive(Debug)]
struct Args { struct Args {
@@ -34,22 +29,6 @@ struct Args {
} }
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc { pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
// init
let git_work_dir = {
let path = if let Ok(default_log_path) = env::var(var_name::WORK_DIR) {
expand_path(default_log_path)
} else {
let fallback_path = expand_path(default_value::WORK_DIR.to_string());
warn!(
"unset env var `{}`, using default path: {}/blog_git",
var_name::WORK_DIR,
fallback_path
);
fallback_path
};
Path::new(&path).join("blog_git")
};
// clone & check update // clone & check update
let git_url = match env::var(var_name::BLOG_GIT_URL) { let git_url = match env::var(var_name::BLOG_GIT_URL) {
Ok(url) => url, Ok(url) => url,
@@ -69,7 +48,7 @@ pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let arc_args = Arc::new(Args { let arc_args = Arc::new(Args {
db_connection, db_connection,
git_work_dir, git_work_dir: GIT_WORK_DIR.clone(),
git_url, git_url,
git_remote_name, git_remote_name,
git_remote_branch, git_remote_branch,
@@ -1,17 +1,19 @@
use std::sync::LazyLock; use std::{collections::HashMap, fs::remove_dir_all, path::Path, path::PathBuf};
use std::{collections::HashMap, fs::remove_dir_all, path::PathBuf};
use crate::{ use crate::{
modules::blog::components::check_update::{ modules::blog::components::{
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update}, check_update::{
error::Error, changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error,
utils::extract_post_id,
},
static_rsc::BLOG_POST_PATH_PATTERN,
}, },
utils::{git, git::FileDelta}, utils::{git, git::FileDelta},
}; };
use markdown::MdFile; use markdown::MdFile;
use git2::{Delta, Repository}; use git2::{Delta, Repository};
use regex::Regex;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
@@ -80,7 +82,7 @@ pub async fn fetch_deltas(
if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) { if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) {
changes.push(Change::Delete(Delete { changes.push(Change::Delete(Delete {
uuid: id, uuid: id,
path: file_path.clone(), path: Some(file_path.clone()),
})) }))
} else { } else {
error!("failed to parse uuid for file {}", file_path.display()); error!("failed to parse uuid for file {}", file_path.display());
@@ -136,18 +138,11 @@ pub async fn fetch_deltas(
} }
// merge adds and deletes deltas to moves // merge adds and deletes deltas to moves
let changes = dbg!(merge_changes(changes)); let changes = merge_changes(changes);
Ok(changes) Ok(changes)
} }
fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["uuid"].as_str() {
Some(id) => Uuid::parse_str(id).ok(),
None => None,
}
}
fn merge_changes(changes: Vec<Change>) -> Vec<Change> { fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new(); let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new();
let mut merged: Vec<Change> = Vec::new(); let mut merged: Vec<Change> = Vec::new();
@@ -175,9 +170,6 @@ fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
merged merged
} }
static TRACKED_DELTA_RULE: LazyLock<Regex> = fn should_delta_tracked(path: &Path) -> bool {
LazyLock::new(|| Regex::new(r"posts/\d{4}/[a-zA-Z0-9-]+/index\.md").unwrap()); BLOG_POST_PATH_PATTERN.is_match(path.to_str().unwrap())
fn should_delta_tracked(path: &PathBuf) -> bool {
TRACKED_DELTA_RULE.is_match(path.to_str().unwrap())
} }
@@ -1,7 +0,0 @@
INSERT INTO posts
(id, identifier, title, subtitle, date_created, date_updated, status, content, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
INSERT INTO post_tag (post, tag)
SELECT $1, tag
FROM UNNEST($10) AS t(tag);
@@ -0,0 +1,3 @@
INSERT INTO posts
(id, identifier, title, subtitle, date_created, date_updated, status, content, hash, category)
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 'draft'), $8, $9, $10);
@@ -0,0 +1,3 @@
INSERT INTO metadata (pid, meta_key, meta_value)
SELECT $1, key, value
FROM UNNEST($2, $3) AS k(key, value);
@@ -0,0 +1,3 @@
INSERT INTO post_tag (post, tag)
SELECT $1, tag
FROM UNNEST($2) AS t(tag);
@@ -7,7 +7,9 @@ use crate::constants::{
use chrono::NaiveDate; use chrono::NaiveDate;
use dirs::home_dir; use dirs::home_dir;
use markdown::MdFile;
use tracing::warn; use tracing::warn;
use uuid::Uuid;
pub fn expand_path(path: String) -> String { pub fn expand_path(path: String) -> String {
if path.starts_with('~') { if path.starts_with('~') {
@@ -31,6 +33,13 @@ pub fn expand_path(path: String) -> String {
} }
} }
pub fn extract_post_id(post: &MdFile) -> Option<Uuid> {
match post.meta["id"].as_str() {
Some(id) => Uuid::parse_str(id).ok(),
None => None,
}
}
pub fn parse_post_identifier(path: &Path) -> Option<String> { pub fn parse_post_identifier(path: &Path) -> Option<String> {
if let Some(comp) = path.components().nth_back(1) { if let Some(comp) = path.components().nth_back(1) {
comp.as_os_str().to_str().map(|s| s.to_string()) comp.as_os_str().to_str().map(|s| s.to_string())
@@ -0,0 +1,130 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
str::FromStr,
};
use crate::modules::blog::components::{
check_update::{
changes::{Change, Create, Delete, Update},
utils::extract_post_id,
},
consistency_guard::{error::Error, utils::rec_find_dir_entries},
static_rsc::BLOG_POST_PATH_PATTERN,
};
use markdown::MdFile;
use sqlx::{FromRow, PgPool, query_as};
use tracing::error;
use uuid::Uuid;
const FETCH_POST_HASH: &str = include_str!("sql/fetch_post_hash.sql");
#[derive(Debug, FromRow)]
struct PostIdHash {
id: Uuid,
hash: String,
}
struct PostPath {
id: Uuid,
path: PathBuf,
payload: MdFile,
}
#[derive(Default)]
struct DeltaSlot {
local: Option<PostPath>,
db: Option<PostIdHash>,
}
impl DeltaSlot {
pub fn to_change(&self) -> Option<Change> {
match (&self.local, &self.db) {
(Some(local), Some(db)) => {
if local.payload.hash == db.hash {
None
} else {
Some(Change::Update(Update {
uuid: local.id,
path: local.path.clone(),
payload: local.payload.clone(),
}))
}
}
(Some(local), None) => Some(Change::Create(Create {
uuid: local.id,
path: local.path.clone(),
payload: local.payload.clone(),
})),
(None, Some(db)) => Some(Change::Delete(Delete {
uuid: db.id,
path: None,
})),
(None, None) => None,
}
}
}
pub async fn compare_db_local_post_hash(
db: &PgPool,
git_work_dir: &Path,
) -> Result<Vec<Change>, Error> {
let db_post_hashes = query_as::<_, PostIdHash>(FETCH_POST_HASH)
.fetch_all(db)
.await?;
let local_posts = fetch_local_posts(git_work_dir)?;
calculate_post_changes(local_posts, db_post_hashes)
}
fn fetch_local_posts(git_work_dir: &Path) -> Result<Vec<PostPath>, Error> {
let mut post_hashes = vec![];
let entries = rec_find_dir_entries(&git_work_dir.join("posts"))?;
for entry in entries {
let relative_pth = entry.strip_prefix(git_work_dir).unwrap();
if !BLOG_POST_PATH_PATTERN.is_match(relative_pth.to_str().unwrap()) {
continue;
}
let post_str = fs::read_to_string(&entry)?;
let post = MdFile::from_str(&post_str)?;
let post_id = match extract_post_id(&post) {
None => {
error!(
"failed to parse uuid for file {}, skipping",
&entry.display()
);
continue;
}
Some(v) => v,
};
post_hashes.push(PostPath {
id: post_id,
path: entry,
payload: post,
});
}
Ok(post_hashes)
}
fn calculate_post_changes(local: Vec<PostPath>, db: Vec<PostIdHash>) -> Result<Vec<Change>, Error> {
let mut slots: HashMap<Uuid, DeltaSlot> = HashMap::new();
for local_post in local {
let id = local_post.id;
slots.entry(id).or_default().local = Some(local_post);
}
for db_post in db {
let id = db_post.id;
slots.entry(id).or_default().db = Some(db_post);
}
let mut changes = vec![];
for slot in slots.values() {
if let Some(change) = slot.to_change() {
changes.push(change);
}
}
Ok(changes)
}
@@ -0,0 +1,19 @@
use std::io;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("db error")]
Db(#[from] sqlx::Error),
#[error("io error")]
Io(#[from] io::Error),
#[error("invalid path: {0}")]
InvalidPath(PathBuf),
#[error("invalid md file")]
MdFile(#[from] markdown::error::Error),
}
@@ -1 +1,44 @@
mod checker;
mod error;
mod utils;
use std::{path::PathBuf, sync::Arc};
use crate::{
modules::blog::components::{
check_update::apply::apply_deltas, consistency_guard::checker::compare_db_local_post_hash,
static_rsc::GIT_WORK_DIR,
},
scheduler::task_func::AsyncTaskFunc,
};
use sqlx::PgPool;
use tracing::{error, trace};
struct Args {
db_connection: PgPool,
git_work_dir: PathBuf,
}
pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let args = Arc::new(Args {
db_connection,
git_work_dir: GIT_WORK_DIR.clone(),
});
move || Box::pin(workflow(args.clone()))
}
async fn workflow(args: Arc<Args>) {
let db = &args.db_connection;
let git_work_dir = &args.git_work_dir;
let changes = match compare_db_local_post_hash(db, git_work_dir).await {
Ok(c) => c,
Err(e) => {
error!("failed to compare db and local post list: {}", e);
return;
}
};
apply_deltas(db, changes).await;
trace!("local-database consistency checked");
}
@@ -0,0 +1,2 @@
SELECT id, hash
FROM posts
@@ -0,0 +1,78 @@
use std::{
fs,
path::{Path, PathBuf},
};
use crate::modules::blog::components::consistency_guard::error::Error;
pub fn rec_find_dir_entries(entry: &Path) -> Result<Vec<PathBuf>, Error> {
if !entry.exists() {
return Err(Error::InvalidPath(entry.to_path_buf()));
}
let mut result = vec![];
if !entry.is_dir() {
return Ok(vec![entry.to_path_buf()]);
}
for entry in fs::read_dir(entry)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
result.extend(rec_find_dir_entries(&path)?);
} else {
result.push(path);
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, File};
use tempfile::TempDir;
#[test]
fn test_valid_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
fs::create_dir(dir_path.join("subdir")).unwrap();
File::create(dir_path.join("file1.txt")).unwrap();
File::create(dir_path.join("subdir/file2.txt")).unwrap();
let entries = rec_find_dir_entries(dir_path).unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn test_non_existent_path() {
let non_existent = PathBuf::from("/non/existent/path");
assert!(matches!(
rec_find_dir_entries(&non_existent),
Err(Error::InvalidPath(_))
));
}
#[test]
fn test_single_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
File::create(&file_path).unwrap();
let entries = rec_find_dir_entries(&file_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], file_path);
}
#[test]
fn test_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let entries = rec_find_dir_entries(temp_dir.path()).unwrap();
assert!(entries.is_empty());
}
}
@@ -1,3 +1,4 @@
pub mod check_update; pub mod check_update;
pub mod consistency_guard; pub mod consistency_guard;
pub mod rag; pub mod rag;
mod static_rsc;
@@ -0,0 +1,31 @@
use std::{
env,
path::{Path, PathBuf},
sync::LazyLock,
};
use crate::{
constants::config::general::{default_value, var_name},
modules::blog::components::check_update::utils::expand_path,
};
use regex::Regex;
use tracing::warn;
pub static BLOG_POST_PATH_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^posts/\d{4}/[a-zA-Z0-9-]+/index\.md$").unwrap());
pub static GIT_WORK_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
let path = if let Ok(default_work_path) = env::var(var_name::WORK_DIR) {
expand_path(default_work_path)
} else {
let fallback_path = expand_path(default_value::WORK_DIR.to_string());
warn!(
"unset env var `{}`, using default path: {}/blog_git",
var_name::WORK_DIR,
fallback_path
);
fallback_path
};
Path::new(&path).join("blog_git")
});
+21 -16
View File
@@ -1,21 +1,15 @@
mod api; mod api;
mod components; mod components;
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::{ use crate::{
modules::{ modules::{
Module, Module,
blog::{ blog::{
api::{ api::{posts, posts::get, posts::pagination},
get_page_count, components::{check_update, consistency_guard},
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
}, },
}, },
scheduler::tasks::CronTask, scheduler::tasks::{CronTask, async_basic::AsyncBasic},
}; };
use axum::{Router, routing::get}; use axum::{Router, routing::get};
@@ -33,9 +27,9 @@ impl Module for Blog {
// todo: move resource init to new func // todo: move resource init to new func
// todo: remove debug comment // todo: remove debug comment
Router::new() Router::new()
// .route("/get_post_list", get(get_post_list)) .route("/posts", get(get::handler))
.route("/get_page_count", get(get_page_count::handler)) .route("/posts/{identifier}", get(posts::get_identifier::handler))
.route("/get_post/{identifier}", get(get_post::handler)) .route("/posts/pagination", get(pagination::handler))
.with_state(db_handler) .with_state(db_handler)
} }
@@ -45,11 +39,22 @@ impl Module for Blog {
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> { fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
let db_handler = self.db_handler.clone(); let db_handler = self.db_handler.clone();
vec![( vec![
"Blog Check Update", (
AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *") "Blog Check Update",
AsyncBasic::new(check_update::task(db_handler.clone()), "*/5 * * * * *")
.unwrap()
.to_task(),
),
(
"Consistency Guard",
AsyncBasic::new(
consistency_guard::task(db_handler.clone()),
"*/15 * * * * *",
)
.unwrap() .unwrap()
.to_task(), .to_task(),
)] ),
]
} }
} }
+51
View File
@@ -0,0 +1,51 @@
use axum::{
http::{StatusCode, header::CONTENT_TYPE},
response::Response,
};
use serde_json::{Value, json};
#[macro_export]
macro_rules! err_resp_log {
($code:expr, $msg:expr) => {{
use tracing::error;
use $crate::utils::axum_response::err_resp;
error!("{}", $msg);
err_resp($code, $msg, None)
}};
($code:expr, $msg:expr, $body:expr) => {{
use tracing::error;
use $crate::utils::axum_response::err_resp;
error!("{}", $msg);
err_resp($code, $msg, Some($body))
}};
}
pub fn err_resp(code: StatusCode, err_msg: &str, body: Option<Value>) -> Response<String> {
let mut payload = json!({
"status": "error",
"msg": err_msg,
});
if let Some(body) = body {
payload["data"] = body;
}
resp_json(code, payload)
}
pub fn succ_resp(code: StatusCode, payload: Value) -> Response<String> {
let payload = json!({
"status": "success",
"data": payload
});
resp_json(code, payload)
}
pub fn resp_json(code: StatusCode, payload: Value) -> Response<String> {
Response::builder()
.status(code)
.header(CONTENT_TYPE, "application/json")
.body(payload.to_string())
.unwrap()
}
+1
View File
@@ -1 +1,2 @@
pub mod axum_response;
pub mod git; pub mod git;
+1
View File
@@ -9,3 +9,4 @@ __pycache__
.cache .cache
content/* content/*
/content/
+2 -2
View File
@@ -10,7 +10,7 @@ from utils.lang import eprint, wprint
POST_TEMPLATE = ''' POST_TEMPLATE = '''
--- ---
uuid: {{ uuid }} id: {{ id }}
title: {{ title }} title: {{ title }}
subtitle: {{ subtitle }} subtitle: {{ subtitle }}
status: {{ status }} status: {{ status }}
@@ -83,6 +83,6 @@ class BlogPost(Template):
update=dt.today().strftime("%Y-%m-%d"), update=dt.today().strftime("%Y-%m-%d"),
tags=get_tags(), tags=get_tags(),
category=get_category(), category=get_category(),
uuid=uuid.uuid4() id=uuid.uuid4()
) )
create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS) create_all_files(self.WORK_PATH.format(year=year, identifier="test"), self.ITEMS)
+2 -2
View File
@@ -5,6 +5,6 @@ RUN ["cargo", "build", "-r"]
FROM gcr.io/distroless/cc AS production FROM gcr.io/distroless/cc AS production
ARG ARCH ARG ARCH
COPY --from=builder /app/target/release/api_server ./api_server COPY --from=builder /app/target/release/server ./server
COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/ COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/
ENTRYPOINT ["./api_server"] ENTRYPOINT ["./server"]
+3
View File
@@ -0,0 +1,3 @@
target/
Cargo.lock
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "daemon"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.5.47", features = ["derive"] }
containerd-client = "0.8.0"
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
+13
View File
@@ -0,0 +1,13 @@
.PHONY: rebuild
rebuild:
cargo clean
cargo build
.PHONY: rebuild-release
rebuild-release:
cargo clean
cargo build -r
.PHONY: run
run:
sudo ./target/debug/daemon
+1
View File
@@ -0,0 +1 @@
mod pull;
+8
View File
@@ -0,0 +1,8 @@
{
"images": [
{
"id": "",
"image": ""
}
]
}
View File
Whitespace-only changes.
+89
View File
@@ -0,0 +1,89 @@
mod actions;
mod config;
use clap::Parser;
use containerd_client::tonic::Request;
use containerd_client::{
connect,
services::v1::{transfer_client::TransferClient, TransferOptions, TransferRequest},
to_any,
types::transfer::OciRegistry,
types::transfer::{ImageStore, UnpackConfiguration},
types::Platform,
with_namespace,
};
use std::env::consts;
#[derive(Parser, Debug)]
struct Args {
/// containerd namespace
#[arg(short, long, default_value = "nocturne")]
namespace: String,
/// containerd socket
#[arg(short, long, default_value = "/run/containerd/containerd.sock")]
socket: String,
}
const IMAGE: &str = "docker.io/library/alpine:latest";
#[tokio::main]
async fn main() {
let args = Args::parse();
let arch = match consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
_ => consts::ARCH,
};
dbg!(&args);
let channel = connect(&args.socket).await.expect("failed to bind socket");
let mut client = TransferClient::new(channel.clone());
let registry = OciRegistry {
reference: IMAGE.to_string(),
resolver: Default::default(),
};
let platform = Platform {
os: "linux".to_string(),
architecture: arch.to_string(),
variant: "".to_string(),
os_version: "".to_string(),
};
let destination = ImageStore {
name: IMAGE.to_string(),
platforms: vec![platform.clone()],
unpacks: vec![UnpackConfiguration {
platform: Some(platform),
..Default::default()
}],
..Default::default()
};
let anys = to_any(&registry);
let anyd = to_any(&destination);
println!(
"Pulling image for linux/{} from source: {:?}",
arch, registry
);
let request = TransferRequest {
source: Some(anys),
destination: Some(anyd),
options: Some(TransferOptions {
..Default::default()
}),
};
// let request = Request::new(request);
let req = with_namespace!(request, args.namespace);
client
.transfer(req)
.await
.expect("unable to transfer image");
}
+8
View File
@@ -0,0 +1,8 @@
.PHONY:all
all: standalone
.PHONY: standalone
standalone:
npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
+53 -31
View File
@@ -4,42 +4,50 @@ import { Chip } from '@nextui-org/react';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { MdCalendarMonth, MdNorthEast } from 'react-icons/md'; import { MdCalendarMonth, MdNorthEast } from 'react-icons/md';
import { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList';
import TagChip from '@/app/(pages)/blog/TagChip';
type Props = { interface Post {
post: JSON id: string,
identifier: string,
title: string,
subtitle: string,
tags: string[]
category: string,
date_created: Date,
date_updated: Date | null,
header_img: string | null
} }
function BlogPostCard({ post }: Props) { type Props = {
let title = (post as any)['title']; post: Post
let sub_title = (post as any)['sub_title'];
// let summary = (post as any)['summary']; highlightSelector: HighlightSelector,
let category = (post as any)['category']; }
let tags = (post as any)['tags'];
// let last_update = new Date((post as any)['last_update']); function BlogPostCard({ post, highlightSelector }: Props) {
let first_update = new Date((post as any)['first_update']); const highlightTags = highlightSelector?.tags;
let post_id = (post as any)['post_id'];
let header_img = ((post as any)['header_img'] as string | null);
return ( return (
<div className={ `w-full` }> <div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` } <Card
className={ `group` } as={ Link } href={ `/blog/post/${ post.identifier }` }
isPressable className={ `group w-full` }
isHoverable isPressable
isHoverable
> >
{ header_img && <div className={ `w-full overflow h-80 relative` }> { post.header_img && <div className={ `w-full overflow h-80 relative` }>
<Image <Image
alt={ 'cover picture' } alt={ 'cover picture' }
src={ header_img } src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-10` } className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' } loading={ 'eager' }
fill fill
></Image> ></Image>
<Image <Image
alt={ 'cover picture shadow' } alt={ 'cover picture shadow' }
src={ header_img } src={ post.header_img }
loading={ 'eager' } loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` } className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill fill
@@ -48,11 +56,11 @@ function BlogPostCard({ post }: Props) {
<CardBody className={ `overflow-visible` }> <CardBody className={ `overflow-visible` }>
<div className={ `flex flex-row justify-between` }> <div className={ `flex flex-row justify-between` }>
<div className={ `mx-2 ${ header_img ? '-mt-6' : 'mt-2' }` }> <div className={ `mx-2 ${ post.header_img ? '-mt-6' : 'mt-2' }` }>
<Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } color={ `secondary` } <Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } color={ `secondary` }
variant={ `solid` }>{ category }</Chip> variant={ `solid` }>{ post.category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p> <p className={ `text-4xl font-bold` }> { post.title }</p>
<p className={ `text-xl` }> { sub_title }</p> <p className={ `text-xl` }> { post.subtitle }</p>
</div> </div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } /> <MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div> </div>
@@ -62,19 +70,33 @@ function BlogPostCard({ post }: Props) {
<div className={ `flex flex-row flex-wrap justify-between w-full` }> <div className={ `flex flex-row flex-wrap justify-between w-full` }>
<div> <div>
<ul className={ `flex flex-row flex-wrap` }> <ul className={ `flex flex-row flex-wrap` }>
{ {/*highlight*/
tags.map((tag: string, key: any) => ( post.tags
<li key={ key } className={ `mx-1 mb-1` }> .filter((tag) => highlightTags?.includes(tag))
<Chip>{ tag }</Chip> .sort()
</li> .map((tag: string, key: any) => (
)) <li key={ key } className={ `mx-1 mb-1` }>
<TagChip tagName={ tag } isHighlighted={ true } />
</li>
))
}
{/*normal */
post.tags
.filter((tag) => !highlightTags?.includes(tag))
.sort()
.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<TagChip tagName={ tag } isHighlighted={ false } />
</li>
))
} }
</ul> </ul>
</div> </div>
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }> <div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }>
<MdCalendarMonth /> <MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }> <p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', { { post.date_created.toLocaleString('default', {
month: `long`, month: `long`,
day: `numeric`, day: `numeric`,
year: `numeric` year: `numeric`
+7 -2
View File
@@ -8,7 +8,9 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const Pagination = ({ total }: { const Pagination = ({ total }: {
total: number, total: number,
}) => { }) => {
const parsePage = Number(useSearchParams().get('page')); const searchParams = useSearchParams();
const parsePage = Number(searchParams.get('page'));
const currPage = parsePage > 0 ? parsePage : 1; const currPage = parsePage > 0 ? parsePage : 1;
const router = useRouter(); const router = useRouter();
@@ -27,7 +29,10 @@ const Pagination = ({ total }: {
color={ 'default' } color={ 'default' }
variant={ 'light' } variant={ 'light' }
onChange={ (page) => { onChange={ (page) => {
router.push(`?page=${ page }`, { scroll: false }); const params = new URLSearchParams(searchParams.toString());
params.set('page', page.toString());
const queryString = params.toString();
router.push(`?${ queryString }`, { scroll: false });
} } } }
/> />
</div> </div>
+24 -30
View File
@@ -1,43 +1,37 @@
'use server';
import React from 'react'; import React from 'react';
import { fetchNocturneApi } from '@/app/(pages)/blog/api'; import { Post } from '@/src/api/blog/post';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard'; import AnimationPostList, { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList';
import { Card, CardBody } from '@nextui-org/card';
import * as motion from 'motion/react-client'; interface Props {
posts: Post[],
tags: string[] | undefined
}
const PostList = async ({ page }: { page: number }) => { const PostList = async ({ posts, tags }: Props) => {
try { try {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
const posts = await res.json();
return ( return (
// blog list
<div className={ 'w-full' }> <div className={ 'w-full' }>
<ul className={ 'flex flex-col items-center w-full space-y-7' }> <AnimationPostList posts={ posts } highlight={ {
{ posts.map((post: JSON, key: number) => ( tags: tags
<li key={ key } className={ 'w-full' }> } } />
<motion.div
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
>
<BlogPostCard post={ post } />
</motion.div>
</li>)
) }
</ul>
</div> </div>
); );
} catch (e: any) { } catch (e: any) {
return ( return (
// todo: use Error.tsx <>
<Card className={ `w-full` }> <div className={ `flex h-full min-h-dvh w-full justify-center items-center` }>
<CardBody> <div className={ `flex-row` }>
Fatal: { e.toString() } <p className={ `text-xl font-bold` }>
</CardBody> { 'Unexpected Error:' }
</Card>); </p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
</>
);
} }
}; };
export default PostList; export default PostList;
+48
View File
@@ -0,0 +1,48 @@
'use client';
import React from 'react';
import { Chip } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation';
interface TagChipProps {
tagName: string,
isHighlighted: boolean
}
const TagChip = ({ tagName, isHighlighted }: TagChipProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const toggleTag = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
const params = new URLSearchParams(searchParams.toString());
const tags = params.getAll('tags');
if (tags.includes(tagName)) {
const newTags = tags.filter(t => t !== tagName);
params.delete('tags');
newTags.forEach(t => params.append('tags', t));
} else {
params.append('tags', tagName);
}
const queryString = params.toString();
router.push(`?${ queryString }`, { scroll: false });
};
return (
<div
onClick={ toggleTag }
className="cursor-pointer relative"
>
<Chip
className={ 'hover:border-amber-400 hover:dark:border-amber-700 transition-all' }
variant={ 'bordered' }
color={ isHighlighted ? 'warning' : 'default' }
>{ tagName }</Chip>
</div>
);
};
export default TagChip;
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
const ToolBar = () => {
return (
<div className={ 'w-full' }>
</div>
);
};
export default ToolBar;
-21
View File
@@ -1,21 +0,0 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
});
export async function fetchNocturneApi(path: string, isServerSideRequest = true) {
return await fetch(`${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`, {
cache: 'no-cache'
});
}
+55 -12
View File
@@ -1,24 +1,67 @@
import PostList from '@/app/(pages)/blog/PostList'; import PostList from '@/app/(pages)/blog/PostList';
import Pagination from '@/app/(pages)/blog/Pagination'; import Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/(pages)/blog/api'; import { fetchPostList } from '@/src/api/blog/post';
import ToolBar from '@/app/(pages)/blog/ToolBar';
async function Blog({ async function Blog({
searchParams searchParams
}: { }: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }> searchParams: Promise<{ [_: string]: string | string[] | undefined }>
}) { }) {
const parsePage = Number((await searchParams)?.page); const sp = await searchParams;
const currPage = parsePage > 0 ? parsePage : 1;
const ret = await fetchNocturneApi(`/blog/get_page_count`); const parsePage = Number(sp?.page);
const pageCount = (await ret.json() as any)['res'] as number; const currPage = parsePage > 0 ? parsePage : 1;
const matchAll = Boolean(sp?.match_all);
const ensureList = (input: string | string[] | undefined): string[] | undefined => {
if (!input) {
return undefined;
}
if (typeof input === 'string') {
return [ input ];
} else {
return input;
}
};
const tags = ensureList(sp?.tags);
try {
const postListResponse = await fetchPostList({
page: currPage,
match_all: matchAll,
tag_filter: tags
});
const pagination = postListResponse.data.pagination;
const posts = postListResponse.data.posts;
return (
<div className={ 'w-full min-w-full space-y-4' }>
<ToolBar />
<PostList posts={ posts } tags={ tags } />
<Pagination total={ pagination.page_count } />
</div>
);
} catch (e: any) {
return (
<div className={ `flex h-full min-h-dvh w-full justify-center items-center` }>
<div className={ `flex-row` }>
<p className={ `text-xl font-bold` }>
{ 'Unexpected Error [loading blog posts]:' }
</p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
);
}
return (
<div className={ 'w-full min-w-full space-y-4' }>
<PostList page={ currPage } />
<Pagination total={ pageCount } />
</div>
);
} }
export default Blog; export default Blog;
+12 -11
View File
@@ -3,27 +3,28 @@ import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
import Link from 'next/link'; import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6'; import { FaCreativeCommons } from 'react-icons/fa6';
interface AdjacentPosts {
prev: any;
next: any;
}
type Props = { type Props = {
// post: any adjacent_posts: AdjacentPosts
post_id: string; perm_link: string;
adjacent_posts: any author: string;
perm_link: string | undefined; author_link: string;
author: string | undefined;
author_link: string | undefined;
first_update: string; first_update: string;
last_update: string | undefined; last_update: string | null;
cc: string | undefined; cc: string | undefined;
title: string; title: string;
} }
const Footer = (props: Props) => { const Footer = (props: Props) => {
const { adjacent_posts, perm_link, post_id, first_update, last_update, title } = props; const { adjacent_posts, perm_link, author_link, author, first_update, last_update, title } = props;
const prev_post = adjacent_posts.prev; const prev_post = adjacent_posts.prev;
const next_post = adjacent_posts.next; const next_post = adjacent_posts.next;
const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`; const link = perm_link;
const author = props.author ?? 'Tao';
const author_link = props.author_link ?? '/about/me';
const post_date = new Date(first_update); const post_date = new Date(first_update);
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined; const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
const cc = props.cc ?? 'by-nc-sa'; const cc = props.cc ?? 'by-nc-sa';
@@ -2,15 +2,11 @@ import React from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Chip } from '@nextui-org/react'; import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md'; import { MdCalendarMonth } from 'react-icons/md';
import TagChip from '@/app/(pages)/blog/post/[id]/TagChip';
// type Props = {
// post: any
// };
type Props = { type Props = {
title: string; title: string;
subtitle: string; subtitle: string;
summary: string | undefined;
category: string; category: string;
tags: string[]; tags: string[];
first_update: string; first_update: string;
@@ -55,7 +51,7 @@ const Headers = (post: Props) => {
{ {
post.tags.map((tag: string, key: any) => ( post.tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }> <li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip> <TagChip tag={ tag } />
</li> </li>
)) ))
} }
@@ -17,7 +17,7 @@ const MinifyPostCard = (props: Props) => {
className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden" className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden"
as={ Link } as={ Link }
isPressable isPressable
href={ `/blog/post/${ post['id'] }` } href={ `/blog/post/${ post['identifier'] }` }
> >
<CardHeader className="absolute z-10 top-1 flex-col items-start"> <CardHeader className="absolute z-10 top-1 flex-col items-start">
<p className={ `text-tiny uppercase font-bold ${ img_url ? 'text-white/60' : 'text-black/60 dark:text-white/60' }` }>{ desc }</p> <p className={ `text-tiny uppercase font-bold ${ img_url ? 'text-white/60' : 'text-black/60 dark:text-white/60' }` }>{ desc }</p>
@@ -36,7 +36,7 @@ const MinifyPostCard = (props: Props) => {
className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none"> className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none">
<div> <div>
<p className="text-2xl font-bold">{ post['title'] }</p> <p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p> <p className="">{ post.subtitle }</p>
</div> </div>
</CardFooter> </CardFooter>
</div> </div>
@@ -46,7 +46,7 @@ const MinifyPostCard = (props: Props) => {
<div className={ 'flex flex-col max-h-fit text-left' }> <div className={ 'flex flex-col max-h-fit text-left' }>
<p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p> <p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
<div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div> <div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p> <p className={ 'text-xl text-pretty break-words' }>{ post.subtitle }</p>
</div> </div>
</div> </div>
} }
@@ -3,25 +3,28 @@ import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/(pages)/blog/post/[id]/Headers'; import Headers from '@/app/(pages)/blog/post/[id]/Headers';
import Footer from '@/app/(pages)/blog/post/[id]/Footer'; import Footer from '@/app/(pages)/blog/post/[id]/Footer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer'; import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { FetchPostResponseData } from '@/src/api/blog/post';
type Props = { type Props = {
post: any post: FetchPostResponseData
} }
async function PostCard(props: Props) { async function PostCard(props: Props) {
let post = props.post; const post = props.post.post;
const metadata: any | null = post.metadata;
const adj = props.post.adjacent;
return ( return (
<div> <div>
<Card className={ `max-w-full relative` }> <Card className={ `max-w-full relative` }>
{/* card headers */ } {/* card headers */ }
<Headers title={ post.title } <Headers title={ post.title }
subtitle={ post.sub_title } subtitle={ post.subtitle }
summary={ post.summary }
category={ post.category } category={ post.category }
tags={ post.tags } tags={ post.tags }
first_update={ post.first_update } first_update={ post.date_created }
header_img={ post.meta.header_img } header_img={ metadata?.header_img }
/> />
{/* card body */ } {/* card body */ }
@@ -36,14 +39,13 @@ async function PostCard(props: Props) {
{/* card footer */ } {/* card footer */ }
<Footer <Footer
adjacent_posts={ post.adj } adjacent_posts={ adj }
perm_link={ post.meta.link } perm_link={ metadata?.perm_link ?? `https://lumither.com/blog/post/${ post.id }` }
author={ post.meta.author } author={ metadata?.author ?? 'Tao' }
author_link={ post.meta.author_link } author_link={ metadata?.author_link ?? '/about/me' }
first_update={ post.first_update } first_update={ post.date_created }
last_update={ post.last_update } last_update={ post.date_updated }
cc={ post.meta.cc } cc={ metadata?.cc }
post_id={ post.meta.id }
title={ post.title } title={ post.title }
/> />
@@ -0,0 +1,20 @@
'use client';
import React from 'react';
import { Chip } from '@nextui-org/react';
import { useRouter } from 'next/navigation';
const TagChip = ({ tag }: { tag: string }) => {
const router = useRouter();
return (
<div onClick={ () => router.push(`/blog?tags=${ tag }`) }>
<Chip
className={ 'hover:border-amber-400 hover:dark:border-amber-700 transition-all' }
variant={ 'faded' }
>{ tag }</Chip>
</div>
);
};
export default TagChip;
+5 -10
View File
@@ -1,22 +1,17 @@
import React, { cache } from 'react'; import React from 'react';
import PostCard from '@/app/(pages)/blog/post/[id]/PostCard'; import PostCard from '@/app/(pages)/blog/post/[id]/PostCard';
import ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack'; import ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack';
import * as motion from 'motion/react-client'; import * as motion from 'motion/react-client';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants'; import { SITE_CONFIG } from '@/src/constants';
import { fetchPost } from '@/src/api/blog/post';
const fetchPost = cache(async (id: string) => {
const res = await fetchNocturneApi(`/blog/get_post/${ id }`);
// throw Error('Not Found');
return await res.json();
});
export async function generateMetadata( export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> }) { params }: { params: Promise<{ id: string }> })
: Promise<Metadata> { : Promise<Metadata> {
try { try {
const title = (await fetchPost((await params).id)).title as string; const response = await fetchPost((await params).id);
const title = response.data.post.title;
return { return {
title: `${ title } - ${ SITE_CONFIG.name }` title: `${ title } - ${ SITE_CONFIG.name }`
}; };
@@ -33,7 +28,7 @@ async function BlogReader({
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
try { try {
let post = await fetchPost((await params).id); let post = (await fetchPost((await params).id)).data;
return ( return (
<motion.div <motion.div
@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import * as motion from 'motion/react-client';
import { AnimatePresence } from 'motion/react';
import { Post } from '@/src/api/blog/post';
export interface HighlightSelector {
tags?: string[];
}
interface Props {
posts: Post[];
highlight: HighlightSelector;
}
const PostList = ({ posts, highlight }: Props) => {
return (
<div className={ 'w-full' }>
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
<AnimatePresence mode={ 'wait' }>
{ posts.map((post, key: number) => (
<motion.li
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeOut', duration: 0.5, delay: key * .1 + .1 } }
key={ key }
className={ 'w-full' }>
<BlogPostCard post={ post } highlightSelector={ highlight } />
</motion.li>)
) }
</AnimatePresence>
</ul>
</div>
);
};
export default PostList;
+1 -1
View File
@@ -14,7 +14,7 @@ function Layout({
<motion.div <motion.div
initial={ { opacity: 0 } } initial={ { opacity: 0 } }
animate={ { opacity: 1 } } animate={ { opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.5 } } transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.1 } }
className={ `justify-center flex flex-row w-full max-w-full` }> className={ `justify-center flex flex-row w-full max-w-full` }>
<Navbar /> <Navbar />
<div className={ `flex flex-col flex-1 max-w-[1024px] min-w-0` }> <div className={ `flex flex-col flex-1 max-w-[1024px] min-w-0` }>
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
const screenSize = new Map<string, number>( const screenSize = new Map<string, number>(
[ [
[ 'xs', 480 ],
[ 'sm', 640 ], [ 'sm', 640 ],
[ 'md', 768 ], [ 'md', 768 ],
[ 'lg', 1024 ], [ 'lg', 1024 ],
+5 -3
View File
@@ -1,4 +1,4 @@
@import "lxgw-wenkai-webfont/style.css"; @import "lxgw-wenkai-webfont/lxgwwenkai-light.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -6,8 +6,10 @@
body { body {
font-family: "LXGW WenKai", sans-serif; font-family: "LXGW WenKai", sans-serif;
/*font-family: "Arial", "monospace", sans-serif;*/
} }
pre, code { pre, code {
font-family: "LXGW WenKai Mono", sans-serif; /*font-family: "monospace", sans-serif;*/
} font-family: "monospace", sans-serif;
}
+67 -35
View File
@@ -5,47 +5,79 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
import { Button } from '@nextui-org/react'; import { Button } from '@nextui-org/react';
import { entries } from '@/app/entries'; import { entries } from '@/app/entries';
import Link from 'next/link'; import Link from 'next/link';
import { useLocalStorage } from 'react-use'; import * as motion from 'motion/react-client';
import { cn } from '@/src/utils';
export default function Home() { export default function Home() {
const isMobile = useScreenSizeTrigger('lg'); const isSm = useScreenSizeTrigger('sm');
const isXs = useScreenSizeTrigger('xs');
const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value');
console.log(test);
// setTest('test value 2');
// console.log(test);
// removeTest();
// console.log(test);
return ( return (
<div className={ `h-dvh relative` }> <div className={ 'h-dvh w-full overflow-hidden relative' }>
<ul className={ `flex flex-col space-y-2 w-full` }> <motion.div
{ initial={ { opacity: 0 } }
entries.map((meta, key) => ( animate={ { opacity: 1 } }
<li key={ key }> transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.0 } }
<Button
as={ Link }
variant={ `light` }
color={ `default` }
fullWidth
isIconOnly={ isMobile }
className={ 'flex lg:justify-start w-fit lg:w-full' }
aria-label={ `navbar: ${ meta.display_name }` }
href={ meta.href }
>
{ meta.icon }
<p className={ `font-bold hidden lg:block` }>{ meta.display_name }</p>
</Button>
</li>
))
}
</ul>
<div
className={ 'absolute bottom-0 w-full h-60 bg-yellow-500 p-12' }
> >
<div className={ 'bg-blue-500 h-full w-full rounded-3xl backdrop-blur' }></div> <div className={ `min-h-dvh h-min content-center z-20 p-10` }>
</div> <p className={ 'text-center text-4xl' }>Atelier Lumiaethor</p>
<p className={ 'text-center text-gray-700 font-bold dark:text-gray-400' }>In pursuit of absolute
simplicity and ultimate
sophistication</p>
</div>
<div
className={ 'absolute left-1/2 -translate-x-1/2 bottom-0 w-full h-60 p-12 max-w-[1536px] justify-center' }
>
<div
className={ 'h-full w-full rounded-3xl backdrop-blur align-middle grid grid-cols-1 sm:grid-cols-2 content-center' }>
{ !isSm &&
<div className={ 'w-full grid content-center' }>
<div
className={ 'p-2 h-fit brightness-50 lg:brightness-75 lg:p-8 xl:p-16 transition-all' }>
<p>
© 2024-{ new Date().getFullYear() } <Link href={ `/about` }>Lumither Tao</Link>
</p>
<p>Powered by Next.js and Rust, built with passion and love</p>
<p>LMTCloud ecosystem</p>
</div>
</div>
}
<div className={ 'h-full content-center p-4' }>
<ul className={ `grid grid-cols-3 sm:grid-cols-2 justify-items-center sm:justify-items-start w-full` }>
{
entries.map((meta, key) => (
<li key={ key } className={ cn(isXs && 'w-fit', !isXs && 'w-full') }>
<Button
as={ Link }
variant={ `light` }
color={ `default` }
fullWidth
isIconOnly={ isXs }
className={ cn(
'flex justify-center',
isXs && 'w-fit',
!isXs && 'sm:justify-start'
) }
aria-label={ `navbar: ${ meta.display_name }` }
href={ meta.href }
>
{ meta.icon }
{ !isXs && <p className={ `font-bold` }>{ meta.display_name }</p> }
</Button>
</li>
))
}
</ul>
</div>
</div>
</div>
</motion.div>
</div> </div>
); );
} }
@@ -4,7 +4,7 @@ const ConstructionPlaceHolder = () => {
return ( return (
<div className={ `flex h-full w-full justify-center items-center` }> <div className={ `flex h-full w-full justify-center items-center` }>
<p className={ `text-2xl font-bold` }> <p className={ `text-2xl font-bold` }>
正在施工中... * Under Construction *
</p> </p>
</div> </div>
); );
+2
View File
@@ -1,3 +1,5 @@
'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { IoLogoGithub } from 'react-icons/io5'; import { IoLogoGithub } from 'react-icons/io5';
+1 -1
View File
@@ -8,7 +8,7 @@ const nextConfig = {
}, },
{ {
protocol: 'https', protocol: 'https',
hostname: 'pic.re' hostname: 'cdn.jsdelivr.net'
} }
] ]
}, },
+8 -5
View File
@@ -11,22 +11,25 @@
"dependencies": { "dependencies": {
"@nextui-org/react": "^2.4.6", "@nextui-org/react": "^2.4.6",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"clsx": "^2.1.1",
"lxgw-wenkai-webfont": "^1.7.0", "lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1", "motion": "^12.23.12",
"next": "^15.0.3", "next": "^15.3.2",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.6.1",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-toc": "^9.0.0", "remark-toc": "^9.0.0",
"sharp": "^0.33.4" "sharp": "^0.33.4",
"tailwind-merge": "^3.3.1",
"ts-pattern": "^5.8.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.14", "@tailwindcss/typography": "^0.5.14",
@@ -34,7 +37,7 @@
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.1",
"eslint-config-next": "14.2.2", "eslint-config-next": "14.2.2",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
View File
Whitespace-only changes.
+115
View File
@@ -0,0 +1,115 @@
import { fetchNocturneJson } from '@/src/api/endpoint';
const DEFAULT_PAGE_SIZE = 8;
export interface Post {
id: string,
identifier: string,
title: string,
subtitle: string,
tags: string[]
category: string,
date_created: Date,
date_updated: Date | null,
header_img: string | null
}
export interface PostListResponse {
data: {
posts: Post[],
pagination: {
page: number,
page_count: number,
page_size: number
}
},
status: string
}
interface FetchPostListParams {
page: number;
page_size?: number;
tag_filter?: string[];
match_all?: boolean;
}
export async function fetchPostList({
page,
page_size = DEFAULT_PAGE_SIZE,
tag_filter = [],
match_all = false
}: FetchPostListParams
): Promise<PostListResponse> {
const pagination_string = `page=${ page }&page_size=${ page_size }`;
const tag_filter_string = tag_filter.length != 0
? `&tags=${ tag_filter.join(',') }&match_all=${ match_all }`
: '';
const url = `/blog/posts?${ pagination_string }${ tag_filter_string }`;
return await fetchNocturneJson(url);
}
export interface PostListPaginationResponseData {
page_count: number,
post_count: number,
}
export interface PostListPaginationResponse {
data: PostListPaginationResponseData;
status: string;
}
export async function fetchPostListPagination(): Promise<PostListPaginationResponse> {
const url = `/blog/posts/pagination`;
return await fetchNocturneJson(url);
}
export interface FetchPostResponseData {
post: {
id: string,
identifier: string,
title: string,
subtitle: string,
status: string,
date_created: string,
date_updated: string | null,
category: string,
tags: string[],
content: string,
metadata: any | null,
},
adjacent: {
prev: {
id: string,
identifier: string,
title: string,
subtitle: string,
date_created: string,
category: string,
tags: string[],
header_img: string | null,
} | null,
next: {
id: string,
identifier: string,
title: string,
subtitle: string,
date_created: string,
category: string,
tags: string[],
header_img: string | null,
} | null,
}
}
export interface FetchPostResponse {
data: FetchPostResponseData;
status: string;
}
export async function fetchPost(id: string): Promise<FetchPostResponse> {
const url = `/blog/posts/${ id }`;
return await fetchNocturneJson(url);
}
+40
View File
@@ -0,0 +1,40 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const FALLBACK_ENDPOINT = 'http://localhost:3001';
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
const get_endpoint = () => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
};
return get_endpoint() ?? FALLBACK_ENDPOINT;
});
export async function fetchNocturneJson<T>(
path: string,
isServerSideRequest = true,
method: string = 'GET'
): Promise<T> {
const url = `${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`;
const future = fetch(url, {
cache: 'no-cache',
method: method
});
const res = (await future);
if (!res.ok) {
throw new Error(`Failed to fetch "${ url }" [${ res.status }]: ${ res.statusText }`);
}
return (await res.json()) as T;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const SITE_CONFIG = { export const SITE_CONFIG = {
name: 'Atelier Lumiaethor', name: 'Atelier Lumiaethor',
desc: '' desc: 'In pursuit of absolute simplicity and ultimate sophistication'
}; };
+89
View File
@@ -0,0 +1,89 @@
export type Result<T, E> = Ok<T, E> | Err<T, E>;
class Ok<T, E> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
isOk(): this is Ok<T, E> {
return true;
}
isErr(): this is Err<T, E> {
return false;
}
map<U>(fn: (val: T) => U): Result<U, E> {
return new Ok(fn(this.value));
}
mapErr<F>(_fn: (err: E) => F): Result<T, F> {
return new Ok(this.value);
}
unwrap(): T {
return this.value;
}
unwrapOr(_default: T): T {
return this.value;
}
match<U>(handlers: { ok: (val: T) => U, err: (err: E) => U }): U {
return handlers.ok(this.value);
}
andThen<U>(fn: (val: T) => Result<U, E>): Result<U, E> {
return fn(this.value);
}
}
class Err<T, E> {
readonly error: E;
constructor(error: E) {
this.error = error;
}
isOk(): this is Ok<T, E> {
return false;
}
isErr(): this is Err<T, E> {
return true;
}
map<U>(_fn: (val: T) => U): Result<U, E> {
return new Err(this.error);
}
mapErr<F>(fn: (err: E) => F): Result<T, F> {
return new Err(fn(this.error));
}
unwrap(): T {
throw new Error('Tried to unwrap Err: ' + this.error);
}
unwrapOr(defaultValue: T): T {
return defaultValue;
}
match<U>(handlers: { ok: (val: T) => U, err: (err: E) => U }): U {
return handlers.err(this.error);
}
andThen<U>(_fn: (val: T) => Result<U, E>): Result<U, E> {
return new Err(this.error);
}
}
export function ok<T, E = never>(value: T): Result<T, E> {
return new Ok(value);
}
export function err<T = never, E = unknown>(error: E): Result<T, E> {
return new Err(error);
}
+3
View File
@@ -0,0 +1,3 @@
export function todo(message: string = 'Not yet implemented'): never {
throw new Error(`TODO: ${ message }`);
}
+6
View File
@@ -0,0 +1,6 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gateway" name = "gateway"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
+1 -3
View File
@@ -1,3 +1 @@
fn main() { fn main() {}
println!("Hello, world!");
}
@@ -14,8 +14,7 @@ CREATE TABLE categories
CREATE TABLE post_status CREATE TABLE post_status
( (
id SERIAL PRIMARY KEY, name VARCHAR(20) PRIMARY KEY
name VARCHAR(20) UNIQUE NOT NULL
); );
INSERT INTO post_status (name) INSERT INTO post_status (name)
@@ -27,14 +26,15 @@ VALUES ('draft'),
CREATE TABLE posts CREATE TABLE posts
( (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
identifier VARCHAR(100) UNIQUE NOT NULL, identifier VARCHAR(100) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL, subtitle VARCHAR(255) NOT NULL,
date_created DATE NOT NULL, date_created DATE NOT NULL,
date_updated DATE, date_updated DATE,
status INTEGER REFERENCES post_status (id) DEFAULT 1 NOT NULL, status VARCHAR(20) REFERENCES post_status (name) NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL hash VARCHAR(44) NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL
); );
CREATE TABLE post_tag CREATE TABLE post_tag
@@ -60,6 +60,7 @@ CREATE INDEX idx_categories_name ON categories (name);
CREATE INDEX idx_posts_identifier ON posts (identifier); CREATE INDEX idx_posts_identifier ON posts (identifier);
CREATE INDEX idx_posts_status ON posts (status); CREATE INDEX idx_posts_status ON posts (status);
CREATE INDEX idx_posts_category ON posts (category); CREATE INDEX idx_posts_category ON posts (category);
CREATE INDEX idx_posts_date_created ON posts (date_created);
CREATE INDEX idx_post_tag_post ON post_tag (post); CREATE INDEX idx_post_tag_post ON post_tag (post);
CREATE INDEX idx_post_tag_tag ON post_tag (tag); CREATE INDEX idx_post_tag_tag ON post_tag (tag);
CREATE INDEX idx_metadata_pid ON metadata (pid); CREATE INDEX idx_metadata_pid ON metadata (pid);
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -e
PGPASSFILE="/root/.pgpass"
echo "postgres:5432:${POSTGRES_DB}:${POSTGRES_USER}:${POSTGRES_PASSWORD}" > "$PGPASSFILE"
chmod 600 "$PGPASSFILE"
echo "waiting for postgres"
until pg_isready -h postgres -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do
echo "retrying, sleep 2s"
sleep 2
done
echo "db ready, running migrations"
for file in /migrations/*.sql; do
echo "applying $file"
psql -h postgres -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$file"
done
echo "migration complete"
+23 -2
View File
@@ -14,7 +14,7 @@ services:
backend: backend:
depends_on: depends_on:
- postgres - migrate
build: build:
context: ./backend context: ./backend
dockerfile: ../container/release/backend.containerfile dockerfile: ../container/release/backend.containerfile
@@ -29,10 +29,31 @@ services:
dns: dns:
- 1.1.1.1 - 1.1.1.1
migrate:
image: postgres:17
depends_on:
postgres:
condition: service_healthy
env_file:
- ./.env
- ./.env.local
volumes:
- ./migration/ddl:/migrations:ro
- ./migration/migration.sh:/migration.sh:ro
entrypoint: [ "/bin/sh", "/migration.sh" ]
postgres: postgres:
image: postgres:16.3 image: postgres:17
ports: ports:
- "5432:5432" - "5432:5432"
env_file: env_file:
- ./.env - ./.env
- ./.env.local - ./.env.local
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s