From 86dc65ab8a20dd265578f81a2db6a64b8d148326 Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Thu, 3 Jul 2025 01:57:44 +0800 Subject: [PATCH 1/9] feat(frontend) little update that proves the repo is still alive --- frontend/app/page.tsx | 73 +++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 15ab87c..5ed5fe7 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -10,40 +10,51 @@ export default function Home() { const isMobile = useScreenSizeTrigger('lg'); - // const [ test, setTest, removeTest ] = useLocalStorage('test', 'test_value'); - // console.log(test); - // setTest('test value 2'); - // console.log(test); - // removeTest(); - // console.log(test); - return ( -
- +
+
+

Atelier Lumiaethor

+
+
-
+
+ +
+
+ test +
+ +
+
+ +
+
    + { + entries.map((meta, key) => ( +
  • + +
  • + )) + } +
+
+
); From 88c2ad86640bb23e51bf4395e4d8c0f027257c29 Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Wed, 27 Aug 2025 02:57:39 +0800 Subject: [PATCH 2/9] feat(ui) construction banner --- frontend/app/layout.tsx | 3 +++ frontend/app/page.tsx | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 04bb4b9..e2fdce6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -21,6 +21,9 @@ export default function RootLayout({ +
+

*System Under Construction*

+
{ children }
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 5ed5fe7..91e4bf3 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,7 +8,7 @@ import Link from 'next/link'; export default function Home() { - const isMobile = useScreenSizeTrigger('lg'); + const isMobile = useScreenSizeTrigger('sm'); return (
@@ -47,7 +47,7 @@ export default function Home() { href={ meta.href } > { meta.icon } - + )) From cd39abbd45315eca713963b0b48215fae4046aab Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Wed, 8 Oct 2025 12:42:54 -0400 Subject: [PATCH 3/9] ref(frontend) several minor updates --- daemon/Makefile | 0 frontend/app/(pages)/blog/BlogPostCard.tsx | 2 +- frontend/app/(pages)/blog/PostList.tsx | 20 +- .../(pages)/blog/post/[id]/MinifyPostCard.tsx | 2 +- .../post/components/AnimationPostList.tsx | 31 ++ frontend/app/globals.css | 8 +- frontend/app/page.tsx | 12 +- frontend/components/BackgroundBox.tsx | 76 +++++ frontend/components/BackgroundBoxGL.tsx | 266 ++++++++++++++++++ frontend/package.json | 7 +- frontend/src/api/blog/post.ts | 26 +- frontend/src/utils.ts | 6 + 12 files changed, 417 insertions(+), 39 deletions(-) create mode 100644 daemon/Makefile create mode 100644 frontend/app/(pages)/blog/post/components/AnimationPostList.tsx create mode 100644 frontend/components/BackgroundBox.tsx create mode 100644 frontend/components/BackgroundBoxGL.tsx create mode 100644 frontend/src/utils.ts diff --git a/daemon/Makefile b/daemon/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/(pages)/blog/BlogPostCard.tsx b/frontend/app/(pages)/blog/BlogPostCard.tsx index 9e9449d..1d49e9e 100644 --- a/frontend/app/(pages)/blog/BlogPostCard.tsx +++ b/frontend/app/(pages)/blog/BlogPostCard.tsx @@ -24,7 +24,7 @@ type Props = { function BlogPostCard({ post }: Props) { return (
- { 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/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/components/AnimationPostList.tsx b/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx new file mode 100644 index 0000000..b5dd424 --- /dev/null +++ b/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx @@ -0,0 +1,31 @@ +'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'; + +const PostList = ({ posts }: { posts: Post[] }) => { + return ( +
+
    + + { posts.map((post, key: number) => ( + + + ) + ) } + +
+ +
+ ); +}; +export default PostList; 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 91e4bf3..fa2b5de 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,14 +5,19 @@ import { useScreenSizeTrigger } from '@/app/(pages)/public/screenSizeTrigger'; import { Button } from '@nextui-org/react'; import { entries } from '@/app/entries'; import Link from 'next/link'; +import BoxesGL from '@/components/BackgroundBoxGL'; +import { BoxesCore } from '@/components/BackgroundBox'; export default function Home() { const isMobile = useScreenSizeTrigger('sm'); return ( -
-
+
+ {/**/ } + + +

Atelier Lumiaethor

@@ -21,7 +26,8 @@ export default function Home() { className={ 'absolute bottom-0 w-full h-60 p-12' } >
+ // className={ 'bg-blue-500 h-full w-full rounded-3xl backdrop-blur align-middle grid grid-cols-2 content-center' }> + className={ 'h-full w-full rounded-3xl backdrop-blur align-middle grid grid-cols-2 content-center' }>
diff --git a/frontend/components/BackgroundBox.tsx b/frontend/components/BackgroundBox.tsx new file mode 100644 index 0000000..295074f --- /dev/null +++ b/frontend/components/BackgroundBox.tsx @@ -0,0 +1,76 @@ +'use client'; +import React from 'react'; +import { motion } from 'motion/react'; +import { cn } from '@/src/utils'; + +export const BoxesCore = ({ className, ...rest }: { className?: string }) => { + const rows = new Array(250).fill(1); + const cols = new Array(200).fill(1); + let colors = [ + '#93c5fd', + '#f9a8d4', + '#86efac', + '#fde047', + '#fca5a5', + '#d8b4fe', + '#93c5fd', + '#a5b4fc', + '#c4b5fd' + ]; + const getRandomColor = () => { + return colors[Math.floor(Math.random() * colors.length)]; + }; + + return ( +
+ { rows.map((_, i) => ( + + { cols.map((_, j) => ( + + { j % 2 === 0 && i % 2 === 0 ? ( + + + + ) : null } + + )) } + + )) } +
+ ); +}; + +export const Boxes = React.memo(BoxesCore); diff --git a/frontend/components/BackgroundBoxGL.tsx b/frontend/components/BackgroundBoxGL.tsx new file mode 100644 index 0000000..7f26ed0 --- /dev/null +++ b/frontend/components/BackgroundBoxGL.tsx @@ -0,0 +1,266 @@ +'use client'; +import React, { useEffect, useRef } from 'react'; + +// WebGL-based rewrite of the grid/hover effect originally implemented with many DOM nodes. +// Key design goals: +// - zero per-cell DOM; a single full-screen draw with a fragment shader +// - hover color derived deterministically from a small palette +// - optional CSS transform to match the original perspective/skew +// - high-DPI aware; resizes with container + +export default function BoxesGL({ + className, + style, + rows = 250, + cols = 200, + lineWidth = 1.0, // device pixels + transformLikeOriginal = true +}: { + className?: string + style?: React.CSSProperties + rows?: number + cols?: number + lineWidth?: number + transformLikeOriginal?: boolean +}) { + const canvasRef = useRef(null); + const rafRef = useRef(null); + const mouseRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + const canvas = canvasRef.current!; + const gl = canvas.getContext('webgl2', { antialias: false, premultipliedAlpha: true }); + if (!gl) { + console.warn('WebGL2 not available'); + // Fail closed; nothing to do if WebGL2 is unavailable + return; + } + + // Create program (fullscreen triangle) + const vs = gl.createShader(gl.VERTEX_SHADER)!; + gl.shaderSource( + vs, + `#version 300 es\n + const vec2 verts[3] = vec2[3]( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + void main(){ + gl_Position = vec4(verts[gl_VertexID], 0.0, 1.0); + } + ` + ); + gl.compileShader(vs); + + const fs = gl.createShader(gl.FRAGMENT_SHADER)!; + gl.shaderSource( + fs, + `#version 300 es\n + precision highp float; + out vec4 o; + + uniform vec2 u_resolution; // canvas size in device pixels + uniform vec2 u_mouse; // mouse in device pixels, (-1,-1) if outside + uniform float u_time; // seconds + uniform float u_rows; + uniform float u_cols; + uniform float u_lineWidth; // in device pixels + + // Border color and plus sign color (matches tailwind slate-700-ish) + const vec3 BORDER = vec3(0.22, 0.27, 0.33); + const vec3 PLUS = vec3(0.22, 0.27, 0.33); + + // A small pastel palette like the original + vec3 palette(int idx){ + // 9-color palette + if(idx==0) return vec3(0.576,0.773,0.992); + if(idx==1) return vec3(0.976,0.659,0.831); + if(idx==2) return vec3(0.525,0.937,0.675); + if(idx==3) return vec3(0.992,0.878,0.278); + if(idx==4) return vec3(0.988,0.648,0.648); + if(idx==5) return vec3(0.847,0.706,0.996); + if(idx==6) return vec3(0.576,0.773,0.992); + if(idx==7) return vec3(0.647,0.706,0.988); + return vec3(0.769,0.710,0.992); + } + + // Hash helpers for stable per-cell randomness + float hash11(float x){ + x = fract(x * 0.1031); + x *= x + 33.33; + return fract(1e4 * x * (x + 0.33)); + } + float hash21(vec2 p){ + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + void main(){ + vec2 frag = gl_FragCoord.xy; // device pixels + vec2 gridSize = vec2(u_cols, u_rows); + vec2 cellSize = u_resolution / gridSize; // pixels per cell + + // Compute integer cell coordinates and local position within cell [0,1) + vec2 uvGrid = frag / u_resolution * gridSize; + vec2 cellId = floor(uvGrid); + vec2 cellUV = fract(uvGrid); + + // Grid lines: draw top+right borders to mimic original (border-t, border-r, border-l on first col) + float lw = u_lineWidth / max(cellSize.x, 1.0); // convert to relative width in [0,1] axis + float lwY = u_lineWidth / max(cellSize.y, 1.0); + + float lineTop = step(1.0 - lwY, cellUV.y); + float lineRight= step(1.0 - lw , cellUV.x); + float lineLeft = step(cellUV.x, lw) * step(0.5, step(0.0, cellId.x)); // left border for all cols via small bias + + float lineMask = max(lineTop, max(lineRight, lineLeft)); + + // Determine hovered cell index + vec2 hoverCell = vec2(-1.0); + bool hovering = u_mouse.x >= 0.0 && u_mouse.y >= 0.0; + if(hovering){ + vec2 hoverUVGrid = u_mouse / u_resolution * gridSize; + hoverCell = floor(hoverUVGrid); + } + bool isHover = hovering && all(equal(hoverCell, cellId)); + + // Background: transparent unless hovered. On hover, fill with palette color chosen by cell hash. + vec3 bg = vec3(0.0); + float alpha = 0.0; + if(isHover){ + // Stable color per cell, with slight time wobble to avoid banding on long hovers + float h = hash21(cellId + vec2(floor(u_time*0.25))); + int idx = int(floor(h * 9.0)) % 9; + bg = palette(idx); + alpha = 1.0; + } + + // Plus sign for even-even cells: thin cross centered + bool evenEven = mod(cellId.x, 2.0) < 0.5 && mod(cellId.y, 2.0) < 0.5; + float plusThicknessX = 0.5 * u_lineWidth / max(cellSize.x, 1.0); + float plusThicknessY = 0.5 * u_lineWidth / max(cellSize.y, 1.0); + float cross = 0.0; + if(evenEven){ + float vert = step(0.5 - plusThicknessX, abs(cellUV.x - 0.5)); + float hori = step(0.5 - plusThicknessY, abs(cellUV.y - 0.5)); + cross = 1.0 - max(vert, hori); + } + + vec3 color = mix(bg, BORDER, max(lineMask, 0.0)); + color = mix(color, PLUS, cross); + float a = max(alpha, max(lineMask, cross)); + + o = vec4(color, a); + } + ` + ); + gl.compileShader(fs); + + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + + // Check compile/link for diagnostics (silent in prod; could surface to console in dev) + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.warn('WebGL program link error:', gl.getProgramInfoLog(prog)); + } + + gl.useProgram(prog); + + const uResolution = gl.getUniformLocation(prog, 'u_resolution'); + const uMouse = gl.getUniformLocation(prog, 'u_mouse'); + const uTime = gl.getUniformLocation(prog, 'u_time'); + const uRows = gl.getUniformLocation(prog, 'u_rows'); + const uCols = gl.getUniformLocation(prog, 'u_cols'); + const uLineWidth = gl.getUniformLocation(prog, 'u_lineWidth'); + + // Single draw call state + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + gl.viewport(0, 0, canvas.width, canvas.height); + + // Resize handling (devicePixelRatio-aware) + const resize = () => { + const dpr = Math.max(window.devicePixelRatio || 1, 1); + const { clientWidth, clientHeight } = canvas; + const w = Math.max(1, Math.floor(clientWidth * dpr)); + const h = Math.max(1, Math.floor(clientHeight * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + gl.viewport(0, 0, w, h); + } + }; + + const observer = new ResizeObserver(resize); + observer.observe(canvas); + resize(); + + // Mouse tracking in device pixels + const onMove = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const dpr = Math.max(window.devicePixelRatio || 1, 1); + const x = (e.clientX - rect.left) * dpr; + const y = (rect.bottom - e.clientY) * dpr; // flip Y to GL coords + mouseRef.current = { x, y }; + }; + const onLeave = () => { + mouseRef.current = null; + }; + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerleave', onLeave); + + const t0 = performance.now(); + const tick = () => { + const t = (performance.now() - t0) / 1000; + gl.useProgram(prog); + gl.uniform2f(uResolution, canvas.width, canvas.height); + const m = mouseRef.current; + if (m) gl.uniform2f(uMouse, m.x, m.y); + else gl.uniform2f(uMouse, -1, -1); + gl.uniform1f(uTime, t); + gl.uniform1f(uRows, rows); + gl.uniform1f(uCols, cols); + gl.uniform1f(uLineWidth, lineWidth); + + gl.drawArrays(gl.TRIANGLES, 0, 3); + rafRef.current = requestAnimationFrame(tick); + }; + tick(); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + observer.disconnect(); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerleave', onLeave); + gl.deleteProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + }; + }, [ rows, cols, lineWidth ]); + + return ( +
+ +
+ ); +} 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..5dedce7 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, 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)); +} From 2a56263382987015c62d5d00cf7ef0f0e984bb0a Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Wed, 8 Oct 2025 12:43:47 -0400 Subject: [PATCH 4/9] draft(gateway) module init --- gateway/Cargo.toml | 2 +- gateway/src/main.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) 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() {} From f3cc387013adab3aca17a6dd3b70c6cc23b1723c Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Wed, 8 Oct 2025 12:48:43 -0400 Subject: [PATCH 5/9] draft(daemon) module init --- daemon/.gitignore | 3 ++ daemon/Cargo.toml | 9 ++++ daemon/Makefile | 13 +++++ daemon/src/actions/mod.rs | 1 + daemon/src/actions/pull.rs | 0 daemon/src/config/default.json | 8 +++ daemon/src/config/mod.rs | 0 daemon/src/main.rs | 89 ++++++++++++++++++++++++++++++++++ 8 files changed, 123 insertions(+) create mode 100644 daemon/.gitignore create mode 100644 daemon/Cargo.toml create mode 100644 daemon/src/actions/mod.rs create mode 100644 daemon/src/actions/pull.rs create mode 100644 daemon/src/config/default.json create mode 100644 daemon/src/config/mod.rs create mode 100644 daemon/src/main.rs 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 index e69de29..a9b9853 100644 --- a/daemon/Makefile +++ 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"); +} From b1a55c363fb40a005cb713e8ad3960b2a8fdd076 Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Thu, 9 Oct 2025 23:26:08 -0400 Subject: [PATCH 6/9] feat(backend::api) add post tag filter --- backend/server/Cargo.toml | 1 + .../server/src/modules/blog/api/posts/get.rs | 93 +++++++++++++++---- .../select_post_with_tag_filter_paginated.sql | 21 +++++ 3 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql 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/src/modules/blog/api/posts/get.rs b/backend/server/src/modules/blog/api/posts/get.rs index b8c2d4a..30841f1 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)] @@ -53,13 +107,18 @@ struct UuidRow { } const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql"); +const SELECT_POST_WITH_TAG_FILTER_PAGINATED: &str = + include_str!("select_post_with_tag_filter_paginated.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); + dbg!(&search_params.tags); + dbg!(&search_params.match_all); + + 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>("SELECT COUNT(*) FROM posts") .fetch_one(&db) .await @@ -84,13 +143,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..b5fa96e --- /dev/null +++ b/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql @@ -0,0 +1,21 @@ +SELECT p.id +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 + ) +ORDER BY p.date_created DESC +LIMIT $2 OFFSET $3; From 314f5780fda3bb8ee26a95c0777955176510709a Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Sat, 11 Oct 2025 11:51:06 -0400 Subject: [PATCH 7/9] fix(blog::api) fix pagination for GET posts --- .../api/posts/count_tag_filtered_posts.sql | 20 +++++++++ .../server/src/modules/blog/api/posts/get.rs | 15 ++++--- .../select_post_with_tag_filter_paginated.sql | 41 ++++++++++--------- 3 files changed, 51 insertions(+), 25 deletions(-) create mode 100644 backend/server/src/modules/blog/api/posts/count_tag_filtered_posts.sql 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 30841f1..b6ddb58 100644 --- a/backend/server/src/modules/blog/api/posts/get.rs +++ b/backend/server/src/modules/blog/api/posts/get.rs @@ -106,20 +106,25 @@ struct UuidRow { id: Uuid, } -const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql"); +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(search_params): Query, ) -> impl IntoResponse { - dbg!(&search_params.tags); - dbg!(&search_params.match_all); - 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>("SELECT COUNT(*) FROM posts") + + 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 { 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 index b5fa96e..4ad75cd 100644 --- 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 @@ -1,21 +1,22 @@ -SELECT p.id -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 - ) -ORDER BY p.date_created DESC +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; From 02e7166fcb08d2a8da2b2d8c08ee38f3abb070c6 Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Mon, 13 Oct 2025 02:59:21 -0400 Subject: [PATCH 8/9] feat(frontend) new tag filter, new landing page --- frontend/app/(pages)/blog/BlogPostCard.tsx | 43 ++- frontend/app/(pages)/blog/Pagination.tsx | 9 +- frontend/app/(pages)/blog/PostList.tsx | 22 +- frontend/app/(pages)/blog/TagChip.tsx | 48 ++++ frontend/app/(pages)/blog/ToolBar.tsx | 11 + frontend/app/(pages)/blog/page.tsx | 37 ++- .../app/(pages)/blog/post/[id]/Headers.tsx | 3 +- .../app/(pages)/blog/post/[id]/TagChip.tsx | 20 ++ .../post/components/AnimationPostList.tsx | 13 +- frontend/app/(pages)/layout.tsx | 2 +- .../app/(pages)/public/screenSizeTrigger.ts | 1 + frontend/app/layout.tsx | 3 - frontend/app/page.tsx | 104 ++++--- frontend/components/BackgroundBox.tsx | 76 ----- frontend/components/BackgroundBoxGL.tsx | 266 ------------------ .../components/ConstructionPlaceHolder.tsx | 2 +- frontend/components/Footer.tsx | 2 + frontend/src/api/blog/post.ts | 21 +- 18 files changed, 256 insertions(+), 427 deletions(-) create mode 100644 frontend/app/(pages)/blog/TagChip.tsx create mode 100644 frontend/app/(pages)/blog/ToolBar.tsx create mode 100644 frontend/app/(pages)/blog/post/[id]/TagChip.tsx delete mode 100644 frontend/components/BackgroundBox.tsx delete mode 100644 frontend/components/BackgroundBoxGL.tsx diff --git a/frontend/app/(pages)/blog/BlogPostCard.tsx b/frontend/app/(pages)/blog/BlogPostCard.tsx index 1d49e9e..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 fa9f234..abc69be 100644 --- a/frontend/app/(pages)/blog/PostList.tsx +++ b/frontend/app/(pages)/blog/PostList.tsx @@ -1,19 +1,19 @@ -'use server'; - import React from 'react'; -import { fetchPostList } from '@/src/api/blog/post'; -import AnimationPostList from '@/app/(pages)/blog/post/components/AnimationPostList'; +import { Post } from '@/src/api/blog/post'; +import AnimationPostList, { HighlightSelector } from '@/app/(pages)/blog/post/components/AnimationPostList'; -const PostList = async ({ page }: { page: number }) => { +interface Props { + posts: Post[], + tags: string[] | undefined +} + +const PostList = async ({ posts, tags }: Props) => { try { - const postListResponse = await fetchPostList(page); - - const posts = postListResponse.data.posts; - return ( - // blog list
- +
); } 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]/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 index b5dd424..2019179 100644 --- a/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx +++ b/frontend/app/(pages)/blog/post/components/AnimationPostList.tsx @@ -7,7 +7,16 @@ import * as motion from 'motion/react-client'; import { AnimatePresence } from 'motion/react'; import { Post } from '@/src/api/blog/post'; -const PostList = ({ posts }: { posts: Post[] }) => { +export interface HighlightSelector { + tags?: string[]; +} + +interface Props { + posts: Post[]; + highlight: HighlightSelector; +} + +const PostList = ({ posts, highlight }: Props) => { return (
      @@ -19,7 +28,7 @@ const PostList = ({ posts }: { posts: Post[] }) => { transition={ { ease: 'easeOut', duration: 0.5, delay: key * .1 + .1 } } key={ key } className={ 'w-full' }> - + ) ) } 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/layout.tsx b/frontend/app/layout.tsx index e2fdce6..04bb4b9 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -21,9 +21,6 @@ export default function RootLayout({ -
      -

      *System Under Construction*

      -
      { children }
      diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index fa2b5de..adaed5c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,63 +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 BoxesGL from '@/components/BackgroundBoxGL'; -import { BoxesCore } from '@/components/BackgroundBox'; +import * as motion from 'motion/react-client'; +import { cn } from '@/src/utils'; export default function Home() { - const isMobile = useScreenSizeTrigger('sm'); + const isSm = useScreenSizeTrigger('sm'); + const isXs = useScreenSizeTrigger('xs'); return (
      - {/**/ } - - -
      -

      Atelier Lumiaethor

      -
      - -
      -
      - className={ 'h-full w-full rounded-3xl backdrop-blur align-middle grid grid-cols-2 content-center' }> +
      +

      Atelier Lumiaethor

      +

      In pursuit of absolute + simplicity and ultimate + sophistication

      +
      -
      -
      - test +
      +
      + + { !isSm && +
      +
      +

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

      +

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

      +

      LMTCloud ecosystem

      +
      +
      + } + +
      +
        + { + entries.map((meta, key) => ( +
      • + +
      • + )) + } +
      -
      -
      -
      -
        - { - entries.map((meta, key) => ( -
      • - -
      • - )) - } -
      -
      +
      ); } diff --git a/frontend/components/BackgroundBox.tsx b/frontend/components/BackgroundBox.tsx deleted file mode 100644 index 295074f..0000000 --- a/frontend/components/BackgroundBox.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; -import React from 'react'; -import { motion } from 'motion/react'; -import { cn } from '@/src/utils'; - -export const BoxesCore = ({ className, ...rest }: { className?: string }) => { - const rows = new Array(250).fill(1); - const cols = new Array(200).fill(1); - let colors = [ - '#93c5fd', - '#f9a8d4', - '#86efac', - '#fde047', - '#fca5a5', - '#d8b4fe', - '#93c5fd', - '#a5b4fc', - '#c4b5fd' - ]; - const getRandomColor = () => { - return colors[Math.floor(Math.random() * colors.length)]; - }; - - return ( -
      - { rows.map((_, i) => ( - - { cols.map((_, j) => ( - - { j % 2 === 0 && i % 2 === 0 ? ( - - - - ) : null } - - )) } - - )) } -
      - ); -}; - -export const Boxes = React.memo(BoxesCore); diff --git a/frontend/components/BackgroundBoxGL.tsx b/frontend/components/BackgroundBoxGL.tsx deleted file mode 100644 index 7f26ed0..0000000 --- a/frontend/components/BackgroundBoxGL.tsx +++ /dev/null @@ -1,266 +0,0 @@ -'use client'; -import React, { useEffect, useRef } from 'react'; - -// WebGL-based rewrite of the grid/hover effect originally implemented with many DOM nodes. -// Key design goals: -// - zero per-cell DOM; a single full-screen draw with a fragment shader -// - hover color derived deterministically from a small palette -// - optional CSS transform to match the original perspective/skew -// - high-DPI aware; resizes with container - -export default function BoxesGL({ - className, - style, - rows = 250, - cols = 200, - lineWidth = 1.0, // device pixels - transformLikeOriginal = true -}: { - className?: string - style?: React.CSSProperties - rows?: number - cols?: number - lineWidth?: number - transformLikeOriginal?: boolean -}) { - const canvasRef = useRef(null); - const rafRef = useRef(null); - const mouseRef = useRef<{ x: number; y: number } | null>(null); - - useEffect(() => { - const canvas = canvasRef.current!; - const gl = canvas.getContext('webgl2', { antialias: false, premultipliedAlpha: true }); - if (!gl) { - console.warn('WebGL2 not available'); - // Fail closed; nothing to do if WebGL2 is unavailable - return; - } - - // Create program (fullscreen triangle) - const vs = gl.createShader(gl.VERTEX_SHADER)!; - gl.shaderSource( - vs, - `#version 300 es\n - const vec2 verts[3] = vec2[3]( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0) - ); - void main(){ - gl_Position = vec4(verts[gl_VertexID], 0.0, 1.0); - } - ` - ); - gl.compileShader(vs); - - const fs = gl.createShader(gl.FRAGMENT_SHADER)!; - gl.shaderSource( - fs, - `#version 300 es\n - precision highp float; - out vec4 o; - - uniform vec2 u_resolution; // canvas size in device pixels - uniform vec2 u_mouse; // mouse in device pixels, (-1,-1) if outside - uniform float u_time; // seconds - uniform float u_rows; - uniform float u_cols; - uniform float u_lineWidth; // in device pixels - - // Border color and plus sign color (matches tailwind slate-700-ish) - const vec3 BORDER = vec3(0.22, 0.27, 0.33); - const vec3 PLUS = vec3(0.22, 0.27, 0.33); - - // A small pastel palette like the original - vec3 palette(int idx){ - // 9-color palette - if(idx==0) return vec3(0.576,0.773,0.992); - if(idx==1) return vec3(0.976,0.659,0.831); - if(idx==2) return vec3(0.525,0.937,0.675); - if(idx==3) return vec3(0.992,0.878,0.278); - if(idx==4) return vec3(0.988,0.648,0.648); - if(idx==5) return vec3(0.847,0.706,0.996); - if(idx==6) return vec3(0.576,0.773,0.992); - if(idx==7) return vec3(0.647,0.706,0.988); - return vec3(0.769,0.710,0.992); - } - - // Hash helpers for stable per-cell randomness - float hash11(float x){ - x = fract(x * 0.1031); - x *= x + 33.33; - return fract(1e4 * x * (x + 0.33)); - } - float hash21(vec2 p){ - vec3 p3 = fract(vec3(p.xyx) * 0.1031); - p3 += dot(p3, p3.yzx + 33.33); - return fract((p3.x + p3.y) * p3.z); - } - - void main(){ - vec2 frag = gl_FragCoord.xy; // device pixels - vec2 gridSize = vec2(u_cols, u_rows); - vec2 cellSize = u_resolution / gridSize; // pixels per cell - - // Compute integer cell coordinates and local position within cell [0,1) - vec2 uvGrid = frag / u_resolution * gridSize; - vec2 cellId = floor(uvGrid); - vec2 cellUV = fract(uvGrid); - - // Grid lines: draw top+right borders to mimic original (border-t, border-r, border-l on first col) - float lw = u_lineWidth / max(cellSize.x, 1.0); // convert to relative width in [0,1] axis - float lwY = u_lineWidth / max(cellSize.y, 1.0); - - float lineTop = step(1.0 - lwY, cellUV.y); - float lineRight= step(1.0 - lw , cellUV.x); - float lineLeft = step(cellUV.x, lw) * step(0.5, step(0.0, cellId.x)); // left border for all cols via small bias - - float lineMask = max(lineTop, max(lineRight, lineLeft)); - - // Determine hovered cell index - vec2 hoverCell = vec2(-1.0); - bool hovering = u_mouse.x >= 0.0 && u_mouse.y >= 0.0; - if(hovering){ - vec2 hoverUVGrid = u_mouse / u_resolution * gridSize; - hoverCell = floor(hoverUVGrid); - } - bool isHover = hovering && all(equal(hoverCell, cellId)); - - // Background: transparent unless hovered. On hover, fill with palette color chosen by cell hash. - vec3 bg = vec3(0.0); - float alpha = 0.0; - if(isHover){ - // Stable color per cell, with slight time wobble to avoid banding on long hovers - float h = hash21(cellId + vec2(floor(u_time*0.25))); - int idx = int(floor(h * 9.0)) % 9; - bg = palette(idx); - alpha = 1.0; - } - - // Plus sign for even-even cells: thin cross centered - bool evenEven = mod(cellId.x, 2.0) < 0.5 && mod(cellId.y, 2.0) < 0.5; - float plusThicknessX = 0.5 * u_lineWidth / max(cellSize.x, 1.0); - float plusThicknessY = 0.5 * u_lineWidth / max(cellSize.y, 1.0); - float cross = 0.0; - if(evenEven){ - float vert = step(0.5 - plusThicknessX, abs(cellUV.x - 0.5)); - float hori = step(0.5 - plusThicknessY, abs(cellUV.y - 0.5)); - cross = 1.0 - max(vert, hori); - } - - vec3 color = mix(bg, BORDER, max(lineMask, 0.0)); - color = mix(color, PLUS, cross); - float a = max(alpha, max(lineMask, cross)); - - o = vec4(color, a); - } - ` - ); - gl.compileShader(fs); - - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - - // Check compile/link for diagnostics (silent in prod; could surface to console in dev) - if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { - console.warn('WebGL program link error:', gl.getProgramInfoLog(prog)); - } - - gl.useProgram(prog); - - const uResolution = gl.getUniformLocation(prog, 'u_resolution'); - const uMouse = gl.getUniformLocation(prog, 'u_mouse'); - const uTime = gl.getUniformLocation(prog, 'u_time'); - const uRows = gl.getUniformLocation(prog, 'u_rows'); - const uCols = gl.getUniformLocation(prog, 'u_cols'); - const uLineWidth = gl.getUniformLocation(prog, 'u_lineWidth'); - - // Single draw call state - gl.disable(gl.DEPTH_TEST); - gl.disable(gl.CULL_FACE); - gl.viewport(0, 0, canvas.width, canvas.height); - - // Resize handling (devicePixelRatio-aware) - const resize = () => { - const dpr = Math.max(window.devicePixelRatio || 1, 1); - const { clientWidth, clientHeight } = canvas; - const w = Math.max(1, Math.floor(clientWidth * dpr)); - const h = Math.max(1, Math.floor(clientHeight * dpr)); - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; - canvas.height = h; - gl.viewport(0, 0, w, h); - } - }; - - const observer = new ResizeObserver(resize); - observer.observe(canvas); - resize(); - - // Mouse tracking in device pixels - const onMove = (e: PointerEvent) => { - const rect = canvas.getBoundingClientRect(); - const dpr = Math.max(window.devicePixelRatio || 1, 1); - const x = (e.clientX - rect.left) * dpr; - const y = (rect.bottom - e.clientY) * dpr; // flip Y to GL coords - mouseRef.current = { x, y }; - }; - const onLeave = () => { - mouseRef.current = null; - }; - canvas.addEventListener('pointermove', onMove); - canvas.addEventListener('pointerleave', onLeave); - - const t0 = performance.now(); - const tick = () => { - const t = (performance.now() - t0) / 1000; - gl.useProgram(prog); - gl.uniform2f(uResolution, canvas.width, canvas.height); - const m = mouseRef.current; - if (m) gl.uniform2f(uMouse, m.x, m.y); - else gl.uniform2f(uMouse, -1, -1); - gl.uniform1f(uTime, t); - gl.uniform1f(uRows, rows); - gl.uniform1f(uCols, cols); - gl.uniform1f(uLineWidth, lineWidth); - - gl.drawArrays(gl.TRIANGLES, 0, 3); - rafRef.current = requestAnimationFrame(tick); - }; - tick(); - - return () => { - if (rafRef.current) cancelAnimationFrame(rafRef.current); - observer.disconnect(); - canvas.removeEventListener('pointermove', onMove); - canvas.removeEventListener('pointerleave', onLeave); - gl.deleteProgram(prog); - gl.deleteShader(vs); - gl.deleteShader(fs); - }; - }, [ rows, cols, lineWidth ]); - - return ( -
      - -
      - ); -} 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/src/api/blog/post.ts b/frontend/src/api/blog/post.ts index 5dedce7..f1046f8 100644 --- a/frontend/src/api/blog/post.ts +++ b/frontend/src/api/blog/post.ts @@ -26,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); } From 3a4af1ae8db804104e411b3f7705187cfd97d36b Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Mon, 13 Oct 2025 14:36:21 -0400 Subject: [PATCH 9/9] feat(migration) add migration --- Makefile | 20 +++++++++++--- backend/server/build.rs | 27 ++++++++++--------- container/release/backend.containerfile | 4 +-- frontend/Makefile | 8 ++++++ frontend/next.config.mjs | 2 +- frontend/src/constants.ts | 2 +- .../modules/blog => migration/ddl}/schema.sql | 0 migration/migration.sh | 18 +++++++++++++ prod.compose.yaml | 25 +++++++++++++++-- 9 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 frontend/Makefile rename {backend/server/src/modules/blog => migration/ddl}/schema.sql (100%) create mode 100644 migration/migration.sh 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/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/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/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/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/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/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