commit ff98547dbba78d5e0057178c8dbc8966c4ba72d9 Author: Superuser Date: Mon May 18 20:10:54 2026 +0800 init: me-web 个人简历前端 Co-Authored-By: Claude Opus 4.7 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..952c41c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.git +*.swp +*.log +.env +.env.local +.env.*.local +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1fd64e --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ + +# TypeScript cache +*.tsbuildinfo + +# Environment +.env +.env.* +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test +coverage/ + +# Database +*.db +*.sqlite +*.sqlite3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7284259 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Generated files +*.tsbuildinfo +coverage/ + +# Package files +package-lock.json +pnpm-lock.yaml + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Environment files +.env* + +# IDE files +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..67c0bc8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1240227 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/api/auth-router.ts b/api/auth-router.ts new file mode 100644 index 0000000..22d0ba9 --- /dev/null +++ b/api/auth-router.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; +import * as cookie from "cookie"; +import { createRouter, publicQuery, authedQuery } from "./middleware"; +import { getSessionCookieOptions } from "./lib/cookies"; +import { signSessionToken } from "./lib/session"; +import { env } from "./lib/env"; +import { findUserByUnionId, upsertUser } from "./queries/users"; +import { Session } from "@contracts/constants"; + +export const authRouter = createRouter({ + login: publicQuery + .input(z.object({ username: z.string(), password: z.string() })) + .mutation(async ({ input, ctx }) => { + if ( + input.username !== env.adminUsername || + input.password !== env.adminPassword + ) { + throw new Error("Invalid username or password"); + } + + await upsertUser({ + unionId: input.username, + name: input.username, + role: "admin" as const, + lastSignInAt: new Date(), + }); + + const user = await findUserByUnionId(input.username); + if (!user) { + throw new Error("Failed to create user"); + } + + const token = await signSessionToken({ userId: user.id }); + + const cookieOpts = getSessionCookieOptions(ctx.req.headers); + ctx.resHeaders.append( + "set-cookie", + cookie.serialize(Session.cookieName, token, { + httpOnly: cookieOpts.httpOnly, + path: cookieOpts.path, + sameSite: cookieOpts.sameSite?.toLowerCase() as "lax" | "none", + secure: cookieOpts.secure, + maxAge: Session.maxAgeMs / 1000, + }), + ); + + return user; + }), + + me: authedQuery.query((opts) => opts.ctx.user), + + logout: authedQuery.mutation(async ({ ctx }) => { + const opts = getSessionCookieOptions(ctx.req.headers); + ctx.resHeaders.append( + "set-cookie", + cookie.serialize(Session.cookieName, "", { + httpOnly: opts.httpOnly, + path: opts.path, + sameSite: opts.sameSite?.toLowerCase() as "lax" | "none", + secure: opts.secure, + maxAge: 0, + }), + ); + return { success: true }; + }), +}); diff --git a/api/boot.ts b/api/boot.ts new file mode 100644 index 0000000..7fe9088 --- /dev/null +++ b/api/boot.ts @@ -0,0 +1,33 @@ +import { Hono } from "hono"; +import { bodyLimit } from "hono/body-limit"; +import type { HttpBindings } from "@hono/node-server"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "./router"; +import { createContext } from "./context"; +import { env } from "./lib/env"; + +const app = new Hono<{ Bindings: HttpBindings }>(); + +app.use(bodyLimit({ maxSize: 50 * 1024 * 1024 })); +app.use("/api/trpc/*", async (c) => { + return fetchRequestHandler({ + endpoint: "/api/trpc", + req: c.req.raw, + router: appRouter, + createContext, + }); +}); +app.all("/api/*", (c) => c.json({ error: "Not Found" }, 404)); + +export default app; + +if (env.isProduction) { + const { serve } = await import("@hono/node-server"); + const { serveStaticFiles } = await import("./lib/vite"); + serveStaticFiles(app); + + const port = parseInt(process.env.PORT || "3000"); + serve({ fetch: app.fetch, port }, () => { + console.log(`Server running on http://localhost:${port}/`); + }); +} diff --git a/api/context.ts b/api/context.ts new file mode 100644 index 0000000..fd56cdf --- /dev/null +++ b/api/context.ts @@ -0,0 +1,31 @@ +import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; +import type { User } from "@db/schema"; +import * as cookie from "cookie"; +import { Session } from "@contracts/constants"; +import { verifySessionToken } from "./lib/session"; +import { findUserById } from "./queries/users"; + +export type TrpcContext = { + req: Request; + resHeaders: Headers; + user?: User; +}; + +export async function createContext( + opts: FetchCreateContextFnOptions, +): Promise { + const ctx: TrpcContext = { req: opts.req, resHeaders: opts.resHeaders }; + try { + const cookies = cookie.parse(opts.req.headers.get("cookie") || ""); + const token = cookies[Session.cookieName]; + if (token) { + const claim = await verifySessionToken(token); + if (claim) { + ctx.user = await findUserById(claim.userId); + } + } + } catch { + // Authentication is optional + } + return ctx; +} diff --git a/api/documents-router.ts b/api/documents-router.ts new file mode 100644 index 0000000..c81ac0b --- /dev/null +++ b/api/documents-router.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; +import { createRouter, publicQuery, adminQuery } from "./middleware"; +import { getDb } from "./queries/connection"; +import { documents } from "@db/schema"; +import { eq, desc } from "drizzle-orm"; + +export const documentsRouter = createRouter({ + list: publicQuery.query(async () => { + const db = getDb(); + return db + .select() + .from(documents) + .where(eq(documents.published, "published")) + .orderBy(desc(documents.createdAt)); + }), + + listAll: adminQuery.query(async () => { + const db = getDb(); + return db + .select() + .from(documents) + .orderBy(desc(documents.createdAt)); + }), + + getBySlug: publicQuery + .input(z.object({ slug: z.string() })) + .query(async ({ input }) => { + const db = getDb(); + const results = await db + .select() + .from(documents) + .where(eq(documents.slug, input.slug)) + .limit(1); + return results[0] ?? null; + }), + + create: adminQuery + .input( + z.object({ + title: z.string().min(1).max(255), + slug: z.string().min(1).max(255), + excerpt: z.string().optional(), + content: z.string().min(1), + coverImage: z.string().optional(), + tags: z.string().optional(), + published: z.enum(["draft", "published"]).default("published"), + }) + ) + .mutation(async ({ input }) => { + const db = getDb(); + const result = await db.insert(documents).values({ + title: input.title, + slug: input.slug, + excerpt: input.excerpt ?? "", + content: input.content, + coverImage: input.coverImage ?? "", + tags: input.tags ?? "", + published: input.published, + }); + return { id: Number(result[0].insertId) }; + }), + + update: adminQuery + .input( + z.object({ + id: z.number(), + title: z.string().min(1).max(255).optional(), + slug: z.string().min(1).max(255).optional(), + excerpt: z.string().optional(), + content: z.string().optional(), + coverImage: z.string().optional(), + tags: z.string().optional(), + published: z.enum(["draft", "published"]).optional(), + }) + ) + .mutation(async ({ input }) => { + const db = getDb(); + const { id, ...data } = input; + await db + .update(documents) + .set(data) + .where(eq(documents.id, id)); + return { success: true }; + }), + + delete: adminQuery + .input(z.object({ id: z.number() })) + .mutation(async ({ input }) => { + const db = getDb(); + await db.delete(documents).where(eq(documents.id, input.id)); + return { success: true }; + }), +}); diff --git a/api/lib/cookies.ts b/api/lib/cookies.ts new file mode 100644 index 0000000..ca288e2 --- /dev/null +++ b/api/lib/cookies.ts @@ -0,0 +1,17 @@ +import type { CookieOptions } from "hono/utils/cookie"; + +function isLocalhost(headers: Headers): boolean { + const host = headers.get("host") || ""; + return host.startsWith("localhost:") || host.startsWith("127.0.0.1:"); +} + +export function getSessionCookieOptions(headers: Headers): CookieOptions { + const localhost = isLocalhost(headers); + + return { + httpOnly: true, + path: "/", + sameSite: localhost ? "Lax" : "None", + secure: !localhost, + }; +} diff --git a/api/lib/env.ts b/api/lib/env.ts new file mode 100644 index 0000000..b162ebd --- /dev/null +++ b/api/lib/env.ts @@ -0,0 +1,17 @@ +import "dotenv/config"; + +function required(name: string): string { + const value = process.env[name]; + if (!value && process.env.NODE_ENV === "production") { + throw new Error(`Missing required environment variable: ${name}`); + } + return value ?? ""; +} + +export const env = { + jwtSecret: required("JWT_SECRET"), + isProduction: process.env.NODE_ENV === "production", + databaseUrl: required("DATABASE_URL"), + adminUsername: required("ADMIN_USERNAME"), + adminPassword: required("ADMIN_PASSWORD"), +}; diff --git a/api/lib/http.ts b/api/lib/http.ts new file mode 100644 index 0000000..53bf2c6 --- /dev/null +++ b/api/lib/http.ts @@ -0,0 +1,77 @@ +interface RequestConfig extends RequestInit { + baseUrl?: string; + params?: Record; + timeout?: number; +} + +export class HttpClient { + private baseUrl: string; + private defaultHeaders: Record; + + constructor(baseURL: string, opts?: { headers?: Record }) { + this.baseUrl = baseURL; + this.defaultHeaders = { + "Content-Type": "application/json", + ...opts?.headers, + }; + } + + async request(endpoint: string, config: RequestConfig = {}): Promise { + const { + method = "GET", + params, + body, + headers, + timeout = 30000, + ...rest + } = config; + + const url = new URL(`${this.baseUrl}${endpoint}`); + if (params) { + Object.entries(params).forEach(([key, value]) => + url.searchParams.append(key, value.toString()), + ); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url.toString(), { + ...rest, + method, + headers: { ...this.defaultHeaders, ...headers }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorData = (await response + .json() + .catch(() => ({}))) as Record; + throw new Error(errorData.message || `HTTP Error: ${response.status}`); + } + + return (await response.json()) as T; + } catch (error: any) { + if (error.name === "AbortError") { + throw new Error("Request timeout"); + } + throw error; + } + } + + get( + url: string, + params?: RequestConfig["params"], + config?: RequestConfig, + ) { + return this.request(url, { ...config, method: "GET", params }); + } + + post(url: string, body?: any, config?: RequestConfig) { + return this.request(url, { ...config, method: "POST", body }); + } +} diff --git a/api/lib/session.ts b/api/lib/session.ts new file mode 100644 index 0000000..4dc544a --- /dev/null +++ b/api/lib/session.ts @@ -0,0 +1,36 @@ +import * as jose from "jose"; +import { env } from "./env"; + +const JWT_ALG = "HS256"; + +export interface SessionPayload { + userId: number; +} + +export async function signSessionToken( + payload: SessionPayload, +): Promise { + const secret = new TextEncoder().encode(env.jwtSecret); + return new jose.SignJWT(payload) + .setProtectedHeader({ alg: JWT_ALG }) + .setIssuedAt() + .setExpirationTime("1 year") + .sign(secret); +} + +export async function verifySessionToken( + token: string, +): Promise { + if (!token) return null; + try { + const secret = new TextEncoder().encode(env.jwtSecret); + const { payload } = await jose.jwtVerify(token, secret, { + algorithms: [JWT_ALG], + }); + const userId = payload.userId as number; + if (!userId) return null; + return { userId }; + } catch { + return null; + } +} diff --git a/api/lib/vite.ts b/api/lib/vite.ts new file mode 100644 index 0000000..c806ad5 --- /dev/null +++ b/api/lib/vite.ts @@ -0,0 +1,23 @@ +import type { Hono } from "hono"; +import type { HttpBindings } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import fs from "fs"; +import path from "path"; + +type App = Hono<{ Bindings: HttpBindings }>; + +export function serveStaticFiles(app: App) { + const distPath = path.resolve(import.meta.dirname, "../dist/public"); + + app.use("*", serveStatic({ root: distPath })); + + app.notFound((c) => { + const accept = c.req.header("accept") ?? ""; + if (!accept.includes("text/html")) { + return c.json({ error: "Not Found" }, 404); + } + const indexPath = path.resolve(distPath, "index.html"); + const content = fs.readFileSync(indexPath, "utf-8"); + return c.html(content); + }); +} diff --git a/api/middleware.ts b/api/middleware.ts new file mode 100644 index 0000000..a70d8d5 --- /dev/null +++ b/api/middleware.ts @@ -0,0 +1,42 @@ +import { ErrorMessages } from "@contracts/constants"; +import { initTRPC, TRPCError } from "@trpc/server"; +import superjson from "superjson"; +import type { TrpcContext } from "./context"; + +const t = initTRPC.context().create({ + transformer: superjson, +}); + +export const createRouter = t.router; +export const publicQuery = t.procedure; + +const requireAuth = t.middleware(async (opts) => { + const { ctx, next } = opts; + + if (!ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: ErrorMessages.unauthenticated, + }); + } + + return next({ ctx: { ...ctx, user: ctx.user } }); +}); + +function requireRole(role: string) { + return t.middleware(async (opts) => { + const { ctx, next } = opts; + + if (!ctx.user || ctx.user.role !== role) { + throw new TRPCError({ + code: "FORBIDDEN", + message: ErrorMessages.insufficientRole, + }); + } + + return next({ ctx: { ...ctx, user: ctx.user } }); + }); +} + +export const authedQuery = t.procedure.use(requireAuth); +export const adminQuery = authedQuery.use(requireRole("admin")); diff --git a/api/queries/connection.ts b/api/queries/connection.ts new file mode 100644 index 0000000..0811ea9 --- /dev/null +++ b/api/queries/connection.ts @@ -0,0 +1,18 @@ +import { drizzle } from "drizzle-orm/mysql2"; +import { env } from "../lib/env"; +import * as schema from "@db/schema"; +import * as relations from "@db/relations"; + +const fullSchema = { ...schema, ...relations }; + +let instance: ReturnType>; + +export function getDb() { + if (!instance) { + instance = drizzle(env.databaseUrl, { + mode: "default", + schema: fullSchema, + }); + } + return instance; +} diff --git a/api/queries/users.ts b/api/queries/users.ts new file mode 100644 index 0000000..67a701b --- /dev/null +++ b/api/queries/users.ts @@ -0,0 +1,35 @@ +import { eq } from "drizzle-orm"; +import * as schema from "@db/schema"; +import type { InsertUser } from "@db/schema"; +import { getDb } from "./connection"; + +export async function findUserByUnionId(unionId: string) { + const rows = await getDb() + .select() + .from(schema.users) + .where(eq(schema.users.unionId, unionId)) + .limit(1); + return rows.at(0); +} + +export async function findUserById(id: number) { + const rows = await getDb() + .select() + .from(schema.users) + .where(eq(schema.users.id, id)) + .limit(1); + return rows.at(0); +} + +export async function upsertUser(data: InsertUser) { + await getDb() + .insert(schema.users) + .values(data) + .onDuplicateKeyUpdate({ + set: { + lastSignInAt: new Date(), + name: data.name, + avatar: data.avatar, + }, + }); +} diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..6f52d27 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,11 @@ +import { authRouter } from "./auth-router"; +import { documentsRouter } from "./documents-router"; +import { createRouter, publicQuery } from "./middleware"; + +export const appRouter = createRouter({ + ping: publicQuery.query(() => ({ ok: true, ts: Date.now() })), + auth: authRouter, + documents: documentsRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/components.json b/components.json new file mode 100644 index 0000000..411f770 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "postcss.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/contracts/constants.ts b/contracts/constants.ts new file mode 100644 index 0000000..3587890 --- /dev/null +++ b/contracts/constants.ts @@ -0,0 +1,13 @@ +export const Session = { + cookieName: "admin_sid", + maxAgeMs: 365 * 24 * 60 * 60 * 1000, +} as const; + +export const ErrorMessages = { + unauthenticated: "Authentication required", + insufficientRole: "Insufficient permissions", +} as const; + +export const Paths = { + login: "/login", +} as const; diff --git a/contracts/errors.ts b/contracts/errors.ts new file mode 100644 index 0000000..b88860f --- /dev/null +++ b/contracts/errors.ts @@ -0,0 +1,15 @@ +type AppError = { tag: "app_error"; status: number; message: string }; + +function appError(status: number, message: string): AppError { + return { tag: "app_error", status, message }; +} + +export const Errors = { + badRequest: (msg: string) => appError(400, msg), + unauthorized: (msg: string) => appError(401, msg), + forbidden: (msg: string) => appError(403, msg), + notFound: (msg: string) => appError(404, msg), + internal: (msg: string) => appError(500, msg), +} as const; + +export type { AppError }; diff --git a/contracts/types.ts b/contracts/types.ts new file mode 100644 index 0000000..77a1127 --- /dev/null +++ b/contracts/types.ts @@ -0,0 +1,2 @@ +export type * from "../db/schema"; +export * from "./errors"; diff --git a/db/migrations/.gitkeep b/db/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db/relations.ts b/db/relations.ts new file mode 100644 index 0000000..941a268 --- /dev/null +++ b/db/relations.ts @@ -0,0 +1 @@ +import {} from "./schema"; diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..0fac7e0 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,46 @@ +import { + mysqlTable, + mysqlEnum, + serial, + varchar, + text, + timestamp, + // bigint, +} from "drizzle-orm/mysql-core"; + +export const users = mysqlTable("users", { + id: serial("id").primaryKey(), + unionId: varchar("unionId", { length: 255 }).notNull().unique(), + name: varchar("name", { length: 255 }), + email: varchar("email", { length: 320 }), + avatar: text("avatar"), + role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + lastSignInAt: timestamp("lastSignInAt").defaultNow().notNull(), +}); + +export type User = typeof users.$inferSelect; +export type InsertUser = typeof users.$inferInsert; + +export const documents = mysqlTable("documents", { + id: serial("id").primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + slug: varchar("slug", { length: 255 }).notNull().unique(), + excerpt: text("excerpt"), + content: text("content").notNull(), + coverImage: text("coverImage"), + tags: text("tags"), + published: mysqlEnum("published", ["draft", "published"]).default("published").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), +}); + +export type Document = typeof documents.$inferSelect; +export type InsertDocument = typeof documents.$inferInsert; diff --git a/db/seed.ts b/db/seed.ts new file mode 100644 index 0000000..6de1189 --- /dev/null +++ b/db/seed.ts @@ -0,0 +1,17 @@ +import { getDb } from "../api/queries/connection"; +// TODO: import tables from "./schema" + +async function seed() { + const db = getDb(); + console.log("Seeding database..."); + + // TODO: insert seed data, e.g. + // await db.insert(schema.posts).values([ + // { title: "First post", content: "Hello world" }, + // ]); + + console.log("Done."); + process.exit(0); // close MySQL connection pool +} + +seed(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff60e2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.8" + +services: + me-web: + build: . + image: me-web:latest + container_name: me-web + restart: always + ports: + - "80:80" + networks: + - app-network + +networks: + app-network: + driver: bridge + name: aliyun-app-network diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..59b2514 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + 邓小洲 + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7effe4c --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy to backend + location /api/ { + proxy_pass http://me-api:3000; + proxy_http_version 1.1; + 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_set_header X-Forwarded-Proto $scheme; + } + + # Static assets cache + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8edbe1 --- /dev/null +++ b/package.json @@ -0,0 +1,97 @@ +{ + "name": "me-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "check": "tsc -b", + "format": "prettier --write .", + "test": "vitest run" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@tanstack/react-query": "^5.90.16", + "@trpc/client": "^11.8.1", + "@trpc/react-query": "^11.8.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.38.0", + "html2canvas": "^1.4.1", + "imagesloaded": "^5.0.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", + "react": "^19.2.0", + "react-day-picker": "^9.13.0", + "react-dom": "^19.2.0", + "react-hook-form": "^7.70.0", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.2.2", + "react-router": "^7.6.1", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "superjson": "^2.2.6", + "tailwind-merge": "^3.4.0", + "three": "^0.184.0", + "vaul": "^1.1.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@trpc/server": "^11.8.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "kimi-plugin-inspect-react": "^1.0.3", + "postcss": "^8.5.6", + "prettier": "^3.7.4", + "tailwindcss": "^3.4.19", + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4", + "vitest": "^4.0.16" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/images/avatar.jpg b/public/images/avatar.jpg new file mode 100644 index 0000000..de38a46 Binary files /dev/null and b/public/images/avatar.jpg differ diff --git a/public/images/hero-bg.jpg b/public/images/hero-bg.jpg new file mode 100644 index 0000000..9fd3d06 Binary files /dev/null and b/public/images/hero-bg.jpg differ diff --git a/public/images/project-1.jpg b/public/images/project-1.jpg new file mode 100644 index 0000000..5c11339 Binary files /dev/null and b/public/images/project-1.jpg differ diff --git a/public/images/project-2.jpg b/public/images/project-2.jpg new file mode 100644 index 0000000..371c234 Binary files /dev/null and b/public/images/project-2.jpg differ diff --git a/public/images/project-3.jpg b/public/images/project-3.jpg new file mode 100644 index 0000000..a16d08a Binary files /dev/null and b/public/images/project-3.jpg differ diff --git a/public/images/project-4.jpg b/public/images/project-4.jpg new file mode 100644 index 0000000..a66ccd0 Binary files /dev/null and b/public/images/project-4.jpg differ diff --git a/public/images/project-5.jpg b/public/images/project-5.jpg new file mode 100644 index 0000000..cef7f75 Binary files /dev/null and b/public/images/project-5.jpg differ diff --git a/public/images/project-6.jpg b/public/images/project-6.jpg new file mode 100644 index 0000000..c43dd62 Binary files /dev/null and b/public/images/project-6.jpg differ diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..fb265d7 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,20 @@ +import { Routes, Route } from "react-router"; +import Home from "./pages/Home"; +import Blog from "./pages/Blog"; +import BlogDetail from "./pages/BlogDetail"; +import Admin from "./pages/Admin"; +import Login from "./pages/Login"; +import NotFound from "./pages/NotFound"; + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/src/components/AuthLayout.tsx b/src/components/AuthLayout.tsx new file mode 100644 index 0000000..a9c185c --- /dev/null +++ b/src/components/AuthLayout.tsx @@ -0,0 +1,266 @@ +import { useAuth } from "@/hooks/useAuth"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, + useSidebar, +} from "@/components/ui/sidebar"; +import { LOGIN_PATH } from "@/const"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; +import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { AuthLayoutSkeleton } from "./AuthLayoutSkeleton"; +import { Button } from "./ui/button"; + +const menuItems = [ + { icon: LayoutDashboard, label: "Page 1", path: "/" }, + { icon: Users, label: "Page 2", path: "/some-path" }, +]; + +const SIDEBAR_WIDTH_KEY = "sidebar-width"; +const DEFAULT_WIDTH = 280; +const MIN_WIDTH = 200; +const MAX_WIDTH = 480; + +export default function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const [sidebarWidth, setSidebarWidth] = useState(() => { + const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY); + return saved ? parseInt(saved, 10) : DEFAULT_WIDTH; + }); + const { isLoading, user } = useAuth(); + + useEffect(() => { + localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString()); + }, [sidebarWidth]); + + if (isLoading) { + return ; + } + + if (!user) { + return ( +
+
+
+

