25 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 15a88074e9 feat(cli::template) update post template 2025-04-14 15:41:31 -07:00
lumither 5bf6d24733 feat(blog::components::check_update, README.md) add tracking filter 2025-04-14 15:41:02 -07:00
lumither 3f02916e8f PBS from Lumither/refactor
Periodic Branch Synchronization
2025-04-13 04:02:54 -07:00
86 changed files with 1804 additions and 474 deletions

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@
.idea
.vscode
# exclude post repo
# macOS system files
.DS_Store
**/.DS_Store
# exclude content repo
/blog_posts_git
# rustc ICE dump
+17 -3
View File
@@ -3,7 +3,21 @@ ifeq ($(ARCH), arm64)
ARCH := aarch64
endif
.PHONY: up
clean:
echo "todo"
.PHONY: all
all:up
.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
+5
View File
@@ -8,6 +8,7 @@ macros = { path = "../macros" }
markdown = { path = "../markdown" }
axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10.3", features = ["query"] }
tracing = { version = "0.1.41", features = ["log"] }
git2 = "0.20.0"
chrono = { version = "0.4.39", features = ["serde"] }
@@ -20,10 +21,14 @@ tracing-appender = "0.2.3"
dirs = "6.0.0"
thiserror = "2.0.11"
tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
regex = "1.11.1"
dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31"
[dev-dependencies]
tempfile = "3.19.1"
[build-dependencies]
git2 = "0.20.0"
chrono = "0.4.39"
+14 -13
View File
@@ -1,16 +1,17 @@
use chrono::Utc;
use git2::Repository;
// use chrono::Utc;
// use git2::Repository;
fn main() {
let repo = Repository::open("../..").expect("failed to open repository");
let commit = repo
.head()
.expect("failed to get HEAD")
.peel_to_commit()
.expect("failed to get HEAD commit");
let hash = commit.id().to_string();
let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
let pkg_version = env!("CARGO_PKG_VERSION");
let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
println!("cargo:rustc-env=BUILD_ID={}", build_id);
// let repo = Repository::open("../..").expect("failed to open repository");
// let commit = repo
// .head()
// .expect("failed to get HEAD")
// .peel_to_commit()
// .expect("failed to get HEAD commit");
// let hash = commit.id().to_string();
// let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string();
// let pkg_version = env!("CARGO_PKG_VERSION");
// let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp);
// 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
#[allow(dead_code)]
pub mod get_post;
// pub mod get_post_list;
pub mod posts;
@@ -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::{
future::{join_all, BoxFuture},
FutureExt,
future::{BoxFuture, join_all},
};
use sqlx::{query, PgPool, Postgres, Row, Transaction};
use sqlx::{PgPool, Postgres, Row, Transaction, query};
use tracing::error;
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 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_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>) {
let handlers = changes
@@ -47,7 +49,7 @@ async fn handle_move(db: &PgPool, mv: Move) {
db,
Delete {
uuid: mv.uuid,
path: mv.from,
path: Some(mv.from),
},
)
.await;
@@ -67,7 +69,7 @@ async fn handle_update(db: &PgPool, update: Update) {
db,
Delete {
uuid: update.uuid,
path: update.path.clone(),
path: Some(update.path.clone()),
},
)
.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 post_id = create.uuid;
let post_identifier = &identifier;
@@ -143,8 +158,9 @@ async fn handle_create(db: &PgPool, create: Create) {
.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_hash = create.payload.hash;
if let Err(e) = query(CREATE_POST)
if let Err(e) = query(CREATE_POST_GENERAL)
.bind(post_id)
.bind(post_identifier)
.bind(post_title)
@@ -153,7 +169,17 @@ async fn handle_create(db: &PgPool, create: Create) {
.bind(post_date_updated)
.bind(post_status)
.bind(post_content)
.bind(post_hash)
.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)
.execute(&mut *tx)
.await
@@ -162,6 +188,17 @@ async fn handle_create(db: &PgPool, create: Create) {
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();
}
@@ -17,7 +17,7 @@ pub enum Change {
#[derive(Debug, Clone)]
pub struct Delete {
pub uuid: Uuid,
pub path: PathBuf,
pub path: Option<PathBuf>,
}
#[allow(dead_code)]
@@ -56,7 +56,7 @@ impl CreateDelete2UpdateSlot {
match (&self.create, &self.delete) {
(Some(create), Some(delete)) => Some(Change::Move(Move {
uuid: create.uuid,
from: delete.path.clone(),
from: delete.path.clone()?,
to: create.path.clone(),
payload: create.payload.clone(),
})),
@@ -1,28 +1,23 @@
use std::{
env,
path::{Path, PathBuf},
sync::Arc,
};
pub mod apply;
pub mod changes;
mod error;
mod pull;
pub mod utils;
use std::{env, path::PathBuf, sync::Arc};
use crate::{
constants::config::general::{default_value, var_name},
modules::blog::components::check_update::{
apply::apply_deltas, pull::fetch_deltas, utils::expand_path,
modules::blog::components::{
check_update::{apply::apply_deltas, pull::fetch_deltas},
static_rsc::GIT_WORK_DIR,
},
scheduler::task_func::AsyncTaskFunc,
};
use macros::panic_with_log;
use sqlx::PgPool;
use tracing::{log::error, trace, warn, Level};
// mod error;
// mod index;
mod apply;
mod changes;
mod error;
mod pull;
mod utils;
use tracing::{Level, log::error, trace};
#[derive(Debug)]
struct Args {
@@ -34,22 +29,6 @@ struct Args {
}
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
let git_url = match env::var(var_name::BLOG_GIT_URL) {
Ok(url) => url,
@@ -69,7 +48,7 @@ pub fn task(db_connection: PgPool) -> impl AsyncTaskFunc {
let arc_args = Arc::new(Args {
db_connection,
git_work_dir,
git_work_dir: GIT_WORK_DIR.clone(),
git_url,
git_remote_name,
git_remote_branch,
@@ -1,9 +1,13 @@
use std::{collections::HashMap, fs::remove_dir_all, path::PathBuf};
use std::{collections::HashMap, fs::remove_dir_all, path::Path, path::PathBuf};
use crate::{
modules::blog::components::check_update::{
modules::blog::components::{
check_update::{
changes::{Change, Create, CreateDelete2UpdateSlot, Delete, Update},
error::Error,
utils::extract_post_id,
},
static_rsc::BLOG_POST_PATH_PATTERN,
},
utils::{git, git::FileDelta},
};
@@ -64,18 +68,21 @@ pub async fn fetch_deltas(
// process deleted deltas before merging commit to prevent "file not found" os error
for delta in commit_deltas.into_iter() {
// todo: limit scanning scope to post only OR extract merging logic to external component
let relative_file_path = delta
.new_path
.clone()
.expect("cannot parse modified post `new_path`");
if !should_delta_tracked(&relative_file_path) {
continue;
}
if delta.status == Delta::Deleted {
let file_path = &git_work_dir.join(
delta
.new_path
.expect("cannot parse modified post `new_path`"),
);
let file_path = &git_work_dir.join(relative_file_path);
if let Some(id) = extract_post_id(&MdFile::from_file(file_path)?) {
changes.push(Change::Delete(Delete {
uuid: id,
path: file_path.clone(),
path: Some(file_path.clone()),
}))
} else {
error!("failed to parse uuid for file {}", file_path.display());
@@ -131,18 +138,11 @@ pub async fn fetch_deltas(
}
// merge adds and deletes deltas to moves
let changes = dbg!(merge_changes(changes));
let changes = merge_changes(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> {
let mut slots: HashMap<Uuid, CreateDelete2UpdateSlot> = HashMap::new();
let mut merged: Vec<Change> = Vec::new();
@@ -169,3 +169,7 @@ fn merge_changes(changes: Vec<Change>) -> Vec<Change> {
merged
}
fn should_delta_tracked(path: &Path) -> bool {
BLOG_POST_PATH_PATTERN.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 dirs::home_dir;
use markdown::MdFile;
use tracing::warn;
use uuid::Uuid;
pub fn expand_path(path: String) -> String {
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> {
if let Some(comp) = path.components().nth_back(1) {
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;
mod consistency_guard;
mod rag;
pub mod consistency_guard;
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")
});
+19 -14
View File
@@ -1,21 +1,15 @@
mod api;
mod components;
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::{
modules::{
Module,
blog::{
api::{
get_page_count,
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
api::{posts, posts::get, posts::pagination},
components::{check_update, consistency_guard},
},
},
scheduler::tasks::CronTask,
scheduler::tasks::{CronTask, async_basic::AsyncBasic},
};
use axum::{Router, routing::get};
@@ -33,9 +27,9 @@ impl Module for Blog {
// todo: move resource init to new func
// todo: remove debug comment
Router::new()
// .route("/get_post_list", get(get_post_list))
.route("/get_page_count", get(get_page_count::handler))
.route("/get_post/{identifier}", get(get_post::handler))
.route("/posts", get(get::handler))
.route("/posts/{identifier}", get(posts::get_identifier::handler))
.route("/posts/pagination", get(pagination::handler))
.with_state(db_handler)
}
@@ -45,11 +39,22 @@ impl Module for Blog {
fn get_cron_tasks(&self) -> Vec<(&str, Box<dyn CronTask>)> {
let db_handler = self.db_handler.clone();
vec![(
vec![
(
"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()
.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;
+1
View File
@@ -9,3 +9,4 @@ __pycache__
.cache
content/*
/content/
+9 -8
View File
@@ -10,13 +10,14 @@ from utils.lang import eprint, wprint
POST_TEMPLATE = '''
---
id: {{ id }}
title: {{ title }}
subtitle: {{ subtitle }}
published_date: {{ published_date }}
last_updated: {{ last_update }}
status: {{ status }}
date: {{ date }}
update: {{ update }}
category: {{ category }}
tags: {{ tags }}
uuid: {{ uuid }}
---
WIP
@@ -38,9 +39,9 @@ def get_subtitle():
return input("subtitle > ")
def get_published_date():
def get_date():
while True:
d = input("published_date (YYYY-MM-DD) > ")
d = input("date (YYYY-MM-DD) > ")
if d == "":
wprint("using today's date")
return dt.today().strftime("%Y-%m-%d")
@@ -78,10 +79,10 @@ class BlogPost(Template):
index_md = POST_TEMPLATE.format(
title=get_title(),
subtitle=get_subtitle(),
published_date=get_published_date(),
last_update=dt.today().strftime("%Y-%m-%d"),
date=get_date(),
update=dt.today().strftime("%Y-%m-%d"),
tags=get_tags(),
category=get_category(),
uuid=uuid.uuid4()
id=uuid.uuid4()
)
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
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/
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/
+48 -26
View File
@@ -4,42 +4,50 @@ import { Chip } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
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 = {
post: JSON
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
}
function BlogPostCard({ post }: Props) {
let title = (post as any)['title'];
let sub_title = (post as any)['sub_title'];
// let summary = (post as any)['summary'];
let category = (post as any)['category'];
let tags = (post as any)['tags'];
// let last_update = new Date((post as any)['last_update']);
let first_update = new Date((post as any)['first_update']);
let post_id = (post as any)['post_id'];
let header_img = ((post as any)['header_img'] as string | null);
type Props = {
post: Post
highlightSelector: HighlightSelector,
}
function BlogPostCard({ post, highlightSelector }: Props) {
const highlightTags = highlightSelector?.tags;
return (
<div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` }
className={ `group` }
<Card
as={ Link } href={ `/blog/post/${ post.identifier }` }
className={ `group w-full` }
isPressable
isHoverable
>
{ header_img && <div className={ `w-full overflow h-80 relative` }>
{ post.header_img && <div className={ `w-full overflow h-80 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' }
fill
></Image>
<Image
alt={ 'cover picture shadow' }
src={ header_img }
src={ post.header_img }
loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill
@@ -48,11 +56,11 @@ function BlogPostCard({ post }: Props) {
<CardBody className={ `overflow-visible` }>
<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` }
variant={ `solid` }>{ category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p>
<p className={ `text-xl` }> { sub_title }</p>
variant={ `solid` }>{ post.category }</Chip>
<p className={ `text-4xl font-bold` }> { post.title }</p>
<p className={ `text-xl` }> { post.subtitle }</p>
</div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div>
@@ -62,10 +70,24 @@ function BlogPostCard({ post }: Props) {
<div className={ `flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
tags.map((tag: string, key: any) => (
{/*highlight*/
post.tags
.filter((tag) => highlightTags?.includes(tag))
.sort()
.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
<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>
))
}
@@ -74,7 +96,7 @@ function BlogPostCard({ post }: Props) {
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', {
{ post.date_created.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
+7 -2
View File
@@ -8,7 +8,9 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const Pagination = ({ total }: {
total: number,
}) => {
const parsePage = Number(useSearchParams().get('page'));
const searchParams = useSearchParams();
const parsePage = Number(searchParams.get('page'));
const currPage = parsePage > 0 ? parsePage : 1;
const router = useRouter();
@@ -27,7 +29,10 @@ const Pagination = ({ total }: {
color={ 'default' }
variant={ 'light' }
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>
+24 -30
View File
@@ -1,43 +1,37 @@
'use server';
import React from 'react';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card';
import { Post } from '@/src/api/blog/post';
import AnimationPostList, { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList';
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 {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
const posts = await res.json();
return (
// blog list
<div className={ 'w-full' }>
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
{ posts.map((post: JSON, key: number) => (
<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>
<AnimationPostList posts={ posts } highlight={ {
tags: tags
} } />
</div>
);
} catch (e: any) {
return (
// todo: use Error.tsx
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>);
<>
<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:' }
</p>
<p className={ `font-bold` }>
{ e.message }
</p>
</div>
</div>
</>
);
}
};
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'
});
}
+50 -7
View File
@@ -1,24 +1,67 @@
import PostList from '@/app/(pages)/blog/PostList';
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({
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 parsePage = Number(sp?.page);
const currPage = parsePage > 0 ? parsePage : 1;
const ret = await fetchNocturneApi(`/blog/get_page_count`);
const pageCount = (await ret.json() as any)['res'] as number;
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' }>
<PostList page={ currPage } />
<Pagination total={ pageCount } />
<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>
);
}
}
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 { FaCreativeCommons } from 'react-icons/fa6';
interface AdjacentPosts {
prev: any;
next: any;
}
type Props = {
// post: any
post_id: string;
adjacent_posts: any
perm_link: string | undefined;
author: string | undefined;
author_link: string | undefined;
adjacent_posts: AdjacentPosts
perm_link: string;
author: string;
author_link: string;
first_update: string;
last_update: string | undefined;
last_update: string | null;
cc: string | undefined;
title: string;
}
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 next_post = adjacent_posts.next;
const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`;
const author = props.author ?? 'Tao';
const author_link = props.author_link ?? '/about/me';
const link = perm_link;
const post_date = new Date(first_update);
const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
const cc = props.cc ?? 'by-nc-sa';
@@ -2,15 +2,11 @@ import React from 'react';
import Image from 'next/image';
import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md';
// type Props = {
// post: any
// };
import TagChip from '@/app/(pages)/blog/post/[id]/TagChip';
type Props = {
title: string;
subtitle: string;
summary: string | undefined;
category: string;
tags: string[];
first_update: string;
@@ -55,7 +51,7 @@ const Headers = (post: Props) => {
{
post.tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
<TagChip tag={ tag } />
</li>
))
}
@@ -17,7 +17,7 @@ const MinifyPostCard = (props: Props) => {
className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden"
as={ Link }
isPressable
href={ `/blog/post/${ post['id'] }` }
href={ `/blog/post/${ post['identifier'] }` }
>
<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>
@@ -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">
<div>
<p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p>
<p className="">{ post.subtitle }</p>
</div>
</CardFooter>
</div>
@@ -46,7 +46,7 @@ const MinifyPostCard = (props: Props) => {
<div className={ 'flex flex-col max-h-fit text-left' }>
<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>
<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>
}
@@ -3,25 +3,28 @@ import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/(pages)/blog/post/[id]/Headers';
import Footer from '@/app/(pages)/blog/post/[id]/Footer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { FetchPostResponseData } from '@/src/api/blog/post';
type Props = {
post: any
post: FetchPostResponseData
}
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 (
<div>
<Card className={ `max-w-full relative` }>
{/* card headers */ }
<Headers title={ post.title }
subtitle={ post.sub_title }
summary={ post.summary }
subtitle={ post.subtitle }
category={ post.category }
tags={ post.tags }
first_update={ post.first_update }
header_img={ post.meta.header_img }
first_update={ post.date_created }
header_img={ metadata?.header_img }
/>
{/* card body */ }
@@ -36,14 +39,13 @@ async function PostCard(props: Props) {
{/* card footer */ }
<Footer
adjacent_posts={ post.adj }
perm_link={ post.meta.link }
author={ post.meta.author }
author_link={ post.meta.author_link }
first_update={ post.first_update }
last_update={ post.last_update }
cc={ post.meta.cc }
post_id={ post.meta.id }
adjacent_posts={ adj }
perm_link={ metadata?.perm_link ?? `https://lumither.com/blog/post/${ post.id }` }
author={ metadata?.author ?? 'Tao' }
author_link={ metadata?.author_link ?? '/about/me' }
first_update={ post.date_created }
last_update={ post.date_updated }
cc={ metadata?.cc }
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 ButtonGoBack from '@/app/(pages)/blog/post/[id]/ButtonGoBack';
import * as motion from 'motion/react-client';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
const fetchPost = cache(async (id: string) => {
const res = await fetchNocturneApi(`/blog/get_post/${ id }`);
// throw Error('Not Found');
return await res.json();
});
import { fetchPost } from '@/src/api/blog/post';
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> })
: Promise<Metadata> {
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 {
title: `${ title } - ${ SITE_CONFIG.name }`
};
@@ -33,7 +28,7 @@ async function BlogReader({
params: Promise<{ id: string }>
}) {
try {
let post = await fetchPost((await params).id);
let post = (await fetchPost((await params).id)).data;
return (
<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
initial={ { opacity: 0 } }
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` }>
<Navbar />
<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>(
[
[ 'xs', 480 ],
[ 'sm', 640 ],
[ 'md', 768 ],
[ 'lg', 1024 ],
+4 -2
View File
@@ -1,4 +1,4 @@
@import "lxgw-wenkai-webfont/style.css";
@import "lxgw-wenkai-webfont/lxgwwenkai-light.css";
@tailwind base;
@tailwind components;
@@ -6,8 +6,10 @@
body {
font-family: "LXGW WenKai", sans-serif;
/*font-family: "Arial", "monospace", sans-serif;*/
}
pre, code {
font-family: "LXGW WenKai Mono", sans-serif;
/*font-family: "monospace", sans-serif;*/
font-family: "monospace", sans-serif;
}
+51 -19
View File
@@ -5,47 +5,79 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
import { Button } from '@nextui-org/react';
import { entries } from '@/app/entries';
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() {
const isMobile = useScreenSizeTrigger('lg');
const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value');
console.log(test);
// setTest('test value 2');
// console.log(test);
// removeTest();
// console.log(test);
const isSm = useScreenSizeTrigger('sm');
const isXs = useScreenSizeTrigger('xs');
return (
<div className={ `h-dvh relative` }>
<ul className={ `flex flex-col space-y-2 w-full` }>
<div className={ 'h-dvh w-full overflow-hidden relative' }>
<motion.div
initial={ { opacity: 0 } }
animate={ { opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.0 } }
>
<div className={ `min-h-dvh h-min content-center z-20 p-10` }>
<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 }>
<li key={ key } className={ cn(isXs && 'w-fit', !isXs && 'w-full') }>
<Button
as={ Link }
variant={ `light` }
color={ `default` }
fullWidth
isIconOnly={ isMobile }
className={ 'flex lg:justify-start w-fit lg:w-full' }
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 }
<p className={ `font-bold hidden lg:block` }>{ meta.display_name }</p>
{ !isXs && <p className={ `font-bold` }>{ 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>
</div>
</div>
</motion.div>
</div>
);
}
@@ -4,7 +4,7 @@ const ConstructionPlaceHolder = () => {
return (
<div className={ `flex h-full w-full justify-center items-center` }>
<p className={ `text-2xl font-bold` }>
正在施工中...
* Under Construction *
</p>
</div>
);
+2
View File
@@ -1,3 +1,5 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { IoLogoGithub } from 'react-icons/io5';
+1 -1
View File
@@ -8,7 +8,7 @@ const nextConfig = {
},
{
protocol: 'https',
hostname: 'pic.re'
hostname: 'cdn.jsdelivr.net'
}
]
},
+8 -5
View File
@@ -11,22 +11,25 @@
"dependencies": {
"@nextui-org/react": "^2.4.6",
"@types/react-syntax-highlighter": "^15.5.13",
"clsx": "^2.1.1",
"lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1",
"next": "^15.0.3",
"motion": "^12.23.12",
"next": "^15.3.2",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-syntax-highlighter": "^15.6.1",
"react-use": "^17.6.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.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": {
"@tailwindcss/typography": "^0.5.14",
@@ -34,7 +37,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.2",
"postcss": "^8.4.41",
"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 = {
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]
name = "gateway"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
+1 -3
View File
@@ -1,3 +1 @@
fn main() {
println!("Hello, world!");
}
fn main() {}
@@ -14,8 +14,7 @@ CREATE TABLE categories
CREATE TABLE post_status
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL
name VARCHAR(20) PRIMARY KEY
);
INSERT INTO post_status (name)
@@ -32,8 +31,9 @@ CREATE TABLE posts
subtitle VARCHAR(255) NOT NULL,
date_created DATE NOT NULL,
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,
hash VARCHAR(44) NOT NULL,
category INTEGER REFERENCES categories (id) NOT NULL
);
@@ -60,6 +60,7 @@ CREATE INDEX idx_categories_name ON categories (name);
CREATE INDEX idx_posts_identifier ON posts (identifier);
CREATE INDEX idx_posts_status ON posts (status);
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_tag ON post_tag (tag);
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:
depends_on:
- postgres
- migrate
build:
context: ./backend
dockerfile: ../container/release/backend.containerfile
@@ -29,10 +29,31 @@ services:
dns:
- 1.1.1.1
migrate:
image: postgres:17
depends_on:
postgres:
image: postgres:16.3
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:
image: postgres:17
ports:
- "5432:5432"
env_file:
- ./.env
- ./.env.local
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s