mirror of
https://github.com/Lumither/nocturne.git
synced 2026-10-17 10:26:21 +00:00
Compare commits
18
Commits
3dab27323a
...
qwik-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4309f42bf7 | ||
|
|
eef32ad695 | ||
|
|
acd0525513 | ||
|
|
8938c96409 | ||
|
|
6da6231c92 | ||
|
|
19f84d2188 | ||
|
|
20c2964242 | ||
|
|
eac84307bf | ||
|
|
a0dc40d2ca | ||
|
|
cbc831bb70 | ||
|
|
bacaea80a3 | ||
|
|
99a19d8d9e | ||
|
|
7f400470ff | ||
|
|
e26003f430 | ||
|
|
c7e3b12856 | ||
|
|
a123ff9f3f | ||
|
|
d98aa3f745 | ||
|
|
6f45a140ba |
No files matched your search
@@ -1,19 +1,19 @@
|
||||
# postgres settings
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_USER=
|
||||
#POSTGRES_PASSWORD=
|
||||
POSTGRES_USER=nocturne
|
||||
POSTGRES_DB=nocturne
|
||||
POSTGRES_HOST=postgres
|
||||
|
||||
# backend settings
|
||||
|
||||
#GIT_WORK_DIR=../blog_posts_git
|
||||
#LOG_ROOT_DIR=/var/log/nocturne
|
||||
|
||||
BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git
|
||||
WORK_DIR=~/.nocturne
|
||||
BACKEND_PORT=3001
|
||||
#BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git
|
||||
BLOG_GIT_URL=https://github.com/Lumither/blog-posts.git
|
||||
WORK_DIR=/nocturne
|
||||
#BACKEND_PORT=3001
|
||||
|
||||
# frontend settings
|
||||
API_LOCAL_URL=http://localhost:3001
|
||||
API_LOCAL_URL=http://backend:3001
|
||||
API_REMOTE_URL=https://lumither.com/api
|
||||
@@ -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
|
||||
|
||||
dclean:
|
||||
docker-compose -f dev.compose.yaml rm -fsv
|
||||
clean:
|
||||
docker-compose -f prod.compose.yaml -f dev.compose.yaml rm -fsv
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
# `Nocturne: personal website & CMS`
|
||||
# `Nocturne: Personal Website`
|
||||
|
||||
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the
|
||||
content repo.
|
||||
This is the code repo of my [personal website](https://lumither.com),
|
||||
and [here](https://github.com/Lumither/blog-posts/) is the link to the
|
||||
content (blog posts etc.) repo.
|
||||
|
||||
Powered by Next.js and Rust, built with passion and love
|
||||
|
||||
<p align="center">
|
||||
<img src="https://avatars.githubusercontent.com/u/46409277?v=4" alt="GitHub Avatar"/>
|
||||
</p>
|
||||
> [!NOTE]
|
||||
> This system is not production ready, and contains bugs, (security risk potentially).
|
||||
> But have fun on it =)
|
||||
|
||||
*will add more here in the future
|
||||
|
||||
## Nav:
|
||||
|
||||
<todo>
|
||||
|
||||
## Todos:
|
||||
|
||||
### Backend
|
||||
|
||||
- [ ] Add: log system
|
||||
- [ ] Add: search api
|
||||
- [ ] Add: tag filter (api)
|
||||
- [ ] Abandon: UUID mechanism
|
||||
- [ ] Fix: db connection issue
|
||||
|
||||
### Frontend
|
||||
|
||||
- [ ] Page: Home
|
||||
- [ ] Page: About
|
||||
- [ ] Page: Tags
|
||||
- [ ] Page: Archive
|
||||
- [ ] Page: Search
|
||||
- [ ] Add: Right sidebar
|
||||
# Todos
|
||||
|
||||
- [ ] the todo list lol
|
||||
- [ ] and much more...
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
|
||||
/Cargo.lock
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"api_server",
|
||||
"constants",
|
||||
"server",
|
||||
"macros",
|
||||
"markdown",
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "api_server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
constants = { path = "../constants" }
|
||||
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 = "0.7.5"
|
||||
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"] }
|
||||
sha2 = "0.10.8"
|
||||
cron = "0.12.1"
|
||||
@@ -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()})))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
use constants::blog::PAGE_SIZE;
|
||||
|
||||
use axum::{extract::Query, extract::State, http::StatusCode, Json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::join_all;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use sqlx::{postgres::PgRow, query, PgPool, Row};
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Params {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod get_page_count;
|
||||
pub mod get_post;
|
||||
pub mod get_post_list;
|
||||
pub mod refresh;
|
||||
@@ -1,190 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
|
||||
// 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)
|
||||
// }
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
) -> Result<(), ()> {
|
||||
todo!()
|
||||
// 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,19 +0,0 @@
|
||||
mod api;
|
||||
mod scheduler;
|
||||
|
||||
use crate::blog::api::{
|
||||
get_page_count::get_page_count, get_post::get_post, get_post_list::get_post_list,
|
||||
};
|
||||
|
||||
use crate::blog::api::refresh::refresh;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
pub fn get_router() -> Router<Pool<Postgres>> {
|
||||
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))
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
use crate::blog::{
|
||||
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 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>>>>,
|
||||
stopped: Arc<RwLock<Vec<Uuid>>>,
|
||||
}
|
||||
|
||||
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>>>>,
|
||||
stopped: Arc<RwLock<Vec<Uuid>>>,
|
||||
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);
|
||||
}
|
||||
stopped.write()?.push(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 {
|
||||
stopped.write()?.push(id);
|
||||
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(), self.stopped.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 stopped_clone = self.stopped.clone();
|
||||
let task_clone = task.clone();
|
||||
thread::spawn(move || Self::run_task(running_clone, stopped_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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blog::scheduler::tasks::basic::BasicTask;
|
||||
use crate::blog::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))?;
|
||||
scheduler.run_non_blocking(&id)?;
|
||||
thread::sleep(Duration::from_secs(4));
|
||||
scheduler.stop(&id)?;
|
||||
// test termination
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use crate::blog::scheduler::scheduler_error::SchedulerError::MutexPoison;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
use std::sync::PoisonError;
|
||||
|
||||
pub enum SchedulerError {
|
||||
IdNotFound,
|
||||
IdAlreadyRunning,
|
||||
MutexPoison(String),
|
||||
}
|
||||
|
||||
impl<T> From<PoisonError<T>> for SchedulerError {
|
||||
fn from(value: PoisonError<T>) -> Self {
|
||||
MutexPoison(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for SchedulerError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SchedulerError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
SchedulerError::IdNotFound => {
|
||||
"SchedulerError::IdNotFound".to_string()
|
||||
}
|
||||
SchedulerError::IdAlreadyRunning => {
|
||||
"SchedulerError::IdAlreadyRunning".to_string()
|
||||
}
|
||||
MutexPoison(str) => {
|
||||
format!("SchedulerError::MutexPoison: {}", str)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SchedulerError {}
|
||||
@@ -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::blog::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,30 +0,0 @@
|
||||
use crate::blog::scheduler::task_func::TaskFunc;
|
||||
use crate::blog::scheduler::tasks::Task;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cron::Schedule;
|
||||
use std::error::Error;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub struct BasicTask {
|
||||
schedule: Schedule,
|
||||
func: Box<dyn TaskFunc>,
|
||||
}
|
||||
|
||||
impl BasicTask {
|
||||
pub fn new(func: fn(), cron_expr: &str) -> Result<Self, Box<dyn Error>> {
|
||||
Ok(BasicTask {
|
||||
schedule: Schedule::from_str(cron_expr)?,
|
||||
func: Box::new(func),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Task for BasicTask {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>> {
|
||||
self.schedule.upcoming(Utc).next()
|
||||
}
|
||||
|
||||
fn call(&self) {
|
||||
(self.func)()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
use crate::blog::scheduler::task_func::TaskFunc;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub mod basic;
|
||||
|
||||
pub trait Task: Send + Sync {
|
||||
fn get_next_execution(&self) -> Option<DateTime<Utc>>;
|
||||
fn call(&self);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
use std::env;
|
||||
|
||||
use crate::logger;
|
||||
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
pub fn logger_init() -> (WorkerGuard, WorkerGuard) {
|
||||
logger::init()
|
||||
}
|
||||
|
||||
pub fn load_env() {
|
||||
if let Some(env_file) = env::args().nth(1) {
|
||||
match dotenv::from_filename(&env_file) {
|
||||
Ok(_) => {
|
||||
println!("[info] env loaded from {}, starting up", env_file);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("[fatal] failed to read {}: {}", env_file, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("[info] no .env file referred, starting up")
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::{env, io};
|
||||
|
||||
use chrono::Local;
|
||||
use dirs::home_dir;
|
||||
use tracing::info;
|
||||
use tracing_appender::{non_blocking, non_blocking::WorkerGuard, rolling};
|
||||
use tracing_subscriber::{
|
||||
filter::LevelFilter, fmt, fmt::format::Writer, fmt::time::FormatTime, fmt::MakeWriter,
|
||||
layer::SubscriberExt, Layer, Registry,
|
||||
};
|
||||
|
||||
struct LocalTimer;
|
||||
|
||||
impl FormatTime for LocalTimer {
|
||||
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
w,
|
||||
"{}",
|
||||
Local::now().format(constants::time::DEFAULT_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| {
|
||||
if let Ok(default_log_path) = env::var("WORK_DIR") {
|
||||
if default_log_path.starts_with('~') {
|
||||
let home_path = home_dir()
|
||||
.expect("[fatal] unable to locate home dir with parsing env `WORK_DIR`, try to use absolute path instead");
|
||||
home_path
|
||||
.join(default_log_path.strip_prefix("~/").unwrap())
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("[fatal] failed to parse env `WORK_DIR`, try to use absolute path instead")
|
||||
} else {
|
||||
default_log_path
|
||||
}
|
||||
} else {
|
||||
panic!("[fatal] failed to parse env `LOG_ROOT_DIR`: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let log_path = Path::new(log_path)
|
||||
.join("log")
|
||||
.join("backend")
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.unwrap_or_else(|e| panic!("[fatal] failed to parse {:?}", e));
|
||||
|
||||
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 info_format = tracing_subscriber::fmt::format()
|
||||
.with_level(true)
|
||||
.with_target(true)
|
||||
.with_source_location(true)
|
||||
.with_ansi(false);
|
||||
let err_format = tracing_subscriber::fmt::format()
|
||||
.with_level(true)
|
||||
.with_target(true)
|
||||
.with_source_location(true)
|
||||
.with_ansi(false);
|
||||
let stdout_format = tracing_subscriber::fmt::format()
|
||||
.with_level(true)
|
||||
.with_target(true)
|
||||
.with_source_location(true)
|
||||
.with_timer(LocalTimer);
|
||||
|
||||
// let default_filter = tracing_subscriber::filter::EnvFilter::try_from_default_env()
|
||||
// .unwrap_or_else(|_| "tower_http=debug,axum::rejection=trace".into());
|
||||
|
||||
let subscribers = Registry::default()
|
||||
.with(
|
||||
fmt::Layer::default()
|
||||
.with_writer(info_log_writer)
|
||||
.event_format(info_format),
|
||||
)
|
||||
.with(
|
||||
fmt::Layer::default()
|
||||
.with_writer(err_log_writer)
|
||||
.event_format(err_format)
|
||||
.with_filter(LevelFilter::ERROR),
|
||||
)
|
||||
.with(
|
||||
fmt::Layer::default()
|
||||
.with_writer(io::stdout)
|
||||
.with_ansi(true)
|
||||
.event_format(stdout_format),
|
||||
);
|
||||
|
||||
tracing::subscriber::set_global_default(subscribers)
|
||||
.expect("[fatal] failed to setup logging system");
|
||||
|
||||
info!("module started: logger");
|
||||
info!("logging to dir: {}", log_path);
|
||||
|
||||
(info_guard, error_guard)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
mod blog;
|
||||
mod init;
|
||||
mod logger;
|
||||
mod server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
init::load_env();
|
||||
let _guards = init::logger_init();
|
||||
|
||||
server::start().await;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use std::env;
|
||||
use std::num::ParseIntError;
|
||||
use std::process::exit;
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::blog;
|
||||
use axum::Router;
|
||||
use sqlx::{
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
ConnectOptions,
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub async fn start() {
|
||||
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(&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()
|
||||
.nest("blog", blog::get_router())
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
@@ -4,3 +4,5 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
@@ -1,14 +1 @@
|
||||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
mod log;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,10 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
|
||||
chrono = { version = "0.4.38", features = ["serde", "alloc"] }
|
||||
serde = { version = "1.0.209", features = ["derive"] }
|
||||
serde_json = "1.0.128"
|
||||
regex = "1.10.4"
|
||||
yaml-rust2 = "0.8.1"
|
||||
yaml-rust2 = "0.9.0"
|
||||
sha2 = "0.10.8"
|
||||
base64 = "0.22.1"
|
||||
thiserror = "2.0.3"
|
||||
@@ -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 },
|
||||
}
|
||||
+26
-13
@@ -1,35 +1,48 @@
|
||||
mod error;
|
||||
mod utils;
|
||||
|
||||
use std::error::Error;
|
||||
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;
|
||||
|
||||
use crate::utils::front_matter::{parse_front_matter, split_md_front_matter};
|
||||
|
||||
#[derive(Default, Debug, Serialize)]
|
||||
pub struct MdFile {
|
||||
file_id: Uuid,
|
||||
file_name: String,
|
||||
meta: Value,
|
||||
content: String,
|
||||
hash: String,
|
||||
pub file_id: Uuid,
|
||||
pub filename: String,
|
||||
pub meta: Value,
|
||||
pub content: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl MdFile {
|
||||
pub async fn from_file(file_path: &str) -> Result<MdFile, Box<dyn Error>> {
|
||||
let raw_file = fs::read_to_string(file_path)?;
|
||||
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 = parse_front_matter(&front_matter_str)?;
|
||||
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 = Uuid::from_str(front_matter["id"].to_string().as_str())?;
|
||||
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()
|
||||
@@ -39,7 +52,7 @@ impl MdFile {
|
||||
|
||||
Ok(MdFile {
|
||||
file_id,
|
||||
file_name,
|
||||
filename: file_name,
|
||||
meta: front_matter,
|
||||
content,
|
||||
hash,
|
||||
|
||||
@@ -16,8 +16,12 @@ pub fn split_md_front_matter(raw_file: String) -> (String, String) {
|
||||
}
|
||||
|
||||
pub fn parse_front_matter(front_matter: &str) -> Result<Value, Box<dyn Error>> {
|
||||
let parsed_f_matter = YamlLoader::load_from_str(front_matter)?[0].clone();
|
||||
Ok(Value::from(yaml_to_serde(&parsed_f_matter)))
|
||||
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)]
|
||||
@@ -60,7 +64,7 @@ tags: [ "test1", "test2" ]
|
||||
|
||||
#[test]
|
||||
fn test_front_matter_parser() {
|
||||
parse_front_matter(
|
||||
dbg!(parse_front_matter(
|
||||
r##"---
|
||||
title: test_title
|
||||
date: 0000-00-00+0000
|
||||
@@ -71,7 +75,7 @@ summary: summary
|
||||
tags: [ "test1", "test2" ]
|
||||
---
|
||||
"##,
|
||||
)
|
||||
))
|
||||
.expect("Failed to parse front matter");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
File renamed without changes.
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod blog;
|
||||
pub mod config;
|
||||
pub mod time;
|
||||
@@ -1 +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";
|
||||
@@ -0,0 +1,7 @@
|
||||
mod constants;
|
||||
|
||||
const BUILD_ID: &str = env!("BUILD_ID");
|
||||
|
||||
fn main() {
|
||||
println!("{}", BUILD_ID);
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
@@ -5,7 +5,7 @@ services:
|
||||
dockerfile: ../container/dev/frontend.containerfile
|
||||
restart: always
|
||||
ports:
|
||||
- 3000:3000
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
dockerfile: ../container/dev/backend.containerfile
|
||||
restart: always
|
||||
ports:
|
||||
- 3001:3001
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./blog_posts_git:/app/blog_posts_git:ro
|
||||
env_file:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/node_modules
|
||||
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
.env*.local
|
||||
@@ -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
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
+33
-34
@@ -1,43 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
# Build
|
||||
/dist
|
||||
/lib
|
||||
/lib-types
|
||||
/server
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
# Development
|
||||
node_modules
|
||||
.env
|
||||
*.local
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
# Cache
|
||||
.cache
|
||||
.mf
|
||||
.rollup.cache
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# bun
|
||||
bun.lockb
|
||||
|
||||
# ide related
|
||||
.idea
|
||||
/package-lock.json
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Yarn
|
||||
.yarn/*
|
||||
!.yarn/releases
|
||||
@@ -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
|
||||
@@ -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`
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
// todo: create component
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
function Layout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={ 'p-7' }>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
|
||||
import React from 'react';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={ `h-dvh` }>
|
||||
<ConstructionPlaceHolder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
export const MotionDiv = motion.div;
|
||||
@@ -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;
|
||||
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export type Result<T, E> = {
|
||||
ok: true;
|
||||
value: T;
|
||||
} | {
|
||||
ok: false;
|
||||
error: E;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+7067
File diff suppressed because it is too large.
Load diff
+38
-36
@@ -1,42 +1,44 @@
|
||||
{
|
||||
"name": "blog",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"name": "my-qwik-empty-starter",
|
||||
"description": "Blank project with routing included",
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nextui-org/react": "^2.4.6",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"framer-motion": "^11.3.24",
|
||||
"lxgw-wenkai-webfont": "^1.7.0",
|
||||
"next": "14.2.3",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-frontmatter": "^5.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-toc": "^9.0.0",
|
||||
"sharp": "^0.33.4"
|
||||
"engines-annotation": "Mostly required by sharp which needs a Node-API v9 compatible runtime",
|
||||
"private": true,
|
||||
"trustedDependencies": [
|
||||
"sharp"
|
||||
],
|
||||
"trustedDependencies-annotation": "Needed for bun to allow running install scripts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "qwik build",
|
||||
"build.client": "vite build",
|
||||
"build.preview": "vite build --ssr src/entry.preview.tsx",
|
||||
"build.types": "tsc --incremental --noEmit",
|
||||
"deploy": "echo 'Run \"npm run qwik add\" to install a server adapter'",
|
||||
"dev": "vite --mode ssr",
|
||||
"dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force",
|
||||
"fmt": "prettier --write .",
|
||||
"fmt.check": "prettier --check .",
|
||||
"lint": "eslint \"src/**/*.ts*\"",
|
||||
"preview": "qwik build preview && vite preview --open",
|
||||
"start": "vite --open --mode ssr",
|
||||
"qwik": "qwik"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.14",
|
||||
"@types/node": "^20.14.15",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.2",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"typescript": "^5.5.4"
|
||||
"@builder.io/qwik": "^1.12.0",
|
||||
"@builder.io/qwik-city": "^1.12.0",
|
||||
"@types/eslint": "8.56.10",
|
||||
"@types/node": "20.14.11",
|
||||
"@typescript-eslint/eslint-plugin": "7.16.1",
|
||||
"@typescript-eslint/parser": "7.16.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-qwik": "^1.12.0",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.4.5",
|
||||
"undici": "*",
|
||||
"vite": "5.3.5",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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.
Vendored
+4
@@ -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 })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* WHAT IS THIS FILE?
|
||||
*
|
||||
* SSR entry point, in all cases the application is rendered outside the browser, this
|
||||
* entry point will be the common one.
|
||||
*
|
||||
* - Server (express, cloudflare...)
|
||||
* - npm run start
|
||||
* - npm run preview
|
||||
* - npm run build
|
||||
*
|
||||
*/
|
||||
import {
|
||||
renderToStream,
|
||||
type RenderToStreamOptions,
|
||||
} from "@builder.io/qwik/server";
|
||||
import { manifest } from "@qwik-client-manifest";
|
||||
import Root from "./root";
|
||||
|
||||
export default function (opts: RenderToStreamOptions) {
|
||||
return renderToStream(<Root />, {
|
||||
manifest,
|
||||
...opts,
|
||||
// Use container attributes to set attributes on the html tag.
|
||||
containerAttributes: {
|
||||
lang: "en-us",
|
||||
...opts.containerAttributes,
|
||||
},
|
||||
serverData: {
|
||||
...opts.serverData,
|
||||
},
|
||||
});
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,38 @@
|
||||
import { component$ } from "@builder.io/qwik";
|
||||
import {
|
||||
QwikCityProvider,
|
||||
RouterOutlet,
|
||||
ServiceWorkerRegister,
|
||||
} from "@builder.io/qwik-city";
|
||||
import { RouterHead } from "./components/router-head/router-head";
|
||||
import { isDev } from "@builder.io/qwik";
|
||||
|
||||
import "./global.css";
|
||||
|
||||
export default component$(() => {
|
||||
/**
|
||||
* The root of a QwikCity site always start with the <QwikCityProvider> component,
|
||||
* immediately followed by the document's <head> and <body>.
|
||||
*
|
||||
* Don't remove the `<head>` and `<body>` elements.
|
||||
*/
|
||||
|
||||
return (
|
||||
<QwikCityProvider>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
{!isDev && (
|
||||
<link
|
||||
rel="manifest"
|
||||
href={`${import.meta.env.BASE_URL}manifest.json`}
|
||||
/>
|
||||
)}
|
||||
<RouterHead />
|
||||
</head>
|
||||
<body lang="en">
|
||||
<RouterOutlet />
|
||||
{!isDev && <ServiceWorkerRegister />}
|
||||
</body>
|
||||
</QwikCityProvider>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { component$ } from "@builder.io/qwik";
|
||||
import type { DocumentHead } from "@builder.io/qwik-city";
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<>
|
||||
<h1>Hi 👋</h1>
|
||||
<div>
|
||||
Can't wait to see what you build with qwik!
|
||||
<br />
|
||||
Happy coding.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const head: DocumentHead = {
|
||||
title: "Welcome to Qwik",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Qwik site description",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { component$, Slot } from "@builder.io/qwik";
|
||||
import type { RequestHandler } from "@builder.io/qwik-city";
|
||||
|
||||
export const onGet: RequestHandler = async ({ cacheControl }) => {
|
||||
// Control caching for this request for best performance and to reduce hosting costs:
|
||||
// https://qwik.dev/docs/caching/
|
||||
cacheControl({
|
||||
// Always serve a cached response by default, up to a week stale
|
||||
staleWhileRevalidate: 60 * 60 * 24 * 7,
|
||||
// Max once every 5 seconds, revalidate on the server to get a fresh version of this page
|
||||
maxAge: 5,
|
||||
});
|
||||
};
|
||||
|
||||
export default component$(() => {
|
||||
return <Slot />;
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* WHAT IS THIS FILE?
|
||||
*
|
||||
* The service-worker.ts file is used to have state of the art prefetching.
|
||||
* https://qwik.dev/qwikcity/prefetching/overview/
|
||||
*
|
||||
* Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline.
|
||||
* You can also use this file to add more functionality that runs in the service worker.
|
||||
*/
|
||||
import { setupServiceWorker } from "@builder.io/qwik-city/service-worker";
|
||||
|
||||
setupServiceWorker();
|
||||
|
||||
addEventListener("install", () => self.skipWaiting());
|
||||
|
||||
addEventListener("activate", () => self.clients.claim());
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { nextui } from '@nextui-org/react';
|
||||
|
||||
import type { Config } from 'tailwindcss';
|
||||
import plugin from 'tailwindcss/plugin';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
// theme: {
|
||||
// extend: {}
|
||||
// },
|
||||
darkMode: 'class',
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
nextui(
|
||||
{
|
||||
// defaultTheme: 'dark',
|
||||
defaultExtendTheme: 'light',
|
||||
themes: {
|
||||
'light': {
|
||||
layout: {},
|
||||
colors: {}
|
||||
},
|
||||
'dark': {
|
||||
layout: {},
|
||||
colors: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
plugin(function ({ addUtilities, addComponents, e, config }) {
|
||||
addUtilities(
|
||||
{
|
||||
'.horizontal-tb': {
|
||||
writingMode: 'horizontal-tb'
|
||||
},
|
||||
'.vertical-rl': {
|
||||
writingMode: 'vertical-rl'
|
||||
},
|
||||
'.vertical-lr': {
|
||||
writingMode: 'vertical-lr'
|
||||
},
|
||||
'.sideways-lr': {
|
||||
writingMode: 'vertical-lr',
|
||||
transform: 'rotate(180deg)'
|
||||
},
|
||||
'.sideways-rl': {
|
||||
writingMode: 'sideways-rl',
|
||||
transform: 'rotate(180deg)'
|
||||
}
|
||||
}
|
||||
);
|
||||
})
|
||||
]
|
||||
};
|
||||
export default config;
|
||||
+15
-17
@@ -1,27 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2017",
|
||||
"module": "ES2022",
|
||||
"lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@builder.io/qwik",
|
||||
"strict": true,
|
||||
"target": "ESNext",
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "Bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"isolatedModules": true,
|
||||
"outDir": "tmp",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"files": ["./.eslintrc.cjs"],
|
||||
"include": ["src", "./*.d.ts", "./*.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* This is the base config for vite.
|
||||
* When building, the adapter config is used which loads this file and extends it.
|
||||
*/
|
||||
import { defineConfig, type UserConfig } from "vite";
|
||||
import { qwikVite } from "@builder.io/qwik/optimizer";
|
||||
import { qwikCity } from "@builder.io/qwik-city/vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import pkg from "./package.json";
|
||||
|
||||
type PkgDep = Record<string, string>;
|
||||
const { dependencies = {}, devDependencies = {} } = pkg as any as {
|
||||
dependencies: PkgDep;
|
||||
devDependencies: PkgDep;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
errorOnDuplicatesPkgDeps(devDependencies, dependencies);
|
||||
|
||||
/**
|
||||
* Note that Vite normally starts from `index.html` but the qwikCity plugin makes start at `src/entry.ssr.tsx` instead.
|
||||
*/
|
||||
export default defineConfig(({ command, mode }): UserConfig => {
|
||||
return {
|
||||
plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
|
||||
// This tells Vite which dependencies to pre-build in dev mode.
|
||||
optimizeDeps: {
|
||||
// Put problematic deps that break bundling here, mostly those with binaries.
|
||||
// For example ['better-sqlite3'] if you use that in server functions.
|
||||
exclude: [],
|
||||
},
|
||||
|
||||
/**
|
||||
* This is an advanced setting. It improves the bundling of your server code. To use it, make sure you understand when your consumed packages are dependencies or dev dependencies. (otherwise things will break in production)
|
||||
*/
|
||||
// ssr:
|
||||
// command === "build" && mode === "production"
|
||||
// ? {
|
||||
// // All dev dependencies should be bundled in the server build
|
||||
// noExternal: Object.keys(devDependencies),
|
||||
// // Anything marked as a dependency will not be bundled
|
||||
// // These should only be production binary deps (including deps of deps), CLI deps, and their module graph
|
||||
// // If a dep-of-dep needs to be external, add it here
|
||||
// // For example, if something uses `bcrypt` but you don't have it as a dep, you can write
|
||||
// // external: [...Object.keys(dependencies), 'bcrypt']
|
||||
// external: Object.keys(dependencies),
|
||||
// }
|
||||
// : undefined,
|
||||
|
||||
server: {
|
||||
headers: {
|
||||
// Don't cache the server response in dev mode
|
||||
"Cache-Control": "public, max-age=0",
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
headers: {
|
||||
// Do cache the server response in preview (non-adapter production build)
|
||||
"Cache-Control": "public, max-age=600",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// *** utils ***
|
||||
|
||||
/**
|
||||
* Function to identify duplicate dependencies and throw an error
|
||||
* @param {Object} devDependencies - List of development dependencies
|
||||
* @param {Object} dependencies - List of production dependencies
|
||||
*/
|
||||
function errorOnDuplicatesPkgDeps(
|
||||
devDependencies: PkgDep,
|
||||
dependencies: PkgDep,
|
||||
) {
|
||||
let msg = "";
|
||||
// Create an array 'duplicateDeps' by filtering devDependencies.
|
||||
// If a dependency also exists in dependencies, it is considered a duplicate.
|
||||
const duplicateDeps = Object.keys(devDependencies).filter(
|
||||
(dep) => dependencies[dep],
|
||||
);
|
||||
|
||||
// include any known qwik packages
|
||||
const qwikPkg = Object.keys(dependencies).filter((value) =>
|
||||
/qwik/i.test(value),
|
||||
);
|
||||
|
||||
// any errors for missing "qwik-city-plan"
|
||||
// [PLUGIN_ERROR]: Invalid module "@qwik-city-plan" is not a valid package
|
||||
msg = `Move qwik packages ${qwikPkg.join(", ")} to devDependencies`;
|
||||
|
||||
if (qwikPkg.length > 0) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// Format the error message with the duplicates list.
|
||||
// The `join` function is used to represent the elements of the 'duplicateDeps' array as a comma-separated string.
|
||||
msg = `
|
||||
Warning: The dependency "${duplicateDeps.join(", ")}" is listed in both "devDependencies" and "dependencies".
|
||||
Please move the duplicated dependencies to "devDependencies" only and remove it from "dependencies"
|
||||
`;
|
||||
|
||||
// Throw an error with the constructed message.
|
||||
if (duplicateDeps.length > 0) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user