15 Commits
114 changed files with 1567 additions and 1340 deletions

No files matched your search

+2 -9
View File
@@ -3,14 +3,7 @@ ifeq ($(ARCH), arm64)
ARCH := aarch64
endif
all: release
release:
docker compose -f prod.compose.yaml build --build-arg ARCH=$(ARCH)
dev:
docker-compose -f dev.compose.yaml up --build
.PHONY: up
clean:
docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv
echo "todo"
+10 -30
View File
@@ -1,36 +1,16 @@
# `Nocturne: personal website & CMS`
# `Nocturne: Personal Website`
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the
content repo.
This is the code repo of my [personal website](https://lumither.com),
and [here](https://github.com/Lumither/blog-posts/) is the link to the
content (blog posts etc.) repo.
Powered by Next.js and Rust, built with passion and love
<p align="center">
<img src="https://avatars.githubusercontent.com/u/46409277?v=4" alt="GitHub Avatar"/>
</p>
> [!NOTE]
> This system is not production ready, and contains bugs, (security risk potentially).
> But have fun on it =)
*will add more here in the future
## Nav:
<todo>
## Todos:
### Backend
- [ ] Add: log system
- [ ] Add: search api
- [ ] Add: tag filter (api)
- [ ] Abandon: UUID mechanism
- [ ] Fix: db connection issue
### Frontend
- [ ] Page: Home
- [ ] Page: About
- [ ] Page: Tags
- [ ] Page: Archive
- [ ] Page: Search
- [ ] Add: Right sidebar
# Todos
- [ ] the todo list lol
- [ ] and much more...
+2 -3
View File
@@ -1,8 +1,7 @@
[workspace]
resolver = "2"
resolver = "3"
members = [
"api_server",
"constants",
"server",
"macros",
"markdown",
]
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "api_server"
version = "0.1.0"
edition = "2021"
[dependencies]
macros = { path = "../macros" }
constants = { path = "../constants" }
markdown = { path = "../markdown" }
chrono = { version = "0.4.38", features = ["serde", "alloc"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "fmt"] }
tracing-appender = "0.2.3"
dotenv = "0.15.0"
dirs = "5.0.1"
tokio = { version = "1.40.0", features = ["full"] }
sqlx = { version = "0.8.2", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono", "uuid"] }
axum = { version = "0.7.5", features = ["macros"] }
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.128"
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
futures = { version = "0.3.30", features = ["executor"] }
cron = "0.13.0"
git2 = "0.19.0"
thiserror = "2.0.3"
@@ -1,26 +0,0 @@
use constants::blog::PAGE_SIZE;
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use sqlx::{query, PgPool, Row};
use tracing::error;
pub async fn get_page_count(
State(db_connection): State<PgPool>,
) -> Result<Json<Value>, Json<Value>> {
match query("select cast(ceil(count(*) / $1) as integer) as res from post")
.bind(PAGE_SIZE as f32)
.fetch_one(&db_connection)
.await
{
Ok(res) => {
let res: i32 = res.get("res");
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()})))
}
}
}
-153
View File
@@ -1,153 +0,0 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::{json, Value};
use sqlx::{query, Error, PgPool, Row};
use std::str::FromStr;
use tracing::error;
use uuid::Uuid;
pub async fn get_post(
State(db_connection): State<PgPool>,
Path(post_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let post_id = match Uuid::from_str(post_id.as_str()) {
Ok(id) => id,
Err(e) => {
error!(
"Failed to parse UUID <{}>: {}",
post_id.as_str(),
e.to_string()
);
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!(("error", e.to_string()))),
));
}
};
let post = match query(
r#"
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS post
FROM post t
WHERE post_id = $1;
"#,
)
.bind(post_id)
.fetch_one(&db_connection)
.await
{
Ok(value) => value.get::<Value, _>("post"),
Err(e) => {
error!(
"Database error on Reading `base post information` from table `post`: <{}>: {}",
post_id.to_string(),
e.to_string()
);
return match e {
Error::RowNotFound => Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": e.to_string()})),
)),
_ => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
)),
};
}
};
let tags: Vec<_> = match query("SELECT tag FROM Tag WHERE post_id = $1;")
.bind(post_id)
.fetch_all(&db_connection)
.await
{
Ok(tags) => tags.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(e) => {
error!("Database query error :{}", e.to_string());
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let meta = 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) => value.get::<Value, _>("meta"),
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"post meta data",
"meta",
post_id.to_string(),
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let adjacent = match query(r"
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = $1),
prev AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date < (SELECT date FROM target_post)
ORDER BY t.date DESC
LIMIT 1),
next AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date > (SELECT date FROM target_post)
ORDER BY t.date
LIMIT 1)
SELECT json_strip_nulls(json_build_object(
'prev',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM prev),
'next',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM next)
)) AS result;
"
)
.bind(post_id)
.fetch_one(&db_connection)
.await
{
Ok(res) => {res.get::<Value, _>("result")}
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"adjoint posts data",
"meta",
post_id.to_string(),
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = post.as_object().unwrap().to_owned();
res.insert("tags".to_string(), tags.into());
res.insert("meta".to_string(), meta);
res.insert("adj".to_string(), adjacent);
Ok(Json::from(json!(res)))
}
-3
View File
@@ -1,3 +0,0 @@
pub mod get_page_count;
pub mod get_post;
pub mod get_post_list;
@@ -1,146 +0,0 @@
use std::env;
use std::fs::remove_dir_all;
use std::path::{Path, PathBuf};
use crate::{
blog::cron::check_update::{
index::drop_index,
utils::{expand_path, get_md_file_basename, index_files, search_md},
},
utils::git,
};
use constants::config::general::{default_value, var_name};
use macros::panic_with_log;
use git2::{Delta, Repository};
use sqlx::{Pool, Postgres};
use tracing::{error, info, warn, Level};
mod error;
mod index;
mod utils;
pub fn task(db_connection: &Pool<Postgres>) {
// 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,
Err(e) => {
panic_with_log!(
Level::ERROR,
"failed to read `{}`: {}",
var_name::BLOG_GIT_URL,
e
)
}
};
let git_remote_name = env::var(var_name::BLOG_GIT_REMOTE_NAME)
.unwrap_or(default_value::BLOG_GIT_REMOTE_NAME.to_string());
let git_remote_branch = env::var(var_name::BLOG_GIT_REMOTE_BRANCH)
.unwrap_or(default_value::BLOG_GIT_REMOTE_BRANCH.to_string());
match Repository::open(&git_work_dir) {
Ok(repo) => {
// prevent git remote url mismatch (clean cache)
match repo.find_remote(&git_remote_name) {
Ok(remote) => {
if remote.url().is_some_and(|url| url != git_url) {
remove_dir_all(&git_work_dir).unwrap();
warn!("blog content git({}) url mismatch, reinitialized", &git_url);
return;
}
}
Err(e) => {
remove_dir_all(&git_work_dir).unwrap();
error!(
"unexpected error on indexing blog content git({}), reinitialized: {}",
&git_url, e
);
}
}
match git::sync(&git_remote_name, &git_remote_branch, &repo) {
Ok(updates) => {
if !updates.is_empty() {
// incremental update
info!(
"blog content git({}) update detected: {:?}",
&git_url, updates
);
let mut update_md: Vec<PathBuf> = vec![];
for update in updates {
if [Delta::Deleted, Delta::Untracked].contains(&update.status) {
if let Some(path) = &update.old_path {
if drop_index(
&get_md_file_basename(
path.file_name().unwrap().to_str().unwrap(),
),
db_connection,
)
.is_ok()
{
info!(
"blog content git({}) file `{}` is detected as deleted/untracked, index dropped",
&git_url,
&path.display()
);
} else {
error!(
"blog content git({}) file `{}` is detected as deleted/untracked, failed to drop index",
&git_url,
&path.display()
)
}
}
} else if let Some(path) = &update.new_path {
if path.components().any(|comp| comp.as_os_str() == "posts")
&& path.extension().is_some_and(|ext| ext == "md")
{
update_md.push(path.to_path_buf());
}
}
}
index_files(&update_md, db_connection);
}
}
Err(e) => {
error!(
"failed to sync blog content git({}) updates: {}",
&git_url, e
);
}
}
}
Err(_) => {
match Repository::clone(&git_url, &git_work_dir) {
Ok(_repo) => {
// full repo index
let files = search_md(git_work_dir.join("posts"));
index_files(&files, db_connection);
}
Err(e) => {
error!("failed to clone blog content git({}): {}", &git_url, e);
}
}
}
};
}
@@ -1,86 +0,0 @@
use dirs::home_dir;
use std::path::PathBuf;
use crate::blog::cron::check_update::index::index_file;
use chrono::{DateTime, FixedOffset, ParseResult};
use constants::config::general::{default_value, var_name};
use constants::time::DEFAULT_DATE_FORMAT;
use futures::executor::block_on;
use futures::future::join_all;
use sqlx::{Pool, Postgres};
use tokio::runtime::Runtime;
use tokio::spawn;
use tracing::{error, warn};
pub fn search_md(entry: PathBuf) -> Vec<PathBuf> {
if entry.ends_with(".git") {
return vec![];
}
if entry.is_dir() {
if let Ok(files) = entry.read_dir() {
files
.flat_map(|file| {
let file_path = file.unwrap().path();
search_md(file_path)
})
.collect()
} else {
vec![]
}
} else if entry.extension().is_some_and(|ext| ext == "md") {
vec![entry]
} else {
vec![]
}
}
pub fn index_files(files: &[PathBuf], db_conn: &Pool<Postgres>) {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let files_clone = files.to_owned();
let handlers = files_clone
.into_iter()
.map(|file: PathBuf| {
let conn_clone = db_conn.clone();
spawn(async move {
if let Err(e) = index_file(&file, &conn_clone).await {
error!("failed to index markdown file `{}`: {}", file.display(), e);
}
})
})
.collect::<Vec<_>>();
block_on(join_all(handlers));
});
}
pub fn expand_path(path: String) -> String {
if path.starts_with('~') {
home_dir()
.map(|dir| {
dir.join(path.strip_prefix("~/").unwrap())
.into_os_string()
.into_string()
.unwrap()
})
.unwrap_or_else(|| {
warn!(
"failed to parse env var `{}`, using default value `{}`",
var_name::WORK_DIR,
default_value::WORK_DIR
);
default_value::WORK_DIR.to_string()
})
} else {
path
}
}
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> {
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT)
}
pub fn get_md_file_basename(f_name: &str) -> String {
f_name.replace(".md", "")
}
-14
View File
@@ -1,14 +0,0 @@
use crate::scheduler::tasks::basic::BasicTask;
use crate::scheduler::tasks::Task;
use sqlx::{Pool, Postgres};
pub mod check_update;
pub fn get_tasks(db_connection: Pool<Postgres>) -> Vec<Box<dyn Task>> {
let db_conn_clone = db_connection.clone();
vec![
BasicTask::new(move || check_update::task(&db_conn_clone), "0/15 * * * * *")
.unwrap()
.to_task(),
]
}
-17
View File
@@ -1,17 +0,0 @@
mod api;
pub mod cron;
use crate::blog::api::{
get_page_count::get_page_count, get_post::get_post, get_post_list::get_post_list,
};
use axum::routing::get;
use axum::Router;
use sqlx::{Pool, Postgres};
pub fn get_router() -> Router<Pool<Postgres>> {
Router::new()
.route("/get_post_list", get(get_post_list))
.route("/get_page_count", get(get_page_count))
.route("/get_post/:post_id", get(get_post))
}
-12
View File
@@ -1,12 +0,0 @@
use crate::blog;
use crate::scheduler::Scheduler;
use sqlx::{Pool, Postgres};
pub fn start(db_pool: Pool<Postgres>) {
let scheduler = Scheduler::new();
let _ = vec![blog::cron::get_tasks(db_pool.clone())]
.into_iter()
.map(|list| scheduler.insert_list(list))
.collect::<Vec<_>>();
scheduler.start().unwrap()
}
-49
View File
@@ -1,49 +0,0 @@
use sqlx::{query, Pool, Postgres};
use tracing::info;
const POST_BASE_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Post
(
post_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(255) NOT NULL,
filename TEXT UNIQUE NOT NULL,
summary TEXT,
sub_title TEXT,
content TEXT NOT NULL,
last_update TIMESTAMPTZ,
first_update TIMESTAMPTZ NOT NULL
);
"##;
// base model, will add more cols at runtime
const POST_META_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Meta
(
post_id UUID PRIMARY KEY
);
"##;
const POST_HASH_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Hash
(
post_id UUID PRIMARY KEY,
hash VARCHAR(44)
);
"##;
const TAG_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Tag
(
post_id UUID NOT NULL,
tag VARCHAR(255) NOT NULL
);
"##;
pub async fn init(db_connection: &Pool<Postgres>) {
query(POST_BASE_MODEL).execute(db_connection).await.unwrap();
query(POST_META_MODEL).execute(db_connection).await.unwrap();
query(TAG_MODEL).execute(db_connection).await.unwrap();
query(POST_HASH_MODEL).execute(db_connection).await.unwrap();
info!("database schema initialized");
}
-52
View File
@@ -1,52 +0,0 @@
use std::env;
use std::error::Error;
use std::num::ParseIntError;
use std::process::exit;
use crate::blog;
use axum::Router;
use constants::config::server::{default_value, var_name};
use macros::panic_with_log;
use sqlx::{Pool, Postgres};
use tracing::{error, info, warn, Level};
pub async fn start(db_pool: Pool<Postgres>) -> Result<(), Box<dyn Error>> {
let app = Router::new()
.nest("/blog", blog::get_router())
.with_state(db_pool);
let port: u32 = match env::var(var_name::PORT) {
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
warn!(
"failed to parse `{}`, using default port: {}",
var_name::PORT,
e.to_string()
);
default_value::PORT
}),
Err(_) => default_value::PORT,
};
let listener =
match tokio::net::TcpListener::bind(format!("{}:{}", default_value::HOST, port)).await {
Ok(listener) => {
info!("server started on {}:{}", default_value::HOST, port);
listener
}
Err(e) => {
panic_with_log!(
Level::ERROR,
"failed to start server on {}:{}: {}",
default_value::HOST,
port,
e.to_string()
);
}
};
axum::serve(listener, app).await.unwrap_or_else(|e| {
error!("failed to start axum: {}", e.to_string());
exit(1);
});
Ok(())
}
-13
View File
@@ -1,13 +0,0 @@
mod blog;
mod init;
mod scheduler;
mod utils;
#[tokio::main]
async fn main() {
init::load_env();
let _guards = init::logger::init();
init::start().await;
}
-194
View File
@@ -1,194 +0,0 @@
use crate::{
scheduler::scheduler_error::SchedulerError,
scheduler::scheduler_error::SchedulerError::IdAlreadyRunning,
scheduler::scheduler_error::SchedulerError::IdNotFound, scheduler::sleeper::SkippableSleeper,
scheduler::tasks::Task,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::{collections::HashMap, sync::Arc, thread};
use tracing::log::error;
use uuid::Uuid;
pub mod scheduler_error;
mod sleeper;
mod task_func;
pub mod tasks;
struct TaskStatus {
sleeper: Option<Arc<SkippableSleeper>>,
is_alive: AtomicBool,
}
#[derive(Default)]
pub struct Scheduler {
tasks: Arc<RwLock<HashMap<Uuid, Arc<Box<dyn Task>>>>>,
running: Arc<RwLock<HashMap<Uuid, Arc<TaskStatus>>>>,
}
impl Scheduler {
/// Start all tasks in the scheduler
pub fn start(&self) -> Result<(), SchedulerError> {
for (k, _v) in self.tasks.read()?.iter() {
self.run_non_blocking(k)?
}
Ok(())
}
/// Terminate all tasks in the scheduler
pub fn halt() {}
fn run_task(
running: Arc<RwLock<HashMap<Uuid, Arc<TaskStatus>>>>,
id: Uuid,
task: Arc<Box<dyn Task>>,
) -> Result<(), SchedulerError> {
if running.read()?.get(&id).is_some() {
return Err(IdAlreadyRunning);
} else {
running.write()?.insert(
id,
TaskStatus {
sleeper: None,
is_alive: AtomicBool::new(true),
}
.into(),
);
}
loop {
if let Some(next_execution) = task.get_next_execution() {
// guarantee is alive
let task_status = {
let running_reading_guard = running.read()?;
running_reading_guard.get(&id).cloned().clone()
};
if let Some(task_status) = task_status {
if !&task_status.is_alive.load(Ordering::SeqCst) {
{
let mut running_lock_guard = running.write()?;
running_lock_guard.remove(&id);
}
return Ok(());
} else {
let sleeper = Arc::from(SkippableSleeper::from(next_execution));
{
let mut running_lock_guard = running.write()?;
running_lock_guard.insert(
id,
TaskStatus {
sleeper: Some(sleeper.clone()),
is_alive: AtomicBool::new(true),
}
.into(),
);
}
sleeper.start();
task.call();
}
}
} else {
error!("unexpected error ");
break;
}
}
Ok(())
}
/// Run a task with `id`
pub fn run(&self, id: &Uuid) -> Result<(), SchedulerError> {
let id = id.to_owned();
if let Some(task) = self.tasks.read()?.get(&id) {
Self::run_task(self.running.clone(), id, task.clone())
} else {
Err(IdNotFound)
}
}
pub fn run_non_blocking(&self, id: &Uuid) -> Result<(), SchedulerError> {
let id = id.to_owned();
if let Some(task) = self.tasks.read()?.get(&id) {
let running_clone = self.running.clone();
let task_clone = task.clone();
thread::spawn(move || Self::run_task(running_clone, id, task_clone));
Ok(())
} else {
Err(IdNotFound)
}
}
fn get_running(&self, id: &Uuid) -> Option<Arc<TaskStatus>> {
let ret = self.running.read().unwrap().get(id).cloned();
ret
}
/// Stop a task with `id`
pub fn stop(&self, id: &Uuid) -> Result<(), SchedulerError> {
match self.get_running(id) {
None => Err(IdNotFound),
Some(status) => {
status.is_alive.store(false, Ordering::SeqCst);
if let Some(s) = &status.sleeper {
s.skip();
} else {
return Err(IdAlreadyRunning);
}
Ok(())
}
}
}
pub fn now(&self, id: &Uuid) -> Result<(), SchedulerError> {
match self.get_running(id) {
None => Err(IdNotFound),
Some(status) => {
if let Some(s) = &status.sleeper {
s.skip()
} else {
return Err(IdAlreadyRunning);
}
Ok(())
}
}
}
pub fn insert(&self, task: Box<dyn Task>) -> Result<Uuid, SchedulerError> {
let id = Uuid::new_v4();
self.tasks.write()?.insert(id, Arc::new(task));
Ok(id)
}
pub fn insert_list(&self, tasks: Vec<Box<dyn Task>>) -> Result<Vec<Uuid>, SchedulerError> {
tasks.into_iter().map(|t| self.insert(t)).collect()
}
pub fn new() -> Self {
Self::default()
}
}
#[cfg(test)]
mod tests {
use crate::scheduler::tasks::basic::BasicTask;
use crate::scheduler::Scheduler;
use std::error::Error;
use std::thread;
use std::time::Duration;
#[test]
fn test_run() -> Result<(), Box<dyn Error>> {
let scheduler = Scheduler::default();
let task = BasicTask::new(|| println!("test print"), "* * * * * *")?;
let id = scheduler.insert(Box::new(task))?;
for i in 1..=2 {
println!("Loop {} start", i);
println!("Run {}", i);
scheduler.run_non_blocking(&id)?;
thread::sleep(Duration::from_secs(4));
scheduler.stop(&id)?;
println!("Stop {}", i);
thread::sleep(Duration::from_secs(2));
println!("Loop {} finished", i);
}
Ok(())
}
}
@@ -1,77 +0,0 @@
use chrono::{DateTime, Utc};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::Duration;
#[derive(Clone, Debug, Default)]
pub struct SkippableSleeper {
pub duration: Duration,
state: Arc<(Mutex<bool>, Condvar)>,
}
impl SkippableSleeper {
pub fn new(duration: Duration) -> Self {
SkippableSleeper {
duration,
state: Arc::new((Mutex::new(false), Condvar::new())),
}
}
// Blocks until the duration has passed or skip() is called
pub fn start(&self) {
let (lock, cvar) = &*self.state;
let mut received = lock.lock().unwrap();
let state_clone = Arc::clone(&self.state);
let duration_clone = self.duration;
thread::spawn(move || {
thread::sleep(duration_clone);
let (lock, cvar) = &*state_clone;
let mut received = lock.lock().unwrap();
*received = true;
cvar.notify_one();
});
while !*received {
received = cvar.wait(received).unwrap();
}
}
// Skip the blocking delay
pub fn skip(&self) {
let (lock, cvar) = &*self.state;
let mut received = lock.lock().unwrap();
*received = true;
cvar.notify_one();
}
}
impl From<DateTime<Utc>> for SkippableSleeper {
fn from(value: DateTime<Utc>) -> Self {
let from_utc_timestamp = value.timestamp() as u64;
let curr_utc_timestamp = Utc::now().timestamp() as u64;
SkippableSleeper::new(Duration::from_secs(from_utc_timestamp - curr_utc_timestamp))
}
}
#[cfg(test)]
mod test {
use crate::scheduler::sleeper::SkippableSleeper;
use std::thread;
use std::time::Duration;
#[test]
fn usage() {
let sleeper = SkippableSleeper::new(Duration::new(10, 0));
thread::spawn({
let sleeper = sleeper.clone();
move || {
println!("Sleeping...");
sleeper.start();
println!("Woke up!");
}
});
println!("Start Sleeping...");
thread::sleep(Duration::from_secs(4));
println!("Skipping sleep...");
sleeper.skip();
}
}
@@ -1,3 +0,0 @@
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
@@ -1,8 +0,0 @@
use chrono::{DateTime, Utc};
pub mod basic;
pub trait Task: Send + Sync {
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
fn call(&self);
}
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "macros"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
tracing = "0.1.40"
+6
View File
@@ -0,0 +1,6 @@
#[macro_export]
macro_rules! dev_consume {
($($var:ident),+) => {
$(let _ = $var;)*
};
}
+24
View File
@@ -0,0 +1,24 @@
/// Includes a UTF-8 encoded file as a string
///
/// The file is located relative to `CARGO_MANIFEST_DIR`.
///
/// # Example
/// ```rust
/// use macros::include_str_from_crate_root;
///
/// let str: &str = include_str_from_crate_root!("/query/scheme.sql");
///
// todo: impl
// const PATH_PREFIX: &str = "/src";
// let str: &str = include_str_from_crate_root!("{}/query/scheme.sql", PATH_PREFIX);
/// ```
#[macro_export]
macro_rules! include_str_from_crate_root {
// ($path:literal $(, $($arg:tt)*)?) => {
// ($path:expr, $($args:tt)*) => {
// include_str!(concat!(env!("CARGO_MANIFEST_DIR"), format_args!($path, $($args)*)))
// };
($path:expr) => {
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), $path))
};
}
+2
View File
@@ -1 +1,3 @@
mod dev;
mod include_str;
mod log;
+21 -2
View File
@@ -6,7 +6,7 @@
/// use macros::panic_with_log;
/// use tracing::Level;
///
/// panic_with_log!(Level::ERROR, "Test panicked: {}", 42);
/// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
/// ```
#[macro_export]
macro_rules! panic_with_log {
@@ -18,6 +18,25 @@ macro_rules! panic_with_log {
}};
}
/// log the message with `Level::ERROR` and exit(1)
///
/// # Example
/// ```rust
///
/// use macros::error_panic;
///
/// error_panic!("test panicked: {}", 42);
/// ```
#[macro_export]
macro_rules! error_panic {
($msg:literal $(, $($arg:tt)*)?) => {{
use std::process;
use tracing::{event, Level};
event!(Level::ERROR, $msg $(, $($arg)*)?);
process::exit(1);
}};
}
#[cfg(test)]
mod tests {
#[test]
@@ -25,7 +44,7 @@ mod tests {
use std::io;
use tracing::Level;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, Registry};
use tracing_subscriber::{Registry, fmt};
let format = tracing_subscriber::fmt::format()
.with_level(true)
+2 -2
View File
@@ -1,14 +1,14 @@
[package]
name = "markdown"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128"
regex = "1.10.4"
yaml-rust2 = "0.9.0"
yaml-rust2 = "0.10.0"
sha2 = "0.10.8"
base64 = "0.22.1"
thiserror = "2.0.3"
+1 -1
View File
@@ -8,7 +8,7 @@ use std::str::FromStr;
use crate::error::Error;
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
use base64::{engine::general_purpose, Engine};
use base64::{Engine, engine::general_purpose};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "server"
version = "0.1.0"
edition = "2024"
[dependencies]
macros = { path = "../macros" }
markdown = { path = "../markdown" }
axum = { version = "0.8.1", features = ["macros"] }
tracing = { version = "0.1.41", features = ["log"] }
git2 = "0.20.0"
chrono = { version = "0.4.39", features = ["serde"] }
cron = "0.15.0"
sqlx = { version = "0.8.3", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono"] }
serde_json = "1.0.133"
serde = { version = "1.0.215", features = ["derive"] }
tracing-subscriber = "0.3.18"
tracing-appender = "0.2.3"
dirs = "6.0.0"
thiserror = "2.0.11"
tokio = { version = "1.42.0", features = ["rt", "rt-multi-thread", "macros"] }
dotenv = "0.15.0"
uuid = { version = "1.13.2", features = ["v4"] }
futures = "0.3.31"
[build-dependencies]
git2 = "0.20.0"
chrono = "0.4.39"
+16
View File
@@ -0,0 +1,16 @@
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);
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -2,7 +2,8 @@ use std::env;
use std::error::Error;
use std::str::FromStr;
use constants::config::database::{default_value, var_name};
use crate::constants::config::database::{default_value, var_name};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, Pool, Postgres};
use tracing::info;
@@ -12,7 +13,7 @@ pub async fn init() -> Result<Pool<Postgres>, Box<dyn Error>> {
.unwrap()
.disable_statement_logging();
Ok(PgPoolOptions::new()
.max_connections(1) // todo: temporal solution, to be fixed
.max_connections(1) // todo: temporary solution, to be fixed
.connect_with(db_connect_option)
.await?)
}
@@ -1,14 +1,15 @@
use std::path::Path;
use std::{env, io};
use crate::constants::config::general::{default_value, var_name};
use chrono::Local;
use constants::config::general::{default_value, var_name};
use dirs::home_dir;
use tracing::info;
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
use tracing_subscriber::{
filter::LevelFilter, fmt, fmt::format::Writer, fmt::time::FormatTime, fmt::MakeWriter,
layer::SubscriberExt, Layer, Registry,
Layer, Registry, filter::LevelFilter, fmt, fmt::MakeWriter, fmt::format::Writer,
fmt::time::FormatTime, layer::SubscriberExt,
};
struct LocalTimer;
@@ -18,7 +19,7 @@ impl FormatTime for LocalTimer {
write!(
w,
"{}",
Local::now().format(constants::time::DEFAULT_TIME_FORMAT)
Local::now().format(crate::constants::time::DEFAULT_TIME_FORMAT)
)
}
}
File renamed without changes.
@@ -1,13 +1,13 @@
mod cron;
mod database;
pub mod logger;
mod misc;
mod schema;
pub mod server;
use macros::panic_with_log;
use std::{env, panic};
use tracing::{info, Level};
use crate::modules::{ModuleTree, blog::Blog};
use macros::error_panic;
use tracing::info;
pub fn load_env() {
if let Some(env_file) = env::args().nth(1) {
@@ -34,25 +34,17 @@ pub async fn start() {
pool
}
Err(e) => {
panic_with_log!(
Level::ERROR,
"database connection pool failed to initialize: {}",
e
);
error_panic!("database connection pool failed to initialize: {}", e);
}
};
// schema init
schema::init(&db_pool).await;
// // schema init
// schema::init(&db_pool).await;
// load cron task
cron::start(db_pool.clone());
// api serve
match server::start(db_pool).await {
Ok(_) => {}
Err(e) => {
panic_with_log!(Level::ERROR, "server panicked: {}", e)
}
}
ModuleTree::new()
.add_module(Blog {
db_handler: db_pool,
})
.blocking_serve()
.await
}
+23
View File
@@ -0,0 +1,23 @@
#[allow(dead_code)]
mod constants;
#[allow(dead_code)]
mod scheduler;
mod init;
mod modules;
mod utils;
use crate::init::{logger, start};
const BUILD_ID: &str = env!("BUILD_ID");
#[tokio::main]
async fn main() {
println!("{}", BUILD_ID);
init::load_env();
let _guards = logger::init();
start().await;
}
@@ -0,0 +1,25 @@
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()})))
}
}
}
@@ -0,0 +1,2 @@
SELECT cast(ceil(count(*) / cast($1 AS FLOAT)) AS INTEGER) AS count
FROM posts;
Whitespace-only changes.
@@ -0,0 +1,34 @@
use macros::dev_consume;
use axum::{
extract::{Path, State},
response::IntoResponse,
Json,
};
use serde_json::json;
use sqlx::PgPool;
const GET_POST_BY_I64_ID: &str = include_str!("get_post_by_i64_id.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::<i64>() {
select_post_with_i64_id(&db_connection, id).await
} else {
select_post_with_str_id(&db_connection, &identifier).await
};
Json::from(json!({"res": "ok"})).into_response()
}
async fn select_post_with_i64_id(db: &PgPool, id: i64) {
dev_consume!(db, id);
todo!()
}
async fn select_post_with_str_id(db: &PgPool, id: &str) {
dev_consume!(db, id);
todo!()
}
@@ -1,6 +1,6 @@
use std::error::Error;
use constants::blog::PAGE_SIZE;
use crate::constants::blog::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
@@ -0,0 +1,6 @@
pub mod get_page_count;
// todo: remove exception tag
#[allow(dead_code)]
pub mod get_post;
// pub mod get_post_list;
@@ -1,10 +1,10 @@
use std::error::Error;
use std::path::Path;
use crate::blog::cron::check_update::utils::get_md_file_basename;
use crate::blog::cron::check_update::{error::PostIdxError, utils::parse_date};
use markdown::MdFile;
use crate::modules::blog::components::check_update::utils::get_md_file_basename;
use crate::modules::blog::components::check_update::{error::PostIdxError, utils::parse_date};
use sqlx::{query, PgPool, Pool, Postgres, Row};
use tokio::runtime::Runtime;
use tracing::{info, warn};
@@ -16,7 +16,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField {
field: "title".to_string(),
filename: post.filename.clone(),
})
});
}
Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid title for `{}`", &post.filename);
@@ -39,7 +39,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField {
field: "category".to_string(),
filename: post.filename.clone(),
})
});
}
Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid category for `{}`", &post.filename);
@@ -56,7 +56,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
err_field: last_update.to_string(),
filename: post.filename.clone(),
msg: e.to_string(),
})
});
}
}
} else {
@@ -68,7 +68,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField {
field: "date".to_string(),
filename: post.filename.clone(),
})
});
}
Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
Ok(date) => date,
@@ -78,7 +78,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
err_field: first_update.to_string(),
filename: post.filename.clone(),
msg: e.to_string(),
})
});
}
},
};
@@ -135,7 +135,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField {
field: "tags".to_string(),
filename: post.filename.clone(),
})
});
}
Some(val) => val,
};
@@ -171,7 +171,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
db_table: "Tag".to_string(),
id: post_id.to_string(),
msg: e.to_string(),
})
});
}
}
}
@@ -0,0 +1,78 @@
use crate::constants::config::general::{default_value, var_name};
use macros::panic_with_log;
use std::sync::Arc;
use std::{
env,
path::{Path, PathBuf},
};
use crate::modules::blog::components::check_update::{
// index::drop_index,
// utils::{expand_path, index_files, search_md},
utils::expand_path,
};
use crate::scheduler::task_func::AsyncTaskFunc;
use sqlx::PgPool;
use tracing::{info, warn, Level};
// mod error;
// mod index;
mod utils;
#[derive(Debug)]
struct Args {
git_work_dir: PathBuf,
git_url: String,
git_remote_name: String,
git_remote_branch: String,
}
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,
Err(e) => {
panic_with_log!(
Level::ERROR,
"failed to read `{}`: {}",
var_name::BLOG_GIT_URL,
e
)
}
};
let git_remote_name = env::var(var_name::BLOG_GIT_REMOTE_NAME)
.unwrap_or(default_value::BLOG_GIT_REMOTE_NAME.to_string());
let git_remote_branch = env::var(var_name::BLOG_GIT_REMOTE_BRANCH)
.unwrap_or(default_value::BLOG_GIT_REMOTE_BRANCH.to_string());
let arc_args = Arc::new(Args {
git_work_dir,
git_url,
git_remote_name,
git_remote_branch,
});
move || Box::pin(test(arc_args.clone()))
}
async fn test(args: Arc<Args>) {
info!("test");
dbg!(&args);
}
@@ -0,0 +1,81 @@
use crate::constants::{
config::general::{default_value, var_name},
time::DEFAULT_DATE_FORMAT,
};
// use crate::modules::blog::components::check_update::index::index_file;
use chrono::{DateTime, FixedOffset, ParseResult};
use dirs::home_dir;
use tracing::warn;
// pub fn search_md(entry: PathBuf) -> Vec<PathBuf> {
// if entry.ends_with(".git") {
// return vec![];
// }
// if entry.is_dir() {
// if let Ok(files) = entry.read_dir() {
// files
// .flat_map(|file| {
// let file_path = file.unwrap().path();
// search_md(file_path)
// })
// .collect()
// } else {
// vec![]
// }
// } else if entry.extension().is_some_and(|ext| ext == "md") {
// vec![entry]
// } else {
// vec![]
// }
// }
// pub fn index_files(files: &[PathBuf], db_conn: &Pool<Postgres>) {
// let rt = Runtime::new().unwrap();
// rt.block_on(async {
// let files_clone = files.to_owned();
// let handlers = files_clone
// .into_iter()
// .map(|file: PathBuf| {
// let conn_clone = db_conn.clone();
// spawn(async move {
// if let Err(e) = index_file(&file, &conn_clone).await {
// error!("failed to index markdown file `{}`: {}", file.display(), e);
// }
// })
// })
// .collect::<Vec<_>>();
//
// block_on(join_all(handlers));
// });
// }
pub fn expand_path(path: String) -> String {
if path.starts_with('~') {
home_dir()
.map(|dir| {
dir.join(path.strip_prefix("~/").unwrap())
.into_os_string()
.into_string()
.unwrap()
})
.unwrap_or_else(|| {
warn!(
"failed to parse env var `{}`, using default value `{}`",
var_name::WORK_DIR,
default_value::WORK_DIR
);
default_value::WORK_DIR.to_string()
})
} else {
path
}
}
pub fn parse_date(date_str: &str) -> ParseResult<DateTime<FixedOffset>> {
DateTime::parse_from_str(format!("{}T120000", date_str).as_str(), DEFAULT_DATE_FORMAT)
}
// pub fn get_md_file_basename(f_name: &str) -> String {
// f_name.replace(".md", "")
// }
@@ -0,0 +1,2 @@
pub mod check_update;
mod rag;
Whitespace-only changes.
+58
View File
@@ -0,0 +1,58 @@
mod api;
mod components;
use crate::{
modules::{
blog::{
api::{
get_page_count,
get_post,
// get_post_list::get_post_list,
},
// cron::check_update,
},
Module,
},
scheduler::tasks::CronTask,
};
use crate::modules::blog::components::check_update;
use crate::scheduler::tasks::async_basic::AsyncBasic;
use crate::scheduler::tasks::basic::BasicTask;
use axum::{routing::get, Router};
use sqlx::PgPool;
const MOUNT_POINT: &str = "/blog";
pub struct Blog {
pub db_handler: PgPool,
}
impl Module for Blog {
fn get_server_router(&self) -> Router {
let db_handler = self.db_handler.clone();
// 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))
.with_state(db_handler)
}
fn get_mount_point(&self) -> &'static str {
MOUNT_POINT
}
fn get_cron_tasks(&self) -> Vec<Box<dyn CronTask>> {
let db_handler = self.db_handler.clone();
vec![
AsyncBasic::new(check_update::task(db_handler.clone()), "* * * * * *")
.unwrap()
.to_task(),
BasicTask::new(|| println!("test lol"), "* * * * * *")
.unwrap()
.to_task(),
]
}
}
@@ -0,0 +1,41 @@
CREATE TABLE tags
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE categories
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE posts
(
id SERIAL PRIMARY KEY,
identifier VARCHAR(50) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255) NOT NULL,
published_date DATE NOT NULL,
last_update DATE,
content TEXT NOT NULL,
category SERIAL REFERENCES categories (id) NOT NULL
);
CREATE TABLE post_tag
(
post SERIAL REFERENCES posts (id),
tag SERIAL REFERENCES tags (id)
);
CREATE TABLE metadata
(
pid SERIAL REFERENCES posts (id) NOT NULL,
meta_key VARCHAR(50) NOT NULL,
meta_value TEXT NOT NULL,
CONSTRAINT meta_pk PRIMARY KEY (pid, meta_key)
);
+85
View File
@@ -0,0 +1,85 @@
pub mod blog;
use std::{env, num::ParseIntError, thread};
use crate::{
constants::config::server::{default_value, var_name},
scheduler::{tasks::CronTask, Scheduler},
};
use macros::error_panic;
use axum::Router;
use futures::executor::block_on;
use tokio::net::TcpListener;
use tracing::{info, warn};
// todo: merge `get_server_router` & `get_mount_point`, make Option
pub trait Module {
fn get_server_router(&self) -> Router;
fn get_mount_point(&self) -> &'static str;
fn get_cron_tasks(&self) -> Vec<Box<dyn CronTask>>;
}
pub struct ModuleTree {
pub modules: Vec<Box<dyn Module>>,
}
impl ModuleTree {
pub fn new() -> Self {
ModuleTree { modules: vec![] }
}
pub fn add_module(&mut self, module: impl Module + 'static) -> &mut Self {
self.modules.push(Box::new(module));
self
}
pub async fn blocking_serve(&self) {
let scheduler = Scheduler::new();
let mut app = Router::new();
let port: u32 = match env::var(var_name::PORT) {
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
warn!(
"failed to parse `{}`, using default port: {}",
var_name::PORT,
e.to_string()
);
default_value::PORT
}),
Err(_) => default_value::PORT,
};
let listener = match TcpListener::bind(format!("{}:{}", default_value::HOST, port)).await {
Ok(listener) => {
info!("server started on {}:{}", default_value::HOST, port);
listener
}
Err(e) => {
error_panic!(
"failed to start server on {}:{}: {}",
default_value::HOST,
port,
e.to_string()
);
}
};
for module in &self.modules {
let _ = scheduler.insert_list(module.get_cron_tasks()).await;
app = app.nest(module.get_mount_point(), module.get_server_router());
}
let _ = thread::spawn(move || {
let fut = scheduler.start();
block_on(fut).unwrap()
})
.join();
axum::serve(listener, app).await.unwrap_or_else(|e| {
error_panic!("failed to start axum: {}", e.to_string());
});
}
}
@@ -1,8 +1,12 @@
use crate::scheduler::scheduler_error::SchedulerError::MutexPoison;
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::sync::PoisonError;
use std::{
error::Error,
fmt::{Debug, Display, Formatter},
sync::PoisonError,
};
use crate::scheduler::error::SchedulerError::MutexPoison;
// todo: refactor with thiserror
pub enum SchedulerError {
IdNotFound,
IdAlreadyRunning,
+145
View File
@@ -0,0 +1,145 @@
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use crate::scheduler::{
error::{SchedulerError, SchedulerError::IdNotFound},
runner::Runner,
tasks::CronTask,
};
use tokio::sync::RwLock;
use uuid::Uuid;
pub mod error;
mod runner;
mod sleeper;
pub mod task_func;
pub mod tasks;
#[derive(Default)]
pub struct Scheduler {
running: RwLock<HashSet<Uuid>>,
parking_pool: RwLock<HashMap<Uuid, Arc<Box<dyn CronTask>>>>,
runner: Runner,
}
impl Scheduler {
/// Start all tasks in the scheduler
pub async fn start(&self) -> Result<(), SchedulerError> {
self.runner.run_all().await
}
/// Terminate all tasks in the scheduler
pub async fn halt(&self) -> Result<(), SchedulerError> {
for id in self.running.read().await.iter() {
let func = &self.runner.terminate_remove(*id).await?;
self.running.write().await.remove(id);
self.parking_pool.write().await.insert(*id, func.clone());
}
Ok(())
}
/// Run a task with `id`
pub async fn run(&self, id: &Uuid) -> Result<(), SchedulerError> {
if self.running.read().await.contains(id) {
Ok(())
} else if self.parking_pool.read().await.contains_key(id) {
let func = self.parking_pool.write().await.remove(id).unwrap();
self.running.write().await.insert(*id);
self.runner.add_with_id(func, id).await?;
self.runner.run_id(id).await
} else {
Err(IdNotFound)
}
}
/// Stop a task with `id`
pub async fn stop(&self, id: &Uuid) -> Result<(), SchedulerError> {
if self.running.read().await.contains(id) {
let func = self.runner.terminate_remove(*id).await?;
self.running.write().await.remove(id);
self.parking_pool.write().await.insert(*id, func.clone());
Ok(())
} else if self.parking_pool.read().await.get(id).is_some() {
Ok(())
} else {
Err(IdNotFound)
}
}
// pub fn now(&self, id: &Uuid) -> Result<(), SchedulerError> {
// }
pub async fn insert(&self, task: Box<dyn CronTask>) -> Result<Uuid, SchedulerError> {
let id = self.runner.add(task.into()).await?;
self.running.write().await.insert(id);
Ok(id)
}
pub async fn insert_list(
&self,
tasks: Vec<Box<dyn CronTask>>,
) -> Result<Vec<Uuid>, SchedulerError> {
let ids = self
.runner
.add_list(tasks.into_iter().map(|t| t.into()).collect())
.await?;
self.running.write().await.extend(&ids);
Ok(ids)
}
pub fn new() -> Self {
Self::default()
}
}
// #[cfg(test)]
// mod tests {
// use std::{error::Error, thread, time::Duration};
//
// use crate::scheduler::{
// tasks::{async_basic::AsyncBasic, basic::BasicTask},
// Scheduler,
// };
//
// use uuid::Uuid;
//
// async fn execution(scheduler: Scheduler, id: Uuid) -> Result<(), Box<dyn Error>> {
// for i in 1..=2 {
// println!("Loop {} start", i);
// println!("Run {}", i);
// scheduler.run(&id).await?;
// println!("Sleep {}", i);
// thread::sleep(Duration::from_secs(4));
// println!("Sleep {} finished", i);
// scheduler.stop(&id)?;
// println!("Stop {}", i);
// thread::sleep(Duration::from_secs(2));
// println!("Loop {} finished", i);
// }
// Ok(())
// }
//
// #[tokio::test]
// async fn test_run() -> Result<(), Box<dyn Error>> {
// let scheduler = Scheduler::default();
// let task = BasicTask::new(|| println!("test print"), "* * * * * *")?;
// let id = scheduler.insert(Box::new(task)).await?;
// scheduler.start().await?;
// execution(scheduler, id)
// }
//
// #[tokio::test]
// async fn test_run_async() -> Result<(), Box<dyn Error>> {
// let scheduler = Scheduler::default();
// let task = AsyncBasic::new(
// || Box::pin(async { println!("test async print") }),
// "* * * * * *",
// )?;
// let id = scheduler.insert(Box::new(task)).await?;
// scheduler.start().await?;
// execution(scheduler, id)
// }
// }
+186
View File
@@ -0,0 +1,186 @@
use crate::scheduler::{
error::{SchedulerError, SchedulerError::IdNotFound},
sleeper::SkippableSleeper,
tasks::CronTask,
};
use futures::future;
use std::{
collections::HashMap,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
};
use tokio::runtime::Runtime;
use tokio::sync::RwLock;
use uuid::Uuid;
#[derive(Debug)]
struct TaskStatus {
sleeper: RwLock<SkippableSleeper>,
is_running: AtomicBool,
expected_running: AtomicBool,
}
pub struct TaskEntity {
func: Arc<Box<dyn CronTask>>,
status: TaskStatus,
}
impl TaskEntity {
pub fn new(func: Arc<Box<dyn CronTask>>) -> Self {
let next_execution: SkippableSleeper = func.get_next_execution().unwrap().into();
Self {
func,
status: TaskStatus {
sleeper: next_execution.into(),
is_running: false.into(),
expected_running: true.into(),
},
}
}
pub async fn run_blocking(&self) {
dbg!("entered");
dbg!("chkpt 1");
loop {
dbg!("chkpt 2");
// deadlock spotted
self.status.sleeper.read().await.start().await;
dbg!("chkpt 3");
if self.status.expected_running.load(Ordering::SeqCst) {
dbg!("chkpt 4");
self.func.call().await;
dbg!("chkpt 5");
if let Some(next_execution) = self.func.get_next_execution() {
dbg!("chkpt 6a");
*(self.status.sleeper.write().await) = next_execution.into();
} else {
dbg!("chkpt 6b");
self.status.is_running.store(false, Ordering::SeqCst);
break;
}
} else {
break;
}
}
}
pub async fn terminate(&self) {
self.status.expected_running.store(false, Ordering::SeqCst);
self.status.is_running.store(false, Ordering::SeqCst);
self.status.sleeper.read().await.skip();
}
}
pub struct Runner {
task_list: RwLock<HashMap<Uuid, Arc<TaskEntity>>>,
rt: Runtime,
}
impl Runner {
pub fn new() -> Self {
Self {
task_list: HashMap::new().into(),
rt: Runtime::new().unwrap(),
}
}
pub async fn run_all(&self) -> Result<(), SchedulerError> {
let list = &self.task_list.read().await;
self.rt.block_on(async {
for (_, entity) in list.iter() {
self.run_entity(entity.clone()).await
}
});
Ok(())
}
pub async fn run_id(&self, id: &Uuid) -> Result<(), SchedulerError> {
if let Some(entity) = self.task_list.read().await.get(id).cloned() {
self.rt.block_on(async { self.run_entity(entity).await });
Ok(())
} else {
Err(IdNotFound)
}
}
pub async fn run_entity(&self, entity: Arc<TaskEntity>) {
if !entity.status.is_running.load(Ordering::SeqCst)
&& entity.status.expected_running.load(Ordering::SeqCst)
{
entity.status.is_running.store(true, Ordering::SeqCst);
let fut = {
let entity_clone = entity.clone();
async move { entity_clone.run_blocking().await }
};
dbg!("spawn");
tokio::spawn(fut);
}
}
pub async fn add_with_id(
&self,
task: Arc<Box<dyn CronTask>>,
id: &Uuid,
) -> Result<(), SchedulerError> {
self.task_list
.write()
.await
.insert(*id, TaskEntity::new(task).into());
Ok(())
}
pub async fn add(&self, task: Arc<Box<dyn CronTask>>) -> Result<Uuid, SchedulerError> {
let id = Uuid::new_v4();
self.add_with_id(task, &id).await?;
Ok(id)
}
pub async fn add_list_with_id(
&self,
tasks: Vec<(&Uuid, Arc<Box<dyn CronTask>>)>,
) -> Result<(), SchedulerError> {
let fut = tasks
.into_iter()
.map(async |(id, entity)| self.add_with_id(entity, id).await)
.collect::<Vec<_>>();
future::join_all(fut).await.into_iter().collect()
}
pub async fn add_list(
&self,
tasks: Vec<Arc<Box<dyn CronTask>>>,
) -> Result<Vec<Uuid>, SchedulerError> {
let fut = tasks
.into_iter()
.map(async |task| {
let id = Uuid::new_v4();
self.add_with_id(task, &id).await.map(move |_| id)
})
.collect::<Vec<_>>();
future::join_all(fut).await.into_iter().collect()
}
pub async fn terminate_remove(
&self,
id: Uuid,
) -> Result<Arc<Box<dyn CronTask>>, SchedulerError> {
let mut list = self.task_list.write().await;
match list.get(&id) {
None => Err(IdNotFound),
Some(task) => {
task.terminate().await;
let func = list.remove(&id).unwrap().func.clone();
Ok(func.clone())
}
}
}
}
impl Default for Runner {
fn default() -> Self {
Self::new()
}
}
+81
View File
@@ -0,0 +1,81 @@
use chrono::{DateTime, Utc};
use std::sync::atomic::{AtomicBool, Ordering};
use std::{sync::Arc, time::Duration};
use tokio::sync::Notify;
#[derive(Debug, Clone)]
pub struct SkippableSleeper {
duration: Duration,
skipped: Arc<AtomicBool>,
notify: Arc<Notify>,
}
impl SkippableSleeper {
pub fn new(duration: Duration) -> Self {
Self {
duration,
skipped: Arc::new(AtomicBool::new(false)),
notify: Arc::new(Notify::new()),
}
}
pub async fn start(&self) {
if self.skipped.load(Ordering::Acquire) {
return;
}
let sleep = tokio::time::sleep(self.duration);
let notify = self.notify.notified();
tokio::select! {
_ = sleep => {}
_ = notify => {
self.skipped.store(true, Ordering::Release);
}
}
}
pub fn skip(&self) {
self.notify.notify_waiters();
}
}
impl From<DateTime<Utc>> for SkippableSleeper {
fn from(value: DateTime<Utc>) -> Self {
let now = Utc::now();
let duration = value.signed_duration_since(now);
let duration = duration.to_std().unwrap_or(Duration::ZERO);
Self::new(duration)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{sleep, Duration};
#[tokio::test]
async fn skip_sleeper() {
let sleeper = SkippableSleeper::new(Duration::from_secs(10));
let sleeper_clone = sleeper.clone();
let handle = tokio::spawn(async move {
println!("Sleeping...");
sleeper_clone.start().await;
println!("Woke up!");
});
println!("Start waiting...");
sleep(Duration::from_secs(4)).await;
println!("Skipping sleep...");
sleeper.skip();
handle.await.unwrap();
}
#[tokio::test]
async fn normal_sleeper() {
let sleeper = SkippableSleeper::new(Duration::from_secs(4));
sleeper.start().await;
}
}
+15
View File
@@ -0,0 +1,15 @@
use std::pin::Pin;
pub trait TaskFunc: Fn() + Send + Sync + 'static {}
impl<T> TaskFunc for T where T: Fn() + Send + Sync + Clone + 'static {}
pub trait AsyncTaskFunc:
Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + 'static
{
}
impl<T> AsyncTaskFunc for T where
T: Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync + Clone + 'static
{
}
@@ -0,0 +1,37 @@
use std::{error::Error, pin::Pin, str::FromStr};
use crate::{scheduler::task_func::AsyncTaskFunc, scheduler::tasks::CronTask};
use chrono::{DateTime, Utc};
use cron::Schedule;
pub struct AsyncBasic {
schedule: Schedule,
func: Box<dyn AsyncTaskFunc>,
}
impl AsyncBasic {
pub fn new<F>(func: F, cron_expr: &str) -> Result<Self, Box<dyn Error>>
where
F: AsyncTaskFunc,
{
Ok(AsyncBasic {
schedule: Schedule::from_str(cron_expr)?,
func: Box::new(func),
})
}
pub fn to_task(self) -> Box<dyn CronTask> {
Box::new(self)
}
}
impl CronTask for AsyncBasic {
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
self.schedule.upcoming(Utc).next()
}
fn call(&self) -> Pin<Box<(dyn Future<Output = ()> + Send)>> {
Box::pin((self.func)())
}
}
@@ -1,9 +1,9 @@
use crate::scheduler::task_func::TaskFunc;
use crate::scheduler::tasks::Task;
use std::{error::Error, pin::Pin, str::FromStr};
use crate::scheduler::{task_func::TaskFunc, tasks::CronTask};
use chrono::{DateTime, Utc};
use cron::Schedule;
use std::error::Error;
use std::str::FromStr;
pub struct BasicTask {
schedule: Schedule,
@@ -21,17 +21,17 @@ impl BasicTask {
})
}
pub fn to_task(self) -> Box<dyn Task> {
pub fn to_task(self) -> Box<dyn CronTask> {
Box::new(self)
}
}
impl Task for BasicTask {
impl CronTask for BasicTask {
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
self.schedule.upcoming(Utc).next()
}
fn call(&self) {
(self.func)()
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async { (self.func)() })
}
}
+11
View File
@@ -0,0 +1,11 @@
use std::pin::Pin;
use chrono::{DateTime, Utc};
pub mod async_basic;
pub mod basic;
pub trait CronTask: Send + Sync {
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}
@@ -23,8 +23,6 @@ fn do_fetch<'a>(
) -> Result<AnnotatedCommit<'a>, git2::Error> {
let mut fetch_option = FetchOptions::new();
// Always fetch all tags.
// Perform a download and also update tips
fetch_option.download_tags(AutotagOption::All);
remote.fetch(refs, Some(&mut fetch_option), None)?;
@@ -45,13 +43,7 @@ fn fast_forward(
// println!("{}", msg);
lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
build::CheckoutBuilder::default()
// For some reason, the force is required to make the working directory actually get updated
// I suspect we should be adding some logic to handle dirty working directory states,
// but this is just an example so maybe not.
.force(),
))?;
repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
Ok(())
}
@@ -73,13 +65,12 @@ fn normal_merge(
return Ok(());
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
// now create the merge commit
let msg = format!("merge({}) -> {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
// Do our merge commit and set the current branch head to that commit.
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
@@ -88,7 +79,6 @@ fn normal_merge(
&result_tree,
&[&local_commit, &remote_commit],
)?;
// Set working tree to match head.
repo.checkout_head(None)?;
Ok(())
}
@@ -98,24 +88,16 @@ fn do_merge<'a>(
remote_branch: &str,
fetch_commit: AnnotatedCommit<'a>,
) -> Result<Vec<FileDelta>, git2::Error> {
// 1. do a merge analysis
let analysis = repo.merge_analysis(&[&fetch_commit])?;
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
// 2. Do the appropriate merge
if analysis.0.is_fast_forward() {
// println!("Doing a fast-forward");
// do a fast-forward
let ref_name = format!("refs/heads/{}", remote_branch);
match repo.find_reference(&ref_name) {
Ok(mut r) => {
fast_forward(repo, &mut r, &fetch_commit)?;
}
Err(_) => {
// The branch doesn't exist, so set the reference to the
// commit directly. Usually this is because you are pulling
// into an empty repository.
repo.reference(
&ref_name,
fetch_commit.id(),
@@ -132,14 +114,8 @@ fn do_merge<'a>(
}
};
} else if analysis.0.is_normal() {
// do a normal merge
normal_merge(repo, &head_commit, &fetch_commit)?;
}
// else {
// // already up-to-date
// println!("Nothing to do...");
// }
calculate_diff(repo, &head_commit, &fetch_commit)
}
@@ -156,23 +132,12 @@ fn calculate_diff(
if let Err(e) = diff.foreach(
&mut |d, _| {
// let delta = d.status();
//
// let n_path = d.new_file().path();
// let o_path = d.old_file().path();
//
// if let Some(path) = n_path {
// updated_files.insert(path.to_owned());
// }
// if let Some(path) = o_path {
// updated_files.insert(path.to_owned());
// }
updated_files.push(FileDelta {
old_path: d.old_file().path().map(PathBuf::from),
new_path: d.new_file().path().map(PathBuf::from),
status: d.status(),
});
true // Continue
true
},
None,
None,
File renamed without changes.
@@ -1,4 +1,10 @@
import React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `About - ${ SITE_CONFIG.name }`
};
function Layout({
children
@@ -1,5 +1,5 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Me = async () => {
File renamed without changes.
@@ -1,5 +1,5 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Website = async () => {
File renamed without changes.
@@ -3,7 +3,7 @@
import React from 'react';
import { Pagination as NextUIPagination } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const Pagination = ({ total }: {
total: number,
@@ -1,11 +1,12 @@
'use server';
import React from 'react';
import { fetchNocturneApi } from '@/app/blog/api';
import { MotionDiv } from '@/app/public/MotionDiv';
import BlogPostCard from '@/app/blog/BlogPostCard';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card';
import * as motion from 'motion/react-client';
const PostList = async ({ page }: { page: number }) => {
try {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`);
@@ -16,13 +17,13 @@ const PostList = async ({ page }: { page: number }) => {
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
{ posts.map((post: JSON, key: number) => (
<li key={ key } className={ 'w-full' }>
<MotionDiv
<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 } />
</MotionDiv>
</motion.div>
</li>)
) }
</ul>
File renamed without changes.
@@ -1,4 +1,10 @@
import React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `Blog - ${ SITE_CONFIG.name }`
};
function Layout({
children
@@ -1,6 +1,6 @@
import PostList from '@/app/blog/PostList';
import Pagination from '@/app/blog/Pagination';
import { fetchNocturneApi } from '@/app/blog/api';
import PostList from '@/app/(pages)/blog/PostList';
import Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/(pages)/blog/api';
async function Blog({
searchParams
@@ -1,24 +1,32 @@
import React from 'react';
import MinifyPostCard from '@/app/blog/post/[id]/MinifyPostCard';
import MinifyPostCard from '@/app/(pages)/blog/post/[id]/MinifyPostCard';
import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6';
type Props = {
post: any
// post: any
post_id: string;
adjacent_posts: any
perm_link: string | undefined;
author: string | undefined;
author_link: string | undefined;
first_update: string;
last_update: string | undefined;
cc: string | undefined;
title: string;
}
const Footer = (props: Props) => {
const { post } = props;
const { adjacent_posts, perm_link, post_id, first_update, last_update, title } = props;
const adjacent_posts = post['adj'];
const prev_post = adjacent_posts['prev'];
const next_post = adjacent_posts['next'];
const link = post['meta']['link'] ?? `https://lumither.com/blog/post/${ post['meta']['id'] }`;
const author = post['meta']['author'] ?? 'Tao';
const author_link = post['meta']['author_link'] ?? '/about';
const post_date = new Date(post['first_update']);
const update_date: Date | undefined = post['last_update'] ? new Date(post['last_update']) : undefined;
const cc = post['meta']['cc'] ?? 'by-nc-sa';
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 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';
return (
@@ -35,7 +43,7 @@ const Footer = (props: Props) => {
<div className={ 'flex flex-col space-y-4 relative' }>
<div className={ 'z-[2]' }>
<p className={ 'text-xl font-bold' }>{ post['meta']['title'] }</p>
<p className={ 'text-xl font-bold' }>{ title }</p>
<Link href={ link }>
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
</Link>
@@ -3,57 +3,57 @@ import Image from 'next/image';
import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md';
// type Props = {
// post: any
// };
type Props = {
post: any
};
title: string;
subtitle: string;
summary: string | undefined;
category: string;
tags: string[];
first_update: string;
header_img: string | undefined;
}
const Headers = (props: Props) => {
let post = props.post;
let title = post['title'];
let sub_title = post['sub_title'];
// let summary = (post as any)['summary'];
let category = post['category'];
let tags = post['tags'];
// let last_update = new Date((post as any)['last_update']);
let first_update = new Date(post['first_update']);
// let post_id = (post as any)['post_id'];
let header_img = post['meta']['header_img'] as string | null;
const Headers = (post: Props) => {
return (
<>
{ header_img && <div className={ `w-full overflow h-96 relative` }>
{ post.header_img && <div className={ `w-full overflow h-96 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-[5]` }
fill
></Image>
<Image
alt={ 'cover picture' }
src={ header_img }
src={ post.header_img }
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
fill
></Image>
</div> }
<div
className={ header_img ?
className={ post.header_img ?
'mx-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
: 'mx-6 mt-6'
}>
<div className={ header_img ? `p-4` : 'p-4 pt-2' }>
<div className={ post.header_img ? `p-4` : 'p-4 pt-2' }>
<Chip className={ `mb-2` } radius={ `sm` } 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>
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
tags.map((tag: string, key: any) => (
post.tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
@@ -64,7 +64,7 @@ const Headers = (props: Props) => {
<div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', {
{ (new Date(post.first_update)).toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
@@ -43,10 +43,10 @@ const MinifyPostCard = (props: Props) => {
}
{ !img_url &&
<div className={ 'h-full grid p-6 place-content-center' }>
<div className={ 'flex flex-row items-center text-center max-h-fit' }>
<p className={ 'text-3xl font-bold' }>{ post['title'] }</p>
<div className={ 'h-full w-1 mx-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl' }>{ post['sub_title'] }</p>
<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>
</div>
</div>
}
@@ -0,0 +1,55 @@
import React from 'react';
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';
type Props = {
post: any
}
async function PostCard(props: Props) {
let post = props.post;
return (
<div>
<Card className={ `max-w-full relative` }>
{/* card headers */ }
<Headers title={ post.title }
subtitle={ post.sub_title }
summary={ post.summary }
category={ post.category }
tags={ post.tags }
first_update={ post.first_update }
header_img={ post.meta.header_img }
/>
{/* card body */ }
<CardBody>
<div className={ 'p-5' }>
<MarkdownRenderer>
{ post.content }
</MarkdownRenderer>
</div>
</CardBody>
{/* 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 }
title={ post.title }
/>
</Card>
</div>
);
}
export default PostCard;
@@ -0,0 +1,70 @@
import React, { cache } 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();
});
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> })
: Promise<Metadata> {
try {
const title = (await fetchPost((await params).id)).title as string;
return {
title: `${ title } - ${ SITE_CONFIG.name }`
};
} catch (e) {
return {
title: 'Unexpected Error'
};
}
}
async function BlogReader({
params
}: {
params: Promise<{ id: string }>
}) {
try {
let post = await fetchPost((await params).id);
return (
<motion.div
className={ `max-w-full` }
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5 } }
>
<div className={ `max-w-full` }>
<PostCard post={ post } />
</div>
<div className={ 'mt-3' }>
<ButtonGoBack />
</div>
</motion.div>
);
} catch (e: any) {
return (
<div className={ `flex h-full 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 BlogReader;
@@ -1,4 +1,10 @@
import React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `Friends - ${ SITE_CONFIG.name }`
};
function Layout({
children
File renamed without changes.
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import * as motion from 'motion/react-client';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div>
<motion.div
initial={ { opacity: 0 } }
animate={ { opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.3, delay: 0.5 } }
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` }>
{ children }
<Footer />
</div>
</motion.div>
</div>
)
;
}
export default Layout;
Whitespace-only changes.
File renamed without changes.
+43
View File
@@ -0,0 +1,43 @@
import React, { useEffect, useState } from 'react';
import { Activity } from '@/src/HomePageActivities/activity';
import { getGhActivities } from '@/src/HomePageActivities/ghActivity';
import * as motion from 'motion/react-client';
const Activities = () => {
const [ activities, setActivities ] = useState<Activity[] | null>(null);
useEffect(() => {
getGhActivities().then(data => setActivities(data));
}, []);
if (!activities) {
return (
<div>
<p>loading</p>
</div>
);
} else {
return (
<div>
<ul className={ 'flex flex-col gap-y-3 items-end' }>
{ activities.map((activity, key) => (
<motion.div
key={ key }
initial={ { opacity: 0, y: 20 } }
animate={ { opacity: 1, y: 0 } }
transition={ { ease: 'easeInOut', duration: 0.2 } }
>
<li className={ 'backdrop-blur bg-white/75 dark:bg-black/45 rounded-xl w-fit' }>
{ activity.toElement() }
</li>
</motion.div>
)) }
</ul>
</div>
);
}
};
export default Activities;
-53
View File
@@ -1,53 +0,0 @@
import React from 'react';
import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/blog/post/[id]/Headers';
import Footer from '@/app/blog/post/[id]/Footer';
import { fetchNocturneApi } from '@/app/blog/api';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
type Props = {
id: string
}
async function PostCard(props: Props) {
try {
const res = await fetchNocturneApi(`/blog/get_post/${ props.id }`);
const ret: any = await res.json();
return (
<div>
<Card className={ `max-w-full relative` }>
{/* card headers */ }
<Headers post={ ret } />
{/* card body */ }
<CardBody>
<div className={ 'p-5' }>
<MarkdownRenderer>
{ ret['content'] }
</MarkdownRenderer>
</div>
</CardBody>
{/* card footer */ }
<Footer post={ ret } />
</Card>
</div>
);
} catch (e: any) {
return <div>
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>
</div>;
}
}
export default PostCard;
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import PostCard from '@/app/blog/post/[id]/PostCard';
import { MotionDiv } from '@/app/public/MotionDiv';
import ButtonGoBack from '@/app/blog/post/[id]/ButtonGoBack';
async function BlogReader({
params
}: {
params: Promise<{ id: string }>
}) {
return (
<MotionDiv
className={ `max-w-full` }
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5 } }
>
<div className={ `max-w-full` }>
<PostCard id={ (await params).id } />
</div>
<div className={ 'mt-3' }>
<ButtonGoBack />
</div>
</MotionDiv>
);
}
export default BlogReader;
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import {
IoFlask,
IoHome,
IoInformationCircle,
IoLink,
IoLogoGithub,
IoMail,
IoPencil,
IoSearch
} from 'react-icons/io5';
export const entries: { display_name: string; href: string; icon: React.ReactNode }[] = [
{
icon: <IoHome size={ `20px` }></IoHome>,
display_name: 'Home',
href: '/'
},
{
icon: <IoInformationCircle size={ `20px` }></IoInformationCircle>,
display_name: 'About',
href: '/about'
},
{
icon: <IoPencil size={ `20px` }></IoPencil>,
display_name: 'Blog',
href: '/blog'
},
{
icon: <IoLink size={ `20px` }></IoLink>,
display_name: 'Friends',
href: '/friends'
},
{
icon: <IoSearch size={ `20px` }></IoSearch>,
display_name: 'Search',
href: '/search'
}, {
icon: <IoFlask size={ '20px' } />,
display_name: 'Lab',
href: 'https://lab.lmt.moe'
}
];
export const connections: { label: string, href: string, icon: React.ReactNode }[] = [
{
label: 'Github',
href: 'https://github.com/Lumither',
icon: <IoLogoGithub size={ `30px` } />
},
{
label: 'email',
href: 'mailto:lumither@outlook.com',
icon: <IoMail size={ `30px` } />
}
];
+7 -12
View File
@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
import './globals.css';
import React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: 'Lumitherの酒馆',
description: '一间位于时间夹缝中的酒馆,静候下一位旅者的到来...'
openGraph: {
siteName: SITE_CONFIG.name
},
title: SITE_CONFIG.name,
description: SITE_CONFIG.desc
};
export default function RootLayout({
@@ -20,13 +21,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }>
<NextThemesProvider attribute="class" defaultTheme="system">
<div 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` }>
{ children }
<Footer />
</div>
</div>
{ children }
</NextThemesProvider>
</body>
</html>
+44 -3
View File
@@ -1,10 +1,51 @@
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
'use client';
import React from 'react';
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';
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);
return (
<div className={ `h-dvh` }>
<ConstructionPlaceHolder />
<div className={ `h-dvh relative` }>
<ul className={ `flex flex-col space-y-2 w-full` }>
{
entries.map((meta, key) => (
<li key={ key }>
<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>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
'use client';
import { motion } from 'framer-motion';
export const MotionDiv = motion.div;
+2 -55
View File
@@ -3,65 +3,12 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@nextui-org/react';
import Image from 'next/image';
import {
IoFlask,
IoHome,
IoInformationCircle,
IoLink,
IoLogoGithub,
IoMail,
IoPencil,
IoSearch
} from 'react-icons/io5';
import Link from 'next/link';
import ThemeSwitcher from '@/components/ThemeSwitcher';
import { usePathname } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
import { connections, entries } from '@/app/entries';
const entries: { display_name: string; href: string; icon: React.ReactNode }[] = [
{
icon: <IoHome size={ `20px` }></IoHome>,
display_name: 'Home',
href: '/'
},
{
icon: <IoInformationCircle size={ `20px` }></IoInformationCircle>,
display_name: 'About',
href: '/about'
},
{
icon: <IoPencil size={ `20px` }></IoPencil>,
display_name: 'Blog',
href: '/blog'
},
{
icon: <IoLink size={ `20px` }></IoLink>,
display_name: 'Friends',
href: '/friends'
},
{
icon: <IoSearch size={ `20px` }></IoSearch>,
display_name: 'Search',
href: '/search'
}, {
icon: <IoFlask size={ '20px' } />,
display_name: 'Lab',
href: 'https://lab.lmt.moe'
}
];
const connections: { label: string, href: string, icon: React.ReactNode }[] = [
{
label: 'Github',
href: 'https://github.com/Lumither',
icon: <IoLogoGithub size={ `30px` } />
},
{
label: 'email',
href: 'mailto:lumither@outlook.com',
icon: <IoMail size={ `30px` } />
}
];
function Navbar() {
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react';
import { MdOutlineDarkMode, MdOutlineDesktopWindows, MdOutlineLightMode } from 'react-icons/md';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const switcherElements = new Map(
[
+2 -1
View File
@@ -11,8 +11,8 @@
"dependencies": {
"@nextui-org/react": "^2.4.6",
"@types/react-syntax-highlighter": "^15.5.13",
"framer-motion": "^11.3.24",
"lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1",
"next": "^15.0.3",
"next-themes": "^0.3.0",
"react": "^18.3.1",
@@ -20,6 +20,7 @@
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0",
"react-use": "^17.6.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-frontmatter": "^5.0.0",
@@ -0,0 +1,18 @@
import React from 'react';
export abstract class Activity {
protected constructor(
readonly time: Date
) {
}
abstract toElement(): React.ReactElement;
}
export abstract class GhActivity extends Activity {
protected constructor(time: Date) {
super(time);
}
}
@@ -0,0 +1 @@
export const GH_USER_NAME = 'Lumither';
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more