+ Sign in to continue +

+

+ Access to this dashboard requires authentication. Continue to + launch the login flow. +

+
+ +
+
+ ); + } + + return ( + + + {children} + + + ); +} + +type AuthLayoutContentProps = { + children: ReactNode; + setSidebarWidth: (width: number) => void; +}; + +function AuthLayoutContent({ + children, + setSidebarWidth, +}: AuthLayoutContentProps) { + const { user, logout } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const { state, toggleSidebar } = useSidebar(); + const isCollapsed = state === "collapsed"; + const [isResizing, setIsResizing] = useState(false); + const sidebarRef = useRef(null); + const activeMenuItem = menuItems.find(item => item.path === location.pathname); + const isMobile = useIsMobile(); + + useEffect(() => { + if (isCollapsed) { + setIsResizing(false); + } + }, [isCollapsed]); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + + const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0; + const newWidth = e.clientX - sidebarLeft; + if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { + setSidebarWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing, setSidebarWidth]); + + return ( + <> +
+ + +
+ + {!isCollapsed ? ( +
+ + Navigation + +
+ ) : null} +
+
+ + + + {menuItems.map(item => { + const isActive = location.pathname === item.path; + return ( + + navigate(item.path)} + tooltip={item.label} + className={`h-10 transition-all font-normal`} + > + + {item.label} + + + ); + })} + + + + + + + + + + + + Sign out + + + + +
+
{ + if (isCollapsed) return; + setIsResizing(true); + }} + style={{ zIndex: 50 }} + /> +
+ + + {isMobile && ( +
+
+ +
+
+ + {activeMenuItem?.label ?? "Menu"} + +
+
+
+
+ )} +
{children}
+
+ + ); +} diff --git a/src/components/AuthLayoutSkeleton.tsx b/src/components/AuthLayoutSkeleton.tsx new file mode 100644 index 0000000..53f7c47 --- /dev/null +++ b/src/components/AuthLayoutSkeleton.tsx @@ -0,0 +1,46 @@ +import { Skeleton } from "./ui/skeleton"; + +export function AuthLayoutSkeleton() { + return ( +
+ {/* Sidebar skeleton */} +
+ {/* Logo area */} +
+ + +
+ + {/* Menu items */} +
+ + + +
+ + {/* User profile area at bottom */} +
+
+ +
+ + +
+
+
+
+ + {/* Main content skeleton */} +
+ {/* Content blocks */} + +
+ + + +
+ +
+
+ ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..54ea995 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect } from "react"; +import { Link, useLocation } from "react-router"; +import { useAuth } from "@/hooks/useAuth"; +import { BookOpen, LayoutDashboard, LogIn, LogOut, User } from "lucide-react"; + +export default function Navbar() { + const [scrolled, setScrolled] = useState(false); + const { user, logout, isLoading } = useAuth(); + const location = useLocation(); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 40); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + const isAdmin = user?.role === "admin"; + + return ( + + ); +} diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..d21b65f --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..935eecf --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return