Author SHA1 Message Date
lumither c1ba41ea16 ref(backend) start of refactor 2024-09-04 23:18:41 -07:00
lumither 3c1fc34c2a feat(nav, blog) new ThemeSwitcher, also several updates on ui 2024-08-19 01:22:27 +08:00
lumither 2ca7e8bc14 feat(friends) style update 2024-08-17 03:14:53 +08:00
lumither ffc29d8bc5 feat(global) several major updates:
- delete page `tag`, `archive`
- add page `friends`
- implement page `about` *partially*
2024-08-16 23:10:59 +08:00
lumither 695e8b574e fix(blog) remove debug constant 2024-08-13 22:49:57 +08:00
lumither 04e1d0a508 fix(blog) remove console.log 2024-08-13 22:36:09 +08:00
lumither eb7ea991ac feat(blog) pagination 2024-08-13 22:32:27 +08:00
lumither 9f72bb01b7 feat(global) container and project structure update 2024-08-13 22:32:04 +08:00
lumither fbc9736831 feat(global) containerize 2024-08-01 01:39:24 +08:00
lumither 36f686c563 feat(blog) add footer for post reading part 2024-07-26 19:30:43 +08:00
lumither dbe84bc9f4 feat(blog) add adj (adjacent posts) to get_post 2024-07-25 00:37:10 +08:00
lumither 91ad20215a feat(blog) add meta to get_post 2024-07-23 02:49:16 +08:00
lumither ee413c5dee feat(blog) add pagination for get_post_list 2024-07-22 21:33:20 +08:00
lumither 1917630265 feat(blog) add force refresh 2024-07-22 19:59:57 +08:00
lumither 67f9cd0b1d feat(blog) add implementation for table meta 2024-07-21 23:09:13 +08:00
lumither 0864f0b950 feat(blog::refresh) add hash check to boost idx speed 2024-07-21 01:12:15 +08:00
lumither de27479242 feat(post) add header, fix style 2024-07-18 21:02:37 +08:00
lumither d982bf0548 feat(global) better logging, add several fields for get_post api
- add sub_title, category, header_img to `get_post`
2024-07-01 17:21:28 +08:00
lumither 371f3cab39 fix(.env) clean env 2024-06-27 02:32:56 +08:00
lumither 74945fe0bb fix(.env) fix deployment 2024-06-27 02:29:27 +08:00
lumither ddfe0701af feat(log) add logging system 2024-06-20 23:41:08 +08:00
lumither 9081e9a86d feat(log) add logrotate 2024-06-19 00:01:20 +08:00
lumither ef0f6b335e docs(README.md) regular update 2024-06-18 14:26:10 +08:00
lumither 59f23e8672 feat(CI/CD) add scripts 2024-06-18 14:25:36 +08:00
lumither 6b75c17b05 ref(global) update project structure 2024-06-18 13:07:40 +08:00
lumither 2fabd8d5c0 feat(env) env update 2024-06-08 02:31:20 +08:00
lumither c62fe7d1ef feat(bolg) add animation 2024-06-06 19:13:25 +08:00
lumither 56d673a9ec feat(global) new api rule, NextImage support, test openresty 2024-06-05 01:57:18 +08:00
lumither ffb20b64f3 feat(Navbar) update avatar, font 2024-05-31 22:18:03 +08:00
lumither bb657545c3 feat(navbar) update navbar 2024-05-30 15:47:55 +08:00
lumither 3f38a6366b fix(blog) fix style 2024-05-29 21:52:01 +08:00
lumither a59f6d7ae0 feat(navbar) update navbar 2024-05-29 18:36:55 +08:00
lumither 99d76f0a8c feat(blog) add footer 2024-05-29 18:36:16 +08:00
lumither b4007b6c6e feat(blog) fix style 2024-05-29 18:35:53 +08:00
lumither bfee26609e feat(navbar) add fix style 2024-05-27 23:35:29 +08:00
lumither d855df0d6e feat(navbar) new style, better performance 2024-05-25 23:20:12 +08:00
lumither 60a4a88246 fix(frontend/blog) fix compatability 2024-05-12 23:03:17 +08:00
lumither 1a2932a194 ref(backend/api/get/get_post_list) add category, header_img 2024-05-12 23:02:33 +08:00
lumither fd8b760b7f ref(backend) major refactor
- remove struct Post, use database schema and Map instead
- add sub_title
- replace NaiveDateTime with DateTime<Utc>
2024-05-12 17:00:37 +08:00
lumither 7d856f25a3 fix(frontend/blog) fix category chip z-axis height 2024-05-12 12:18:03 +08:00
lumither a11d859f40 feat(frontend/blog) even better post card 2024-05-12 12:11:33 +08:00
lumither fd4bdb4a6f feat(frontend/blog) font, lighthouse improve 2024-05-09 22:35:01 +08:00
lumither 7e0b015cbc feat(frontend/blog) better post card 2024-05-09 17:31:21 +08:00
lumither 85d474e902 fix(frontend/blog) fix style 2024-05-05 00:53:54 +08:00
lumither ac84a6acb5 feat(backend/api/get/post) fix error handling 2024-05-04 22:55:45 +08:00
lumither a4d0b7140a fix(frontend/blog/post) fix compatibility with new backend
- switch to axios
2024-05-04 22:54:38 +08:00
lumither 4f09648a34 feat(backend/api/get/post/<post_id>) implement api 2024-05-04 21:06:01 +08:00
lumither 131789bf83 feat(backend/post) new id strategy 2024-05-04 15:55:00 +08:00
lumither 4e71a1ccf0 feat(backend/api/get/post_list) add first_update field 2024-05-04 14:02:54 +08:00
lumither 7e3687c4c9 feat(backend/database/post) add first_update 2024-05-04 13:16:03 +08:00
lumither a9ba365bac fix(frontend/Blog) fix compatibility with new backend 2024-05-04 00:51:32 +08:00
lumither b17e8a19eb feat(api) update /api/get/post_list 2024-05-04 00:35:47 +08:00
lumither 5d82152932 feat(api) finish /api/get/post_list 2024-05-03 23:43:19 +08:00
lumither 70016f508f feat(idx) finish idx system 2024-05-03 22:42:14 +08:00
lumither 36825b4950 feat(idx) add auto idx 2024-05-03 21:40:53 +08:00
lumither 6b311ab255 feat(readme) update README.md 2024-05-03 14:36:09 +08:00
lumither 2d42e933c8 feat(api) add refresh.rs 2024-05-03 02:17:11 +08:00
lumither dce7a28b89 feat(project) add refresh.sh 2024-05-02 22:12:13 +08:00
lumither 1668de832f feat(backend) backed init 2024-05-02 19:02:13 +08:00
lumither a685537499 feat(reader) add reader 2024-04-30 00:40:59 +08:00
109 changed files with 3181 additions and 420 deletions

No files matched your search

