15 Commits
114 changed files with 1566 additions and 1337 deletions

No files matched your search

+2 -9
View File
@@ -3,14 +3,7 @@ ifeq ($(ARCH), arm64)
ARCH := aarch64 ARCH := aarch64
endif endif
all: release .PHONY: up
release:
docker compose -f prod.compose.yaml build --build-arg ARCH=$(ARCH)
dev:
docker-compose -f dev.compose.yaml up --build
clean: clean:
docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv echo "todo"
+10 -28
View File
@@ -1,34 +1,16 @@
# `Nocturne: personal website & CMS` # `Nocturne: Personal Website`
> This is the last working version 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
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the content (blog posts etc.) repo.
content repo.
Powered by Next.js and Rust, built with passion and love Powered by Next.js and Rust, built with passion and love
*will add more here in the future > [!NOTE]
> This system is not production ready, and contains bugs, (security risk potentially).
> But have fun on it =)
## Nav: # Todos
<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
- [ ] the todo list lol
- [ ] and much more...
+2 -3
View File
@@ -1,8 +1,7 @@
[workspace] [workspace]
resolver = "2" resolver = "3"
members = [ members = [
"api_server", "server",
"constants",
"macros", "macros",
"markdown", "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] [package]
name = "macros" name = "macros"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
tracing = "0.1.40" 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; mod log;
+21 -2
View File
@@ -6,7 +6,7 @@
/// use macros::panic_with_log; /// use macros::panic_with_log;
/// use tracing::Level; /// use tracing::Level;
/// ///
/// panic_with_log!(Level::ERROR, "Test panicked: {}", 42); /// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
/// ``` /// ```
#[macro_export] #[macro_export]
macro_rules! panic_with_log { 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)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
@@ -25,7 +44,7 @@ mod tests {
use std::io; use std::io;
use tracing::Level; use tracing::Level;
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, Registry}; use tracing_subscriber::{Registry, fmt};
let format = tracing_subscriber::fmt::format() let format = tracing_subscriber::fmt::format()
.with_level(true) .with_level(true)
+2 -2
View File
@@ -1,14 +1,14 @@
[package] [package]
name = "markdown" name = "markdown"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[dependencies] [dependencies]
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] } uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
serde = { version = "1.0.209", features = ["derive"] } serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128" serde_json = "1.0.128"
regex = "1.10.4" regex = "1.10.4"
yaml-rust2 = "0.9.0" yaml-rust2 = "0.10.0"
sha2 = "0.10.8" sha2 = "0.10.8"
base64 = "0.22.1" base64 = "0.22.1"
thiserror = "2.0.3" thiserror = "2.0.3"
+1 -1
View File
@@ -8,7 +8,7 @@ use std::str::FromStr;
use crate::error::Error; use crate::error::Error;
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter}; 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::Serialize;
use serde_json::Value; use serde_json::Value;
use sha2::{Digest, Sha256}; 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::error::Error;
use std::str::FromStr; 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::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, Pool, Postgres}; use sqlx::{ConnectOptions, Pool, Postgres};
use tracing::info; use tracing::info;
@@ -12,7 +13,7 @@ pub async fn init() -> Result<Pool<Postgres>, Box<dyn Error>> {
.unwrap() .unwrap()
.disable_statement_logging(); .disable_statement_logging();
Ok(PgPoolOptions::new() 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) .connect_with(db_connect_option)
.await?) .await?)
} }
@@ -1,14 +1,15 @@
use std::path::Path; use std::path::Path;
use std::{env, io}; use std::{env, io};
use crate::constants::config::general::{default_value, var_name};
use chrono::Local; use chrono::Local;
use constants::config::general::{default_value, var_name};
use dirs::home_dir; use dirs::home_dir;
use tracing::info; use tracing::info;
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling}; use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
use tracing_subscriber::{ use tracing_subscriber::{
filter::LevelFilter, fmt, fmt::format::Writer, fmt::time::FormatTime, fmt::MakeWriter, Layer, Registry, filter::LevelFilter, fmt, fmt::MakeWriter, fmt::format::Writer,
layer::SubscriberExt, Layer, Registry, fmt::time::FormatTime, layer::SubscriberExt,
}; };
struct LocalTimer; struct LocalTimer;
@@ -18,7 +19,7 @@ impl FormatTime for LocalTimer {
write!( write!(
w, 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; mod database;
pub mod logger; pub mod logger;
mod misc; mod misc;
mod schema;
pub mod server;
use macros::panic_with_log;
use std::{env, panic}; 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() { pub fn load_env() {
if let Some(env_file) = env::args().nth(1) { if let Some(env_file) = env::args().nth(1) {
@@ -34,25 +34,17 @@ pub async fn start() {
pool pool
} }
Err(e) => { Err(e) => {
panic_with_log!( error_panic!("database connection pool failed to initialize: {}", e);
Level::ERROR,
"database connection pool failed to initialize: {}",
e
);
} }
}; };
// schema init // // schema init
schema::init(&db_pool).await; // schema::init(&db_pool).await;
// load cron task ModuleTree::new()
cron::start(db_pool.clone()); .add_module(Blog {
db_handler: db_pool,
// api serve })
match server::start(db_pool).await { .blocking_serve()
Ok(_) => {} .await
Err(e) => {
panic_with_log!(Level::ERROR, "server panicked: {}", e)
}
}
} }
+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 std::error::Error;
use constants::blog::PAGE_SIZE; use crate::constants::blog::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json}; use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc}; 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::error::Error;
use std::path::Path; 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 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 sqlx::{query, PgPool, Pool, Postgres, Row};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -16,7 +16,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField { return Err(PostIdxError::MissingField {
field: "title".to_string(), field: "title".to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
}) });
} }
Some(title) => title.as_str().unwrap_or_else(|| { Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid title for `{}`", &post.filename); warn!("invalid title for `{}`", &post.filename);
@@ -39,7 +39,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField { return Err(PostIdxError::MissingField {
field: "category".to_string(), field: "category".to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
}) });
} }
Some(title) => title.as_str().unwrap_or_else(|| { Some(title) => title.as_str().unwrap_or_else(|| {
warn!("invalid category for `{}`", &post.filename); 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(), err_field: last_update.to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
msg: e.to_string(), msg: e.to_string(),
}) });
} }
} }
} else { } else {
@@ -68,7 +68,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField { return Err(PostIdxError::MissingField {
field: "date".to_string(), field: "date".to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
}) });
} }
Some(first_update) => match parse_date(first_update.as_str().unwrap()) { Some(first_update) => match parse_date(first_update.as_str().unwrap()) {
Ok(date) => date, Ok(date) => date,
@@ -78,7 +78,7 @@ async fn base(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
err_field: first_update.to_string(), err_field: first_update.to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
msg: e.to_string(), msg: e.to_string(),
}) });
} }
}, },
}; };
@@ -135,7 +135,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
return Err(PostIdxError::MissingField { return Err(PostIdxError::MissingField {
field: "tags".to_string(), field: "tags".to_string(),
filename: post.filename.clone(), filename: post.filename.clone(),
}) });
} }
Some(val) => val, Some(val) => val,
}; };
@@ -171,7 +171,7 @@ async fn tag(db_connection: &PgPool, post: &MdFile) -> Result<(), PostIdxError>
db_table: "Tag".to_string(), db_table: "Tag".to_string(),
id: post_id.to_string(), id: post_id.to_string(),
msg: e.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::{
use std::error::Error; error::Error,
use std::fmt::{Debug, Display, Formatter}; fmt::{Debug, Display, Formatter},
use std::sync::PoisonError; sync::PoisonError,
};
use crate::scheduler::error::SchedulerError::MutexPoison;
// todo: refactor with thiserror
pub enum SchedulerError { pub enum SchedulerError {
IdNotFound, IdNotFound,
IdAlreadyRunning, 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 std::{error::Error, pin::Pin, str::FromStr};
use crate::scheduler::tasks::Task;
use crate::scheduler::{task_func::TaskFunc, tasks::CronTask};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use cron::Schedule; use cron::Schedule;
use std::error::Error;
use std::str::FromStr;
pub struct BasicTask { pub struct BasicTask {
schedule: Schedule, 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) Box::new(self)
} }
} }
impl Task for BasicTask { impl CronTask for BasicTask {
fn get_next_execution(&self) -> Option<DateTime<Utc>> { fn get_next_execution(&self) -> Option<DateTime<Utc>> {
self.schedule.upcoming(Utc).next() self.schedule.upcoming(Utc).next()
} }
fn call(&self) { fn call(&self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
(self.func)() 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> { ) -> Result<AnnotatedCommit<'a>, git2::Error> {
let mut fetch_option = FetchOptions::new(); let mut fetch_option = FetchOptions::new();
// Always fetch all tags.
// Perform a download and also update tips
fetch_option.download_tags(AutotagOption::All); fetch_option.download_tags(AutotagOption::All);
remote.fetch(refs, Some(&mut fetch_option), None)?; remote.fetch(refs, Some(&mut fetch_option), None)?;
@@ -45,13 +43,7 @@ fn fast_forward(
// println!("{}", msg); // println!("{}", msg);
lb.set_target(rc.id(), &msg)?; lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?; repo.set_head(&name)?;
repo.checkout_head(Some( repo.checkout_head(Some(build::CheckoutBuilder::default().force()))?;
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(),
))?;
Ok(()) Ok(())
} }
@@ -73,13 +65,12 @@ fn normal_merge(
return Ok(()); return Ok(());
} }
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?; 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 msg = format!("merge({}) -> {}", remote.id(), local.id());
let sig = repo.signature()?; let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?; let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.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( let _merge_commit = repo.commit(
Some("HEAD"), Some("HEAD"),
&sig, &sig,
@@ -88,7 +79,6 @@ fn normal_merge(
&result_tree, &result_tree,
&[&local_commit, &remote_commit], &[&local_commit, &remote_commit],
)?; )?;
// Set working tree to match head.
repo.checkout_head(None)?; repo.checkout_head(None)?;
Ok(()) Ok(())
} }
@@ -98,24 +88,16 @@ fn do_merge<'a>(
remote_branch: &str, remote_branch: &str,
fetch_commit: AnnotatedCommit<'a>, fetch_commit: AnnotatedCommit<'a>,
) -> Result<Vec<FileDelta>, git2::Error> { ) -> Result<Vec<FileDelta>, git2::Error> {
// 1. do a merge analysis
let analysis = repo.merge_analysis(&[&fetch_commit])?; let analysis = repo.merge_analysis(&[&fetch_commit])?;
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?; let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
// 2. Do the appropriate merge
if analysis.0.is_fast_forward() { if analysis.0.is_fast_forward() {
// println!("Doing a fast-forward");
// do a fast-forward
let ref_name = format!("refs/heads/{}", remote_branch); let ref_name = format!("refs/heads/{}", remote_branch);
match repo.find_reference(&ref_name) { match repo.find_reference(&ref_name) {
Ok(mut r) => { Ok(mut r) => {
fast_forward(repo, &mut r, &fetch_commit)?; fast_forward(repo, &mut r, &fetch_commit)?;
} }
Err(_) => { 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( repo.reference(
&ref_name, &ref_name,
fetch_commit.id(), fetch_commit.id(),
@@ -132,14 +114,8 @@ fn do_merge<'a>(
} }
}; };
} else if analysis.0.is_normal() { } else if analysis.0.is_normal() {
// do a normal merge
normal_merge(repo, &head_commit, &fetch_commit)?; normal_merge(repo, &head_commit, &fetch_commit)?;
} }
// else {
// // already up-to-date
// println!("Nothing to do...");
// }
calculate_diff(repo, &head_commit, &fetch_commit) calculate_diff(repo, &head_commit, &fetch_commit)
} }
@@ -156,23 +132,12 @@ fn calculate_diff(
if let Err(e) = diff.foreach( if let Err(e) = diff.foreach(
&mut |d, _| { &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 { updated_files.push(FileDelta {
old_path: d.old_file().path().map(PathBuf::from), old_path: d.old_file().path().map(PathBuf::from),
new_path: d.new_file().path().map(PathBuf::from), new_path: d.new_file().path().map(PathBuf::from),
status: d.status(), status: d.status(),
}); });
true // Continue true
}, },
None, None,
None, None,
File renamed without changes.
@@ -1,4 +1,10 @@
import React from 'react'; 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({ function Layout({
children children
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer'; import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch'; import { fetchText } from '@/app/fetch';
const Me = async () => { const Me = async () => {
File renamed without changes.
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer'; import MarkdownRenderer from '@/app/(pages)/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch'; import { fetchText } from '@/app/fetch';
const Website = async () => { const Website = async () => {
File renamed without changes.
@@ -3,7 +3,7 @@
import React from 'react'; import React from 'react';
import { Pagination as NextUIPagination } from '@nextui-org/react'; import { Pagination as NextUIPagination } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger'; import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger';
const Pagination = ({ total }: { const Pagination = ({ total }: {
total: number, total: number,
@@ -1,11 +1,12 @@
'use server'; 'use server';
import React from 'react'; import React from 'react';
import { fetchNocturneApi } from '@/app/blog/api'; import { fetchNocturneApi } from '@/app/(pages)/blog/api';
import { MotionDiv } from '@/app/public/MotionDiv'; import BlogPostCard from '@/app/(pages)/blog/BlogPostCard';
import BlogPostCard from '@/app/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card'; import { Card, CardBody } from '@nextui-org/card';
import * as motion from 'motion/react-client';
const PostList = async ({ page }: { page: number }) => { const PostList = async ({ page }: { page: number }) => {
try { try {
const res = await fetchNocturneApi(`/blog/get_post_list?page=${ page }`); 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' }> <ul className={ 'flex flex-col items-center w-full space-y-7' }>
{ posts.map((post: JSON, key: number) => ( { posts.map((post: JSON, key: number) => (
<li key={ key } className={ 'w-full' }> <li key={ key } className={ 'w-full' }>
<MotionDiv <motion.div
initial={ { y: 20, opacity: 0 } } initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } } animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } } transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
> >
<BlogPostCard post={ post } /> <BlogPostCard post={ post } />
</MotionDiv> </motion.div>
</li>) </li>)
) } ) }
</ul> </ul>
File renamed without changes.
@@ -1,4 +1,10 @@
import React from 'react'; 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({ function Layout({
children children
@@ -1,6 +1,6 @@
import PostList from '@/app/blog/PostList'; import PostList from '@/app/(pages)/blog/PostList';
import Pagination from '@/app/blog/Pagination'; import Pagination from '@/app/(pages)/blog/Pagination';
import { fetchNocturneApi } from '@/app/blog/api'; import { fetchNocturneApi } from '@/app/(pages)/blog/api';
async function Blog({ async function Blog({
searchParams searchParams
@@ -1,24 +1,32 @@
import React from 'react'; 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 Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6'; import { FaCreativeCommons } from 'react-icons/fa6';
type Props = { 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 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 prev_post = adjacent_posts['prev']; const next_post = adjacent_posts.next;
const next_post = adjacent_posts['next']; const link = perm_link ?? `https://lumither.com/blog/post/${ post_id }`;
const link = post['meta']['link'] ?? `https://lumither.com/blog/post/${ post['meta']['id'] }`; const author = props.author ?? 'Tao';
const author = post['meta']['author'] ?? 'Tao'; const author_link = props.author_link ?? '/about/me';
const author_link = post['meta']['author_link'] ?? '/about'; const post_date = new Date(first_update);
const post_date = new Date(post['first_update']); const update_date: Date | undefined = last_update ? new Date(last_update) : undefined;
const update_date: Date | undefined = post['last_update'] ? new Date(post['last_update']) : undefined; const cc = props.cc ?? 'by-nc-sa';
const cc = post['meta']['cc'] ?? 'by-nc-sa';
return ( return (
@@ -35,7 +43,7 @@ const Footer = (props: Props) => {
<div className={ 'flex flex-col space-y-4 relative' }> <div className={ 'flex flex-col space-y-4 relative' }>
<div className={ 'z-[2]' }> <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 }> <Link href={ link }>
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p> <p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
</Link> </Link>
@@ -3,57 +3,57 @@ import Image from 'next/image';
import { Chip } from '@nextui-org/react'; import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md'; import { MdCalendarMonth } from 'react-icons/md';
// type Props = {
// post: any
// };
type Props = { 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) => { const Headers = (post: 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;
return ( return (
<> <>
{ header_img && <div className={ `w-full overflow h-96 relative` }> { post.header_img && <div className={ `w-full overflow h-96 relative` }>
<Image <Image
alt={ 'cover picture' } alt={ 'cover picture' }
src={ header_img } src={ post.header_img }
className={ `w-full object-cover rounded-b-none z-[5]` } className={ `w-full object-cover rounded-b-none z-[5]` }
fill fill
></Image> ></Image>
<Image <Image
alt={ 'cover picture' } alt={ 'cover picture' }
src={ header_img } src={ post.header_img }
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` } className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
fill fill
></Image> ></Image>
</div> } </div> }
<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-8 -mt-16 backdrop-blur transition bg-zinc-300/80 dark:bg-gray-600/25 rounded-2xl z-30'
: 'mx-6 mt-6' : '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` } <Chip className={ `mb-2` } radius={ `sm` } color={ `secondary` }
variant={ `solid` }>{ category }</Chip> variant={ `solid` }>{ post.category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p> <p className={ `text-4xl font-bold` }> { post.title }</p>
<p className={ `text-xl` }> { sub_title }</p> <p className={ `text-xl` }> { post.subtitle }</p>
</div> </div>
<div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }> <div className={ `mx-2 -mt-2 mb-2 flex flex-row flex-wrap justify-between w-full` }>
<div> <div>
<ul className={ `flex flex-row flex-wrap` }> <ul className={ `flex flex-row flex-wrap` }>
{ {
tags.map((tag: string, key: any) => ( post.tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }> <li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip> <Chip>{ tag }</Chip>
</li> </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` }> <div className={ `flex flex-row place-content-end mb-1 mr-6 items-center mx-1` }>
<MdCalendarMonth /> <MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }> <p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', { { (new Date(post.first_update)).toLocaleString('default', {
month: `long`, month: `long`,
day: `numeric`, day: `numeric`,
year: `numeric` year: `numeric`
@@ -43,10 +43,10 @@ const MinifyPostCard = (props: Props) => {
} }
{ !img_url && { !img_url &&
<div className={ 'h-full grid p-6 place-content-center' }> <div className={ 'h-full grid p-6 place-content-center' }>
<div className={ 'flex flex-row items-center text-center max-h-fit' }> <div className={ 'flex flex-col max-h-fit text-left' }>
<p className={ 'text-3xl font-bold' }>{ post['title'] }</p> <p className={ 'text-3xl font-bold text-balance' }>{ post['title'] }</p>
<div className={ 'h-full w-1 mx-2 bg-zinc-500 dark:bg-zinc-400' }></div> <div className={ 'w-1/4 h-[2px] my-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl' }>{ post['sub_title'] }</p> <p className={ 'text-xl text-pretty break-words' }>{ post['sub_title'] }</p>
</div> </div>
</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 React from 'react';
import { Metadata } from 'next';
import { SITE_CONFIG } from '@/src/constants';
export const metadata: Metadata = {
title: `Friends - ${ SITE_CONFIG.name }`
};
function Layout({ function Layout({
children 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` } />
}
];
+6 -11
View File
@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
import './globals.css'; import './globals.css';
import React from 'react'; import React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes'; import { ThemeProvider as NextThemesProvider } from 'next-themes';
import Navbar from '@/components/Navbar'; import { SITE_CONFIG } from '@/src/constants';
import Footer from '@/components/Footer';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Lumitherの酒馆', openGraph: {
description: '一间位于时间夹缝中的酒馆,静候下一位旅者的到来...' siteName: SITE_CONFIG.name
},
title: SITE_CONFIG.name,
description: SITE_CONFIG.desc
}; };
export default function RootLayout({ export default function RootLayout({
@@ -20,13 +21,7 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }> <body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }>
<NextThemesProvider attribute="class" defaultTheme="system"> <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 } { children }
<Footer />
</div>
</div>
</NextThemesProvider> </NextThemesProvider>
</body> </body>
</html> </html>
+44 -3
View File
@@ -1,10 +1,51 @@
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder'; 'use client';
import React from 'react'; 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() { 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 ( return (
<div className={ `h-dvh` }> <div className={ `h-dvh relative` }>
<ConstructionPlaceHolder /> <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> </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 React, { useEffect, useState } from 'react';
import { Button } from '@nextui-org/react'; import { Button } from '@nextui-org/react';
import Image from 'next/image'; import Image from 'next/image';
import {
IoFlask,
IoHome,
IoInformationCircle,
IoLink,
IoLogoGithub,
IoMail,
IoPencil,
IoSearch
} from 'react-icons/io5';
import Link from 'next/link'; import Link from 'next/link';
import ThemeSwitcher from '@/components/ThemeSwitcher'; import ThemeSwitcher from '@/components/ThemeSwitcher';
import { usePathname } from 'next/navigation'; 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() { function Navbar() {
+1 -1
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react'; import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react';
import { MdOutlineDarkMode, MdOutlineDesktopWindows, MdOutlineLightMode } from 'react-icons/md'; 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( const switcherElements = new Map(
[ [
+2 -1
View File
@@ -11,8 +11,8 @@
"dependencies": { "dependencies": {
"@nextui-org/react": "^2.4.6", "@nextui-org/react": "^2.4.6",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"framer-motion": "^11.3.24",
"lxgw-wenkai-webfont": "^1.7.0", "lxgw-wenkai-webfont": "^1.7.0",
"motion": "^12.3.1",
"next": "^15.0.3", "next": "^15.0.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -20,6 +20,7 @@
"react-icons": "^5.2.1", "react-icons": "^5.2.1",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-use": "^17.6.0",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0", "rehype-sanitize": "^6.0.0",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
@@ -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