diff --git a/Makefile b/Makefile index 581ed84..bfdd1e5 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,21 @@ ifeq ($(ARCH), arm64) ARCH := aarch64 endif -.PHONY: up -clean: - echo "todo" +.PHONY: all +all:up +.PHONY: build +build: + ARCH=$(ARCH) docker compose -f prod.compose.yaml build + +.PHONY: up +up: + ARCH=$(ARCH) docker compose -f prod.compose.yaml up -d + +.PHONY: down +down: + ARCH=$(ARCH) docker compose -f prod.compose.yaml down + +.PHONY: rm +rm: + ARCH=$(ARCH) docker compose -f prod.compose.yaml rm diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 80fa351..368017e 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -8,6 +8,7 @@ macros = { path = "../macros" } markdown = { path = "../markdown" } axum = { version = "0.8.1", features = ["macros"] } +axum-extra = { version = "0.10.3", features = ["query"] } tracing = { version = "0.1.41", features = ["log"] } git2 = "0.20.0" chrono = { version = "0.4.39", features = ["serde"] } diff --git a/backend/server/build.rs b/backend/server/build.rs index aae7d8b..cdd610d 100644 --- a/backend/server/build.rs +++ b/backend/server/build.rs @@ -1,16 +1,17 @@ -use chrono::Utc; -use git2::Repository; +// use chrono::Utc; +// use git2::Repository; fn main() { - let repo = Repository::open("../..").expect("failed to open repository"); - let commit = repo - .head() - .expect("failed to get HEAD") - .peel_to_commit() - .expect("failed to get HEAD commit"); - let hash = commit.id().to_string(); - let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string(); - let pkg_version = env!("CARGO_PKG_VERSION"); - let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp); - println!("cargo:rustc-env=BUILD_ID={}", build_id); + // let repo = Repository::open("../..").expect("failed to open repository"); + // let commit = repo + // .head() + // .expect("failed to get HEAD") + // .peel_to_commit() + // .expect("failed to get HEAD commit"); + // let hash = commit.id().to_string(); + // let utc_timestamp = Utc::now().date_naive().format("%Y%m%d").to_string(); + // let pkg_version = env!("CARGO_PKG_VERSION"); + // let build_id = format!("{}:{}@{}", pkg_version, &hash[..8], utc_timestamp); + // println!("cargo:rustc-env=BUILD_ID={}", build_id); + println!("cargo:rustc-env=BUILD_ID=undefined"); } diff --git a/backend/server/src/modules/blog/api/posts/count_tag_filtered_posts.sql b/backend/server/src/modules/blog/api/posts/count_tag_filtered_posts.sql new file mode 100644 index 0000000..8cdd760 --- /dev/null +++ b/backend/server/src/modules/blog/api/posts/count_tag_filtered_posts.sql @@ -0,0 +1,20 @@ +WITH filtered_posts AS + (SELECT p.id, p.date_created + FROM posts p + WHERE $1::text[] IS NULL + OR ( + CASE + WHEN $4 THEN (SELECT COUNT(DISTINCT t.name) + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) = cardinality($1) + ELSE EXISTS (SELECT 1 + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) + END + )) +SELECT COUNT(*) AS total_count +FROM filtered_posts; diff --git a/backend/server/src/modules/blog/api/posts/get.rs b/backend/server/src/modules/blog/api/posts/get.rs index b8c2d4a..b6ddb58 100644 --- a/backend/server/src/modules/blog/api/posts/get.rs +++ b/backend/server/src/modules/blog/api/posts/get.rs @@ -4,17 +4,65 @@ use crate::{ utils::axum_response::{err_resp, succ_resp}, }; -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use std::collections::HashSet; + +use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use axum_extra::extract::Query; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::{FromRow, PgPool, query_as}; +use sqlx::{query_as, FromRow, PgPool}; use uuid::Uuid; +fn tags_deserializer<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let deserialize_each = |input: String| -> Result, D::Error> { + Ok(input.split(',').map(|s| s.to_string()).collect()) + }; + + #[derive(Deserialize)] + #[serde(untagged)] + enum Cardinality { + One(String), + Many(Vec), + } + let cardinality: Option = Deserialize::deserialize(deserializer)?; + + let mut tags = HashSet::new(); + + match cardinality { + Some(Cardinality::One(s)) => { + for item in deserialize_each(s)? { + let item = item.trim().to_string(); + if !item.is_empty() { + tags.insert(item); + } + } + let ret = tags.into_iter().collect::>(); + Ok(if ret.is_empty() { None } else { Some(ret) }) + } + Some(Cardinality::Many(v)) => { + for items in v + .into_iter() + .map(deserialize_each) + .collect::, _>>()? + { + for item in items { + let item = item.trim().to_string(); + if !item.is_empty() { + tags.insert(item); + } + } + } + let ret = tags.into_iter().collect::>(); + Ok(if ret.is_empty() { None } else { Some(ret) }) + } + None => Ok(None), + } +} + #[derive(FromRow, Serialize)] struct PostBriefModel { id: Uuid, @@ -29,9 +77,15 @@ struct PostBriefModel { } #[derive(Deserialize, Serialize)] -pub struct Pagination { +pub struct SearchParams { page: Option, page_size: Option, + + #[serde(default, deserialize_with = "tags_deserializer")] + tags: Option>, + + #[serde(default)] + match_all: bool, } #[derive(Serialize)] @@ -52,15 +106,25 @@ struct UuidRow { id: Uuid, } +const I32_PLACEHOLDER: i32 = 0; + +const COUNT_TAG_FILTERED_POSTS: &str = include_str!("count_tag_filtered_posts.sql"); +const SELECT_POST_WITH_TAG_FILTER_PAGINATED: &str = + include_str!("select_post_with_tag_filter_paginated.sql"); const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql"); pub async fn handler( State(db): State, - Query(pagination): Query, + Query(search_params): Query, ) -> impl IntoResponse { - let page = pagination.page.unwrap_or(1); - let page_size = pagination.page_size.unwrap_or(PAGE_SIZE); - let page_count = match sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM posts") + let page = search_params.page.unwrap_or(1); + let page_size = search_params.page_size.unwrap_or(PAGE_SIZE); + + let page_count = match sqlx::query_scalar::<_, i64>(COUNT_TAG_FILTERED_POSTS) + .bind(&search_params.tags) + .bind(I32_PLACEHOLDER) // not used, but help sqlx/psql hit cache + .bind(I32_PLACEHOLDER) + .bind(search_params.match_all) .fetch_one(&db) .await { @@ -84,13 +148,13 @@ pub async fn handler( let offset = page_size * (page - 1); - let ids = match query_as::<_, UuidRow>( - "SELECT id FROM posts ORDER BY date_created DESC LIMIT $1 OFFSET $2", - ) - .bind(page_size as i32) - .bind(offset as i32) - .fetch_all(&db) - .await + let ids = match query_as::<_, UuidRow>(SELECT_POST_WITH_TAG_FILTER_PAGINATED) + .bind(search_params.tags) + .bind(page_size as i32) + .bind(offset as i32) + .bind(search_params.match_all) + .fetch_all(&db) + .await { Ok(res) => res.into_iter().map(|item| item.id).collect::>(), Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), diff --git a/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql b/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql new file mode 100644 index 0000000..4ad75cd --- /dev/null +++ b/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql @@ -0,0 +1,22 @@ +WITH filtered_posts AS + (SELECT p.id, p.date_created + FROM posts p + WHERE $1::text[] IS NULL + OR ( + CASE + WHEN $4 THEN (SELECT COUNT(DISTINCT t.name) + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) = cardinality($1) + ELSE EXISTS (SELECT 1 + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) + END + )) +SELECT id +FROM filtered_posts +ORDER BY date_created DESC +LIMIT $2 OFFSET $3; diff --git a/container/release/backend.containerfile b/container/release/backend.containerfile index a82fbb1..c30d3b7 100644 --- a/container/release/backend.containerfile +++ b/container/release/backend.containerfile @@ -5,6 +5,6 @@ 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 /app/target/release/server ./server COPY --from=builder /usr/lib/${ARCH}-linux-gnu/libz.so.1 /usr/lib/${ARCH}-linux-gnu/ -ENTRYPOINT ["./api_server"] \ No newline at end of file +ENTRYPOINT ["./server"] \ No newline at end of file diff --git a/daemon/.gitignore b/daemon/.gitignore new file mode 100644 index 0000000..596b9e4 --- /dev/null +++ b/daemon/.gitignore @@ -0,0 +1,3 @@ +target/ + +Cargo.lock \ No newline at end of file diff --git a/daemon/Cargo.toml b/daemon/Cargo.toml new file mode 100644 index 0000000..bef63de --- /dev/null +++ b/daemon/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "daemon" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4.5.47", features = ["derive"] } +containerd-client = "0.8.0" +tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } diff --git a/daemon/Makefile b/daemon/Makefile new file mode 100644 index 0000000..a9b9853 --- /dev/null +++ b/daemon/Makefile @@ -0,0 +1,13 @@ +.PHONY: rebuild +rebuild: + cargo clean + cargo build + +.PHONY: rebuild-release +rebuild-release: + cargo clean + cargo build -r + +.PHONY: run +run: + sudo ./target/debug/daemon diff --git a/daemon/src/actions/mod.rs b/daemon/src/actions/mod.rs new file mode 100644 index 0000000..6f65dd5 --- /dev/null +++ b/daemon/src/actions/mod.rs @@ -0,0 +1 @@ +mod pull; diff --git a/daemon/src/actions/pull.rs b/daemon/src/actions/pull.rs new file mode 100644 index 0000000..e69de29 diff --git a/daemon/src/config/default.json b/daemon/src/config/default.json new file mode 100644 index 0000000..8e29c7e --- /dev/null +++ b/daemon/src/config/default.json @@ -0,0 +1,8 @@ +{ + "images": [ + { + "id": "", + "image": "" + } + ] +} \ No newline at end of file diff --git a/daemon/src/config/mod.rs b/daemon/src/config/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/daemon/src/main.rs b/daemon/src/main.rs new file mode 100644 index 0000000..66c6511 --- /dev/null +++ b/daemon/src/main.rs @@ -0,0 +1,89 @@ +mod actions; +mod config; + +use clap::Parser; +use containerd_client::tonic::Request; +use containerd_client::{ + connect, + services::v1::{transfer_client::TransferClient, TransferOptions, TransferRequest}, + to_any, + types::transfer::OciRegistry, + types::transfer::{ImageStore, UnpackConfiguration}, + types::Platform, + with_namespace, +}; +use std::env::consts; + +#[derive(Parser, Debug)] +struct Args { + /// containerd namespace + #[arg(short, long, default_value = "nocturne")] + namespace: String, + + /// containerd socket + #[arg(short, long, default_value = "/run/containerd/containerd.sock")] + socket: String, +} + +const IMAGE: &str = "docker.io/library/alpine:latest"; + +#[tokio::main] +async fn main() { + let args = Args::parse(); + + let arch = match consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + _ => consts::ARCH, + }; + + dbg!(&args); + + let channel = connect(&args.socket).await.expect("failed to bind socket"); + let mut client = TransferClient::new(channel.clone()); + + let registry = OciRegistry { + reference: IMAGE.to_string(), + resolver: Default::default(), + }; + + let platform = Platform { + os: "linux".to_string(), + architecture: arch.to_string(), + variant: "".to_string(), + os_version: "".to_string(), + }; + + let destination = ImageStore { + name: IMAGE.to_string(), + platforms: vec![platform.clone()], + unpacks: vec![UnpackConfiguration { + platform: Some(platform), + ..Default::default() + }], + ..Default::default() + }; + + let anys = to_any(®istry); + let anyd = to_any(&destination); + + println!( + "Pulling image for linux/{} from source: {:?}", + arch, registry + ); + + let request = TransferRequest { + source: Some(anys), + destination: Some(anyd), + options: Some(TransferOptions { + ..Default::default() + }), + }; + // let request = Request::new(request); + let req = with_namespace!(request, args.namespace); + + client + .transfer(req) + .await + .expect("unable to transfer image"); +} diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000..266c5ba --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,8 @@ + +.PHONY:all +all: standalone + +.PHONY: standalone +standalone: + npm run build + cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/ diff --git a/frontend/app/(pages)/blog/BlogPostCard.tsx b/frontend/app/(pages)/blog/BlogPostCard.tsx index 9e9449d..66eb703 100644 --- a/frontend/app/(pages)/blog/BlogPostCard.tsx +++ b/frontend/app/(pages)/blog/BlogPostCard.tsx @@ -4,6 +4,8 @@ import { Chip } from '@nextui-org/react'; import Image from 'next/image'; import Link from 'next/link'; import { MdCalendarMonth, MdNorthEast } from 'react-icons/md'; +import { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList'; +import TagChip from '@/app/(pages)/blog/TagChip'; interface Post { id: string, @@ -19,15 +21,20 @@ interface Post { type Props = { post: Post + + highlightSelector: HighlightSelector, } -function BlogPostCard({ post }: Props) { +function BlogPostCard({ post, highlightSelector }: Props) { + const highlightTags = highlightSelector?.tags; + return (
- { post.header_img &&
@@ -63,12 +70,26 @@ function BlogPostCard({ post }: Props) {
    - { - post.tags.map((tag: string, key: any) => ( -
  • - { tag } -
  • - )) + {/*highlight*/ + post.tags + .filter((tag) => highlightTags?.includes(tag)) + .sort() + .map((tag: string, key: any) => ( +
  • + +
  • + )) + } + + {/*normal */ + post.tags + .filter((tag) => !highlightTags?.includes(tag)) + .sort() + .map((tag: string, key: any) => ( +
  • + +
  • + )) }
diff --git a/frontend/app/(pages)/blog/Pagination.tsx b/frontend/app/(pages)/blog/Pagination.tsx index 3d381ab..2216726 100644 --- a/frontend/app/(pages)/blog/Pagination.tsx +++ b/frontend/app/(pages)/blog/Pagination.tsx @@ -8,7 +8,9 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger'; const Pagination = ({ total }: { total: number, }) => { - const parsePage = Number(useSearchParams().get('page')); + const searchParams = useSearchParams(); + + const parsePage = Number(searchParams.get('page')); const currPage = parsePage > 0 ? parsePage : 1; const router = useRouter(); @@ -27,7 +29,10 @@ const Pagination = ({ total }: { color={ 'default' } variant={ 'light' } onChange={ (page) => { - router.push(`?page=${ page }`, { scroll: false }); + const params = new URLSearchParams(searchParams.toString()); + params.set('page', page.toString()); + const queryString = params.toString(); + router.push(`?${ queryString }`, { scroll: false }); } } />
diff --git a/frontend/app/(pages)/blog/PostList.tsx b/frontend/app/(pages)/blog/PostList.tsx index 63f7d0c..abc69be 100644 --- a/frontend/app/(pages)/blog/PostList.tsx +++ b/frontend/app/(pages)/blog/PostList.tsx @@ -1,33 +1,19 @@ -'use server'; - import React from 'react'; -import BlogPostCard from '@/app/(pages)/blog/BlogPostCard'; +import { Post } from '@/src/api/blog/post'; +import AnimationPostList, { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList'; -import * as motion from 'motion/react-client'; -import { fetchPostList } from '@/src/api/blog/post'; +interface Props { + posts: Post[], + tags: string[] | undefined +} -const PostList = async ({ page }: { page: number }) => { +const PostList = async ({ posts, tags }: Props) => { try { - const postListResponse = await fetchPostList(page); - - const posts = postListResponse.data.posts; return ( - // blog list
-
    - { posts.map((post, key: number) => ( -
  • - - - -
  • ) - ) } -
- +
); } catch (e: any) { diff --git a/frontend/app/(pages)/blog/TagChip.tsx b/frontend/app/(pages)/blog/TagChip.tsx new file mode 100644 index 0000000..874d8fe --- /dev/null +++ b/frontend/app/(pages)/blog/TagChip.tsx @@ -0,0 +1,48 @@ +'use client'; + +import React from 'react'; +import { Chip } from '@nextui-org/react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +interface TagChipProps { + tagName: string, + isHighlighted: boolean +} + +const TagChip = ({ tagName, isHighlighted }: TagChipProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + const toggleTag = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + const params = new URLSearchParams(searchParams.toString()); + const tags = params.getAll('tags'); + + if (tags.includes(tagName)) { + const newTags = tags.filter(t => t !== tagName); + params.delete('tags'); + newTags.forEach(t => params.append('tags', t)); + } else { + params.append('tags', tagName); + } + + const queryString = params.toString(); + router.push(`?${ queryString }`, { scroll: false }); + }; + return ( +
+ { tagName } +
+ ); +}; + +export default TagChip; \ No newline at end of file diff --git a/frontend/app/(pages)/blog/ToolBar.tsx b/frontend/app/(pages)/blog/ToolBar.tsx new file mode 100644 index 0000000..b67df1f --- /dev/null +++ b/frontend/app/(pages)/blog/ToolBar.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const ToolBar = () => { + return ( +
+ +
+ ); +}; + +export default ToolBar; \ No newline at end of file diff --git a/frontend/app/(pages)/blog/page.tsx b/frontend/app/(pages)/blog/page.tsx index a544c74..bddb92a 100644 --- a/frontend/app/(pages)/blog/page.tsx +++ b/frontend/app/(pages)/blog/page.tsx @@ -1,6 +1,7 @@ import PostList from '@/app/(pages)/blog/PostList'; import Pagination from '@/app/(pages)/blog/Pagination'; -import { fetchPostListPagination } from '@/src/api/blog/post'; +import { fetchPostList } from '@/src/api/blog/post'; +import ToolBar from '@/app/(pages)/blog/ToolBar'; async function Blog({ @@ -9,18 +10,40 @@ async function Blog({ searchParams: Promise<{ [_: string]: string | string[] | undefined }> }) { - const parsePage = Number((await searchParams)?.page); + const sp = await searchParams; + + const parsePage = Number(sp?.page); const currPage = parsePage > 0 ? parsePage : 1; + const matchAll = Boolean(sp?.match_all); + + const ensureList = (input: string | string[] | undefined): string[] | undefined => { + if (!input) { + return undefined; + } + if (typeof input === 'string') { + return [ input ]; + } else { + return input; + } + }; + const tags = ensureList(sp?.tags); try { - const pagination = await fetchPostListPagination(); - const pageCount = pagination.data.page_count; + const postListResponse = await fetchPostList({ + page: currPage, + match_all: matchAll, + tag_filter: tags + }); + + const pagination = postListResponse.data.pagination; + const posts = postListResponse.data.posts; return (
- - + + +
); } catch (e: any) { @@ -28,7 +51,7 @@ async function Blog({

- { 'Unexpected Error [loading pagination]:' } + { 'Unexpected Error [loading blog posts]:' }

{ e.message } diff --git a/frontend/app/(pages)/blog/post/[id]/Headers.tsx b/frontend/app/(pages)/blog/post/[id]/Headers.tsx index a295173..6564636 100644 --- a/frontend/app/(pages)/blog/post/[id]/Headers.tsx +++ b/frontend/app/(pages)/blog/post/[id]/Headers.tsx @@ -2,6 +2,7 @@ import React from 'react'; import Image from 'next/image'; import { Chip } from '@nextui-org/react'; import { MdCalendarMonth } from 'react-icons/md'; +import TagChip from '@/app/(pages)/blog/post/[id]/TagChip'; type Props = { title: string; @@ -50,7 +51,7 @@ const Headers = (post: Props) => { { post.tags.map((tag: string, key: any) => (

  • - { tag } +
  • )) } diff --git a/frontend/app/(pages)/blog/post/[id]/MinifyPostCard.tsx b/frontend/app/(pages)/blog/post/[id]/MinifyPostCard.tsx index 4352659..8ec3c9b 100644 --- a/frontend/app/(pages)/blog/post/[id]/MinifyPostCard.tsx +++ b/frontend/app/(pages)/blog/post/[id]/MinifyPostCard.tsx @@ -17,7 +17,7 @@ const MinifyPostCard = (props: Props) => { className="w-full h-[300px] shadow-none border-0 rounded-none overflow-hidden" as={ Link } isPressable - href={ `/blog/post/${ post['id'] }` } + href={ `/blog/post/${ post['identifier'] }` } >

    { desc }

    diff --git a/frontend/app/(pages)/blog/post/[id]/TagChip.tsx b/frontend/app/(pages)/blog/post/[id]/TagChip.tsx new file mode 100644 index 0000000..fa48a8d --- /dev/null +++ b/frontend/app/(pages)/blog/post/[id]/TagChip.tsx @@ -0,0 +1,20 @@ +'use client'; + +import React from 'react'; +import { Chip } from '@nextui-org/react'; +import { useRouter } from 'next/navigation'; + +const TagChip = ({ tag }: { tag: string }) => { + const router = useRouter(); + + return ( +
    router.push(`/blog?tags=${ tag }`) }> + { tag } +
    + ); +}; + +export default TagChip; \ No newline at end of file diff --git a/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx b/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx new file mode 100644 index 0000000..2019179 --- /dev/null +++ b/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import BlogPostCard from '@/app/(pages)/blog/BlogPostCard'; + +import * as motion from 'motion/react-client'; +import { AnimatePresence } from 'motion/react'; +import { Post } from '@/src/api/blog/post'; + +export interface HighlightSelector { + tags?: string[]; +} + +interface Props { + posts: Post[]; + highlight: HighlightSelector; +} + +const PostList = ({ posts, highlight }: Props) => { + return ( +
    +
      + + { posts.map((post, key: number) => ( + + + ) + ) } + +
    + +
    + ); +}; +export default PostList; diff --git a/frontend/app/(pages)/layout.tsx b/frontend/app/(pages)/layout.tsx index 61f980a..c070318 100644 --- a/frontend/app/(pages)/layout.tsx +++ b/frontend/app/(pages)/layout.tsx @@ -14,7 +14,7 @@ function Layout({
    diff --git a/frontend/app/(pages)/public/screenSizeTrigger.ts b/frontend/app/(pages)/public/screenSizeTrigger.ts index 7a25074..225b598 100644 --- a/frontend/app/(pages)/public/screenSizeTrigger.ts +++ b/frontend/app/(pages)/public/screenSizeTrigger.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; const screenSize = new Map( [ + [ 'xs', 480 ], [ 'sm', 640 ], [ 'md', 768 ], [ 'lg', 1024 ], diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b9df942..b952fe6 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,4 +1,4 @@ -@import "lxgw-wenkai-webfont/style.css"; +@import "lxgw-wenkai-webfont/lxgwwenkai-light.css"; @tailwind base; @tailwind components; @@ -6,8 +6,10 @@ body { font-family: "LXGW WenKai", sans-serif; + /*font-family: "Arial", "monospace", sans-serif;*/ } pre, code { - font-family: "LXGW WenKai Mono", sans-serif; -} + /*font-family: "monospace", sans-serif;*/ + font-family: "monospace", sans-serif; +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 15ab87c..adaed5c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,46 +5,79 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger'; import { Button } from '@nextui-org/react'; import { entries } from '@/app/entries'; import Link from 'next/link'; +import * as motion from 'motion/react-client'; +import { cn } from '@/src/utils'; export default function Home() { - const isMobile = useScreenSizeTrigger('lg'); - - // const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value'); - // console.log(test); - // setTest('test value 2'); - // console.log(test); - // removeTest(); - // console.log(test); + const isSm = useScreenSizeTrigger('sm'); + const isXs = useScreenSizeTrigger('xs'); return ( -
    -
      - { - entries.map((meta, key) => ( -
    • - -
    • - )) - } -
    -
    + -
    -
    +
    +

    Atelier Lumiaethor

    +

    In pursuit of absolute + simplicity and ultimate + sophistication

    +
    + +
    +
    + + { !isSm && +
    +
    +

    + © 2024-{ new Date().getFullYear() } Lumither Tao +

    +

    Powered by Next.js and Rust, built with passion and love

    +

    LMTCloud ecosystem

    +
    +
    + } + +
    +
      + { + entries.map((meta, key) => ( +
    • + +
    • + )) + } +
    +
    + + +
    +
    +
    ); } diff --git a/frontend/components/ConstructionPlaceHolder.tsx b/frontend/components/ConstructionPlaceHolder.tsx index 803d41a..40568a2 100644 --- a/frontend/components/ConstructionPlaceHolder.tsx +++ b/frontend/components/ConstructionPlaceHolder.tsx @@ -4,7 +4,7 @@ const ConstructionPlaceHolder = () => { return (

    - 正在施工中... + * Under Construction *

    ); diff --git a/frontend/components/Footer.tsx b/frontend/components/Footer.tsx index 5036a02..6418200 100644 --- a/frontend/components/Footer.tsx +++ b/frontend/components/Footer.tsx @@ -1,3 +1,5 @@ +'use client'; + import React from 'react'; import Link from 'next/link'; import { IoLogoGithub } from 'react-icons/io5'; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index c98ab2c..460605e 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -8,7 +8,7 @@ const nextConfig = { }, { protocol: 'https', - hostname: 'pic.re' + hostname: 'cdn.jsdelivr.net' } ] }, diff --git a/frontend/package.json b/frontend/package.json index a88597e..7fc5928 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,9 @@ "dependencies": { "@nextui-org/react": "^2.4.6", "@types/react-syntax-highlighter": "^15.5.13", + "clsx": "^2.1.1", "lxgw-wenkai-webfont": "^1.7.0", - "motion": "^12.3.1", + "motion": "^12.23.12", "next": "^15.3.2", "next-themes": "^0.3.0", "react": "^18.3.1", @@ -26,7 +27,9 @@ "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.0", "remark-toc": "^9.0.0", - "sharp": "^0.33.4" + "sharp": "^0.33.4", + "tailwind-merge": "^3.3.1", + "ts-pattern": "^5.8.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.14", diff --git a/frontend/src/api/blog/post.ts b/frontend/src/api/blog/post.ts index fc3f0b9..f1046f8 100644 --- a/frontend/src/api/blog/post.ts +++ b/frontend/src/api/blog/post.ts @@ -1,20 +1,22 @@ import { fetchNocturneJson } from '@/src/api/endpoint'; -const DEFAULT_PAGE_SIZE = 6; +const DEFAULT_PAGE_SIZE = 8; + +export interface Post { + id: string, + identifier: string, + title: string, + subtitle: string, + tags: string[] + category: string, + date_created: Date, + date_updated: Date | null, + header_img: string | null +} export interface PostListResponse { data: { - posts: { - id: string, - identifier: string, - title: string, - subtitle: string, - tags: string[] - category: string, - date_created: Date, - date_updated: Date | null, - header_img: string | null - }[], + posts: Post[], pagination: { page: number, page_count: number, @@ -24,9 +26,26 @@ export interface PostListResponse { status: string } +interface FetchPostListParams { + page: number; + page_size?: number; + tag_filter?: string[]; + match_all?: boolean; +} -export async function fetchPostList(page: number, page_size: number = DEFAULT_PAGE_SIZE): Promise { - const url = `/blog/posts?page=${ page }&page_size=${ page_size }`; +export async function fetchPostList({ + page, + page_size = DEFAULT_PAGE_SIZE, + tag_filter = [], + match_all = false + }: FetchPostListParams +): Promise { + const pagination_string = `page=${ page }&page_size=${ page_size }`; + const tag_filter_string = tag_filter.length != 0 + ? `&tags=${ tag_filter.join(',') }&match_all=${ match_all }` + : ''; + + const url = `/blog/posts?${ pagination_string }${ tag_filter_string }`; return await fetchNocturneJson(url); } diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 37ff01d..2288946 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,4 +1,4 @@ export const SITE_CONFIG = { name: 'Atelier Lumiaethor', - desc: '' + desc: 'In pursuit of absolute simplicity and ultimate sophistication' }; \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts new file mode 100644 index 0000000..883213c --- /dev/null +++ b/frontend/src/utils.ts @@ -0,0 +1,6 @@ +import { ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 3417b86..5c754ae 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gateway" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] diff --git a/gateway/src/main.rs b/gateway/src/main.rs index e7a11a9..f328e4d 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -1,3 +1 @@ -fn main() { - println!("Hello, world!"); -} +fn main() {} diff --git a/backend/server/src/modules/blog/schema.sql b/migration/ddl/schema.sql similarity index 100% rename from backend/server/src/modules/blog/schema.sql rename to migration/ddl/schema.sql diff --git a/migration/migration.sh b/migration/migration.sh new file mode 100644 index 0000000..0cb0aa6 --- /dev/null +++ b/migration/migration.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +PGPASSFILE="/root/.pgpass" +echo "postgres:5432:${POSTGRES_DB}:${POSTGRES_USER}:${POSTGRES_PASSWORD}" > "$PGPASSFILE" +chmod 600 "$PGPASSFILE" + +echo "waiting for postgres" +until pg_isready -h postgres -U "$POSTGRES_USER" -d "$POSTGRES_DB"; do + echo "retrying, sleep 2s" + sleep 2 +done +echo "db ready, running migrations" +for file in /migrations/*.sql; do + echo "applying $file" + psql -h postgres -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$file" +done +echo "migration complete" diff --git a/prod.compose.yaml b/prod.compose.yaml index aece627..93e8cac 100644 --- a/prod.compose.yaml +++ b/prod.compose.yaml @@ -14,7 +14,7 @@ services: backend: depends_on: - - postgres + - migrate build: context: ./backend dockerfile: ../container/release/backend.containerfile @@ -29,10 +29,31 @@ services: dns: - 1.1.1.1 + + migrate: + image: postgres:17 + depends_on: + postgres: + condition: service_healthy + env_file: + - ./.env + - ./.env.local + volumes: + - ./migration/ddl:/migrations:ro + - ./migration/migration.sh:/migration.sh:ro + entrypoint: [ "/bin/sh", "/migration.sh" ] + + postgres: - image: postgres:16.3 + image: postgres:17 ports: - "5432:5432" env_file: - ./.env - ./.env.local + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ] + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s