21 Commits
Author SHA1 Message Date
lumither 4309f42bf7 feat(frontend) qwik init 2025-02-17 15:50:53 -08:00
lumither eef32ad695 feat(backend, lb) add missing components 2025-02-17 15:28:33 -08:00
lumither acd0525513 feat(backend) rebuild start 2025-02-17 15:27:41 -08:00
lumither 8938c96409 feat(frontend) add Activities.tsx 2025-02-05 13:57:20 -08:00
lumither 6da6231c92 feat(lab) add link to lab 2024-12-10 04:00:57 -08:00
lumither 19f84d2188 fix(blog::post::footer) remove update_date section if undefined 2024-12-10 04:00:10 -08:00
lumither 20c2964242 feat(container) production build 2024-12-08 22:25:47 -08:00
lumither eac84307bf Merge pull request #1 from Lumither/backend-refactor
Backend refactor
2024-12-01 02:39:00 -08:00
lumither a0dc40d2ca feat(blog::api) fix apis, frontend 2024-12-01 02:35:13 -08:00
lumither cbc831bb70 fix(cron::update) fix several errors 2024-11-27 04:00:38 -08:00
lumither bacaea80a3 feat(cron::update) auto update git repo and update db index 2024-11-27 00:18:08 -08:00
lumither 99a19d8d9e feat(init::schema) init database schema 2024-11-20 21:38:36 -08:00
lumither 7f400470ff feat(cron) call the module 2024-11-20 19:26:33 -08:00
lumither e26003f430 ref(global) move modules, extract constants 2024-11-20 00:57:54 -08:00
lumither c7e3b12856 feat(macros) add panic_with_log 2024-11-19 00:33:09 -08:00
lumither a123ff9f3f fix(scheduler) fn type 2024-11-17 21:06:36 -08:00
lumither d98aa3f745 fix(scheduler) remove unnecessary field 2024-10-30 23:07:22 -07:00
lumither 6f45a140ba ref(scheduler) move module 2024-10-30 22:54:39 -07:00
lumither 3dab27323a ref, feat(scheduler) global project refactor, add scheduler 2024-10-29 18:37:14 -07:00
lumither 3baf011be9 feat(backend) improve logger.rs 2024-09-08 00:05:14 -07:00
lumither c1ba41ea16 ref(backend) start of refactor 2024-09-04 23:18:41 -07:00
121 changed files with 8205 additions and 2962 deletions

No files matched your search