+20 -2
View File
@@ -1,2 +1,20 @@
BLOG_ENDPOINT=https://raw.githubusercontents.com/lumither/blog-posts/master
; use mirror
# postgres settings
DB_HOST=localhost
DB_PORT=5432
POSTGRES_PASSWORD=
POSTGRES_USER=
POSTGRES_DB=nocturne
# backend settings
GIT_WORK_DIR=../blog_posts_git
LOG_ROOT_DIR=/var/log/nocturne
BLOG_ENDPOINT=https://github.com/Lumither/blog-posts.git
WORD_DIR=~/.nocturne
BACKEND_PORT=3001
# frontend settings
API_LOCAL_URL=http://localhost:3001
API_REMOTE_URL=https://lumither.com/api
+7 -40
View File
@@ -1,42 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# confidential
.env.*
/container/*/.env
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lockb
# ide related
# idea related
.idea
# exclude post repo
/blog_posts_git
+6
View File
@@ -0,0 +1,6 @@
ddev:
docker-compose -f dev.compose.yaml up --build
dclean:
docker-compose -f dev.compose.yaml rm -fsv
+24 -24
View File
@@ -1,36 +1,36 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
# `Nocturne: personal website & CMS`
## Getting Started
This is the code repo of my personal website, and [here](https://github.com/Lumither/blog-posts/) is the link of the
content repo.
First, run the development server:
Powered by Next.js and Rust, built with passion and love
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
<p align="center">
<img src="https://avatars.githubusercontent.com/u/46409277?v=4" alt="GitHub Avatar"/>
</p>
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
*will add more here in the future
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Nav:
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
<todo>
## Learn More
## Todos:
To learn more about Next.js, take a look at the following resources:
### Backend
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [ ] Add: log system
- [ ] Add: search api
- [ ] Add: tag filter (api)
- [ ] Abandon: UUID mechanism
- [ ] Fix: db connection issue
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
### Frontend
## Deploy on Vercel
- [ ] Page: Home
- [ ] Page: About
- [ ] Page: Tags
- [ ] Page: Archive
- [ ] Page: Search
- [ ] Add: Right sidebar
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
-9
View File
@@ -1,9 +0,0 @@
import React from 'react';
function About() {
return (
<div></div>
);
}
export default About;
-33
View File
@@ -1,33 +0,0 @@
import React from 'react';
import Link from 'next/link';
import { Card, CardBody, CardHeader } from '@nextui-org/card';
import { Divider } from '@nextui-org/react';
type Props = {
post: JSON
}
function BlogPostCard({ post }: Props) {
const name = (post as any)['name'];
const { title, desc } = (post as any)['meta'] as { title: string, desc: string };
return (
<div>
{/*todo: to be update*/ }
{/*<Link href={ `/blog/${ name }` }>*/}
<Card>
<CardHeader>
<p className={ 'font-bold' }>{ title }</p>
</CardHeader>
<Divider />
<CardBody>
{ desc }
</CardBody>
</Card>
{/*</Link>*/}
</div>
);
}
export default BlogPostCard;
-19
View File
@@ -1,19 +0,0 @@
import { Result } from '@/app/public/type/Result';
export default async function fetch_posts(url: string): Promise<Result<JSON[], Error>> {
const response = await fetch(`${ url }/posts/idx.json`);
if (!response.ok) {
return {
ok: false,
error: new Error(response.statusText)
};
}
const res = await response.json();
const posts = res['idx'].filter((posts: { type: string }) => posts.type === 'md');
return {
ok: true,
value: posts
};
}
-28
View File
@@ -1,28 +0,0 @@
import React from 'react';
import fetch_posts from '@/app/blog/fetch_posts';
import BlogPostCard from '@/app/blog/BlogPostCard';
async function Blog() {
const res = await fetch_posts(process.env.BLOG_ENDPOINT!);
if (!res.ok) {
console.log(res.error);
return;
}
const posts = res.value;
// console.log(posts);
return (
// blog list
<ul className={ 'flex flex-col items-center w-full max-w-[1024px]' }>
{ posts.map((post, key) => (
<li key={ key } className={ 'my-3.5 mx-7 w-[80%]' }>
<BlogPostCard post={ post } />
</li>)
) }
</ul>
);
}
export default Blog;
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-30
View File
@@ -1,30 +0,0 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import Navbar from '@/components/Navbar';
const inter = Inter({ subsets: [ 'latin' ] });
export const metadata: Metadata = {
title: 'Lumither\'s site',
description: 'a small corner on internet'
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={ inter.className }>
<NextThemesProvider attribute="class" defaultTheme="dark">
<Navbar />
{ children }
</NextThemesProvider>
</body>
</html>
);
}
-7
View File
@@ -1,7 +0,0 @@
export default function Home() {
return (
<main>
{/*<ThemeSwitcher />*/ }
</main>
);
}
+1
View File
@@ -0,0 +1 @@
/target
+18
View File
@@ -0,0 +1,18 @@
### Rust template
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
.env.local
+7
View File
@@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = [
"api_server",
"constants",
"markdown"
]
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "api_server"
version = "0.1.0"
edition = "2021"
[dependencies]
constants = { path = "../constants" }
chrono = "0.4.38"
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
tracing-appender = "0.2.3"
dotenv = "0.15.0"
+22
View File
@@ -0,0 +1,22 @@
use crate::logger;
use std::env;
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")
}
}
+68
View File
@@ -0,0 +1,68 @@
use std::{env, io};
use chrono::Local;
use tracing::info;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::{non_blocking, rolling};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{fmt, 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| {
panic!("[fatal] failed to load LOG_ROOT_DIR: {}", e);
});
let log_path = format!("{}/backend", log_path);
let (info_log_writer, info_guard) = non_blocking(rolling::never(&log_path, "info.log"));
let (err_log_writer, error_guard) = non_blocking(rolling::never(&log_path, "error.log"));
let log_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_timer(LocalTimer);
let subscribers = Registry::default()
.with(
fmt::Layer::default()
.with_writer(info_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(err_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(io::stdout)
.with_ansi(true)
.event_format(log_format),
);
tracing::subscriber::set_global_default(subscribers)
.expect("[fatal] Failed to setup logging system");
info!("Module Started: Logger");
(info_guard, error_guard)
}
+7
View File
@@ -0,0 +1,7 @@
mod init;
mod logger;
fn main() {
init::load_env();
let _guards = init::logger_init();
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "constants"
version = "0.1.0"
edition = "2021"
[dependencies]
+1
View File
@@ -0,0 +1 @@
pub const PAGE_SIZE: u32 = 6;
+2
View File
@@ -0,0 +1,2 @@
pub mod blog;
pub mod time;
+1
View File
@@ -0,0 +1 @@
pub const DEFAULT_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.7.5"
dotenv = "0.15.0"
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.116"
tokio = { version = "1.37.0", features = ["full"] }
uuid = { version = "1.8.0", features = ["v4", "fast-rng", "serde"] }
sqlx = { version = "0.7.4", features = ["runtime-tokio", "tls-native-tls", "postgres", "chrono", "uuid"] }
chrono = { version = "0.4.38", features = ["serde", "alloc"] }
regex = "1.10.4"
futures = { version = "0.3.30", features = ["executor"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
tracing-appender = "0.2.3"
log = "0.4.21"
sha2 = "0.10.8"
thiserror = "1.0.63"
base64 = "0.22.1"
@@ -0,0 +1,25 @@
use crate::constants::PAGE_SIZE;
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use sqlx::{query, PgPool, Row};
use tracing::error;
pub async fn get_page_count(
State(db_connection): State<PgPool>,
) -> Result<Json<Value>, Json<Value>> {
match query("select cast(ceil(count(*) / $1) as integer) as res from post")
.bind(PAGE_SIZE as f32)
.fetch_one(&db_connection)
.await
{
Ok(res) => {
let res: i32 = res.get("res");
Ok(Json::from(json!({"res": res})))
}
Err(e) => {
error!("Failed to get blog page count: {}", e.to_string());
Err(Json::from(json!({"error": e.to_string()})))
}
}
}
+154
View File
@@ -0,0 +1,154 @@
use std::str::FromStr;
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde_json::{json, Value};
use sqlx::{Error, PgPool, query, Row};
use tracing::error;
use uuid::Uuid;
pub async fn get_post(
State(db_connection): State<PgPool>,
Path(post_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let post_id = match Uuid::from_str(post_id.as_str()) {
Ok(id) => id,
Err(e) => {
error!(
"Failed to parse UUID <{}>: {}",
post_id.as_str(),
e.to_string()
);
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!(("error", e.to_string()))),
));
}
};
let post = match query(
r#"
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS post
FROM post t
WHERE post_id = $1;
"#,
)
.bind(post_id)
.fetch_one(&db_connection)
.await
{
Ok(value) => value.get::<Value, _>("post"),
Err(e) => {
error!(
"Database error on Reading `base post information` from table `post`: <{}>: {}",
post_id.to_string(),
e.to_string()
);
return match e {
Error::RowNotFound => Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": e.to_string()})),
)),
_ => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
)),
};
}
};
let tags: Vec<_> = match query("SELECT tag FROM Tag WHERE post_id = $1;")
.bind(post_id)
.fetch_all(&db_connection)
.await
{
Ok(tags) => tags.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(e) => {
error!("Database query error :{}", e.to_string());
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let meta = match query(
r#"
SELECT jsonb_strip_nulls(to_jsonb(t.*)) - 'post_id' AS meta
FROM meta t
WHERE post_id = $1;
"#,
)
.bind(post_id)
.fetch_one(&db_connection)
.await
{
Ok(value) => value.get::<Value, _>("meta"),
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"post meta data",
"meta",
post_id.to_string(),
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let adjacent = match query(r"
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = $1),
prev AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date < (SELECT date FROM target_post)
ORDER BY t.date DESC
LIMIT 1),
next AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date > (SELECT date FROM target_post)
ORDER BY t.date
LIMIT 1)
SELECT json_strip_nulls(json_build_object(
'prev',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM prev),
'next',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM next)
)) AS result;
"
)
.bind(post_id)
.fetch_one(&db_connection)
.await
{
Ok(res) => {res.get::<Value, _>("result")}
Err(e) => {
error!(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
"adjoint posts data",
"meta",
post_id.to_string(),
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = post.as_object().unwrap().to_owned();
res.insert("tags".to_string(), tags.into());
res.insert("meta".to_string(), meta);
res.insert("adj".to_string(), adjacent);
Ok(Json::from(json!(res)))
}
@@ -0,0 +1,87 @@
use crate::constants::PAGE_SIZE;
use axum::{extract::Query, extract::State, http::StatusCode, Json};
use chrono::{DateTime, Utc};
use futures::future::join_all;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sqlx::{postgres::PgRow, query, PgPool, Row};
use tracing::error;
use uuid::Uuid;
#[derive(Deserialize)]
pub struct Params {
page: Option<u32>,
}
pub async fn get_post_list(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let search_param_page = params.page.unwrap_or(1);
if search_param_page < 1 {
return Err((
StatusCode::BAD_REQUEST,
Json::from(json!({"error": "`page` should be greater than 0"})),
));
}
let offset = PAGE_SIZE * (search_param_page - 1);
let post_list = sqlx::query("SELECT * FROM post ORDER BY first_update DESC LIMIT $1 OFFSET $2")
.bind(PAGE_SIZE as i32)
.bind(offset as i32)
.fetch_all(&db_connection)
.await;
let post_list = match post_list {
Ok(post_list) => post_list,
Err(e) => {
error!(
"Database query error on fetching post list: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": e.to_string()})),
));
}
};
let mut res = Map::new();
let posts: Vec<_> = post_list
.into_iter()
.map(|post| analyze_post(&db_connection, post))
.collect();
let posts = join_all(posts).await;
res.insert("posts".to_string(), Value::from(posts));
Ok(Json::from(Value::from(res)))
}
async fn analyze_post(db_connection: &PgPool, post: PgRow) -> Value {
let post_id: Uuid = post.get("post_id");
let tags = fetch_post_tags(db_connection, post_id).await;
json!({
"post_id": post_id.to_string(),
"title": post.get::<String, _>("title"),
"category": post.get::<String, _>("category"),
"header_img": post.get::<String, _>("header_img"),
"sub_title": post.get::<String, _>("sub_title"),
"summary": post.get::<String, _>("summary"),
"last_update": post.get::<DateTime<Utc>, _>("last_update"),
"first_update": post.get::<DateTime<Utc>, _>("first_update"),
"tags": tags,
})
}
async fn fetch_post_tags(db_connection: &PgPool, post_id: Uuid) -> Vec<String> {
match query("SELECT tag FROM Tag WHERE post_id = $1")
.bind(post_id)
.fetch_all(db_connection)
.await
{
Ok(res) => res.iter().map(|tag| tag.get::<String, _>("tag")).collect(),
Err(_) => vec![],
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod get_page_count;
pub mod get_post;
pub mod get_post_list;
+2
View File
@@ -0,0 +1,2 @@
pub mod get;
pub mod post;
@@ -0,0 +1 @@
pub mod refresh;
@@ -0,0 +1,61 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PostIdxError {
#[error("Failed to parse UUID <{}>: {}", id, err_msg)]
InvalidUUID { id: String, err_msg: String },
#[error(
"Failed to parse `last update time` for <{}> when creating post index: {}",
id,
err_msg
)]
InvalidLastUpdate { id: String, err_msg: String },
#[error(
"Failed to parse `first update time` for <{}> when creating post index: {}",
id,
err_msg
)]
InvalidFirstUpdate { id: String, err_msg: String },
#[error(
"Failed to parse `tag` for <{}> when creating post index: <{}>: {}",
id,
invalid_string,
err_msg
)]
InvalidTagString {
id: String,
invalid_string: String,
err_msg: String,
},
#[error(
"Database error on writing `{}` to table `{}`: <{}>: {}",
data_desc,
db_table,
id,
err_msg
)]
DBWriteFailure {
data_desc: String,
db_table: String,
id: String,
err_msg: String,
},
#[error(
"Database error on Reading `{}` from table `{}`: <{}>: {}",
data_desc,
db_table,
id,
err_msg
)]
DBReadFailure {
data_desc: String,
db_table: String,
id: String,
err_msg: String,
},
}
+3
View File
@@ -0,0 +1,3 @@
mod errors;
pub mod refresh;
mod utils;
+192
View File
@@ -0,0 +1,192 @@
use std::path::Path;
use std::str::FromStr;
use std::{env, fs};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::Json;
use futures::{stream, StreamExt};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use sqlx::{query, PgPool};
use tracing::error;
use uuid::Uuid;
use crate::api::post::errors::refresh::PostIdxError;
use crate::api::post::utils::{build_post_idx, post};
use crate::model::blog::{POST_BASE_MODEL, POST_HASH_MODEL, POST_META_MODEL, TAG_MODEL};
#[derive(Deserialize)]
pub struct Params {
/// refresh level
level: Option<String>,
}
pub async fn refresh(
State(db_connection): State<PgPool>,
Query(params): Query<Params>,
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
let is_force_refresh = params.level.unwrap_or("".to_string()) == "force";
// search post
let data_repo_dir = match env::var("GIT_WORK_DIR") {
Ok(value) => value,
Err(_) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!((
"error",
"error at parsing environment variable: GIT_WORK_DIR"
))),
));
}
};
let post_dir = Path::new(&data_repo_dir).join("posts");
if is_force_refresh {
if let Err(e) = query("DROP TABLE IF EXISTS hash, meta, post, tag;")
.execute(&db_connection)
.await
{
error!(
"DB query error when deleting table: `hash`, `meta`, `post`, `tag`: {}",
e.to_string()
);
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!(("error", e.to_string()))),
));
}
}
// generate post idx
let post_path = search_md(&post_dir);
let post_list_with_hash: Vec<(Map<_, _>, Vec<_>)> = post_path
.into_iter()
.filter_map(|post_path| match post::from_path(&post_path) {
Ok(post) => match fs::read(&post_path) {
Ok(bytes) => {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize().to_vec();
Some((post, hash))
}
Err(e) => {
error!(
"Failed to read file while hashing the post <{}>, skipping: {}",
post_path,
e.to_string()
);
None
}
},
Err(e) => {
error!(
"Failed to parse post <{}>, skipping: {}",
post_path,
e.to_string()
);
None
}
})
.collect();
// db create
query(POST_BASE_MODEL)
.execute(&db_connection)
.await
.unwrap();
query(POST_META_MODEL)
.execute(&db_connection)
.await
.unwrap();
query(TAG_MODEL).execute(&db_connection).await.unwrap();
query(POST_HASH_MODEL)
.execute(&db_connection)
.await
.unwrap();
let res: Vec<_> = stream::iter(post_list_with_hash)
.map(|(post, hash)| {
let db_clone = db_connection.clone();
async move { build_post_idx(&db_clone, post, hash).await }
})
.buffer_unordered(usize::MAX)
.collect()
.await;
let errs: Vec<_> = res
.into_iter()
.filter_map(|res| res.err())
.map(|e| e.to_string())
.collect();
if !errs.is_empty() {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json::from(json!({"error": errs})),
))
} else {
Ok(StatusCode::OK)
}
}
/// Search a dir, return a list of markdown files
///
/// # Arguments
/// * `entry`: dir path
///
/// # Returns
/// Vec<String, Global>
/// * `String`: path of a markdown file
///
fn search_md(entry: &Path) -> Vec<String> {
if entry.ends_with(".git") {
return vec![];
}
if entry.is_dir() {
if let Ok(files) = entry.read_dir() {
files
.flat_map(|file| {
let file_path = file.unwrap().path();
search_md(file_path.as_path())
})
.collect()
} else {
vec![]
}
} else if entry.extension().is_some_and(|ext| ext == "md") {
vec![entry.to_str().unwrap().to_string()]
} else {
vec![]
}
}
async fn build_post_idx(
db_connection: &PgPool,
post: Map<String, Value>,
hash: Vec<u8>,
) -> Result<(), PostIdxError> {
let meta = &post["meta"];
// extract post id
let post_id = match Uuid::from_str(post["post_id"].as_str().unwrap()) {
Ok(id) => id,
Err(e) => {
return Err(PostIdxError::InvalidUUID {
id: post["post_id"].as_str().unwrap().to_string(),
err_msg: e.to_string(),
})
}
};
if build_post_idx::hash(db_connection, post_id, hash).await? {
build_post_idx::base(db_connection, &post, post_id, meta).await?;
build_post_idx::tag(db_connection, post_id, meta).await?;
build_post_idx::meta(db_connection, post_id, meta).await?;
}
Ok(())
}
@@ -0,0 +1,288 @@
use base64::{Engine, engine::general_purpose};
use chrono::DateTime;
use serde_json::{Map, Value};
use sqlx::{PgPool, query, Row};
use uuid::Uuid;
use crate::api::post::errors::refresh::PostIdxError;
use crate::constants::GLOBAL_TIME_FORMAT;
pub async fn base(
db_connection: &PgPool,
post: &Map<String, Value>,
post_id: Uuid,
meta: &Value,
) -> Result<(), PostIdxError> {
let title = post["title"].as_str();
let summary = post
.get("summary")
.and_then(|summary| summary.as_str())
.unwrap_or("");
let content = post["content"].as_str();
let last_update =
match DateTime::parse_from_str(post["last_update"].as_str().unwrap(), GLOBAL_TIME_FORMAT) {
Ok(time) => time,
Err(e) => {
return Err(PostIdxError::InvalidLastUpdate {
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
};
let first_update = match DateTime::parse_from_str(
post["first_update"].as_str().unwrap(),
GLOBAL_TIME_FORMAT,
) {
Ok(time) => time,
Err(e) => {
return Err(PostIdxError::InvalidFirstUpdate {
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
};
let sub_title = meta
.get("sub_title")
.and_then(|sub_title| sub_title.as_str())
.unwrap_or("");
let category = meta
.get("category")
.and_then(|category| category.as_str())
.unwrap_or("N/A");
let header_img = meta
.get("header_img")
.and_then(|header_img| header_img.as_str())
.unwrap_or("");
match query(
r##"
INSERT INTO Post (post_id, title, summary, content, last_update, first_update, sub_title, category, header_img)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (post_id) DO
UPDATE SET
title = EXCLUDED.title,
summary = EXCLUDED.summary,
content = EXCLUDED.content,
last_update = EXCLUDED.last_update,
first_update = EXCLUDED.first_update,
sub_title = EXCLUDED.sub_title,
category = EXCLUDED.category,
header_img = EXCLUDED.header_img;
"##,
)
.bind(post_id)
.bind(title)
.bind(summary)
.bind(content)
.bind(last_update)
.bind(first_update)
.bind(sub_title)
.bind(category)
.bind(header_img)
.execute(db_connection)
.await
{
Ok(_) => {}
Err(e) => {
return Err(
PostIdxError::DBWriteFailure {
data_desc: "post base information".to_string(),
db_table: "Post".to_string(),
id: post_id.to_string(),
err_msg: e.to_string()
}
)
}
}
Ok(())
}
pub async fn tag(db_connection: &PgPool, post_id: Uuid, meta: &Value) -> Result<(), PostIdxError> {
if let Some(tags_string) = meta.get("tags").to_owned() {
// parse tags string
let parsed: Value = match serde_json::from_str(tags_string.as_str().unwrap()) {
Ok(value) => value,
Err(e) => {
return Err(PostIdxError::InvalidTagString {
id: post_id.to_string(),
invalid_string: tags_string.to_string().to_string(),
err_msg: e.to_string(),
})
}
};
let string_array = parsed.as_array().unwrap();
let tags: Vec<String> = string_array
.iter()
.map(|value| value.as_str().unwrap().to_string())
.collect();
// remove old record
if let Err(e) = query("DELETE FROM Tag WHERE post_id = $1")
.bind(post_id)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
// add new record
for tag in tags {
match query("INSERT INTO Tag VALUES ($1, $2);")
.bind(post_id)
.bind(tag)
.execute(db_connection)
.await
{
Ok(_) => {}
Err(e) => {
return Err(PostIdxError::DBWriteFailure {
data_desc: "post tag information".to_string(),
db_table: "Tag".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
}
}
}
Ok(())
}
/// # Arguments
///
/// * `db_connection`: database connection
/// * `post_id`: UUID of the post
/// * `hash`: Hex String
///
/// # Returns
/// Result<bool, PostIdxError>
/// - `bool`: if the hash is different from database, return true, else false
pub async fn hash(
db_connection: &PgPool,
post_id: Uuid,
hash: Vec<u8>,
) -> Result<bool, PostIdxError> {
let base64_form = general_purpose::STANDARD.encode(hash);
match query("SELECT hash FROM Hash WHERE post_id = $1")
.bind(post_id)
.fetch_optional(db_connection)
.await
{
Ok(row) => {
let db_record: String = match row {
None => "".to_string(),
Some(row) => row.try_get("hash").unwrap_or("".to_string()),
};
if base64_form == db_record {
Ok(false)
} else {
if let Err(e) = query(
r#"
INSERT INTO Hash (post_id, hash) VALUES ($1, $2)
ON CONFLICT (post_id) DO UPDATE SET hash = EXCLUDED.hash"#,
)
.bind(post_id)
.bind(&base64_form)
.execute(db_connection)
.await
{
return Err(PostIdxError::DBWriteFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
});
}
Ok(true)
}
}
Err(e) => Err(PostIdxError::DBReadFailure {
data_desc: "post hash".to_string(),
db_table: "Hash".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
}),
}
}
pub async fn meta(db_connection: &PgPool, post_id: Uuid, meta: &Value) -> Result<(), PostIdxError> {
let mut err_trails = 5;
loop {
if let Err(e) = query(
format!(
r#"
DO
$$
DECLARE
_post_id UUID := '{}';
_meta_cols JSONB := '{}';
_exists BOOLEAN;
_col_name TEXT;
_col_value TEXT;
_sql TEXT;
_insert_columns TEXT := 'post_id';
_insert_values TEXT := quote_literal(_post_id);
_update_set TEXT := '';
_blacklist TEXT[] := ARRAY['tags'];
BEGIN
SELECT EXISTS(SELECT 1 FROM meta WHERE post_id = _post_id) INTO _exists;
FOR _col_name, _col_value IN SELECT key, value FROM jsonb_each_text(_meta_cols)
LOOP
IF _col_name = ANY(_blacklist) THEN
CONTINUE;
END IF;
IF NOT EXISTS (SELECT 1
FROM information_schema.columns
WHERE table_name = 'meta'
AND column_name = _col_name
-- may change in the future
AND table_schema = 'public') THEN
_sql := 'ALTER TABLE meta ADD COLUMN ' || quote_ident(_col_name) || ' TEXT';
EXECUTE _sql;
END IF;
_insert_columns := _insert_columns || ', ' || quote_ident(_col_name);
_insert_values := _insert_values || ', ' || quote_literal(_col_value);
_update_set := _update_set || ', ' || quote_ident(_col_name) || ' = ' || quote_literal(_col_value);
END LOOP;
_update_set := substring(_update_set FROM 3);
IF _exists THEN
_sql := 'UPDATE meta SET ' || _update_set || ' WHERE post_id = ' || quote_literal(_post_id);
EXECUTE _sql;
ELSE
_sql := 'INSERT INTO meta (' || _insert_columns || ') VALUES (' || _insert_values || ')';
EXECUTE _sql;
END IF;
END;
$$ LANGUAGE plpgsql;
"#, post_id.to_string().as_str(), meta.to_string()
).as_str()
)
.execute(db_connection)
.await {
if err_trails >= 0 {
err_trails -= 1;
continue
} else {
return Err(PostIdxError::DBWriteFailure {
data_desc: "post meta data".to_string(),
db_table: "Meta".to_string(),
id: post_id.to_string(),
err_msg: e.to_string(),
})
}
} else {
return Ok(())
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod build_post_idx;
pub mod post;
+120
View File
@@ -0,0 +1,120 @@
use std::{env, fs};
use std::error::Error;
use std::process::Command;
use std::str;
use std::str::FromStr;
use chrono::{DateTime, Utc};
use serde_json::{Map, Value};
use tracing::{error, warn};
use uuid::Uuid;
use crate::constants::GLOBAL_TIME_FORMAT;
use crate::markdown::meta::parse_meta;
pub fn from_path(path: &str) -> Result<Map<String, Value>, Box<dyn Error>> {
let mut res = Map::new();
// field `content`
let md_content = fs::read_to_string(path)?;
res.insert("content".to_string(), Value::from(md_content.to_owned()));
// field `meta`
let meta = parse_meta(&md_content);
let meta_map: Map<String, Value> = meta
.clone()
.into_iter()
.map(|(k, v)| (k, Value::from(v)))
.collect();
res.insert("meta".to_string(), Value::from(meta_map));
// field `post_id`
let post_id = match meta.get("id") {
None => Uuid::new_v4(),
Some(id) => match Uuid::from_str(id.as_str()) {
Ok(id) => id,
Err(e) => {
error!("Failed to parse UUID <{}>: {}", id.as_str(), e.to_string());
return Err(Box::new(e));
}
},
};
res.insert("post_id".to_string(), Value::from(post_id.to_string()));
// field `title`
let title = match meta.get("title") {
None => "Title Not Found".to_string(),
Some(title) => title.to_owned(),
};
res.insert("title".to_string(), Value::from(title));
// field `summary`
let summary = match meta.get("summary") {
None => "".to_string(),
Some(summary) => summary.to_owned(),
};
res.insert("summary".to_string(), Value::from(summary));
// field `last_update`
let git_output = match Command::new("git")
.arg("--no-pager")
.arg("log")
.arg("-1")
.arg("--format=%cI")
.arg("--")
.current_dir(env::var("GIT_WORK_DIR")?)
.arg(path)
.output()
{
Ok(res) => res,
Err(e) => {
error!("Failed to execute `git` command: {}", e.to_string());
return Err(Box::new(e));
}
};
let last_update: DateTime<Utc> = if git_output.status.success() {
chrono::DateTime::parse_from_str(
str::from_utf8(&git_output.stdout).unwrap().trim_end(),
GLOBAL_TIME_FORMAT,
)
.unwrap_or_else(|_| DateTime::from(Utc::now()))
.to_utc()
} else {
Utc::now().to_utc()
};
res.insert(
"last_update".to_string(),
Value::from(last_update.format(GLOBAL_TIME_FORMAT).to_string()),
);
// field `first_update`
let first_update: DateTime<Utc> = match meta.get("date") {
None => {
warn!(
"Failed to parse first update time for post {}, using current time instead",
post_id.to_string()
);
Utc::now().to_utc()
}
Some(date) => DateTime::from(
match DateTime::parse_from_str(format!("{}T000000", date).as_str(), "%Y-%m-%d%zT%H%M%S")
{
Ok(time) => time,
Err(e) => {
warn!(
"Failed to parse first update time for post {}, using current time instead: {}",
post_id.to_string(),
e.to_string()
);
DateTime::from(Utc::now())
}
},
),
};
res.insert(
"first_update".to_string(),
Value::from(first_update.format(GLOBAL_TIME_FORMAT).to_string()),
);
Ok(res)
}
+3
View File
@@ -0,0 +1,3 @@
pub const GLOBAL_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%z";
pub const PAGE_SIZE: u32 = 6;
+67
View File
@@ -0,0 +1,67 @@
use std::{env, io};
use chrono::Local;
use tracing::info;
use tracing_appender::{non_blocking, rolling};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{fmt, Registry};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::layer::SubscriberExt;
use crate::constants::GLOBAL_TIME_FORMAT;
struct LocalTimer;
impl FormatTime for LocalTimer {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
write!(w, "{}", Local::now().format(GLOBAL_TIME_FORMAT))
}
}
type _LogWriter = dyn for<'writer> MakeWriter<'writer, Writer = dyn io::Write + 'static> + 'static;
pub fn init() -> (WorkerGuard, WorkerGuard) {
let log_path = env::var("LOG_ROOT_DIR").unwrap_or_else(|e| {
panic!("[Fatal] failed to load LOG_ROOT_DIR: {}", e);
});
let log_path = format!("{}/backend", log_path);
let (info_log_writer, info_guard) = non_blocking(rolling::never(&log_path, "info.log"));
let (err_log_writer, error_guard) = non_blocking(rolling::never(&log_path, "error.log"));
let log_format = tracing_subscriber::fmt::format()
.with_level(true)
.with_target(true)
.with_source_location(true)
.with_timer(LocalTimer);
let subscribers = Registry::default()
.with(
fmt::Layer::default()
.with_writer(info_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(err_log_writer)
.with_ansi(false),
)
.with(
fmt::Layer::default()
.with_writer(io::stdout)
.with_ansi(true)
.event_format(log_format),
);
tracing::subscriber::set_global_default(subscribers)
.expect("[Fatal] Failed to setup logging system");
info!("Module Started: Logger");
// error!("test");
(info_guard, error_guard)
}
+95
View File
@@ -0,0 +1,95 @@
use std::env;
use std::error::Error;
use std::num::ParseIntError;
use std::process::exit;
use std::str::FromStr;
use crate::api::{
get::{get_page_count::get_page_count, get_post::get_post, get_post_list::get_post_list},
post::refresh::refresh,
};
use axum::routing::{get, post};
use axum::Router;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::ConnectOptions;
use tracing::{error, info, warn};
mod api;
mod constants;
mod logger;
mod markdown;
mod model;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
if let Some(env_file) = env::args().nth(1) {
match dotenv::from_filename(&env_file) {
Ok(_) => {
println!("[Info] env loaded from {}, starting up", env_file);
}
Err(e) => {
panic!("[Fatal] failed to read {}: {}", env_file, e);
}
}
} else {
println!("[info] no .env file referred, starting up")
}
let _guards = logger::init();
let port: u32 = match env::var("BACKEND_PORT") {
Ok(value) => value.parse().unwrap_or_else(|e: ParseIntError| {
warn!("Failed to parse BACKEND_PORT: {}", e.to_string());
3001
}),
Err(_) => 3001,
};
let db_connect_option = PgConnectOptions::from_str(&parse_db_uri())
.unwrap()
.disable_statement_logging();
let db_pool = match PgPoolOptions::new().connect_with(db_connect_option).await {
Ok(pool) => {
info!("Database connected");
pool
}
Err(e) => {
error!("Failed to load database: {}", e.to_string());
exit(1);
}
};
let app = Router::new()
.route("/refresh_posts", post(refresh))
.route("/get_post_list", get(get_post_list))
.route("/get_page_count", get(get_page_count))
.route("/get_post/:post_id", get(get_post))
.with_state(db_pool);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap_or_else(|e| {
error!("Failed to build TCP listener: {}", e.to_string());
exit(1);
});
axum::serve(listener, app).await.unwrap_or_else(|e| {
error!("Failed to start axum: {}", e.to_string());
exit(1);
});
Ok(())
}
fn parse_db_uri() -> String {
let postgres_user = env::var("POSTGRES_USER").unwrap_or("".to_string());
let postgres_password = env::var("POSTGRES_PASSWORD").unwrap_or("".to_string());
let db_host = env::var("DB_HOST").unwrap_or("localhost".to_string());
let db_port = env::var("DB_PORT")
.unwrap_or("5432".to_string())
.to_string();
let postgres_db = env::var("POSTGRES_DB").unwrap_or("nocturne".to_string());
format!("postgresql://{postgres_user}:{postgres_password}@{db_host}:{db_port}/{postgres_db}")
}
+55
View File
@@ -0,0 +1,55 @@
use std::collections::HashMap;
use regex::Regex;
/// return the meta info contained in the .md file front matter
pub fn parse_meta(md_content: &str) -> HashMap<String, String> {
let mut fields: HashMap<String, String> = HashMap::new();
let match_regex = Regex::new(r"(?s)---\n(.*\n)---\n").unwrap();
if let Some(front_matter) = match_regex.captures(md_content) {
let fm_content = front_matter.get(1).unwrap().as_str();
for line in fm_content.lines() {
if let Some((k, v)) = extract_kv(line) {
fields.insert(k, v);
}
}
}
fields
}
fn extract_kv(line: &str) -> Option<(String, String)> {
let split: Vec<&str> = line.splitn(2, ':').map(|s| s.trim()).collect();
if split.len() == 2 {
let key = split[0].to_string();
let value = split[1].to_string();
Some((key, value))
} else {
None
}
}
#[cfg(test)]
mod meta_test {
use std::error::Error;
use crate::markdown::meta::parse_meta;
#[test]
fn test_parse_meta_front_matter() -> Result<(), Box<dyn Error>> {
let parsed_meta = parse_meta(
r##"
---
title: Hello World!
tags: [ "test", "hello_world" ]
---
"##,
);
dbg!(&parsed_meta);
assert_eq!(parsed_meta.get("title"), Some(&"Hello World!".to_string()));
assert_eq!(
parsed_meta.get("tags"),
Some(&r#"[ "test", "hello_world" ]"#.to_string())
);
Ok(())
}
}
+1
View File
@@ -0,0 +1 @@
pub mod meta;
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS Hash
(
post_id UUID PRIMARY KEY,
hash VARCHAR(44)
);
+79
View File
@@ -0,0 +1,79 @@
CREATE TABLE IF NOT EXISTS Meta
(
post_id UUID PRIMARY KEY
);
DROP table meta;
DO
$$
DECLARE
_post_id UUID := '{}';
_meta_cols JSONB := '{}';
_exists BOOLEAN;
_col_name TEXT;
_col_value TEXT;
_sql TEXT;
_insert_columns TEXT := 'post_id';
_insert_values TEXT := quote_literal(_post_id);
_update_set TEXT := '';
_blacklist TEXT[] := ARRAY ['id', 'tags'];
BEGIN
SELECT EXISTS(SELECT 1 FROM meta WHERE post_id = _post_id) INTO _exists;
FOR _col_name, _col_value IN SELECT key, value FROM jsonb_each_text(_meta_cols)
LOOP
IF _col_name = ANY (_blacklist) THEN
CONTINUE;
END IF;
IF NOT EXISTS (SELECT 1
FROM information_schema.columns
WHERE table_name = 'meta'
AND column_name = _col_name
-- may change in the future
AND table_schema = 'public') THEN
_sql := 'ALTER TABLE meta ADD COLUMN ' || quote_ident(_col_name) || ' TEXT';
EXECUTE _sql;
END IF;
_insert_columns := _insert_columns || ', ' || quote_ident(_col_name);
_insert_values := _insert_values || ', ' || quote_literal(_col_value);
_update_set := _update_set || ', ' || quote_ident(_col_name) || ' = ' || quote_literal(_col_value);
END LOOP;
_update_set := substring(_update_set FROM 3);
IF _exists THEN
_sql := 'UPDATE meta SET ' || _update_set || ' WHERE post_id = ' || quote_literal(_post_id);
EXECUTE _sql;
ELSE
_sql := 'INSERT INTO meta (' || _insert_columns || ') VALUES (' || _insert_values || ')';
EXECUTE _sql;
END IF;
END;
$$ LANGUAGE plpgsql;
SELECT jsonb_strip_nulls(to_jsonb(t)) AS meta
FROM meta t
WHERE post_id = '8ca3fa2b-f040-448c-8885-81aab56db9bb';
WITH target_post AS (SELECT t.date FROM meta t WHERE post_id = '8ca3fa2b-f040-448c-8885-81aab56db9bb'),
prev AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date < (SELECT date FROM target_post)
ORDER BY t.date DESC
LIMIT 1),
next AS (SELECT post_id, t.title, t.sub_title, t.header_img
FROM meta t
WHERE t.date > (SELECT date FROM target_post)
ORDER BY t.date
LIMIT 1)
SELECT json_strip_nulls(json_build_object(
'prev',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM prev),
'next',
(SELECT json_build_object('id', post_id, 'title', title, 'sub_title', sub_title, 'header_img', header_img)
FROM next)
)) AS result;
+38
View File
@@ -0,0 +1,38 @@
pub const POST_BASE_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Post
(
post_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(255) NOT NULL,
header_img TEXT,
summary TEXT,
sub_title TEXT,
content TEXT NOT NULL,
last_update TIMESTAMPTZ NOT NULL,
first_update TIMESTAMPTZ NOT NULL
);
"##;
// base mode, will add more cols in runtime
pub const POST_META_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Meta
(
post_id UUID PRIMARY KEY
);
"##;
pub const POST_HASH_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Hash
(
post_id UUID PRIMARY KEY,
hash VARCHAR(44)
);
"##;
pub const TAG_MODEL: &str = r##"
CREATE TABLE IF NOT EXISTS Tag
(
post_id UUID NOT NULL,
tag VARCHAR(255) NOT NULL
);
"##;
+12
View File
@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS Post
(
post_id UUID PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(255) NOT NULL,
header_img TEXT,
summary TEXT,
sub_title TEXT,
content TEXT NOT NULL,
last_update TIMESTAMPTZ NOT NULL,
first_update TIMESTAMPTZ NOT NULL
);
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS Tag
(
post_id UUID NOT NULL,
tag VARCHAR(255) NOT NULL
);
+1
View File
@@ -0,0 +1 @@
pub mod blog;
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "markdown"
version = "0.1.0"
edition = "2021"
[dependencies]
+14
View File
@@ -0,0 +1,14 @@
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);
}
}
-116
View File
@@ -1,116 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Image,
Navbar as NextUINavbar,
NavbarBrand,
NavbarContent,
NavbarItem,
NavbarMenu,
NavbarMenuItem,
NavbarMenuToggle
} from '@nextui-org/react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import ThemeSwitcher from '@/components/ThemeSwitcher';
const entries: { display_name: string; href: string; }[] = [
{
display_name: 'Home',
href: '/'
},
{
display_name: 'About',
href: '/about'
},
{
display_name: 'Blog',
href: '/blog'
}
];
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);
};
}, []);
return (
<>
<NextUINavbar isBlurred isBordered>
<NavbarContent className="sm:hidden" justify="start">
<NavbarMenuToggle />
</NavbarContent>
<NavbarContent className="sm:hidden pr-3" justify="center">
<Image
src={ 'https://avatars.githubusercontent.com/u/46409277?v=4' }
alt={ 'avatar' }
className={ 'rounded-full h-[3rem]' }
></Image>
<NavbarBrand>
<p className="font-bold text-inherit">{ 'Lumither\'s site' }</p>
</NavbarBrand>
</NavbarContent>
<NavbarContent className="hidden sm:flex gap-4" justify="center">
<Image
src={ 'https://avatars.githubusercontent.com/u/46409277?v=4' }
alt={ 'avatar' }
className={ 'rounded-full h-[3rem]' }
></Image>
<NavbarBrand>
<p className="font-bold text-inherit">{ 'Lumither\'s site' }</p>
</NavbarBrand>
{ entries.map((item, index) => (
// eslint-disable-next-line react-hooks/rules-of-hooks
<NavbarItem key={ index } isActive={ usePathname() === item.href }>
<Link
className="w-full"
href={ item.href }
>
{ item.display_name }
</Link>
</NavbarItem>
)) }
</NavbarContent>
<NavbarContent justify="end">
<NavbarItem>
<ThemeSwitcher />
</NavbarItem>
</NavbarContent>
<NavbarMenu>
{ entries.map((item, index) => (
<NavbarMenuItem key={ index }>
<Link
className="w-full"
href={ item.href }
>
{ item.display_name }
</Link>
</NavbarMenuItem>
)) }
</NavbarMenu>
</NextUINavbar>
</>
);
}
export default Navbar;
-38
View File
@@ -1,38 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { Switch } from '@nextui-org/react';
import { MdOutlineDarkMode, MdOutlineLightMode } from 'react-icons/md';
function ThemeSwitcher() {
const [ mounted, setMounted ] = useState(false);
const { setTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div>
<Switch
color={ 'default' }
startContent={ <MdOutlineLightMode /> }
endContent={ <MdOutlineDarkMode /> }
onValueChange={ (isSelected) => {
if (isSelected) {
setTheme('light');
} else {
setTheme('dark');
}
} }
></Switch>
</div>
);
}
export default ThemeSwitcher;
+9
View File
@@ -0,0 +1,9 @@
FROM rust:latest
WORKDIR /app
COPY . .
RUN cargo build
CMD cargo r
+13
View File
@@ -0,0 +1,13 @@
# docker/dev.Dockerfile
FROM node:latest
WORKDIR /app
COPY package.json ./
RUN npm install
ENV NEXT_TELEMETRY_DISABLED 1
CMD npm run dev
Whitespace-only changes.
Whitespace-only changes.
+36
View File
@@ -0,0 +1,36 @@
services:
frontend:
build:
context: ./frontend
dockerfile: ../container/dev/frontend.containerfile
restart: always
ports:
- 3000:3000
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
env_file:
- ./container/dev/.env
dns:
- 1.1.1.1
backend:
build:
context: ./backend
dockerfile: ../container/dev/backend.containerfile
restart: always
ports:
- 3001:3001
volumes:
- ./blog_posts_git:/app/blog_posts_git:ro
env_file:
- ./container/dev/.env
postgres:
image: postgres:16.3
ports:
- 5432:5432
env_file:
- ./container/dev/.env
+10
View File
@@ -0,0 +1,10 @@
/node_modules
/.next/
/out/
/build
.DS_Store
*.pem
.env*.local
File renamed without changes.
+43
View File
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# bun
bun.lockb
# ide related
.idea
/package-lock.json
@@ -6,7 +6,7 @@ function Layout({
children: React.ReactNode;
}) {
return (
<div className={ 'flex items-stretch justify-center' }>
<div className={ 'flex p-7' }>
{ children }
</div>
);
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Me = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/me.md') }
</MarkdownRenderer>
</div>
);
};
export default Me;
+58
View File
@@ -0,0 +1,58 @@
import { Card } from '@nextui-org/react';
import React from 'react';
import { CardBody } from '@nextui-org/card';
import Link from 'next/link';
const About = () => {
return (
<div className={ `flex flex-col w-full justify-center align-middle h-dvh` }>
<div className={ 'md:relative md:h-[130px] mb-4 md:mb-0' }>
<div className={ 'md:relative md:left-[20%] flex flex-col w-fit' }>
<p className={ 'text-pretty text-3xl' }>{ '讷,远道而来的' }<span
className={ 'break-keep underline decoration-sky-400 dark:decoration-sky-700 decoration-4 underline-offset-0' }>旅人</span>
</p>
<p className={ 'text-pretty md:text-2xl mt-1 md:ml-4' }>{ '愿意坐下来听个故事吗...' }</p>
<p className={ 'hidden md:block text-lg md:ml-8' }>{ '一个关于一位' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '追寻' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ ',探索' }<span
className={ 'text-gray-700 dark:text-gray-400 text-sm transition-colors' }>{ '█' }</span>{ '的故事' }
</p>
</div>
</div>
<div className={ `flex justify-center w-full h-[60%] md:h-[40%]` }>
<div
className={ `flex flex-col md:flex-row w-full md:w-[85%] lg:w-[90%] xl:w-[85%] p-4 gap-x-7 gap-y-5` }>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/me' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于我</p>
</div>
</CardBody>
</Card>
<Card
isHoverable
className={ 'w-full h-[45%] md:h-full' }
as={ Link }
href={ '/about/website' }
isPressable
>
<CardBody>
<div className={ 'h-full flex flex-col justify-center' }>
<p className={ 'text-center text-3xl h-fit' }>关于这个网站</p>
</div>
</CardBody>
</Card>
</div>
</div>
<p className={ 'text-center' }>*目前还没想到如何设计这一页,先摸鱼会(笑)</p>
</div>
);
};
export default About;
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
import { fetchText } from '@/app/fetch';
const Website = async () => {
return (
<div className={ 'p-5' }>
<MarkdownRenderer>
{ await fetchText('https://raw.githubusercontent.com/lumither/blog-posts/master/about/website.md') }
</MarkdownRenderer>
</div>
);
};
export default Website;
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { Card, CardBody, CardFooter } from '@nextui-org/card';
import { Chip } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
import { MdCalendarMonth, MdNorthEast } from 'react-icons/md';
type Props = {
post: JSON
}
function BlogPostCard({ post }: Props) {
let title = (post as any)['title'];
let sub_title = (post as any)['sub_title'];
// let summary = (post as any)['summary'];
let category = (post as any)['category'];
let tags = (post as any)['tags'];
// let last_update = new Date((post as any)['last_update']);
let first_update = new Date((post as any)['first_update']);
let post_id = (post as any)['post_id'];
let header_img = (post as any)['header_img'];
return (
<div className={ `w-full` }>
<Card as={ Link } href={ `/blog/post/${ post_id }` }
className={ `group` }
isPressable
isHoverable
>
{ header_img !== '' && <div className={ `w-full overflow h-80 relative` }>
<Image
alt={ 'cover picture' }
src={ header_img }
className={ `w-full object-cover rounded-b-none z-10` }
loading={ 'eager' }
fill
></Image>
<Image
alt={ 'cover picture shadow' }
src={ header_img }
loading={ 'eager' }
className={ `w-full object-cover rounded-b-none scale-y-110 blur-2xl` }
fill
></Image>
</div> }
<CardBody className={ `overflow-visible` }>
<div className={ `flex flex-row justify-between` }>
<div className={ `mx-2 ${ header_img !== '' ? '-mt-6' : 'mt-2' }` }>
<Chip className={ `mb-2 z-30 text-lg` } radius={ `sm` } size={ 'lg' } color={ `secondary` }
variant={ `solid` }>{ category }</Chip>
<p className={ `text-4xl font-bold` }> { title }</p>
<p className={ `text-xl` }> { sub_title }</p>
</div>
<MdNorthEast className={ `mt-1 mr-2` } size={ `40px` } />
</div>
</CardBody>
<CardFooter className={ 'pt-0' }>
<div className={ `flex flex-row flex-wrap justify-between w-full` }>
<div>
<ul className={ `flex flex-row flex-wrap` }>
{
tags.map((tag: string, key: any) => (
<li key={ key } className={ `mx-1 mb-1` }>
<Chip>{ tag }</Chip>
</li>
))
}
</ul>
</div>
<div className={ `flex flex-row place-content-end mb-1 items-center mx-1` }>
<MdCalendarMonth />
<p className={ `text-zinc-500 dark:text-zinc-400 ml-1` }>
{ first_update.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }
</p>
</div>
</div>
</CardFooter>
</Card>
</div>
);
}
export default BlogPostCard;
+1
View File
@@ -0,0 +1 @@
// todo: create component
+37
View File
@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { Pagination as NextUIPagination } from '@nextui-org/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
const Pagination = ({ total }: {
total: number,
}) => {
const parsePage = Number(useSearchParams().get('page'));
const currPage = parsePage > 0 ? parsePage : 1;
const router = useRouter();
const isMobile = useScreenSizeTrigger('md');
return (
<div className={ 'w-full p-4 pt-6 flex justify-center' }>
<NextUIPagination
initialPage={ currPage }
total={ total }
siblings={ isMobile ? 0 : 1 }
size={ isMobile ? 'sm' : 'md' }
showControls
showShadow
color={ 'default' }
variant={ 'light' }
onChange={ (page) => {
router.push(`?page=${ page }`, { scroll: false });
} }
/>
</div>
);
};
export default Pagination;
+42
View File
@@ -0,0 +1,42 @@
'use server';
import React from 'react';
import { fetchNocturneApi } from '@/app/blog/api';
import { MotionDiv } from '@/app/public/MotionDiv';
import BlogPostCard from '@/app/blog/BlogPostCard';
import { Card, CardBody } from '@nextui-org/card';
const PostList = async ({ page }: { page: number }) => {
try {
const res = await fetchNocturneApi(`/get_post_list?page=${ page }`);
let posts = (await res.json() as any)['posts'];
return (
// blog list
<div className={ 'w-full' }>
<ul className={ 'flex flex-col items-center w-full space-y-7' }>
{ posts.map((post: JSON, key: number) => (
<li key={ key } className={ 'w-full' }>
<MotionDiv
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5, delay: key * .2 + .25 } }
>
<BlogPostCard post={ post } />
</MotionDiv>
</li>)
) }
</ul>
</div>
);
} catch (e: any) {
return (
// todo: use Error.tsx
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>);
}
};
export default PostList;
+21
View File
@@ -0,0 +1,21 @@
const LOCAL_ENDPOINT = process.env.API_LOCAL_URL;
const REMOTE_ENDPOINT = process.env.API_REMOTE_URL;
const NOCTURNE_ENDPOINT = ((isServerSide = true) => {
if (process.env.NODE_ENV === 'production') {
if (isServerSide) {
return LOCAL_ENDPOINT;
} else {
return REMOTE_ENDPOINT;
}
} else {
return LOCAL_ENDPOINT;
}
});
export async function fetchNocturneApi(path: string, isServerSideRequest = true) {
return await fetch(`${ NOCTURNE_ENDPOINT(isServerSideRequest) }${ path }`, {
cache: 'no-cache'
});
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'flex flex-auto p-7 pl-2 lg:pl-7 w-full justify-center' }>
<main className={ 'w-full' }>
{ children }
</main>
</div>
);
}
export default Layout;
+24
View File
@@ -0,0 +1,24 @@
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;
@@ -0,0 +1,21 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@nextui-org/react';
import { IoArrowBackOutline } from 'react-icons/io5';
const ButtonGoBack = () => {
const router = useRouter();
return (
<Button
startContent={ <IoArrowBackOutline /> }
variant={ 'light' }
onClick={ () => router.back() }
>
{ 'Back' }
</Button>
);
};
export default ButtonGoBack;
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import MinifyPostCard from '@/app/blog/post/[id]/MinifyPostCard';
import Link from 'next/link';
import { FaCreativeCommons } from 'react-icons/fa6';
type Props = {
post: any
}
const Footer = (props: Props) => {
const { post } = props;
const adjacent_posts = post['adj'];
const prev_post = adjacent_posts['prev'];
const next_post = adjacent_posts['next'];
const link = post['meta']['link'] ?? `https://lumither.com/blog/post/${ post['meta']['id'] }`;
const author = post['meta']['author'] ?? 'Tao';
const author_link = post['meta']['author_link'] ?? '/about';
const post_date = new Date(post['first_update']);
const update_date = new Date(post['last_update']);
const cc = post['meta']['cc'] ?? 'by-nc-sa';
return (
<div className={ `w-full` }>
<div className={ 'flex transition bg-zinc-300/80 dark:bg-gray-600/25' }>
<div className={ `p-8 w-full overflow-hidden` }>
<div className={ 'relative' }>
<FaCreativeCommons
size={ '160px' }
className={ 'absolute right-0 top-0 -mt-[4.8rem] -mr-16 opacity-30 brightness-50 ' }
/>
</div>
<div className={ 'flex flex-col space-y-4 relative' }>
<div className={ 'z-[2]' }>
<p className={ 'text-xl font-bold' }>{ post['meta']['title'] }</p>
<Link href={ link }>
<p className={ 'text-sm underline decoration-indigo-500 dark:decoration-sky-500' }>{ link }</p>
</Link>
</div>
<div className={ 'z-[2] flex flex-row flex-wrap gap-x-7 gap-y-2' }>
<div>
<p className={ 'text-lg font-bold' }>Author</p>
<Link href={ author_link }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ author }</p>
</Link>
</div>
<div>
<p className={ 'text-lg font-bold' }>Posted on</p>
<p>{ post_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
<div>
<p className={ 'text-lg font-bold' }>Updated on</p>
<p>{ update_date.toLocaleString('default', {
month: `long`,
day: `numeric`,
year: `numeric`
}) }</p>
</div>
<div>
<p className={ 'text-lg font-bold' }>Published under</p>
<Link href={ `https://creativecommons.org/licenses/${ cc }/4.0/` }>
<p className={ 'underline decoration-indigo-500 dark:decoration-sky-500' }>{ `CC ${ cc.toUpperCase() } 4.0` }</p>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className={ `flex justify-center` }>
<div className={ `w-full shadow-2xl ` }>
<div className={ `flex flex-col md:flex-row w-full` }>
{
next_post &&
<MinifyPostCard post={ next_post } desc={ 'prev' }></MinifyPostCard>
}
{
prev_post &&
<MinifyPostCard post={ prev_post } desc={ 'next' }></MinifyPostCard>
}
</div>
</div>
</div>
</div>
);
};
export default Footer;
+80
View File
@@ -0,0 +1,80 @@
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;
@@ -0,0 +1,58 @@
import React from 'react';
import { Card, CardFooter, CardHeader } from '@nextui-org/card';
import Image from 'next/image';
import Link from 'next/link';
type Props = {
post: any
desc: string
}
const MinifyPostCard = (props: Props) => {
const { post, desc } = props;
const img_url = post['header_img'] as string;
return (
<div className={ 'w-full transition hover:brightness-75' }>
<Card isFooterBlurred
className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden"
as={ Link }
isPressable
href={ `/blog/post/${ post['id'] }` }
>
<CardHeader className="absolute z-10 top-1 flex-col items-start">
<p className={ `text-tiny uppercase font-bold ${ img_url ? 'text-white/60' : 'text-black/60 dark:text-white/60' }` }>{ desc }</p>
</CardHeader>
{ img_url &&
<div className={ 'h-full' }>
<div className={ 'relative h-[300px] overflow-hidden' }>
<Image
className={ 'object-cover object-center ' }
src={ img_url }
alt={ post.title }
fill
/>
</div>
<CardFooter
className="absolute transition-colors bg-zinc-300/80 dark:bg-gray-600/25 dark:backdrop-brightness-75 bottom-0 justify-between rounded-none">
<div>
<p className="text-2xl font-bold">{ post['title'] }</p>
<p className="">{ post['sub_title'] }</p>
</div>
</CardFooter>
</div>
}
{ !img_url &&
<div className={ 'h-full grid p-6 place-content-center' }>
<div className={ 'flex flex-row items-center text-center max-h-fit' }>
<p className={ 'text-3xl font-bold' }>{ post['title'] }</p>
<div className={ 'h-full w-1 mx-2 bg-zinc-500 dark:bg-zinc-400' }></div>
<p className={ 'text-xl' }>{ post['sub_title'] }</p>
</div>
</div>
}
</Card>
</div>
);
};
export default MinifyPostCard;
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
import { Card, CardBody } from '@nextui-org/card';
import Headers from '@/app/blog/post/[id]/Headers';
import Footer from '@/app/blog/post/[id]/Footer';
import { fetchNocturneApi } from '@/app/blog/api';
import MarkdownRenderer from '@/app/public/MarkdownRenderer';
type Props = {
id: string
}
async function PostCard(props: Props) {
try {
const res = await fetchNocturneApi(`/get_post/${ props.id }`);
const ret: any = await res.json();
return (
<div>
<Card className={ `max-w-full relative` }>
{/* card headers */ }
<Headers post={ ret } />
{/* card body */ }
<CardBody>
<div className={ 'p-5' }>
<MarkdownRenderer>
{ ret['content'] }
</MarkdownRenderer>
</div>
</CardBody>
{/* card footer */ }
<Footer post={ ret } />
</Card>
</div>
);
} catch (e: any) {
return <div>
<Card className={ `w-full` }>
<CardBody>
Fatal: { e.toString() }
</CardBody>
</Card>
</div>;
}
}
export default PostCard;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import PostCard from '@/app/blog/post/[id]/PostCard';
import { MotionDiv } from '@/app/public/MotionDiv';
import ButtonGoBack from '@/app/blog/post/[id]/ButtonGoBack';
function BlogReader({ params }: { params: { id: string } }) {
return (
<MotionDiv
className={ `max-w-full` }
initial={ { y: 20, opacity: 0 } }
animate={ { y: 0, opacity: 1 } }
transition={ { ease: 'easeInOut', duration: 0.5 } }
>
<div className={ `max-w-full` }>
<PostCard id={ params.id } />
</div>
<div className={ 'mt-3' }>
<ButtonGoBack />
</div>
</MotionDiv>
);
}
export default BlogReader;
+17
View File
@@ -0,0 +1,17 @@
export async function fetchText(url: string, ttl: number = 600) {
const res = await fetch(url, {
next: {
revalidate: ttl
}
});
return await res.text();
}
export async function fetchJson(url: string, ttl: number = 600) {
const res = await fetch(url, {
next: {
revalidate: ttl
}
});
return await res.json();
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
function Layout({
children
}: {
children: React.ReactNode;
}) {
return (
<div className={ 'p-7' }>
{ children }
</div>
);
}
export default Layout;
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { fetchJson } from '@/app/fetch';
import { Card, CardBody } from '@nextui-org/react';
import Image from 'next/image';
import Link from 'next/link';
export default async function Friends() {
const list = await fetchJson('https://raw.githubusercontent.com/Lumither/friends/master/friends.json');
const listLength = list.length;
const listMaxIdx = listLength - 1;
const listIsOdd = listLength % 2 !== 0;
return (
<div>
<div className={ `min-h-dvh pb-10` }>
<div className={ 'p-7 pt-[10%] md:pt-[20%]' }>
<div className={ 'flex flex-col md:flex-row gap-x-3 justify-center' }>
<p className={ 'text-3xl text-center text-balance' }>海内存知己</p>
<p className={ 'text-3xl text-center text-balance' }>天涯若比邻</p>
</div>
<p className={ 'text-center text-balance text-zinc-500 dark:text-zinc-400' }>{ 'Though far apart, kin at heart' }</p>
</div>
<div className={ 'flex w-full h-full justify-center pt-3' }>
<ul className={ 'grid grid-cols-2 gap-x-6 gap-y-3 md:max-w-[90%] xl:max-w-[85%] w-full' }>
{
list.map((friend: any, key: number) => (
<li
key={ key }
className={ (key === listMaxIdx && listIsOdd) ? 'col-span-2 flex justify-center' : 'col-span-2 md:col-span-1' }
>
<Card
isHoverable
isPressable
shadow={ 'none' }
as={ Link }
href={ friend['url'] }
className={ 'dark:bg-[#282830] w-full md:w-fit max-w-full shadow' }
>
<CardBody className={ 'overflow-visible pb-0.5' }>
<div className={ 'flex flex-row' }>
<div className={ 'flex p-2' }>
<div
className={ 'h-[80px] w-[80px] relative rounded-2xl' }>
<Image
alt="avatar"
className="object-cover rounded-2xl z-10"
fill
unoptimized
src={ friend['avatar'] }
/>
<Image
alt="avatar"
className="relative object-cover rounded-2xl scale-110 blur-3xl"
fill
unoptimized
src={ friend['avatar'] }
/>
</div>
</div>
<div className={ 'p-2 flex-col max-w-full' }>
<p className={ 'text-xl' }>{ friend['title'] }</p>
{ (friend['name'] === undefined || friend['name'].isNull) ??
<p className={ 'text-zinc-600 dark:text-zinc-400' }>{ `@ ${ friend['name'] }` }</p> }
{ (friend['desc'] === undefined || friend['desc'].isNull) ??
<p className={ 'text-sm text-pretty text-zinc-600 dark:text-zinc-300 mt-1' }>{ friend['desc'] }</p> }
</div>
</div>
</CardBody>
<div className={ 'h-2 relative w-full' }>
<Image
alt="theme bar"
draggable={ false }
className="object-cover object-center scale-110 blur-lg"
fill
unoptimized
src={ friend['avatar'] }
/>
</div>
</Card>
</li>
))
}
</ul>
</div>
</div>
<p className={ 'text-center' }>想要成为好伙伴嘛,向<span
className={ 'text-blue-800 dark:text-blue-500' }><Link
href={ 'https://github.com/Lumither/friends/' }>这里</Link></span>发起PR吧
</p>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
@import "lxgw-wenkai-webfont/style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: "LXGW WenKai", sans-serif;
}
pre, code {
font-family: "LXGW WenKai Mono", sans-serif;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from 'next';
import './globals.css';
import React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
export const metadata: Metadata = {
title: 'Lumitherの酒馆',
description: '一间位于时间夹缝中的旅馆,静候下一位旅者的到来...'
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={ `transition duration-300 dark:bg-[#282830] min-h-screen` }>
<NextThemesProvider attribute="class" defaultTheme="system">
<div className={ `justify-center flex flex-row w-full max-w-full` }>
<Navbar />
<div className={ `flex flex-col flex-1 max-w-[1024px] min-w-0` }>
{ children }
<Footer />
</div>
</div>
</NextThemesProvider>
</body>
</html>
);
}
+10
View File
@@ -0,0 +1,10 @@
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
import React from 'react';
export default function Home() {
return (
<div className={ `h-dvh` }>
<ConstructionPlaceHolder />
</div>
);
}
File renamed without changes.
+64
View File
@@ -0,0 +1,64 @@
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkToc from 'remark-toc';
import remarkFrontmatter from 'remark-frontmatter';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
const MarkdownRenderer = ({ children }: { children: React.ReactNode }) => {
return (
<div className={ 'w-full' }>
<Markdown
className={ `max-w-full text-justify text-pretty prose prose-sm prose-neutral dark:prose-invert
md:prose-md lg:prose-lg prose-code:overflow-x-scroll`
}
remarkPlugins={ [ remarkGfm, remarkToc, remarkFrontmatter ] }
rehypePlugins={ [ rehypeRaw, rehypeSanitize ] }
components={ {
pre(props) {
const { node, className, children, ...rest } = props;
if ((children as any)['type'] === 'code') {
const match = /language-(\w+)/.exec((children as any)['props']['className'] || '');
let lang = match ? match[1] : 'text';
return (
<pre className={ `not-prose` }>
<SyntaxHighlighter
// @ts-ignore
style={ oneDark }
language={ lang }
showLineNumbers
// wrapLongLines
// wrapLines
classNames={ className }
PreTag="div"
{ ...rest }
>
{
String((children as any)['props']['children']).replace(/\n$/, '')
}
</SyntaxHighlighter>
</pre>
);
} else {
return (
<pre className={ `not-prose` }>
<code className={ className } { ...props }>
{ children }
</code>
</pre>
);
}
}
} }
>
{
children as any
}
</Markdown>
</div>
);
};
export default MarkdownRenderer;
+5
View File
@@ -0,0 +1,5 @@
'use client';
import { motion } from 'framer-motion';
export const MotionDiv = motion.div;
View File
Whitespace-only changes.
+35
View File
@@ -0,0 +1,35 @@
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;
};
File renamed without changes.
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ConstructionPlaceHolder from '@/components/ConstructionPlaceHolder';
const Search = () => {
return (
<div className={ `h-dvh` }>
<ConstructionPlaceHolder />
</div>
);
};
export default Search;
@@ -0,0 +1,13 @@
import React from 'react';
const ConstructionPlaceHolder = () => {
return (
<div className={ `flex h-full w-full justify-center items-center` }>
<p className={ `text-2xl font-bold` }>
正在施工中...
</p>
</div>
);
};
export default ConstructionPlaceHolder;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import Link from 'next/link';
import { IoLogoGithub } from 'react-icons/io5';
const Footer = () => {
return (
<div className={ `p-5 mb-4` }>
<div className={ `h-1 w-20 bg-zinc-500 dark:bg-zinc-400 mb-3` }></div>
<p>
© 2024-{ new Date().getFullYear() } <Link href={ `/about` }>Lumither Tao</Link>
</p>
<p>
Powered by Next.js and Rust, built with passion and love
</p>
<Link
href={ `https://github.com/Lumither/blog` }
aria-label={ `GitHub repo for this website` }
>
<IoLogoGithub className={ `mt-1` } />
</Link>
</div>
);
};
export default Footer;
+168
View File
@@ -0,0 +1,168 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Button } from '@nextui-org/react';
import Image from 'next/image';
import { IoHome, IoInformationCircle, IoLink, IoLogoGithub, IoMail, IoPencil, IoSearch } from 'react-icons/io5';
import Link from 'next/link';
import ThemeSwitcher from '@/components/ThemeSwitcher';
import { usePathname } from 'next/navigation';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
const entries: { display_name: string; href: string; icon: React.ReactNode }[] = [
{
icon: <IoHome size={ `20px` }></IoHome>,
display_name: 'Home',
href: '/'
},
{
icon: <IoInformationCircle size={ `20px` }></IoInformationCircle>,
display_name: 'About',
href: '/about'
},
{
icon: <IoPencil size={ `20px` }></IoPencil>,
display_name: 'Blog',
href: '/blog'
},
{
icon: <IoLink size={ `20px` }></IoLink>,
display_name: 'Friends',
href: '/friends'
},
{
icon: <IoSearch size={ `20px` }></IoSearch>,
display_name: 'Search',
href: '/search'
}
];
const connections: { label: string, href: string, icon: React.ReactNode }[] = [
{
label: 'Github',
href: 'https://github.com/Lumither',
icon: <IoLogoGithub size={ `30px` } />
},
{
label: 'email',
href: 'mailto:lumither@outlook.com',
icon: <IoMail size={ `30px` } />
}
];
function Navbar() {
// const [ scrolled, setScrolled ] = useState(false);
// useEffect(() => {
// const handleScroll = () => {
// const isScrolled = window.scrollY > 0;
// setScrolled(isScrolled);
// };
// window.addEventListener('scroll', handleScroll);
// return () => {
// window.removeEventListener('scroll', handleScroll);
// };
// }, []);
const path = usePathname();
const [ shownPath, setShownPath ] = useState(path);
useEffect(() => {
let trimmed_path = path.split(`/`)[1];
if (trimmed_path === '') {
setShownPath('HOME');
} else {
setShownPath(trimmed_path.toUpperCase());
}
}, [ path ]);
const isMobile = useScreenSizeTrigger('lg');
return (
<>
<div
className={ `flex flex-col min-w-[50px] lg:min-w-[200px] ml-2 mr-0 lg:ml-8 lg:mr-6 min-h-screen pt-8 sticky` }>
<div className={ `fixed min-h-screen h-full` }>
<div className={ `hidden lg:block` }>
<div className={ `flex justify-center items-center my-7 h-[200px] w-[200px]` }>
<div className={ 'relative w-full h-full' }>
<Image
src={ 'https://oss.lumither.com/blog/pictures/avatar.webp' }
alt={ 'avatar' }
fill
className={ 'rounded-full object-cover' }
loading={ 'eager' }
></Image>
</div>
</div>
<div>
<p className={ `font-bold text-2xl` }>Lumither Tao</p>
<p className={ `text-xl text-zinc-500 dark:text-zinc-400` }>Ad Astra</p>
</div>
</div>
<div>
<ul className={ `flex flex-col space-y-2 lg:space-y-0 lg:flex-row items-center my-4 lg:space-x-2` }>
{
connections.map((conn, key) => (
<li key={ key }>
<Button as={ Link }
href={ conn.href }
aria-label={ conn.label }
variant={ `light` }
color={ `default` }
className={ `w-fit` }
isIconOnly
>
{ conn.icon }
</Button>
</li>
))
}
</ul>
</div>
<div
className={ `flex justify-center lg:justify-start w-full mt-8 ml-0 lg:-ml-3` }>
<ul className={ `flex flex-col space-y-2 w-full` }>
{
entries.map((meta, key) => (
<li key={ key }>
<Button
as={ Link }
variant={ `light` }
color={ `default` }
fullWidth
isIconOnly={ isMobile }
className={ 'flex lg:justify-start w-fit lg:w-full' }
aria-label={ `navbar: ${ meta.display_name }` }
href={ meta.href }
>
{ meta.icon }
<p className={ `font-bold hidden lg:block` }>{ meta.display_name }</p>
</Button>
</li>
))
}
</ul>
</div>
<div className={ `mt-8 flex lg:hidden justify-center w-full` }>
<p className={ `text-center sideways-lr font-bold text-2xl text-zinc-500 dark:text-zinc-400` }>
{ shownPath }
</p>
</div>
<div
className={ `absolute bottom-16` }
>
<ThemeSwitcher />
</div>
</div>
</div>
</>
);
}
export default Navbar;
+93
View File
@@ -0,0 +1,93 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@nextui-org/react';
import { MdOutlineDarkMode, MdOutlineDesktopWindows, MdOutlineLightMode } from 'react-icons/md';
import { useScreenSizeTrigger } from '@/app/public/screenSizeTrigger';
const switcherElements = new Map(
[
[ 'dark', {
'display_name': 'Dark Mode',
'icon': <MdOutlineDarkMode />
} ],
[ 'light', {
'display_name': 'Light Mode',
'icon': <MdOutlineLightMode />
} ],
[ 'system', {
'display_name': 'Follow System',
'icon': <MdOutlineDesktopWindows />
} ]
]
);
function ThemeSwitcher() {
const [ mounted, setMounted ] = useState(false);
const { theme, setTheme } = useTheme();
const [ currTheme, setCurrTheme ] = useState(new Set([ theme as string ]));
const isMobile = useScreenSizeTrigger('lg');
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<div className={ `` }>
<Dropdown backdrop={ 'blur' }>
<DropdownTrigger>
<Button
variant={ 'flat' }
aria-label={ 'Theme Trigger' }
fullWidth
isIconOnly={ isMobile }
>
{ switcherElements.get(currTheme.values().next().value)?.icon }
<p className={ 'hidden lg:block' }>
{ switcherElements.get(currTheme.values().next().value)?.display_name }
</p>
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label={ 'Toggle Theme' }
variant={ 'flat' }
selectionMode={ 'single' }
selectedKeys={ currTheme }
onSelectionChange={ ({ anchorKey }) => {
setCurrTheme(new Set([ anchorKey as string ]));
setTheme(anchorKey as string);
} }
>
<DropdownItem
key={ 'light' }
startContent={ <MdOutlineLightMode /> }>
Light Mode
</DropdownItem>
<DropdownItem
key={ 'dark' }
startContent={ <MdOutlineDarkMode /> }
>
Dark Mode
</DropdownItem>
<DropdownItem
key={ 'system' }
startContent={ <MdOutlineDesktopWindows /> }
>
Follow System
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
);
}
export default ThemeSwitcher;
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'oss.lumither.com'
},
{
protocol: 'https',
hostname: 'pic.re'
}
]
}
// output: 'standalone'
};
export default nextConfig;
+42
View File
@@ -0,0 +1,42 @@
{
"name": "blog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"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"
},
"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"
}
}
@@ -1,8 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
export default config;
+60
View File
@@ -0,0 +1,60 @@
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;
File renamed without changes.
+8
View File
@@ -0,0 +1,8 @@
/var/log/nocturne/*.log {
size 20K
compress
rotate 10
missingok
copytruncate
delaycompress
}
-4
View File
@@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
# server {
# listen 80;
# server_name lumither.com;
# return 301 https://$host$request_uri;
# }
server {
listen 80;
# listen 443 ssl;
# server_name lumither.com;
# ssl_certificate certs/cert.pem;
# ssl_certificate_key certs/key.pem;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
location / {
default_type text/html;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://127.0.0.1:3000;
proxy_redirect off;
}
location /api/ {
rewrite ^/api(/.*)$ $1 break;
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more