+10 -9
View File
@@ -1,18 +1,19 @@
# postgres settings # postgres settings
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5432 DB_PORT=5432
POSTGRES_PASSWORD= #POSTGRES_PASSWORD=
POSTGRES_USER= POSTGRES_USER=nocturne
POSTGRES_DB=nocturne POSTGRES_DB=nocturne
POSTGRES_HOST=postgres
# backend settings # backend settings
GIT_WORK_DIR=../blog_posts_git #GIT_WORK_DIR=../blog_posts_git
BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git #LOG_ROOT_DIR=/var/log/nocturne
LOG_ROOT_DIR=/var/log/nocturne #BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git
BACKEND_PORT=3001 BLOG_GIT_URL=https://github.com/Lumither/blog-posts.git
WORK_DIR=/nocturne
#BACKEND_PORT=3001
# frontend settings # frontend settings
API_LOCAL_URL=http://localhost:3001 API_LOCAL_URL=http://backend:3001
API_REMOTE_URL=https://lumither.com/api API_REMOTE_URL=https://lumither.com/api
+2 -1
View File
@@ -2,8 +2,9 @@
.env.* .env.*
/container/*/.env /container/*/.env
# idea related # IDE related
.idea .idea
.vscode
# exclude post repo # exclude post repo
/blog_posts_git /blog_posts_git
+13 -3
View File
@@ -1,6 +1,16 @@
ddev: ARCH := $(shell uname -m)
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 docker-compose -f dev.compose.yaml up --build
dclean: clean:
docker-compose -f dev.compose.yaml rm -fsv docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv
+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 This is the code repo of my [personal website](https://lumither.com),
content repo. 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 Powered by Next.js and Rust, built with passion and love
<p align="center"> > [!NOTE]
<img src="https://avatars.githubusercontent.com/u/46409277?v=4" alt="GitHub Avatar"/> > This system is not production ready, and contains bugs, (security risk potentially).
</p> > But have fun on it =)
*will add more here in the future # Todos
## 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
- [ ] the todo list lol
- [ ] and much more...
+2
View File
@@ -1 +1,3 @@
/target /target
/Cargo.lock
+7 -25
View File
@@ -1,25 +1,7 @@
[package] [workspace]
name = "backend" resolver = "2"
version = "0.1.0" members = [
edition = "2021" "server",
"macros",
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html "markdown",
]
[dependencies]
axum = "0.7.5"
dotenv = "0.15.0"
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.116"
tokio = { version = "1.37.0", features = ["full"] }
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono", "uuid"] }
chrono = { version = "0.4.38", features = ["serde", "alloc"] }
regex = "1.10.4"
futures = { version = "0.3.30", features = ["executor"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
tracing-appender = "0.2.3"
log = "0.4.21"
sha2 = "0.10.8"
thiserror = "1.0.63"
base64 = "0.22.1"
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "macros"
version = "0.1.0"
edition = "2021"
[dependencies]
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
+1
View File
@@ -0,0 +1 @@
mod log;
+64
View File
@@ -0,0 +1,64 @@
/// log the message at specific log level and exit(1)
///
/// # Example
/// ```rust
///
/// use macros::panic_with_log;
/// use tracing::Level;
///
/// panic_with_log!(Level::ERROR, "test panicked: {}", 42);
/// ```
#[macro_export]
macro_rules! panic_with_log {
($level:expr, $msg:literal $(, $($arg:tt)*)?) => {{
use std::process;
use tracing::event;
event!($level, $msg $(, $($arg)*)?);
process::exit(1);
}};
}
/// 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]
fn test_panic_with_log() {
use std::io;
use tracing::Level;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, Registry};
let format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true);
let subscriber = Registry::default().with(
fmt::Layer::default()
.with_writer(io::stderr)
.event_format(format),
);
tracing::subscriber::set_global_default(subscriber).unwrap();
panic_with_log!(Level::ERROR, "Test panicked: {}", 42);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "markdown"
version = "0.1.0"
edition = "2021"
[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"
sha2 = "0.10.8"
base64 = "0.22.1"
thiserror = "2.0.3"
+16
View File
@@ -0,0 +1,16 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("missing front-matter field: id")]
MissingID,
#[error("invalid file id: {}", msg)]
InvalidID { msg: String },
#[error("invalid front-matter: {}", msg)]
InvalidFrontMatter { msg: String },
#[error("fs error: {}", msg)]
FSError { msg: String },
}
+68
View File
@@ -0,0 +1,68 @@
mod error;
mod utils;
use std::fs;
use std::path::Path;
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 serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
#[derive(Default, Debug, Serialize)]
pub struct MdFile {
pub file_id: Uuid,
pub filename: String,
pub meta: Value,
pub content: String,
pub hash: String,
}
impl MdFile {
pub fn from_file(file_path: &Path) -> Result<MdFile, Error> {
let raw_file = match fs::read_to_string(file_path) {
Ok(file) => file,
Err(e) => return Err(Error::FSError { msg: e.to_string() }),
};
let raw_file_bytes = raw_file.as_bytes().to_vec();
let (front_matter_str, content) = split_md_front_matter(raw_file);
let front_matter = match parse_front_matter(&front_matter_str) {
Ok(front_matter) => front_matter,
Err(e) => return Err(Error::InvalidFrontMatter { msg: e.to_string() }),
};
let hash = calculate_hash_base64(&raw_file_bytes);
let file_id = match front_matter["id"].as_str() {
Some(id) => match Uuid::from_str(id) {
Ok(id) => id,
Err(e) => return Err(Error::InvalidID { msg: e.to_string() }),
},
None => return Err(Error::MissingID),
};
let file_name = Path::new(file_path)
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string();
Ok(MdFile {
file_id,
filename: file_name,
meta: front_matter,
content,
hash,
})
}
}
fn calculate_hash_base64(bytes: &Vec<u8>) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize().to_vec();
general_purpose::STANDARD.encode(hash)
}
@@ -0,0 +1,81 @@
use crate::utils::yaml::yaml_to_serde;
use regex::Regex;
use serde_json::Value;
use std::error::Error;
use yaml_rust2::YamlLoader;
pub fn split_md_front_matter(raw_file: String) -> (String, String) {
let re = Regex::new(r"(?s)^(---\n.*?\n---\n)(.*)").unwrap();
if let Some(captures) = re.captures(raw_file.as_str()) {
let front_matter = captures.get(1).map_or("", |m| m.as_str()).to_string();
let main_content = captures.get(2).map_or("", |m| m.as_str()).to_string();
(front_matter, main_content)
} else {
(String::new(), raw_file)
}
}
pub fn parse_front_matter(front_matter: &str) -> Result<Value, Box<dyn Error>> {
let parsed_f_matter = YamlLoader::load_from_str(front_matter)?;
if parsed_f_matter.is_empty() {
Ok(Value::String(String::new()))
} else {
Ok(yaml_to_serde(&parsed_f_matter[0]))
}
}
#[cfg(test)]
mod tests {
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
#[test]
fn front_matter_to_string() {
let (front_matter, main_content) = split_md_front_matter(
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
test body
"##
.to_string(),
);
assert_eq!(
front_matter,
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
"##
);
assert_eq!(main_content, "\ntest body\n\n");
}
#[test]
fn test_front_matter_parser() {
dbg!(parse_front_matter(
r##"---
title: test_title
date: 0000-00-00+0000
category: test
id: bf03afd8-61b4-4e78-a34a-8b3ee39adf16
sub_title: sub_title
summary: summary
tags: [ "test1", "test2" ]
---
"##,
))
.expect("Failed to parse front matter");
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod front_matter;
mod yaml;
+29
View File
@@ -0,0 +1,29 @@
use serde_json::Value;
use std::str::FromStr;
use yaml_rust2::Yaml;
pub fn yaml_to_serde(yaml: &Yaml) -> Value {
match yaml {
Yaml::Null => Value::Null,
Yaml::Boolean(b) => Value::Bool(*b),
Yaml::Integer(i) => Value::Number(serde_json::Number::from(*i)),
Yaml::Real(s) => Value::Number(serde_json::Number::from_str(s).unwrap()),
Yaml::String(s) => Value::String(s.clone()),
Yaml::Array(arr) => Value::Array(arr.iter().map(yaml_to_serde).collect()),
Yaml::Hash(hash) => {
let obj: serde_json::map::Map<String, Value> = hash
.iter()
.map(|(k, v)| {
let key = match k {
Yaml::String(s) => s.clone(),
_ => "INVALID_KEY".to_string(),
};
(key, yaml_to_serde(v))
})
.collect();
Value::Object(obj)
}
Yaml::Alias(_) => Value::Null,
Yaml::BadValue => Value::Null,
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
macros = { path = "../macros" }
tracing = "0.1.41"
git2 = "0.20.0"
[build-dependencies]
git2 = "0.20.0"
chrono = "0.4.39"
+15
View File
@@ -0,0 +1,15 @@
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_date = Utc::now().date_naive().format("%Y%m%d").to_string();
let build_id = format!("{}@{}", utc_date, &hash[..8]);
println!("cargo:rustc-env=BUILD_ID={}", build_id);
}
+32
View File
@@ -0,0 +1,32 @@
CREATE TABLE tag
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE category
(
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE post
(
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 category (id) NOT NULL
);
CREATE TABLE post_tag
(
post SERIAL REFERENCES post (id),
tag SERIAL REFERENCES tag (id)
)
+1
View File
@@ -0,0 +1 @@
pub const PAGE_SIZE: u32 = 6;
+48
View File
@@ -0,0 +1,48 @@
// Database related
pub mod database {
pub mod var_name {
pub const URI: &str = "DATABASE_URL";
pub const USERNAME: &str = "POSTGRES_USER";
pub const PASSWORD: &str = "POSTGRES_PASSWORD";
pub const HOST: &str = "POSTGRES_HOST";
pub const PORT: &str = "POSTGRES_PORT";
pub const DBNAME: &str = "POSTGRES_DB";
}
pub mod default_value {
pub const USERNAME: &str = "";
pub const PASSWORD: &str = "";
pub const HOST: &str = "localhost";
pub const PORT: &str = "5432";
pub const DBNAME: &str = "nocturne";
pub const DATABASE_TYPE: &str = "postgresql";
}
}
pub mod server {
pub mod var_name {
pub const PORT: &str = "BACKEND_PORT";
pub const HOST: &str = "BACKEND_HOST";
}
pub mod default_value {
pub const PORT: u32 = 3001;
pub const HOST: &str = "0.0.0.0";
}
}
pub mod general {
pub mod var_name {
pub const LOG_DIR: &str = "LOG_DIR";
pub const WORK_DIR: &str = "WORK_DIR";
pub const BLOG_GIT_URL: &str = "BLOG_GIT_URL";
pub const BLOG_GIT_REMOTE_BRANCH: &str = "BLOG_GIT_REMOTE_BRANCH";
pub const BLOG_GIT_REMOTE_NAME: &str = "BLOG_GIT_REMOTE_NAME";
}
pub mod default_value {
pub const WORK_DIR: &str = "~/.nocturne";
pub const BLOG_GIT_REMOTE_BRANCH: &str = "master";
pub const BLOG_GIT_REMOTE_NAME: &str = "origin";
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod blog;
pub mod config;
pub mod time;
+2
View File
@@ -0,0 +1,2 @@
pub const DEFAULT_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
pub const DEFAULT_DATE_FORMAT: &str = "%Y-%m-%d%zT%H%M%S";
+7
View File
@@ -0,0 +1,7 @@
mod constants;
const BUILD_ID: &str = env!("BUILD_ID");
fn main() {
println!("{}", BUILD_ID);
}
-25
View File
@@ -1,25 +0,0 @@
use crate::constants::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()})))
}
}
}
-154
View File
@@ -1,154 +0,0 @@
use std::str::FromStr;
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::{json, Value};
use sqlx::{Error, PgPool, query, Row};
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)))
}
-87
View File
@@ -1,87 +0,0 @@
use crate::constants::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use futures::future::join_all;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sqlx::{postgres::PgRow, query, PgPool, Row};
use tracing::error;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct Params {
page: Option<u32>,
}
pub async fn get_post_list(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let search_param_page = params.page.unwrap_or(1);
if search_param_page < 1 {
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": "`page` should be greater than 0"})),
));
}
let offset = PAGE_SIZE * (search_param_page - 1);
let post_list = sqlx::query("SELECT * FROM post ORDER BY first_update DESC LIMIT $1 OFFSET $2")
.bind(PAGE_SIZE as i32)
.bind(offset as i32)
.fetch_all(&db_connection)
.await;
let post_list = match post_list {
Ok(post_list) => post_list,
Err(e) => {
error!(
"Database query error on fetching post list: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = Map::new();
let posts: Vec<_> = post_list
.into_iter()
.map(|post| analyze_post(&db_connection, post))
.collect();
let posts = join_all(posts).await;
res.insert("posts".to_string(), Value::from(posts));
Ok(Json::from(Value::from(res)))
}
async fn analyze_post(db_connection: &PgPool, post: PgRow) -> Value {
let post_id: Uuid = post.get("post_id");
let tags = fetch_post_tags(db_connection, post_id).await;
json!({
"post_id": post_id.to_string(),
"title": post.get::<String, _>("title"),
"category": post.get::<String, _>("category"),
"header_img": post.get::<String, _>("header_img"),
"sub_title": post.get::<String, _>("sub_title"),
"summary": post.get::<String, _>("summary"),
"last_update": post.get::<DateTime<Utc>, _>("last_update"),
"first_update": post.get::<DateTime<Utc>, _>("first_update"),
"tags": tags,
})
}
async fn fetch_post_tags(db_connection: &PgPool, post_id: Uuid) -> Vec<String> {
match query("SELECT tag FROM Tag WHERE post_id = $1")
.bind(post_id)
.fetch_all(db_connection)
.await
{
Ok(res) => res.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(_) => vec![],
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod get_page_count;
pub mod get_post;
pub mod get_post_list;
-2
View File
@@ -1,2 +0,0 @@
pub mod get;
pub mod post;
-1
View File
@@ -1 +0,0 @@
pub mod refresh;
-61
View File
@@ -1,61 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PostIdxError {
#[error("Failed to parse UUID <{}>: {}", id, err_msg)]
InvalidUUID { id: String, err_msg: String },
#[error(
"Failed to parse `last update time` for <{}> when creating post index: {}",
id,
err_msg
)]
InvalidLastUpdate { id: String, err_msg: String },
#[error(
"Failed to parse `first update time` for <{}> when creating post index: {}",
id,
err_msg
)]
InvalidFirstUpdate { id: String, err_msg: String },
#[error(
"Failed to parse `tag` for <{}> when creating post index: <{}>: {}",
id,
invalid_string,
err_msg
)]
InvalidTagString {
id: String,
invalid_string: String,
err_msg: String,
},
#[error(
"Database error on writing `{}` to table `{}`: <{}>: {}",
data_desc,
db_table,
id,
err_msg
)]
DBWriteFailure {
data_desc: String,
db_table: String,
id: String,
err_msg: String,
},
#[error(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
data_desc,
db_table,
id,
err_msg
)]
DBReadFailure {
data_desc: String,
db_table: String,
id: String,
err_msg: String,
},
}
-3
View File
@@ -1,3 +0,0 @@
mod errors;
pub mod refresh;
mod utils;
-192
View File
@@ -1,192 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use std::{env, fs};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use futures::{stream, StreamExt};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use sqlx::{query, PgPool};
use tracing::error;
use uuid::Uuid;
use crate::api::post::errors::refresh::PostIdxError;
use crate::api::post::utils::{build_post_idx, post};
use crate::model::blog::{POST_BASE_MODEL, POST_HASH_MODEL, POST_META_MODEL, TAG_MODEL};
#[derive(Deserialize)]
pub struct Params {
/// refresh level
level: Option<String>,
}
pub async fn refresh(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
let is_force_refresh = params.level.unwrap_or("".to_string()) == "force";
// search post
let data_repo_dir = match env::var("GIT_WORK_DIR") {
Ok(value) => value,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!((
"error",
"error at parsing environment variable: GIT_WORK_DIR"
))),
));
}
};
let post_dir = Path::new(&data_repo_dir).join("posts");
if is_force_refresh {
if let Err(e) = query("DROP TABLE IF EXISTS hash, meta, post, tag;")
.execute(&db_connection)
.await
{
error!(
"DB query error when deleting table: `hash`, `meta`, `post`, `tag`: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!(("error", e.to_string()))),
));
}
}
// generate post idx
let post_path = search_md(&post_dir);
let post_list_with_hash: Vec<(Map<_, _>, Vec<_>)> = post_path
.into_iter()
.filter_map(|post_path| match post::from_path(&post_path) {
Ok(post) => match fs::read(&post_path) {
Ok(bytes) => {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize().to_vec();
Some((post, hash))
}
Err(e) => {
error!(
"Failed to read file while hashing the post <{}>, skipping: {}",
post_path,
e.to_string()
);
None
}
},
Err(e) => {
error!(
"Failed to parse post <{}>, skipping: {}",
post_path,
e.to_string()
);
None
}
})
.collect();
// db create
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();
let res: Vec<_> = stream::iter(post_list_with_hash)
.map(|(post, hash)| {
let db_clone = db_connection.clone();
async move { build_post_idx(&db_clone, post, hash).await }
})
.buffer_unordered(usize::MAX)
.collect()
.await;
let errs: Vec<_> = res
.into_iter()
.filter_map(|res| res.err())
.map(|e| e.to_string())
.collect();
if !errs.is_empty() {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": errs})),
))
} else {
Ok(StatusCode::OK)
}
}
/// Search a dir, return a list of markdown files
///
/// # Arguments
/// * `entry`: dir path
///
/// # Returns
/// Vec<String, Global>
/// * `String`: path of a markdown file
///
fn search_md(entry: &Path) -> Vec<String> {
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.as_path())
})
.collect()
} else {
vec![]
}
} else if entry.extension().is_some_and(|ext| ext == "md") {
vec![entry.to_str().unwrap().to_string()]
} else {
vec![]
}
}
async fn build_post_idx(
db_connection: &PgPool,
post: Map<String, Value>,
hash: Vec<u8>,
) -> Result<(), PostIdxError> {
let meta = &post["meta"];
// extract post id
let post_id = match Uuid::from_str(post["post_id"].as_str().unwrap()) {
Ok(id) => id,
Err(e) => {
return Err(PostIdxError::InvalidUUID {
id: post["post_id"].as_str().unwrap().to_string(),
err_msg: e.to_string(),
})
}
};
if build_post_idx::hash(db_connection, post_id, hash).await? {
build_post_idx::base(db_connection, &post, post_id, meta).await?;
build_post_idx::tag(db_connection, post_id, meta).await?;
build_post_idx::meta(db_connection, post_id, meta).await?;
}
Ok(())
}
@@ -1,288 +0,0 @@
use base64::{Engine, engine::general_purpose};
use chrono::DateTime;
use serde_json::{Map, Value};
use sqlx::{PgPool, query, Row};
use uuid::Uuid;
use crate::api::post::errors::refresh::PostIdxError;
use crate::constants::GLOBAL_TIME_FORMAT;
pub async fn base(
db_connection: &PgPool,
post: &Map<String, Value>,
post_id: Uuid,
meta: &Value,
) -> Result<(), PostIdxError> {
let title = post["title"].as_str();
let summary = post
.get("summary")
.and_then(|summary| summary.as_str())
.unwrap_or("");
let content = post["content"].as_str();
let last_update =
match DateTime::parse_from_str(post["last_update"].as_str().unwrap(), GLOBAL_TIME_FORMAT) {
Ok(time) => time,
Err(e) => {
return Err(PostIdxError::InvalidLastUpdate {
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
};
let first_update = match DateTime::parse_from_str(
post["first_update"].as_str().unwrap(),
GLOBAL_TIME_FORMAT,
) {
Ok(time) => time,
Err(e) => {
return Err(PostIdxError::InvalidFirstUpdate {
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
};
let sub_title = meta
.get("sub_title")
.and_then(|sub_title| sub_title.as_str())
.unwrap_or("");
let category = meta
.get("category")
.and_then(|category| category.as_str())
.unwrap_or("N/A");
let header_img = meta
.get("header_img")
.and_then(|header_img| header_img.as_str())
.unwrap_or("");
match query(
r##"
INSERT INTO Post (post_id, title, summary, content, last_update, first_update, sub_title, category, header_img)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (post_id) DO
UPDATE SET
title = EXCLUDED.title,
summary = EXCLUDED.summary,
content = EXCLUDED.content,
last_update = EXCLUDED.last_update,
first_update = EXCLUDED.first_update,
sub_title = EXCLUDED.sub_title,
category = EXCLUDED.category,
header_img = EXCLUDED.header_img;
"##,
)
.bind(post_id)
.bind(title)
.bind(summary)
.bind(content)
.bind(last_update)
.bind(first_update)
.bind(sub_title)
.bind(category)
.bind(header_img)
.execute(db_connection)
.await
{
Ok(_) => {}
Err(e) => {
return Err(
PostIdxError::DBWriteFailure {
data_desc: "post base information".to_string(),
db_table: "Post".to_string(),
id: post_id.to_string(),
err_msg: e.to_string()
}
)
}
}
Ok(())
}
pub async fn tag(db_connection: &PgPool, post_id: Uuid, meta: &Value) -> Result<(), PostIdxError> {
if let Some(tags_string) = meta.get("tags").to_owned() {
// parse tags string
let parsed: Value = match serde_json::from_str(tags_string.as_str().unwrap()) {
Ok(value) => value,
Err(e) => {
return Err(PostIdxError::InvalidTagString {
id: post_id.to_string(),
invalid_string: tags_string.to_string().to_string(),
err_msg: e.to_string(),
})
}
};
let string_array = parsed.as_array().unwrap();
let tags: Vec<String> = string_array
.iter()
.map(|value| value.as_str().unwrap().to_string())
.collect();
// remove old record
if let Err(e) = query("DELETE FROM Tag WHERE post_id = $1")
.bind(post_id)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
// add new record
for tag in tags {
match query("INSERT INTO Tag VALUES ($1, $2);")
.bind(post_id)
.bind(tag)
.execute(db_connection)
.await
{
Ok(_) => {}
Err(e) => {
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
}
}
}
Ok(())
}
/// # Arguments
///
/// * `db_connection`: database connection
/// * `post_id`: UUID of the post
/// * `hash`: Hex String
///
/// # Returns
/// Result<bool, PostIdxError>
/// - `bool`: if the hash is different from database, return true, else false
pub async fn hash(
db_connection: &PgPool,
post_id: Uuid,
hash: Vec<u8>,
) -> Result<bool, PostIdxError> {
let base64_form = general_purpose::STANDARD.encode(hash);
match query("SELECT hash FROM Hash WHERE post_id = $1")
.bind(post_id)
.fetch_optional(db_connection)
.await
{
Ok(row) => {
let db_record: String = match row {
None => "".to_string(),
Some(row) => row.try_get("hash").unwrap_or("".to_string()),
};
if base64_form == db_record {
Ok(false)
} else {
if let Err(e) = query(
r#"
INSERT INTO Hash (post_id, hash) VALUES ($1, $2)
ON CONFLICT (post_id) DO UPDATE SET hash = EXCLUDED.hash"#,
)
.bind(post_id)
.bind(&base64_form)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
Ok(true)
}
}
Err(e) => Err(PostIdxError::DBReadFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
}),
}
}
pub async fn meta(db_connection: &PgPool, post_id: Uuid, meta: &Value) -> Result<(), PostIdxError> {
let mut err_trails = 5;
loop {
if let Err(e) = query(
format!(
r#"
DO
$$
DECLARE
_post_id UUID := '{}';
_meta_cols JSONB := '{}';
_exists BOOLEAN;
_col_name TEXT;
_col_value TEXT;
_sql TEXT;
_insert_columns TEXT := 'post_id';
_insert_values TEXT := quote_literal(_post_id);
_update_set TEXT := '';
_blacklist TEXT[] := ARRAY['tags'];
BEGIN
SELECT EXISTS(SELECT 1 FROM meta WHERE post_id = _post_id) INTO _exists;
FOR _col_name, _col_value IN SELECT key, value FROM jsonb_each_text(_meta_cols)
LOOP
IF _col_name = ANY(_blacklist) THEN
CONTINUE;
END IF;
IF NOT EXISTS (SELECT 1
FROM information_schema.columns
WHERE table_name = 'meta'
AND column_name = _col_name
-- may change in the future
AND table_schema = 'public') THEN
_sql := 'ALTER TABLE meta ADD COLUMN ' || quote_ident(_col_name) || ' TEXT';
EXECUTE _sql;
END IF;
_insert_columns := _insert_columns || ', ' || quote_ident(_col_name);
_insert_values := _insert_values || ', ' || quote_literal(_col_value);
_update_set := _update_set || ', ' || quote_ident(_col_name) || ' = ' || quote_literal(_col_value);
END LOOP;
_update_set := substring(_update_set FROM 3);
IF _exists THEN
_sql := 'UPDATE meta SET ' || _update_set || ' WHERE post_id = ' || quote_literal(_post_id);
EXECUTE _sql;
ELSE
_sql := 'INSERT INTO meta (' || _insert_columns || ') VALUES (' || _insert_values || ')';
EXECUTE _sql;
END IF;
END;
$$ LANGUAGE plpgsql;
"#, post_id.to_string().as_str(), meta.to_string()
).as_str()
)
.execute(db_connection)
.await {
if err_trails >= 0 {
err_trails -= 1;
continue
} else {
return Err(PostIdxError::DBWriteFailure {
data_desc: "post meta data".to_string(),
db_table: "Meta".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
} else {
return Ok(())
}
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod build_post_idx;
pub mod post;
-120
View File
@@ -1,120 +0,0 @@
use std::{env, fs};
use std::error::Error;
use std::process::Command;
use std::str;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde_json::{Map, Value};
use tracing::{error, warn};
use uuid::Uuid;
use crate::constants::GLOBAL_TIME_FORMAT;
use crate::markdown::meta::parse_meta;
pub fn from_path(path: &str) -> Result<Map<String, Value>, Box<dyn Error>> {
let mut res = Map::new();
// field `content`
let md_content = fs::read_to_string(path)?;
res.insert("content".to_string(), Value::from(md_content.to_owned()));
// field `meta`
let meta = parse_meta(&md_content);
let meta_map: Map<String, Value> = meta
.clone()
.into_iter()
.map(|(k, v)| (k, Value::from(v)))
.collect();
res.insert("meta".to_string(), Value::from(meta_map));
// field `post_id`
let post_id = match meta.get("id") {
None => Uuid::new_v4(),
Some(id) => match Uuid::from_str(id.as_str()) {
Ok(id) => id,
Err(e) => {
error!("Failed to parse UUID <{}>: {}", id.as_str(), e.to_string());
return Err(Box::new(e));
}
},
};
res.insert("post_id".to_string(), Value::from(post_id.to_string()));
// field `title`
let title = match meta.get("title") {
None => "Title Not Found".to_string(),
Some(title) => title.to_owned(),
};
res.insert("title".to_string(), Value::from(title));
// field `summary`
let summary = match meta.get("summary") {
None => "".to_string(),
Some(summary) => summary.to_owned(),
};
res.insert("summary".to_string(), Value::from(summary));
// field `last_update`
let git_output = match Command::new("git")
.arg("--no-pager")
.arg("log")
.arg("-1")
.arg("--format=%cI")
.arg("--")
.current_dir(env::var("GIT_WORK_DIR")?)
.arg(path)
.output()
{
Ok(res) => res,
Err(e) => {
error!("Failed to execute `git` command: {}", e.to_string());
return Err(Box::new(e));
}
};
let last_update: DateTime<Utc> = if git_output.status.success() {
chrono::DateTime::parse_from_str(
str::from_utf8(&git_output.stdout).unwrap().trim_end(),
GLOBAL_TIME_FORMAT,
)
.unwrap_or_else(|_| DateTime::from(Utc::now()))
.to_utc()
} else {
Utc::now().to_utc()
};
res.insert(
"last_update".to_string(),
Value::from(last_update.format(GLOBAL_TIME_FORMAT).to_string()),
);
// field `first_update`
let first_update: DateTime<Utc> = match meta.get("date") {
None => {
warn!(
"Failed to parse first update time for post {}, using current time instead",
post_id.to_string()
);
Utc::now().to_utc()
}
Some(date) => DateTime::from(
match DateTime::parse_from_str(format!("{}T000000", date).as_str(), "%Y-%m-%d%zT%H%M%S")
{
Ok(time) => time,
Err(e) => {
warn!(
"Failed to parse first update time for post {}, using current time instead: {}",
post_id.to_string(),
e.to_string()
);
DateTime::from(Utc::now())
}
},
),
};
res.insert(
"first_update".to_string(),
Value::from(first_update.format(GLOBAL_TIME_FORMAT).to_string()),
);
Ok(res)
}
-3
View File
@@ -1,3 +0,0 @@
pub const GLOBAL_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
pub const PAGE_SIZE: u32 = 6;
-67
View File
@@ -1,67 +0,0 @@
use std::{env, io};
use chrono::Local;
use tracing::info;
use tracing_appender::{non_blocking, rolling};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, Registry};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::layer::SubscriberExt;
use crate::constants::GLOBAL_TIME_FORMAT;
struct LocalTimer;
impl FormatTime for LocalTimer {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
write!(w, "{}", Local::now().format(GLOBAL_TIME_FORMAT))
}
}
type _LogWriter = dyn for<'writer> MakeWriter<'writer, Writer = dyn io::Write + 'static> + 'static;
pub fn init() -> (WorkerGuard, WorkerGuard) {
let log_path = env::var("LOG_ROOT_DIR").unwrap_or_else(|e| {
panic!("[Fatal] failed to load LOG_ROOT_DIR: {}", e);
});
let log_path = format!("{}/backend", log_path);
let (info_log_writer, info_guard) = non_blocking(rolling::never(&log_path, "info.log"));
let (err_log_writer, error_guard) = non_blocking(rolling::never(&log_path, "error.log"));
let log_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_timer(LocalTimer);
let subscribers = Registry::default()
.with(
fmt::Layer::default()
.with_writer(info_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(err_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(io::stdout)
.with_ansi(true)
.event_format(log_format),
);
tracing::subscriber::set_global_default(subscribers)
.expect("[Fatal] Failed to setup logging system");
info!("Module Started: Logger");
// error!("test");
(info_guard, error_guard)
}
-102
View File
@@ -1,102 +0,0 @@
use std::env;
use std::error::Error;
use std::num::ParseIntError;
use std::process::exit;
use std::str::FromStr;
use crate::api::get::get_page_count::get_page_count;
use crate::api::get::get_post::get_post;
use crate::api::get::get_post_list::get_post_list;
use crate::api::post::refresh::refresh;
use axum::routing::{get, post};
use axum::Router;
use dotenv::dotenv;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::ConnectOptions;
use tracing::{error, info, warn};
mod api;
mod constants;
mod logger;
mod markdown;
mod model;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
if let Some(env_file) = env::args().nth(1) {
match dotenv::from_filename(&env_file) {
Ok(_) => {
println!("[Info] env loaded from {}", env_file);
}
Err(e) => {
panic!("[Fatal] failed to read {}: {}", env_file, e);
}
}
};
match dotenv() {
Ok(_) => {
println!("[Info] env loaded from .env file, starting up...")
}
Err(_) => {
println!("[Info] .env not detected, starting up...")
}
};
let _guards = logger::init();
let port: u32 = match env::var("BACKEND_PORT") {
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
warn!("Failed to parse BACKEND_PORT: {}", e.to_string());
3001
}),
Err(_) => 3001,
};
let db_connect_option = PgConnectOptions::from_str(dbg!(&parse_db_uri()))
.unwrap()
.disable_statement_logging();
let db_pool = match PgPoolOptions::new().connect_with(db_connect_option).await {
Ok(pool) => {
info!("Database connected");
pool
}
Err(e) => {
error!("Failed to load database: {}", e.to_string());
exit(1);
}
};
let app = Router::new()
.route("/refresh_posts", post(refresh))
.route("/get_post_list", get(get_post_list))
.route("/get_page_count", get(get_page_count))
.route("/get_post/:post_id", get(get_post))
.with_state(db_pool);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap_or_else(|e| {
error!("Failed to build TCP listener: {}", e.to_string());
exit(1);
});
axum::serve(listener, app).await.unwrap_or_else(|e| {
error!("Failed to start axum: {}", e.to_string());
exit(1);
});
Ok(())
}
fn parse_db_uri() -> String {
let postgres_user = env::var("POSTGRES_USER").unwrap_or("".to_string());
let postgres_password = env::var("POSTGRES_PASSWORD").unwrap_or("".to_string());
let db_host = env::var("DB_HOST").unwrap_or("localhost".to_string());
let db_port = env::var("DB_PORT")
.unwrap_or("5432".to_string())
.to_string();
let postgres_db = env::var("POSTGRES_DB").unwrap_or("nocturne".to_string());
format!("postgresql://{postgres_user}:{postgres_password}@{db_host}:{db_port}/{postgres_db}")
}
-55
View File
@@ -1,55 +0,0 @@
use std::collections::HashMap;
use regex::Regex;
/// return the meta info contained in the .md file front matter
pub fn parse_meta(md_content: &str) -> HashMap<String, String> {
let mut fields: HashMap<String, String> = HashMap::new();
let match_regex = Regex::new(r"(?s)---\n(.*\n)---\n").unwrap();
if let Some(front_matter) = match_regex.captures(md_content) {
let fm_content = front_matter.get(1).unwrap().as_str();
for line in fm_content.lines() {
if let Some((k, v)) = extract_kv(line) {
fields.insert(k, v);
}
}
}
fields
}
fn extract_kv(line: &str) -> Option<(String, String)> {
let split: Vec<&str> = line.splitn(2, ':').map(|s| s.trim()).collect();
if split.len() == 2 {
let key = split[0].to_string();
let value = split[1].to_string();
Some((key, value))
} else {
None
}
}
#[cfg(test)]
mod meta_test {
use std::error::Error;
use crate::markdown::meta::parse_meta;
#[test]
fn test_parse_meta_front_matter() -> Result<(), Box<dyn Error>> {
let parsed_meta = parse_meta(
r##"
---
title: Hello World!
tags: [ "test", "hello_world" ]
---
"##,
);
dbg!(&parsed_meta);
assert_eq!(parsed_meta.get("title"), Some(&"Hello World!".to_string()));
assert_eq!(
parsed_meta.get("tags"),
Some(&r#"[ "test", "hello_world" ]"#.to_string())
);
Ok(())
}
}
-1
View File
@@ -1 +0,0 @@
pub mod meta;
-5
View File
@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS Hash
(
post_id UUID PRIMARY KEY,
hash VARCHAR(44)
);
-79
View File
@@ -1,79 +0,0 @@
CREATE TABLE IF NOT EXISTS Meta
(
post_id UUID PRIMARY KEY
);
DROP table meta;
DO
$$
DECLARE
_post_id UUID := '{}';
_meta_cols JSONB := '{}';
_exists BOOLEAN;
_col_name TEXT;
_col_value TEXT;
_sql TEXT;
_insert_columns TEXT := 'post_id';
_insert_values TEXT := quote_literal(_post_id);
_update_set TEXT := '';
_blacklist TEXT[] := ARRAY ['id', 'tags'];
BEGIN
SELECT EXISTS(SELECT 1 FROM meta WHERE post_id = _post_id) INTO _exists;
FOR _col_name, _col_value IN SELECT key, value FROM jsonb_each_text(_meta_cols)
LOOP
IF _col_name = ANY (_blacklist) THEN
CONTINUE;
END IF;
IF NOT EXISTS (SELECT 1
FROM information_schema.columns
WHERE table_name = 'meta'
AND column_name = _col_name
-- may change in the future
AND table_schema = 'public') THEN
_sql := 'ALTER TABLE meta ADD COLUMN ' || quote_ident(_col_name) || ' TEXT';
EXECUTE _sql;
END IF;
_insert_columns := _insert_columns || ', ' || quote_ident(_col_name);
_insert_values := _insert_values || ', ' || quote_literal(_col_value);
_update_set := _update_set || ', ' || quote_ident(_col_name) || ' = ' || quote_literal(_col_value);
END LOOP;
_update_set := substring(_update_set FROM 3);
IF _exists THEN
_sql := 'UPDATE meta SET ' || _update_set || ' WHERE post_id = ' || quote_literal(_post_id);
EXECUTE _sql;
ELSE
_sql := 'INSERT INTO meta (' || _insert_columns || ') VALUES (' || _insert_values || ')';
EXECUTE _sql;
END IF;
END;
$$ LANGUAGE plpgsql;
SELECT jsonb_strip_nulls(to_jsonb(t)) AS meta
FROM meta t
WHERE post_id = '8ca3fa2b-f040-448c-8885-81aab56db9bb';
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = '8ca3fa2b-f040-448c-8885-81aab56db9bb'),
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;
-38
View File
@@ -1,38 +0,0 @@
pub 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,
header_img TEXT,
summary TEXT,
sub_title TEXT,
content TEXT NOT NULL,
last_update TIMESTAMPTZ NOT NULL,
first_update TIMESTAMPTZ NOT NULL
);
"##;
// base mode, will add more cols in runtime
pub const POST_META_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Meta
(
post_id UUID PRIMARY KEY
);
"##;
pub const POST_HASH_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Hash
(
post_id UUID PRIMARY KEY,
hash VARCHAR(44)
);
"##;
pub const TAG_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Tag
(
post_id UUID NOT NULL,
tag VARCHAR(255) NOT NULL
);
"##;
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS Post
(
post_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(255) NOT NULL,
header_img TEXT,
summary TEXT,
sub_title TEXT,
content TEXT NOT NULL,
last_update TIMESTAMPTZ NOT NULL,
first_update TIMESTAMPTZ NOT NULL
);
-5
View File
@@ -1,5 +0,0 @@
CREATE TABLE IF NOT EXISTS Tag
(
post_id UUID NOT NULL,
tag VARCHAR(255) NOT NULL
);
-1
View File
@@ -1 +0,0 @@
pub mod blog;
+10
View File
@@ -0,0 +1,10 @@
FROM rust:latest AS builder
WORKDIR /app
COPY . .
RUN ["cargo", "build", "-r"]
FROM gcr.io/distroless/cc AS production
ARG ARCH
COPY --from=builder /app/target/release/api_server ./api_server
COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/
ENTRYPOINT ["./api_server"]
+15
View File
@@ -0,0 +1,15 @@
FROM node:22 AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22 AS production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
ENV HOSTNAME="0.0.0.0"
CMD ["./server.js"]
+2 -2
View File
@@ -5,7 +5,7 @@ services:
dockerfile: ../container/dev/frontend.containerfile dockerfile: ../container/dev/frontend.containerfile
restart: always restart: always
ports: ports:
- 3000:3000 - "3000:3000"
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
@@ -22,7 +22,7 @@ services:
dockerfile: ../container/dev/backend.containerfile dockerfile: ../container/dev/backend.containerfile
restart: always restart: always
ports: ports:
- 3001:3001 - "3001:3001"
volumes: volumes:
- ./blog_posts_git:/app/blog_posts_git:ro - ./blog_posts_git:/app/blog_posts_git:ro
env_file: env_file:
-10
View File
@@ -1,10 +0,0 @@
/node_modules
/.next/
/out/
/build
.DS_Store
*.pem
.env*.local
+38
View File
@@ -0,0 +1,38 @@
**/*.log
**/.DS_Store
*.
.vscode/settings.json
.history
.yarn
bazel-*
bazel-bin
bazel-out
bazel-qwik
bazel-testlogs
dist
dist-dev
lib
lib-types
etc
external
node_modules
temp
tsc-out
tsdoc-metadata.json
target
output
rollup.config.js
build
.cache
.vscode
.rollup.cache
dist
tsconfig.tsbuildinfo
vite.config.ts
*.spec.tsx
*.spec.ts
.netlify
pnpm-lock.yaml
package-lock.json
yarn.lock
server
+42
View File
@@ -0,0 +1,42 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:qwik/recommended",
],
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
ecmaVersion: 2021,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-inferrable-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prefer-spread": "off",
"no-case-declarations": "off",
"no-console": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/no-unnecessary-condition": "warn",
},
};
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}
+33 -34
View File
@@ -1,43 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Build
/dist
/lib
/lib-types
/server
# dependencies # Development
/node_modules node_modules
/.pnp .env
.pnp.js *.local
.yarn/install-state.gz
# testing # Cache
/coverage .cache
.mf
.rollup.cache
tsconfig.tsbuildinfo
# next.js # Logs
/.next/ logs
/out/ *.log
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# local env files # Editor
.env*.local .vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lockb
# ide related
.idea .idea
/package-lock.json .DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Yarn
.yarn/*
!.yarn/releases
+37
View File
@@ -0,0 +1,37 @@
**/*.log
**/.DS_Store
*.
.vscode/settings.json
.history
.yarn
bazel-*
bazel-bin
bazel-out
bazel-qwik
bazel-testlogs
dist
dist-dev
lib
lib-types
etc
external
node_modules
temp
tsc-out
tsdoc-metadata.json
target
output
rollup.config.js
build
.cache
.vscode
.rollup.cache
tsconfig.tsbuildinfo
vite.config.ts
*.spec.tsx
*.spec.ts
.netlify
pnpm-lock.yaml
package-lock.json
yarn.lock
server
+65
View File
@@ -0,0 +1,65 @@
# Qwik City App ⚡️
- [Qwik Docs](https://qwik.dev/)
- [Discord](https://qwik.dev/chat)
- [Qwik GitHub](https://github.com/QwikDev/qwik)
- [@QwikDev](https://twitter.com/QwikDev)
- [Vite](https://vitejs.dev/)
---
## Project Structure
This project is using Qwik with [QwikCity](https://qwik.dev/qwikcity/overview/). QwikCity is just an extra set of tools on top of Qwik to make it easier to build a full site, including directory-based routing, layouts, and more.
Inside your project, you'll see the following directory structure:
```
├── public/
│ └── ...
└── src/
├── components/
│ └── ...
└── routes/
└── ...
```
- `src/routes`: Provides the directory-based routing, which can include a hierarchy of `layout.tsx` layout files, and an `index.tsx` file as the page. Additionally, `index.ts` files are endpoints. Please see the [routing docs](https://qwik.dev/qwikcity/routing/overview/) for more info.
- `src/components`: Recommended directory for components.
- `public`: Any static assets, like images, can be placed in the public directory. Please see the [Vite public directory](https://vitejs.dev/guide/assets.html#the-public-directory) for more info.
## Add Integrations and deployment
Use the `npm run qwik add` command to add additional integrations. Some examples of integrations includes: Cloudflare, Netlify or Express Server, and the [Static Site Generator (SSG)](https://qwik.dev/qwikcity/guides/static-site-generation/).
```shell
npm run qwik add # or `yarn qwik add`
```
## Development
Development mode uses [Vite's development server](https://vitejs.dev/). The `dev` command will server-side render (SSR) the output during development.
```shell
npm start # or `yarn start`
```
> Note: during dev mode, Vite may request a significant number of `.js` files. This does not represent a Qwik production build.
## Preview
The preview command will create a production build of the client modules, a production build of `src/entry.preview.tsx`, and run a local server. The preview server is only for convenience to preview a production build locally and should not be used as a production server.
```shell
npm run preview # or `yarn preview`
```
## Production
The production build will generate client and server modules by running both client and server build commands. The build command will use Typescript to run a type check on the source code.
```shell
npm run build # or `yarn build`
```
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex p-7' }>
{ children }
</div>
);
}
export default Layout;
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Me = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/me.md') }
</MarkdownRenderer>
</div>
);
};
export default Me;
-58
View File
@@ -1,58 +0,0 @@
import { Card } from '@nextui-org/react';
import React from 'react';
import { CardBody } from '@nextui-org/card';
import Link from 'next/link';
const About = () => {
return (
<div className={ `flex flex-col w-full justify-center align-middle h-dvh` }>
<div className={ 'md:relative md:h-[130px] mb-4 md:mb-0' }>
<div className={ 'md:relative md:left-[20%] flex flex-col w-fit' }>
<p className={ 'text-pretty text-3xl' }>{ '讷,远道而来的' }<span
className={ 'break-keep underline decoration-sky-400 dark:decoration-sky-700 decoration-4 underline-offset-0' }>旅人</span>
</p>
<p className={ 'text-pretty md:text-2xl mt-1 md:ml-4' }>{ '愿意坐下来听个故事吗...' }</p>
<p className={ 'hidden md:block text-lg md:ml-8' }>{ '一个关于一位' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '追寻' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ ',探索' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '的故事' }
</p>
</div>
</div>
<div className={ `flex justify-center w-full h-[60%] md:h-[40%]` }>
<div
className={ `flex flex-col md:flex-row w-full md:w-[85%] lg:w-[90%] xl:w-[85%] p-4 gap-x-7 gap-y-5` }>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/me' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于我</p>
</div>
</CardBody>
</Card>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/website' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于这个网站</p>
</div>
</CardBody>
</Card>
</div>
</div>
<p className={ 'text-center' }>*目前还没想到如何设计这一页,先摸鱼会(笑)</p>
</div>
);
};
export default About;
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Website = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/website.md') }
</MarkdownRenderer>
</div>
);
};
export default Website;
-92
View File
@@ -1,92 +0,0 @@
import React from 'react';
import { Card, CardBody, CardFooter } from '@nextui-org/card';
import { Chip } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
import { MdCalendarMonth, MdNorthEast } from 'react-icons/md';
type Props = {
post: JSON
}
function BlogPostCard({ post }: Props) {
let title = (post as any)['title'];
let sub_title = (post as any)['sub_title'];
// let summary = (post as any)['summary'];
let category = (post as any)['category'];
let tags = (post as any)['tags'];
// let last_update = new Date((post as any)['last_update']);
let first_update = new Date((post as any)['first_update']);
let post_id = (post as any)['post_id'];
let header_img = (post as any)['header_img'];
return (
<div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` }
className={ `group` }
isPressable
isHoverable
>
{ header_img !== '' && <div className={ `w-full overflow h-80 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' }
fill
></Image>
<Image
alt={ 'cover picture shadow' }
src={ header_img }
loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill
></Image>
</div> }
<CardBody className={ `overflow-visible` }>
<div className={ `flex flex-row justify-between` }>
<div className={ `mx-2 ${ header_img !== '' ? '-mt-6' : 'mt-2' }` }>
<Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } color={ `secondary` }
variant={ `solid` }>{ category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p>
<p className={ `text-xl` }> { sub_title }</p>
</div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div>
</CardBody>
<CardFooter className={ 'pt-0' }>
<div className={ `flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
))
}
</ul>
</div>
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }
</p>
</div>
</div>
</CardFooter>
</Card>
</div>
);
}
export default BlogPostCard;
-1
View File
@@ -1 +0,0 @@
// todo: create component
-37
View File
@@ -1,37 +0,0 @@
'use client';
import React from 'react';
import { Pagination as NextUIPagination } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
const Pagination = ({ total }: {
total: number,
}) => {
const parsePage = Number(useSearchParams().get('page'));
const currPage = parsePage > 0 ? parsePage : 1;
const router = useRouter();
const isMobile = useScreenSizeTrigger('md');
return (
<div className={ 'w-full p-4 pt-6 flex justify-center' }>
<NextUIPagination
initialPage={ currPage }
total={ total }
siblings={ isMobile ? 0 : 1 }
size={ isMobile ? 'sm' : 'md' }
showControls
showShadow
color={ 'default' }
variant={ 'light' }
onChange={ (page) => {
router.push(`?page=${ page }`, { scroll: false });
} }
/>
</div>
);
};
export default Pagination;
-42
View File
@@ -1,42 +0,0 @@
'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 { Card, CardBody } from '@nextui-org/card';
const PostList = async ({ page }: { page: number }) => {
try {
const res = await fetchNocturneApi(`/get_post_list?page=${ page }`);
let posts = (await res.json() as any)['posts'];
return (
// blog list
<div className={ 'w-full' }>
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
{ posts.map((post: JSON, key: number) => (
<li key={ key } className={ 'w-full' }>
<MotionDiv
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
>
<BlogPostCard post={ post } />
</MotionDiv>
</li>)
) }
</ul>
</div>
);
} catch (e: any) {
return (
// todo: use Error.tsx
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>);
}
};
export default PostList;
-21
View File
@@ -1,21 +0,0 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
});
export async function fetchNocturneApi(path: string, isServerSideRequest = true) {
return await fetch(`${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`, {
cache: 'no-cache'
});
}
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex flex-auto p-7 pl-2 lg:pl-7 w-full justify-center' }>
<main className={ 'w-full' }>
{ children }
</main>
</div>
);
}
export default Layout;
-24
View File
@@ -1,24 +0,0 @@
import PostList from '@/app/blog/PostList';
import Pagination from '@/app/blog/Pagination';
import { fetchNocturneApi } from '@/app/blog/api';
async function Blog({ searchParams }: {
searchParams?: {
page?: number
};
}) {
const parsePage = Number(searchParams?.page);
const currPage = parsePage > 0 ? parsePage : 1;
const ret = await fetchNocturneApi(`/get_page_count`);
const pageCount = (await ret.json() as any)['res'] as number;
return (
<div className={ 'w-full min-w-full space-y-4' }>
<PostList page={ currPage } />
<Pagination total={ pageCount } />
</div>
);
}
export default Blog;
@@ -1,21 +0,0 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@nextui-org/react';
import { IoArrowBackOutline } from 'react-icons/io5';
const ButtonGoBack = () => {
const router = useRouter();
return (
<Button
startContent={ <IoArrowBackOutline /> }
variant={ 'light' }
onClick={ () => router.back() }
>
{ 'Back' }
</Button>
);
};
export default ButtonGoBack;
-94
View File
@@ -1,94 +0,0 @@
import React from 'react';
import MinifyPostCard from '@/app/blog/post/[id]/MinifyPostCard';
import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6';
type Props = {
post: any
}
const Footer = (props: Props) => {
const { post } = 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 = new Date(post['last_update']);
const cc = post['meta']['cc'] ?? 'by-nc-sa';
return (
<div className={ `w-full` }>
<div className={ 'flex transition bg-zinc-300/80 dark:bg-gray-600/25' }>
<div className={ `p-8 w-full overflow-hidden` }>
<div className={ 'relative' }>
<FaCreativeCommons
size={ '160px' }
className={ 'absolute right-0 top-0 -mt-[4.8rem] -mr-16 opacity-30 brightness-50 ' }
/>
</div>
<div className={ 'flex flex-col space-y-4 relative' }>
<div className={ 'z-[2]' }>
<p className={ 'text-xl font-bold' }>{ post['meta']['title'] }</p>
<Link href={ link }>
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
</Link>
</div>
<div className={ 'z-[2] flex flex-row flex-wrap gap-x-7 gap-y-2' }>
<div>
<p className={ 'text-lg font-bold' }>Author</p>
<Link href={ author_link }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ author }</p>
</Link>
</div>
<div>
<p className={ 'text-lg font-bold' }>Posted on</p>
<p>{ post_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
<div>
<p className={ 'text-lg font-bold' }>Updated on</p>
<p>{ update_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
<div>
<p className={ 'text-lg font-bold' }>Published under</p>
<Link href={ `https://creativecommons.org/licenses/${ cc }/4.0/` }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ `CC ${ cc.toUpperCase() } 4.0` }</p>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className={ `flex justify-center` }>
<div className={ `w-full shadow-2xl ` }>
<div className={ `flex flex-col md:flex-row w-full` }>
{
next_post &&
<MinifyPostCard post={ next_post } desc={ 'prev' }></MinifyPostCard>
}
{
prev_post &&
<MinifyPostCard post={ prev_post } desc={ 'next' }></MinifyPostCard>
}
</div>
</div>
</div>
</div>
);
};
export default Footer;
-80
View File
@@ -1,80 +0,0 @@
import React from 'react';
import Image from 'next/image';
import { Chip } from '@nextui-org/react';
import { MdCalendarMonth } from 'react-icons/md';
type Props = {
post: any
};
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['header_img'];
return (
<>
{ header_img !== '' && <div className={ `w-full overflow h-96 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
className={ `w-full object-cover rounded-b-none z-[5]` }
fill
></Image>
<Image
alt={ 'cover picture' }
src={ header_img }
className={ `w-full relative scale-110 blur-3xl object-cover rounded-b-none` }
fill
></Image>
</div> }
<div
className={ 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' }>
<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>
</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) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
))
}
</ul>
</div>
<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', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }
</p>
</div>
</div>
</div>
</>
);
};
export default Headers;
@@ -1,58 +0,0 @@
import React from 'react';
import { Card, CardFooter, CardHeader } from '@nextui-org/card';
import Image from 'next/image';
import Link from 'next/link';
type Props = {
post: any
desc: string
}
const MinifyPostCard = (props: Props) => {
const { post, desc } = props;
const img_url = post['header_img'] as string;
return (
<div className={ 'w-full transition hover:brightness-75' }>
<Card isFooterBlurred
className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden"
as={ Link }
isPressable
href={ `/blog/post/${ post['id'] }` }
>
<CardHeader className="absolute z-10 top-1 flex-col items-start">
<p className={ `text-tiny uppercase font-bold ${ img_url ? 'text-white/60' : 'text-black/60 dark:text-white/60' }` }>{ desc }</p>
</CardHeader>
{ img_url &&
<div className={ 'h-full' }>
<div className={ 'relative h-[300px] overflow-hidden' }>
<Image
className={ 'object-cover object-center ' }
src={ img_url }
alt={ post.title }
fill
/>
</div>
<CardFooter
className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none">
<div>
<p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p>
</div>
</CardFooter>
</div>
}
{ !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>
</div>
}
</Card>
</div>
);
};
export default MinifyPostCard;
-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(`/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;
-25
View File
@@ -1,25 +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';
function BlogReader({ params }: { params: { 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={ params.id } />
</div>
<div className={ 'mt-3' }>
<ButtonGoBack />
</div>
</MotionDiv>
);
}
export default BlogReader;
-17
View File
@@ -1,17 +0,0 @@
export async function fetchText(url: string, ttl: number = 600) {
const res = await fetch(url, {
next: {
revalidate: ttl
}
});
return await res.text();
}
export async function fetchJson(url: string, ttl: number = 600) {
const res = await fetch(url, {
next: {
revalidate: ttl
}
});
return await res.json();
}
-15
View File
@@ -1,15 +0,0 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'p-7' }>
{ children }
</div>
);
}
export default Layout;
-93
View File
@@ -1,93 +0,0 @@
import React from 'react';
import { fetchJson } from '@/app/fetch';
import { Card, CardBody } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
export default async function Friends() {
const list = await fetchJson('https://raw.githubusercontent.com/Lumither/friends/master/friends.json');
const listLength = list.length;
const listMaxIdx = listLength - 1;
const listIsOdd = listLength % 2 !== 0;
return (
<div>
<div className={ `min-h-dvh pb-10` }>
<div className={ 'p-7 pt-[10%] md:pt-[20%]' }>
<div className={ 'flex flex-col md:flex-row gap-x-3 justify-center' }>
<p className={ 'text-3xl text-center text-balance' }>海内存知己</p>
<p className={ 'text-3xl text-center text-balance' }>天涯若比邻</p>
</div>
<p className={ 'text-center text-balance text-zinc-500 dark:text-zinc-400' }>{ 'Though far apart, kin at heart' }</p>
</div>
<div className={ 'flex w-full h-full justify-center pt-3' }>
<ul className={ 'grid grid-cols-2 gap-x-6 gap-y-3 md:max-w-[90%] xl:max-w-[85%] w-full' }>
{
list.map((friend: any, key: number) => (
<li
key={ key }
className={ (key === listMaxIdx && listIsOdd) ? 'col-span-2 flex justify-center' : 'col-span-2 md:col-span-1' }
>
<Card
isHoverable
isPressable
shadow={ 'none' }
as={ Link }
href={ friend['url'] }
className={ 'dark:bg-[#282830] w-full md:w-fit max-w-full shadow' }
>
<CardBody className={ 'overflow-visible pb-0.5' }>
<div className={ 'flex flex-row' }>
<div className={ 'flex p-2' }>
<div
className={ 'h-[80px] w-[80px] relative rounded-2xl' }>
<Image
alt="avatar"
className="object-cover rounded-2xl z-10"
fill
unoptimized
src={ friend['avatar'] }
/>
<Image
alt="avatar"
className="relative object-cover rounded-2xl scale-110 blur-3xl"
fill
unoptimized
src={ friend['avatar'] }
/>
</div>
</div>
<div className={ 'p-2 flex-col max-w-full' }>
<p className={ 'text-xl' }>{ friend['title'] }</p>
{ (friend['name'] === undefined || friend['name'].isNull) ??
<p className={ 'text-zinc-600 dark:text-zinc-400' }>{ `@ ${ friend['name'] }` }</p> }
{ (friend['desc'] === undefined || friend['desc'].isNull) ??
<p className={ 'text-sm text-pretty text-zinc-600 dark:text-zinc-300 mt-1' }>{ friend['desc'] }</p> }
</div>
</div>
</CardBody>
<div className={ 'h-2 relative w-full' }>
<Image
alt="theme bar"
draggable={ false }
className="object-cover object-center scale-110 blur-lg"
fill
unoptimized
src={ friend['avatar'] }
/>
</div>
</Card>
</li>
))
}
</ul>
</div>
</div>
<p className={ 'text-center' }>想要成为好伙伴嘛,向<span
className={ 'text-blue-800 dark:text-blue-500' }><Link
href={ 'https://github.com/Lumither/friends/' }>这里</Link></span>发起PR吧
</p>
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
@import "lxgw-wenkai-webfont/style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: "LXGW WenKai", sans-serif;
}
pre, code {
font-family: "LXGW WenKai Mono", sans-serif;
}
-34
View File
@@ -1,34 +0,0 @@
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';
export const metadata: Metadata = {
title: 'Lumitherの酒馆',
description: '一间位于时间夹缝中的旅馆,静候下一位旅者的到来...'
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<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>
</NextThemesProvider>
</body>
</html>
);
}
-10
View File
@@ -1,10 +0,0 @@
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
import React from 'react';
export default function Home() {
return (
<div className={ `h-dvh` }>
<ConstructionPlaceHolder />
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
'use client';
import { NextUIProvider } from '@nextui-org/react';
import React from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<NextUIProvider>
{ children }
</NextUIProvider>
);
}
-64
View File
@@ -1,64 +0,0 @@
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkToc from 'remark-toc';
import remarkFrontmatter from 'remark-frontmatter';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
const MarkdownRenderer = ({ children }: { children: React.ReactNode }) => {
return (
<div className={ 'w-full' }>
<Markdown
className={ `max-w-full text-justify text-pretty prose prose-sm prose-neutral dark:prose-invert
md:prose-md lg:prose-lg prose-code:overflow-x-scroll`
}
remarkPlugins={ [ remarkGfm, remarkToc, remarkFrontmatter ] }
rehypePlugins={ [ rehypeRaw, rehypeSanitize ] }
components={ {
pre(props) {
const { node, className, children, ...rest } = props;
if ((children as any)['type'] === 'code') {
const match = /language-(\w+)/.exec((children as any)['props']['className'] || '');
let lang = match ? match[1] : 'text';
return (
<pre className={ `not-prose` }>
<SyntaxHighlighter
// @ts-ignore
style={ oneDark }
language={ lang }
showLineNumbers
// wrapLongLines
// wrapLines
classNames={ className }
PreTag="div"
{ ...rest }
>
{
String((children as any)['props']['children']).replace(/\n$/, '')
}
</SyntaxHighlighter>
</pre>
);
} else {
return (
<pre className={ `not-prose` }>
<code className={ className } { ...props }>
{ children }
</code>
</pre>
);
}
}
} }
>
{
children as any
}
</Markdown>
</div>
);
};
export default MarkdownRenderer;
-5
View File
@@ -1,5 +0,0 @@
'use client';
import { motion } from 'framer-motion';
export const MotionDiv = motion.div;
-35
View File
@@ -1,35 +0,0 @@
import { useEffect, useState } from 'react';
const screenSize = new Map<string, number>(
[
[ 'sm', 640 ],
[ 'md', 768 ],
[ 'lg', 1024 ],
[ 'xl', 1280 ],
[ '2xl', 2536 ]
]
);
export const useScreenSizeTrigger = (size: number | string) => {
const criticalSize = (typeof size === 'string') ? (screenSize.get(size) ?? 768) : size;
const [ isCritical, setIsCritical ] = useState(false);
useEffect(() => {
const updateMobileWidth = () => {
if (window.innerWidth < criticalSize) {
setIsCritical(true);
} else {
setIsCritical(false);
}
};
updateMobileWidth();
window.addEventListener('resize', updateMobileWidth);
return () => {
window.removeEventListener('resize', updateMobileWidth);
};
});
return isCritical;
};
-7
View File
@@ -1,7 +0,0 @@
export type Result<T, E> = {
ok: true;
value: T;
} | {
ok: false;
error: E;
};
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
const Search = () => {
return (
<div className={ `h-dvh` }>
<ConstructionPlaceHolder />
</div>
);
};
export default Search;
@@ -1,13 +0,0 @@
import React from 'react';
const ConstructionPlaceHolder = () => {
return (
<div className={ `flex h-full w-full justify-center items-center` }>
<p className={ `text-2xl font-bold` }>
正在施工中...
</p>
</div>
);
};
export default ConstructionPlaceHolder;
-25
View File
@@ -1,25 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { IoLogoGithub } from 'react-icons/io5';
const Footer = () => {
return (
<div className={ `p-5 mb-4` }>
<div className={ `h-1 w-20 bg-zinc-500 dark:bg-zinc-400 mb-3` }></div>
<p>
© 2024-{ new Date().getFullYear() } <Link href={ `/about` }>Lumither Tao</Link>
</p>
<p>
Powered by Next.js and Rust, built with passion and love
</p>
<Link
href={ `https://github.com/Lumither/blog` }
aria-label={ `GitHub repo for this website` }
>
<IoLogoGithub className={ `mt-1` } />
</Link>
</div>
);
};
export default Footer;
-168
View File
@@ -1,168 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Button } from '@nextui-org/react';
import Image from 'next/image';
import { 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';
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'
}
];
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() {
// const [ scrolled, setScrolled ] = useState(false);
// useEffect(() => {
// const handleScroll = () => {
// const isScrolled = window.scrollY > 0;
// setScrolled(isScrolled);
// };
// window.addEventListener('scroll', handleScroll);
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
// }, []);
const path = usePathname();
const [ shownPath, setShownPath ] = useState(path);
useEffect(() => {
let trimmed_path = path.split(`/`)[1];
if (trimmed_path === '') {
setShownPath('HOME');
} else {
setShownPath(trimmed_path.toUpperCase());
}
}, [ path ]);
const isMobile = useScreenSizeTrigger('lg');
return (
<>
<div
className={ `flex flex-col min-w-[50px] lg:min-w-[200px] ml-2 mr-0 lg:ml-8 lg:mr-6 min-h-screen pt-8 sticky` }>
<div className={ `fixed min-h-screen h-full` }>
<div className={ `hidden lg:block` }>
<div className={ `flex justify-center items-center my-7 h-[200px] w-[200px]` }>
<div className={ 'relative w-full h-full' }>
<Image
src={ 'https://oss.lumither.com/blog/pictures/avatar.webp' }
alt={ 'avatar' }
fill
className={ 'rounded-full object-cover' }
loading={ 'eager' }
></Image>
</div>
</div>
<div>
<p className={ `font-bold text-2xl` }>Lumither Tao</p>
<p className={ `text-xl text-zinc-500 dark:text-zinc-400` }>Ad Astra</p>
</div>
</div>
<div>
<ul className={ `flex flex-col space-y-2 lg:space-y-0 lg:flex-row items-center my-4 lg:space-x-2` }>
{
connections.map((conn, key) => (
<li key={ key }>
<Button as={ Link }
href={ conn.href }
aria-label={ conn.label }
variant={ `light` }
color={ `default` }
className={ `w-fit` }
isIconOnly
>
{ conn.icon }
</Button>
</li>
))
}
</ul>
</div>
<div
className={ `flex justify-center lg:justify-start w-full mt-8 ml-0 lg:-ml-3` }>
<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>
<div className={ `mt-8 flex lg:hidden justify-center w-full` }>
<p className={ `text-center sideways-lr font-bold text-2xl text-zinc-500 dark:text-zinc-400` }>
{ shownPath }
</p>
</div>
<div
className={ `absolute bottom-16` }
>
<ThemeSwitcher />
</div>
</div>
</div>
</>
);
}
export default Navbar;
-93
View File
@@ -1,93 +0,0 @@
'use client';
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';
const switcherElements = new Map(
[
[ 'dark', {
'display_name': 'Dark Mode',
'icon': <MdOutlineDarkMode />
} ],
[ 'light', {
'display_name': 'Light Mode',
'icon': <MdOutlineLightMode />
} ],
[ 'system', {
'display_name': 'Follow System',
'icon': <MdOutlineDesktopWindows />
} ]
]
);
function ThemeSwitcher() {
const [ mounted, setMounted ] = useState(false);
const { theme, setTheme } = useTheme();
const [ currTheme, setCurrTheme ] = useState(new Set([ theme as string ]));
const isMobile = useScreenSizeTrigger('lg');
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div className={ `` }>
<Dropdown backdrop={ 'blur' }>
<DropdownTrigger>
<Button
variant={ 'flat' }
aria-label={ 'Theme Trigger' }
fullWidth
isIconOnly={ isMobile }
>
{ switcherElements.get(currTheme.values().next().value)?.icon }
<p className={ 'hidden lg:block' }>
{ switcherElements.get(currTheme.values().next().value)?.display_name }
</p>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label={ 'Toggle Theme' }
variant={ 'flat' }
selectionMode={ 'single' }
selectedKeys={ currTheme }
onSelectionChange={ ({ anchorKey }) => {
setCurrTheme(new Set([ anchorKey as string ]));
setTheme(anchorKey as string);
} }
>
<DropdownItem
key={ 'light' }
startContent={ <MdOutlineLightMode /> }>
Light Mode
</DropdownItem>
<DropdownItem
key={ 'dark' }
startContent={ <MdOutlineDarkMode /> }
>
Dark Mode
</DropdownItem>
<DropdownItem
key={ 'system' }
startContent={ <MdOutlineDesktopWindows /> }
>
Follow System
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
);
}
export default ThemeSwitcher;
-18
View File
@@ -1,18 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'oss.lumither.com'
},
{
protocol: 'https',
hostname: 'pic.re'
}
]
}
// output: 'standalone'
};
export default nextConfig;
+7067
View File
File diff suppressed because it is too large. Load diff
+38 -36
View File
@@ -1,42 +1,44 @@
{ {
"name": "blog", "name": "my-qwik-empty-starter",
"version": "0.1.0", "description": "Blank project with routing included",
"private": true, "engines": {
"scripts": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}, },
"dependencies": { "engines-annotation": "Mostly required by sharp which needs a Node-API v9 compatible runtime",
"@nextui-org/react": "^2.4.6", "private": true,
"@types/react-syntax-highlighter": "^15.5.13", "trustedDependencies": [
"framer-motion": "^11.3.24", "sharp"
"lxgw-wenkai-webfont": "^1.7.0", ],
"next": "14.2.3", "trustedDependencies-annotation": "Needed for bun to allow running install scripts",
"next-themes": "^0.3.0", "type": "module",
"react": "^18.3.1", "scripts": {
"react-dom": "^18.3.1", "build": "qwik build",
"react-icons": "^5.2.1", "build.client": "vite build",
"react-markdown": "^9.0.1", "build.preview": "vite build --ssr src/entry.preview.tsx",
"react-syntax-highlighter": "^15.5.0", "build.types": "tsc --incremental --noEmit",
"rehype-raw": "^7.0.0", "deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'",
"rehype-sanitize": "^6.0.0", "dev": "vite --mode ssr",
"remark-frontmatter": "^5.0.0", "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
"remark-gfm": "^4.0.0", "fmt": "prettier --write .",
"remark-toc": "^9.0.0", "fmt.check": "prettier --check .",
"sharp": "^0.33.4" "lint": "eslint \"src/**/*.ts*\"",
"preview": "qwik build preview && vite preview --open",
"start": "vite --open --mode ssr",
"qwik": "qwik"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.14", "@builder.io/qwik": "^1.12.0",
"@types/node": "^20.14.15", "@builder.io/qwik-city": "^1.12.0",
"@types/react": "^18.3.3", "@types/eslint": "8.56.10",
"@types/react-dom": "^18.3.0", "@types/node": "20.14.11",
"autoprefixer": "^10.4.20", "@typescript-eslint/eslint-plugin": "7.16.1",
"eslint": "^8.57.0", "@typescript-eslint/parser": "7.16.1",
"eslint-config-next": "14.2.2", "eslint": "8.57.0",
"postcss": "^8.4.41", "eslint-plugin-qwik": "^1.12.0",
"tailwindcss": "^3.4.9", "prettier": "3.3.3",
"typescript": "^5.5.4" "typescript": "5.4.5",
"undici": "*",
"vite": "5.3.5",
"vite-tsconfig-paths": "^4.2.1"
} }
} }
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 500 500"><g clip-path="url(#a)"><circle cx="250" cy="250" r="250" fill="#fff"/><path fill="#18B6F6" d="m367.87 418.45-61.17-61.18-.94.13v-.67L175.7 227.53l32.05-31.13L188.9 87.73 99.56 199.09c-15.22 15.42-18.03 40.51-7.08 59.03l55.83 93.11a46.82 46.82 0 0 0 40.73 22.81l27.65-.27 151.18 44.68Z"/><path fill="#AC7EF4" d="m401.25 196.94-12.29-22.81-6.41-11.67-2.54-4.56-.26.26-33.66-58.63a47.07 47.07 0 0 0-41.27-23.75l-29.51.8-88.01.28a47.07 47.07 0 0 0-40.33 23.34L93.4 207l95.76-119.54L314.7 226.19l-22.3 22.67 13.35 108.54.13-.26v.26h-.26l.26.27 10.42 10.2 50.62 49.78c2.13 2 5.6-.4 4.13-2.96l-31.25-61.85 54.5-101.3 1.73-2c.67-.81 1.33-1.62 1.87-2.42a46.8 46.8 0 0 0 3.34-50.18Z"/><path fill="#fff" d="M315.1 225.65 189.18 87.6l17.9 108.14L175 227l130.5 130.27-11.75-108.14 21.37-23.48Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h500v500H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 947 B

+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "qwik-project-name",
"short_name": "Welcome to Qwik",
"start_url": ".",
"display": "standalone",
"background_color": "#fff",
"description": "A Qwik project app."
}
File renamed without changes.
+4
View File
@@ -0,0 +1,4 @@
// This file can be used to add references for global types like `vite/client`.
// Add global `vite/client` types. For more info, see: https://vitejs.dev/guide/features#client-types
/// <reference types="vite/client" />
@@ -0,0 +1,48 @@
import { component$ } from "@builder.io/qwik";
import { useDocumentHead, useLocation } from "@builder.io/qwik-city";
/**
* The RouterHead component is placed inside of the document `<head>` element.
*/
export const RouterHead = component$(() => {
const head = useDocumentHead();
const loc = useLocation();
return (
<>
<title>{head.title}</title>
<link rel="canonical" href={loc.url.href} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
{head.meta.map((m) => (
<meta key={m.key} {...m} />
))}
{head.links.map((l) => (
<link key={l.key} {...l} />
))}
{head.styles.map((s) => (
<style
key={s.key}
{...s.props}
{...(s.props?.dangerouslySetInnerHTML
? {}
: { dangerouslySetInnerHTML: s.style })}
/>
))}
{head.scripts.map((s) => (
<script
key={s.key}
{...s.props}
{...(s.props?.dangerouslySetInnerHTML
? {}
: { dangerouslySetInnerHTML: s.script })}
/>
))}
</>
);
});
+17
View File
@@ -0,0 +1,17 @@
/*
* WHAT IS THIS FILE?
*
* Development entry point using only client-side modules:
* - Do not use this mode in production!
* - No SSR
* - No portion of the application is pre-rendered on the server.
* - All of the application is running eagerly in the browser.
* - More code is transferred to the browser than in SSR mode.
* - Optimizer/Serialization/Deserialization code is not exercised!
*/
import { render, type RenderOptions } from "@builder.io/qwik";
import Root from "./root";
export default function (opts: RenderOptions) {
return render(document, <Root />, opts);
}
+21
View File
@@ -0,0 +1,21 @@
/*
* WHAT IS THIS FILE?
*
* It's the bundle entry point for `npm run preview`.
* That is, serving your app built in production mode.
*
* Feel free to modify this file, but don't remove it!
*
* Learn more about Vite's preview command:
* - https://vitejs.dev/config/preview-options.html#preview-options
*
*/
import { createQwikCity } from "@builder.io/qwik-city/middleware/node";
import qwikCityPlan from "@qwik-city-plan";
// make sure qwikCityPlan is imported before entry
import render from "./entry.ssr";
/**
* The default export is the QwikCity adapter used by Vite preview.
*/
export default createQwikCity({ render, qwikCityPlan });
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more