chore: 统一代码格式并配置开发工具

- 添加 ESLint 和 Prettier 配置以统一代码风格
- 配置项目级 TypeScript 设置
- 更新前后端依赖版本
- 修复代码格式问题(引号、分号、尾随逗号等)
- 优化文件结构和导入路径
This commit is contained in:
2026-03-10 18:24:19 +08:00
parent 58373a15a9
commit 35d835f68c
58 changed files with 14240 additions and 922 deletions

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -4,101 +4,87 @@
## 1. 项目初始化与依赖配置 ## 1. 项目初始化与依赖配置
* [ ] **检查与安装依赖** - [ ] **检查与安装依赖**
- [ ] Web 端安装 `swr`, `zustand`
* [ ] Web 端安装 `swr`, `zustand` - [ ] 确认 `backend``web` 的 workspace 关联,确保类型共享
* [ ] 确认 `backend``web` 的 workspace 关联,确保类型共享。 - [ ] **类型安全配置 (Eden)**
- [ ] 在 Backend 导出 `App` 类型。
* [ ] **类型安全配置 (Eden)** - [ ] 在 Web 端正确引入 `App` 类型以实现端到端类型安全。
* [ ] 在 Backend 导出 `App` 类型。 - [ ] **环境变量配置**
- [ ] 配置 Backend `.env` (数据库路径, Auth Secret 等)。
* [ ] Web 端正确引入 `App` 类型以实现端到端类型安全 - [ ] 配置 Web `.env` (API 地址等)
* [ ] **环境变量配置**
* [ ] 配置 Backend `.env` (数据库路径, Auth Secret 等)。
* [ ] 配置 Web `.env` (API 地址等)。
## 2. 数据库与后端基础 ## 2. 数据库与后端基础
* [ ] **Prisma Schema 设计** - [ ] **Prisma Schema 设计**
- [ ] 完善 `User` 模型 (适配 better-auth)。
* [ ] 完善 `User` 模型 (适配 better-auth) - [ ] 设计 `Resource` (资源/传送门) 模型:包含标题、描述、链接、图标/图片、分类等字段
* [ ] 设计 `Resource` (资源/传送门) 模型:包含标题、描述、链接、图标/图片、分类等字段 - [ ] 设计 `Post` (帖子) 模型:包含标题、内容、作者、发布时间等
* [ ] 设计 `Post` (帖子) 模型:包含标题、内容、作者、发布时间等 - [ ] 执行 `bun prisma migrate dev` 生成数据库表
* [ ] 执行 `bun prisma migrate dev` 生成数据库表。 - [ ] **后端核心功能**
- [ ] **Auth**: 集成 `better-auth`,实现注册、登录、注销、获取当前用户接口。
* [ ] **后端核心功能** - [ ] **Resources**: 实现资源的增删改查 (CRUD) 接口。
* [ ] **Auth**: 集成 `better-auth`,实现注册、登录、注销、获取当前用户接口。 - [ ] **Posts**: 实现帖子的增删改查接口。
* [ ] **Resources**: 实现资源的增删改查 (CRUD) 接口 - [ ] **API 导出**: 确保所有路由都挂载到主 App 实例
* [ ] **Posts**: 实现帖子的增删改查接口。
* [ ] **API 导出**: 确保所有路由都挂载到主 App 实例。
## 3. 前端基础架构 (Web) ## 3. 前端基础架构 (Web)
* [ ] **HeroUI v3 配置** - [ ] **HeroUI v3 配置**
- [ ] 确保 `tailwind.css``HeroUI` 样式正确加载。
* [ ] 确保 `tailwind.css``HeroUI` 样式正确加载 - [ ] 配置全局 Theme (亮/暗色)
* [ ] 配置全局 Theme (亮/暗色)。 - [ ] **状态管理 & 数据请求**
- [ ] 封装 `useClient` 或类似 Hook结合 `eden``swr` 进行数据请求。
* [ ] **状态管理 & 数据请求** - [ ] 使用 `zustand` 管理全局状态 (如当前用户信息、UI状态)。
* [ ] 封装 `useClient` 或类似 Hook结合 `eden``swr` 进行数据请求。 - [ ] **路由配置**
- [ ] 使用 `react-router` 配置页面路由 (首页、登录、注册、资源页、帖子页、管理页)。
* [ ] 使用 `zustand` 管理全局状态 (如当前用户信息、UI状态)。
* [ ] **路由配置**
* [ ] 使用 `react-router` 配置页面路由 (首页、登录、注册、资源页、帖子页、管理页)。
## 4. 功能模块实现 ## 4. 功能模块实现
* [ ] **认证模块 (Auth)** - [ ] **认证模块 (Auth)**
- [ ] 实现登录页面 (Login Page)。
* [ ] 实现登录页面 (Login Page)。 - [ ] 实现注册页面 (Register Page)。
* [ ] 实现注册页面 (Register Page)。 - [ ] 实现受保护路由 (Protected Routes)。
* [ ] 实现受保护路由 (Protected Routes)。 - [ ] **资源/传送门模块 (Resources/Teleport)**
- [ ] **展示**: 使用 HeroUI `Card` 组件展示资源列表。
* [ ] **资源/传送门模块 (Resources/Teleport)** - [ ] **编辑**: 实现“在线编辑”功能 (管理员或特定用户权限),支持添加/修改/删除资源卡片。
* [ ] **展示**: 使用 HeroUI `Card` 组件展示资源列表。 - [ ] **帖子模块 (Posts)**
- [ ] **列表**: 展示帖子列表。
* [ ] **编辑**: 实现“在线编辑”功能 (管理员或特定用户权限),支持添加/修改/删除资源卡片 - [ ] **详情**: 展示帖子详情
* [ ] **帖子模块 (Posts)** - [ ] **发布**: 实现发帖编辑器和发布功能。
* [ ] **列表**: 展示帖子列表。
* [ ] **详情**: 展示帖子详情。
* [ ] **发布**: 实现发帖编辑器和发布功能。
## 5. 测试与优化 ## 5. 测试与优化
* [ ] **功能测试** - [ ] **功能测试**
- [ ] 测试完整的注册/登录流程。
* [ ] 测试完整的注册/登录流程 - [ ] 测试资源的增删改查
* [ ] 测试资源的增删改查 - [ ] 测试发帖功能
* [ ] 测试发帖功能。 - [ ] **UI/UX 优化**
- [ ] 响应式布局调整。
* [ ] **UI/UX 优化**
* [ ] 响应式布局调整。
* [ ] Loading 状态和错误处理。
- [ ] Loading 状态和错误处理。

0
backend/dev.db Normal file
View File

View File

@@ -9,17 +9,24 @@
"start": "NODE_ENV=production bun src/index.ts", "start": "NODE_ENV=production bun src/index.ts",
"start:dist": "bun run dist/server", "start:dist": "bun run dist/server",
"build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile dist/server src/index.ts", "build": "bun build --compile --minify-whitespace --minify-syntax --target bun --outfile dist/server src/index.ts",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "bun --bun tsc --noEmit" "typecheck": "bun --bun tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"elysia": "^1.0.0",
"prisma": "^5.0.0",
"@prisma/client": "^5.0.0",
"better-auth": "^0.7.0",
"@elysiajs/cors": "^1.0.0", "@elysiajs/cors": "^1.0.0",
"@elysiajs/eden": "^1.0.0" "@elysiajs/eden": "^1.0.0",
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "^7.4.2",
"@prisma/client": "^7.4.2",
"better-auth": "^1.5.4",
"dotenv": "^17.3.1",
"elysia": "^1.0.0",
"prisma": "^7.4.2"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@prisma/config": "^7.4.2",
"@types/bun": "latest",
"eslint": "^10.0.3"
} }
} }

12
backend/prisma.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import "dotenv/config";
import { defineConfig, env } from "@prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL") || "file:./dev.db",
},
});

View File

@@ -2,12 +2,14 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema // learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
output = "../src/generated/prisma"
engineType = "client"
runtime = "bun"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:./dev.db"
} }
model User { model User {

View File

@@ -1,10 +1,10 @@
import { betterAuth } from 'better-auth'; import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'; import { prismaAdapter } from 'better-auth/adapters/prisma'
import { prisma } from './prisma'; import { prisma } from './prisma'
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
provider: 'sqlite' provider: 'sqlite',
}), }),
basePath: '/api/auth', basePath: '/api/auth',
emailAndPassword: { emailAndPassword: {
@@ -14,7 +14,9 @@ export const auth = betterAuth({
github: { github: {
clientId: process.env.GITHUB_CLIENT_ID || 'dummy', clientId: process.env.GITHUB_CLIENT_ID || 'dummy',
clientSecret: process.env.GITHUB_CLIENT_SECRET || 'dummy', clientSecret: process.env.GITHUB_CLIENT_SECRET || 'dummy',
enabled: !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET), enabled: !!(
process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
),
}, },
}, },
}); })

View File

@@ -0,0 +1,54 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
/**
* Model Post
*
*/
export type Post = Prisma.PostModel
/**
* Model Resource
*
*/
export type Resource = Prisma.ResourceModel
/**
* Model Comment
*
*/
export type Comment = Prisma.CommentModel
/**
* Model VerificationToken
*
*/
export type VerificationToken = Prisma.VerificationTokenModel

View File

@@ -0,0 +1,78 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts'
export * from "./enums.ts"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Account
*
*/
export type Account = Prisma.AccountModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
/**
* Model Post
*
*/
export type Post = Prisma.PostModel
/**
* Model Resource
*
*/
export type Resource = Prisma.ResourceModel
/**
* Model Comment
*
*/
export type Comment = Prisma.CommentModel
/**
* Model VerificationToken
*
*/
export type VerificationToken = Prisma.VerificationTokenModel

View File

@@ -0,0 +1,348 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type IntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type IntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedIntNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedIntNullableFilter<$PrismaModel>
_max?: Prisma.NestedIntNullableFilter<$PrismaModel>
}
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}

View File

@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Account: 'Account',
Session: 'Session',
Post: 'Post',
Resource: 'Resource',
Comment: 'Comment',
VerificationToken: 'VerificationToken'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'emailVerified',
phone: 'phone',
phoneVerified: 'phoneVerified',
image: 'image',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const AccountScalarFieldEnum = {
id: 'id',
userId: 'userId',
type: 'type',
provider: 'provider',
providerAccountId: 'providerAccountId',
refresh_token: 'refresh_token',
access_token: 'access_token',
expires_at: 'expires_at',
token_type: 'token_type',
scope: 'scope',
id_token: 'id_token',
session_state: 'session_state'
} as const
export type AccountScalarFieldEnum = (typeof AccountScalarFieldEnum)[keyof typeof AccountScalarFieldEnum]
export const SessionScalarFieldEnum = {
id: 'id',
sessionToken: 'sessionToken',
userId: 'userId',
expires: 'expires'
} as const
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
export const PostScalarFieldEnum = {
id: 'id',
title: 'title',
content: 'content',
userId: 'userId',
published: 'published',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type PostScalarFieldEnum = (typeof PostScalarFieldEnum)[keyof typeof PostScalarFieldEnum]
export const ResourceScalarFieldEnum = {
id: 'id',
title: 'title',
description: 'description',
url: 'url',
icon: 'icon',
category: 'category',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ResourceScalarFieldEnum = (typeof ResourceScalarFieldEnum)[keyof typeof ResourceScalarFieldEnum]
export const CommentScalarFieldEnum = {
id: 'id',
content: 'content',
userId: 'userId',
postId: 'postId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CommentScalarFieldEnum = (typeof CommentScalarFieldEnum)[keyof typeof CommentScalarFieldEnum]
export const VerificationTokenScalarFieldEnum = {
identifier: 'identifier',
token: 'token',
expires: 'expires'
} as const
export type VerificationTokenScalarFieldEnum = (typeof VerificationTokenScalarFieldEnum)[keyof typeof VerificationTokenScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View File

@@ -0,0 +1,18 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.ts'
export type * from './models/Account.ts'
export type * from './models/Session.ts'
export type * from './models/Post.ts'
export type * from './models/Resource.ts'
export type * from './models/Comment.ts'
export type * from './models/VerificationToken.ts'
export type * from './commonInputTypes.ts'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
import { Elysia } from 'elysia'; import { Elysia } from 'elysia'
import { cors } from '@elysiajs/cors'; import { cors } from '@elysiajs/cors'
import { auth } from './auth'; import { auth } from './auth'
import { prisma } from './prisma'; import { prisma } from './prisma'
import { resources } from './resources'; import { resources } from './resources'
import { posts } from './posts'; import { posts } from './posts'
const app = new Elysia() const app = new Elysia()
.use( .use(
@@ -11,8 +11,8 @@ const app = new Elysia()
origin: 'http://localhost:5173', origin: 'http://localhost:5173',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true, credentials: true,
allowedHeaders: ['Content-Type', 'Authorization'] allowedHeaders: ['Content-Type', 'Authorization'],
}) }),
) )
.mount(auth.handler) .mount(auth.handler)
.get('/health', () => 'OK') .get('/health', () => 'OK')
@@ -21,15 +21,15 @@ const app = new Elysia()
.get('/user/:id', async ({ params }) => { .get('/user/:id', async ({ params }) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: params.id id: params.id,
} },
}); })
return user; return user
}); })
app.listen(3001, () => { app.listen(3001, () => {
console.log('Server running on port 3001'); console.log('Server running on port 3001')
}); })
export default app; export default app
export type App = typeof app; export type App = typeof app

View File

@@ -1,12 +1,12 @@
import { Elysia, t } from 'elysia'; import { Elysia, t } from 'elysia'
import { prisma } from './prisma'; import { prisma } from './prisma'
export const posts = new Elysia({ prefix: '/posts' }) export const posts = new Elysia({ prefix: '/posts' })
.get('/', async () => { .get('/', async () => {
return await prisma.post.findMany({ return await prisma.post.findMany({
include: { user: true }, include: { user: true },
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' },
}); })
}) })
.get('/:id', async ({ params: { id } }) => { .get('/:id', async ({ params: { id } }) => {
return await prisma.post.findUnique({ return await prisma.post.findUnique({
@@ -15,24 +15,28 @@ export const posts = new Elysia({ prefix: '/posts' })
user: true, user: true,
comments: { comments: {
include: { include: {
user: true user: true,
}, },
orderBy: { orderBy: {
createdAt: 'desc' createdAt: 'desc',
} },
} },
} },
});
})
.post('/', async ({ body }) => {
return await prisma.post.create({
data: body
});
}, {
body: t.Object({
title: t.String(),
content: t.String(),
userId: t.String(),
published: t.Optional(t.Boolean())
}) })
}); })
.post(
'/',
async ({ body }) => {
return await prisma.post.create({
data: body,
})
},
{
body: t.Object({
title: t.String(),
content: t.String(),
userId: t.String(),
published: t.Optional(t.Boolean()),
}),
},
)

View File

@@ -1,3 +1,12 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from "./generated/prisma/client";
import { PrismaLibSql } from "@prisma/adapter-libsql";
import { createClient } from "@libsql/client";
export const prisma = new PrismaClient(); const libsql = createClient({
url: process.env.DATABASE_URL || "file:./prisma/dev.db",
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adapter = new PrismaLibSql(libsql as any);
export const prisma = new PrismaClient({ adapter });

View File

@@ -1,41 +1,49 @@
import { Elysia, t } from 'elysia'; import { Elysia, t } from 'elysia'
import { prisma } from './prisma'; import { prisma } from './prisma'
export const resources = new Elysia({ prefix: '/resources' }) export const resources = new Elysia({ prefix: '/resources' })
.get('/', async () => { .get('/', async () => {
return await prisma.resource.findMany({ return await prisma.resource.findMany({
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' },
});
})
.post('/', async ({ body }) => {
return await prisma.resource.create({
data: body
});
}, {
body: t.Object({
title: t.String(),
description: t.Optional(t.String()),
url: t.String(),
icon: t.Optional(t.String()),
category: t.Optional(t.String())
})
})
.put('/:id', async ({ params: { id }, body }) => {
return await prisma.resource.update({
where: { id },
data: body
});
}, {
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
url: t.Optional(t.String()),
icon: t.Optional(t.String()),
category: t.Optional(t.String())
}) })
}) })
.post(
'/',
async ({ body }) => {
return await prisma.resource.create({
data: body,
})
},
{
body: t.Object({
title: t.String(),
description: t.Optional(t.String()),
url: t.String(),
icon: t.Optional(t.String()),
category: t.Optional(t.String()),
}),
},
)
.put(
'/:id',
async ({ params: { id }, body }) => {
return await prisma.resource.update({
where: { id },
data: body,
})
},
{
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
url: t.Optional(t.String()),
icon: t.Optional(t.String()),
category: t.Optional(t.String()),
}),
},
)
.delete('/:id', async ({ params: { id } }) => { .delete('/:id', async ({ params: { id } }) => {
return await prisma.resource.delete({ return await prisma.resource.delete({
where: { id } where: { id },
}); })
}); })

8
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["bun"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

833
bun.lock

File diff suppressed because it is too large Load Diff

54
eslint.config.js Normal file
View File

@@ -0,0 +1,54 @@
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 react from 'eslint-plugin-react'
import prettier from 'eslint-config-prettier'
export default tseslint.config(
{
ignores: [
'dist',
'node_modules',
'.cursor',
'.trae',
'backend/prisma/migrations',
],
},
{
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
...globals.es2020,
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
react,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'error',
},
},
)

View File

@@ -15,10 +15,28 @@
"build:web": "bun run --filter=web build", "build:web": "bun run --filter=web build",
"build:backend": "bun run --filter=backend build", "build:backend": "bun run --filter=backend build",
"build:seq": "bun run build:backend && bun run build:web", "build:seq": "bun run build:backend && bun run build:web",
"lint": "concurrently -n 'backend,web' 'cd backend && bun run typecheck' 'cd web && bun run lint'" "lint": "concurrently -n 'backend,web' 'cd backend && bun run lint' 'cd web && bun run lint'",
"lint:fix": "concurrently -n 'backend,web' 'cd backend && bun run lint:fix' 'cd web && bun run lint:fix'",
"format": "prettier --write .",
"typecheck": "concurrently -n 'backend,web' 'cd backend && bun run typecheck' 'cd web && bun run typecheck'"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "^1.3.10",
"@types/node": "^25.4.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"bun-plugin-tailwind": "^0.1.2",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"typescript": "~5.9.3" "eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0"
} }
} }

1
temp.md Normal file
View File

@@ -0,0 +1 @@
https://v3.heroui.com/docs/react/components

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true
},
"include": ["web/src", "backend/src"],
"exclude": ["node_modules", "dist", "web/dist", "backend/dist"]
}

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) { import plugin from 'bun-plugin-tailwind'
import { existsSync } from 'fs'
import { rm } from 'fs/promises'
import path from 'path'
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(` console.log(`
🏗️ Bun Build Script 🏗️ Bun Build Script
@@ -29,121 +30,129 @@ Common Options:
Example: Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`); `)
process.exit(0); process.exit(0)
} }
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase()); const toCamelCase = (str: string): string =>
str.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
const parseValue = (value: string): any => { const parseValue = (value: string): string | number | boolean | string[] => {
if (value === "true") return true; if (value === 'true') return true
if (value === "false") return false; if (value === 'false') return false
if (/^\d+$/.test(value)) return parseInt(value, 10); if (/^\d+$/.test(value)) return parseInt(value, 10)
if (/^\d*\.\d+$/.test(value)) return parseFloat(value); if (/^\d*\.\d+$/.test(value)) return parseFloat(value)
if (value.includes(",")) return value.split(",").map(v => v.trim()); if (value.includes(',')) return value.split(',').map((v) => v.trim())
return value; return value
}; }
function parseArgs(): Partial<Bun.BuildConfig> { function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {}; const config: Record<string, unknown> = {}
const args = process.argv.slice(2); const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i]
if (arg === undefined) continue; if (arg === undefined) continue
if (!arg.startsWith("--")) continue; if (!arg.startsWith('--')) continue
if (arg.startsWith("--no-")) { if (arg.startsWith('--no-')) {
const key = toCamelCase(arg.slice(5)); const key = toCamelCase(arg.slice(5))
config[key] = false; config[key] = false
continue; continue
} }
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) { if (
const key = toCamelCase(arg.slice(2)); !arg.includes('=') &&
config[key] = true; (i === args.length - 1 || args[i + 1]?.startsWith('--'))
continue; ) {
const key = toCamelCase(arg.slice(2))
config[key] = true
continue
} }
let key: string; let key: string
let value: string; let value: string
if (arg.includes("=")) { if (arg.includes('=')) {
[key, value] = arg.slice(2).split("=", 2) as [string, string]; ;[key, value] = arg.slice(2).split('=', 2) as [string, string]
} else { } else {
key = arg.slice(2); key = arg.slice(2)
value = args[++i] ?? ""; value = args[++i] ?? ''
} }
key = toCamelCase(key); key = toCamelCase(key)
if (key.includes(".")) { if (key.includes('.')) {
const [parentKey, childKey] = key.split("."); const [parentKey, childKey] = key.split('.')
config[parentKey] = config[parentKey] || {}; if (parentKey && childKey) {
config[parentKey][childKey] = parseValue(value); config[parentKey] = config[parentKey] || {}
config[parentKey][childKey] = parseValue(value)
}
} else { } else {
config[key] = parseValue(value); config[key] = parseValue(value)
} }
} }
return config; return config as Partial<Bun.BuildConfig>
} }
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"]; const units = ['B', 'KB', 'MB', 'GB']
let size = bytes; let size = bytes
let unitIndex = 0; let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024; size /= 1024
unitIndex++; unitIndex++
} }
return `${size.toFixed(2)} ${units[unitIndex]}`; return `${size.toFixed(2)} ${units[unitIndex]}`
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
} }
const start = performance.now(); console.log('\n🚀 Starting build process...\n')
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")] const cliConfig = parseArgs()
.map(a => path.resolve("src", a)) const outdir = cliConfig.outdir || path.join(process.cwd(), 'dist')
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`); if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`)
await rm(outdir, { recursive: true, force: true })
}
const start = performance.now()
const entrypoints = [...new Bun.Glob('**.html').scanSync('src')]
.map((a) => path.resolve('src', a))
.filter((dir) => !dir.includes('node_modules'))
console.log(
`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`,
)
const result = await Bun.build({ const result = await Bun.build({
entrypoints, entrypoints,
outdir, outdir,
plugins: [plugin], plugins: [plugin],
minify: true, minify: true,
target: "browser", target: 'browser',
sourcemap: "linked", sourcemap: 'linked',
define: { define: {
"process.env.NODE_ENV": JSON.stringify("production"), 'process.env.NODE_ENV': JSON.stringify('production'),
}, },
...cliConfig, ...cliConfig,
}); })
const end = performance.now(); const end = performance.now()
const outputTable = result.outputs.map(output => ({ const outputTable = result.outputs.map((output) => ({
File: path.relative(process.cwd(), output.path), File: path.relative(process.cwd(), output.path),
Type: output.kind, Type: output.kind,
Size: formatFileSize(output.size), Size: formatFileSize(output.size),
})); }))
console.table(outputTable); console.table(outputTable)
const buildTime = (end - start).toFixed(2); const buildTime = (end - start).toFixed(2)
console.log(`\n✅ Build completed in ${buildTime}ms\n`); console.log(`\n✅ Build completed in ${buildTime}ms\n`)

12
web/bun-env.d.ts vendored
View File

@@ -1,17 +1,17 @@
// Generated by `bun init` // Generated by `bun init`
declare module "*.svg" { declare module '*.svg' {
/** /**
* A path to the SVG file * A path to the SVG file
*/ */
const path: `${string}.svg`; const path: `${string}.svg`
export = path; export = path
} }
declare module "*.module.css" { declare module '*.module.css' {
/** /**
* A record of class names to their corresponding CSS module classes * A record of class names to their corresponding CSS module classes
*/ */
const classes: { readonly [key: string]: string }; const classes: { readonly [key: string]: string }
export = classes; export = classes
} }

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HLAE中文站</title> <title>HLAE中文站</title>
<script> <script>
const theme = localStorage.getItem('theme') || 'light'; const theme = localStorage.getItem('theme') || 'light'
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme)
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme)
</script> </script>
</head> </head>
<body> <body>

View File

@@ -7,6 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -28,10 +30,11 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"backend": "workspace:*",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"vite": "^5.0.0", "@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^4.0.0" "backend": "workspace:*",
"eslint": "^10.0.3",
"vite": "^5.0.0"
} }
} }

View File

@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; }

View File

@@ -1,7 +1,5 @@
import { edenTreaty } from '@elysiajs/eden'; import { edenTreaty } from '@elysiajs/eden'
import type { App } from 'backend'; import type { App } from 'backend'
// 创建 Eden 客户端,自动推断类型 // 创建 Eden 客户端,自动推断类型
export const api = edenTreaty<App>('http://localhost:3001'); export const api = edenTreaty<App>('http://localhost:3001')

View File

@@ -1,14 +1,16 @@
import { Button, Card } from "@heroui/react"; import { Button, Card } from '@heroui/react'
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom'
export default function AboutPage() { export default function AboutPage() {
const navigate = useNavigate(); const navigate = useNavigate()
return ( return (
<div className="container mx-auto px-6 py-12 max-w-4xl"> <div className="container mx-auto max-w-4xl px-6 py-12">
<Card> <Card>
<Card.Header> <Card.Header>
<Card.Title className="text-3xl font-bold"> HLAE </Card.Title> <Card.Title className="text-3xl font-bold">
HLAE
</Card.Title>
</Card.Header> </Card.Header>
<Card.Content className="prose dark:prose-invert"> <Card.Content className="prose dark:prose-invert">
<p className="text-default-500 mb-6 leading-relaxed"> <p className="text-default-500 mb-6 leading-relaxed">
@@ -17,11 +19,9 @@ export default function AboutPage() {
</p> </p>
</Card.Content> </Card.Content>
<Card.Footer> <Card.Footer>
<Button onPress={() => navigate("/")}> <Button onPress={() => navigate('/')}></Button>
</Button>
</Card.Footer> </Card.Footer>
</Card> </Card>
</div> </div>
); )
} }

View File

@@ -1,14 +1,16 @@
import { Button, Card } from "@heroui/react"; import { Button, Card } from '@heroui/react'
import { useNavigate } from "react-router-dom"; import { useNavigate } from 'react-router-dom'
export default function DemoPage() { export default function DemoPage() {
const navigate = useNavigate(); const navigate = useNavigate()
return ( return (
<div className="container mx-auto px-6 py-12 max-w-4xl"> <div className="container mx-auto max-w-4xl px-6 py-12">
<Card> <Card>
<Card.Header> <Card.Header>
<Card.Title className="text-3xl font-bold"> (Kill Generation)</Card.Title> <Card.Title className="text-3xl font-bold">
(Kill Generation)
</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<p className="text-default-500 mb-4"> <p className="text-default-500 mb-4">
@@ -16,11 +18,9 @@ export default function DemoPage() {
</p> </p>
</Card.Content> </Card.Content>
<Card.Footer> <Card.Footer>
<Button onPress={() => navigate("/")}> <Button onPress={() => navigate('/')}></Button>
</Button>
</Card.Footer> </Card.Footer>
</Card> </Card>
</div> </div>
); )
} }

View File

@@ -1,12 +1,12 @@
import { useState } from 'react'; import { useState } from 'react'
import { useResources } from '../hooks/useApi'; import { useResources, Resource } from '../hooks/useApi'
import { CustomCard } from '../components/CustomCard'; import { CustomCard } from '../components/CustomCard'
import { Hero } from '../components/Hero'; import { Hero } from '../components/Hero'
import { Button, Link, Chip } from '@heroui/react'; import { Button, Chip } from '@heroui/react'
import { buttonVariants, linkVariants } from '@heroui/styles'; import { buttonVariants } from '@heroui/styles'
import { CreateResourceModal } from '../components/CreateResourceModal'; import { CreateResourceModal } from '../components/CreateResourceModal'
import { EditResourceModal } from '../components/EditResourceModal'; import { EditResourceModal } from '../components/EditResourceModal'
import { useSession } from '../lib/auth-client'; import { useSession } from '../lib/auth-client'
import { import {
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
BookOpenIcon, BookOpenIcon,
@@ -15,9 +15,9 @@ import {
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
ListBulletIcon, ListBulletIcon,
VideoCameraIcon, VideoCameraIcon,
UserGroupIcon UserGroupIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline'
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom'
const PORTALS = [ const PORTALS = [
{ {
@@ -68,14 +68,16 @@ const PORTALS = [
icon: VideoCameraIcon, icon: VideoCameraIcon,
url: 'https://www.hltv.org', url: 'https://www.hltv.org',
}, },
]; ]
export default function HomePage() { export default function HomePage() {
const { resources, isLoading } = useResources(); const { resources, isLoading } = useResources()
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedResource, setSelectedResource] = useState<any>(null); const [selectedResource, setSelectedResource] = useState<Resource | null>(
const { data: session } = useSession(); null,
)
const { data: session } = useSession()
return ( return (
<div className="pb-20"> <div className="pb-20">
@@ -83,23 +85,25 @@ export default function HomePage() {
<Hero /> <Hero />
{/* Portals Section */} {/* Portals Section */}
<section className="mb-20"> <section className="mb-20">
<h2 className="text-3xl font-bold text-center mb-12"></h2> <h2 className="mb-12 text-center text-3xl font-bold"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{PORTALS.map((portal) => { {PORTALS.map((portal) => {
const isExternal = portal.url.startsWith('http'); const isExternal = portal.url.startsWith('http')
const Content = ( const Content = (
<CustomCard className="group cursor-pointer p-6 h-full"> <CustomCard className="group h-full cursor-pointer p-6">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="p-3 rounded-2xl bg-default-100 text-default-600 group-hover:bg-primary-50 group-hover:text-primary transition-colors"> <div className="bg-default-100 text-default-600 group-hover:bg-primary-50 group-hover:text-primary rounded-2xl p-3 transition-colors">
<portal.icon className="w-6 h-6" /> <portal.icon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="text-lg font-bold">{portal.title}</h3> <h3 className="text-lg font-bold">{portal.title}</h3>
<p className="text-sm text-default-500 line-clamp-1">{portal.description}</p> <p className="text-default-500 line-clamp-1 text-sm">
{portal.description}
</p>
</div> </div>
</div> </div>
</CustomCard> </CustomCard>
); )
return isExternal ? ( return isExternal ? (
<a <a
@@ -107,7 +111,7 @@ export default function HomePage() {
href={portal.url} href={portal.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="no-underline text-inherit block" className="block text-inherit no-underline"
> >
{Content} {Content}
</a> </a>
@@ -115,18 +119,18 @@ export default function HomePage() {
<RouterLink <RouterLink
key={portal.title} key={portal.title}
to={portal.url} to={portal.url}
className="no-underline text-inherit block" className="block text-inherit no-underline"
> >
{Content} {Content}
</RouterLink> </RouterLink>
); )
})} })}
</div> </div>
</section> </section>
{/* Resources Section */} {/* Resources Section */}
<section> <section>
<div className="flex justify-center items-center mb-12 relative"> <div className="relative mb-12 flex items-center justify-center">
<h2 className="text-3xl font-bold"></h2> <h2 className="text-3xl font-bold"></h2>
{session && ( {session && (
<Button <Button
@@ -139,7 +143,10 @@ export default function HomePage() {
)} )}
</div> </div>
<CreateResourceModal isOpen={isModalOpen} onOpenChange={setIsModalOpen} /> <CreateResourceModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
/>
<EditResourceModal <EditResourceModal
isOpen={isEditModalOpen} isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen} onOpenChange={setIsEditModalOpen}
@@ -149,42 +156,54 @@ export default function HomePage() {
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-20">...</div> <div className="flex justify-center py-20">...</div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{resources?.map((res) => ( {resources?.map((res) => (
<CustomCard key={res.id} className="p-6"> <CustomCard key={res.id} className="p-6">
<div className="flex gap-4 mb-4"> <div className="mb-4 flex gap-4">
<div className="w-16 h-16 rounded-2xl bg-default-100 overflow-hidden flex-shrink-0 flex items-center justify-center"> <div className="bg-default-100 flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl">
{res.icon ? ( {res.icon ? (
<img <img
alt={res.title} alt={res.title}
src={res.icon} src={res.icon}
className="w-10 h-10 object-contain" className="h-10 w-10 object-contain"
/> />
) : ( ) : (
<div className="text-2xl font-bold text-default-400"> <div className="text-default-400 text-2xl font-bold">
{res.title[0]} {res.title[0]}
</div> </div>
)} )}
</div> </div>
<div className="flex flex-col gap-1 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-lg font-bold truncate">{res.title}</h3> <h3 className="truncate text-lg font-bold">
{res.title}
</h3>
{res.category && ( {res.category && (
<Chip size="sm" variant="soft" className="text-[10px] h-5"> <Chip
size="sm"
variant="soft"
className="h-5 text-[10px]"
>
{res.category} {res.category}
</Chip> </Chip>
)} )}
</div> </div>
<p className="text-sm text-default-500 line-clamp-2">{res.description}</p> <p className="text-default-500 line-clamp-2 text-sm">
{res.description}
</p>
</div> </div>
</div> </div>
<div className="flex gap-3 mt-auto"> <div className="mt-auto flex gap-3">
<a <a
href={res.url} href={res.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={buttonVariants({ fullWidth: true, className: "bg-[#C14B4B] text-white font-medium hover:opacity-90 flex items-center justify-center h-10 px-4 rounded-3xl" })} className={buttonVariants({
fullWidth: true,
className:
'flex h-10 items-center justify-center rounded-3xl bg-[#C14B4B] px-4 font-medium text-white hover:opacity-90',
})}
> >
</a> </a>
@@ -192,7 +211,12 @@ export default function HomePage() {
href={res.url} href={res.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={buttonVariants({ fullWidth: true, variant: "tertiary", className: "bg-default-200 text-default-600 font-medium hover:bg-default-300 flex items-center justify-center h-10 px-4 rounded-3xl" })} className={buttonVariants({
fullWidth: true,
variant: 'tertiary',
className:
'bg-default-200 text-default-600 hover:bg-default-300 flex h-10 items-center justify-center rounded-3xl px-4 font-medium',
})}
> >
</a> </a>
@@ -201,26 +225,26 @@ export default function HomePage() {
isIconOnly isIconOnly
variant="secondary" variant="secondary"
onPress={() => { onPress={() => {
setSelectedResource(res); setSelectedResource(res)
setIsEditModalOpen(true); setIsEditModalOpen(true)
}} }}
className="flex-shrink-0" className="flex-shrink-0"
> >
<WrenchScrewdriverIcon className="w-4 h-4" /> <WrenchScrewdriverIcon className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
</CustomCard> </CustomCard>
))} ))}
{!resources?.length && ( {!resources?.length && (
<div className="col-span-full text-center text-default-400 py-20"> <div className="text-default-400 col-span-full py-20 text-center">
</div> </div>
)} )}
</div> </div>
)} )}
</section> </section>
</main> </main>
</div> </div>
); )
} }

View File

@@ -1,41 +1,56 @@
import { useState } from 'react'; import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link as RouterLink } from 'react-router-dom'
import { Card, Input, Button, Form, toast, TextField, Label, FieldError } from '@heroui/react'; import {
import { signIn } from '../../lib/auth-client'; Card,
Input,
Button,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { signIn } from '../../lib/auth-client'
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('')
const [password, setPassword] = useState(''); const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const navigate = useNavigate(); const navigate = useNavigate()
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => { const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
setLoading(true); setLoading(true)
try { try {
await signIn.email({ await signIn.email(
email, {
password, email,
}, { password,
onSuccess: () => {
navigate('/');
}, },
onError: (ctx) => { {
toast.danger(ctx.error.message); onSuccess: () => {
} navigate('/')
}); },
onError: (ctx) => {
toast.danger(ctx.error.message)
},
},
)
} catch (err) { } catch (err) {
console.error(err); console.error(err)
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
return ( return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] p-4"> <div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<Card.Header> <Card.Header>
<Card.Title className="text-2xl font-bold text-center w-full"> HLAE </Card.Title> <Card.Title className="w-full text-center text-2xl font-bold">
HLAE
</Card.Title>
</Card.Header> </Card.Header>
<Form validationBehavior="native" onSubmit={handleLogin}> <Form validationBehavior="native" onSubmit={handleLogin}>
<Card.Content className="flex flex-col gap-6"> <Card.Content className="flex flex-col gap-6">
@@ -63,21 +78,42 @@ export default function LoginPage() {
<FieldError /> <FieldError />
</TextField> </TextField>
</Card.Content> </Card.Content>
<Card.Footer className="flex flex-col gap-3 mt-2"> <Card.Footer className="mt-2 flex flex-col gap-3">
<Button type="submit" isPending={loading} fullWidth className="font-bold"> <Button
type="submit"
isPending={loading}
fullWidth
className="font-bold"
>
</Button> </Button>
<Button as={Link} to="/register" variant="tertiary" fullWidth className="font-bold"> <RouterLink
to="/register"
className={buttonVariants({
variant: 'tertiary',
fullWidth: true,
className: 'font-bold',
})}
>
</Button> </RouterLink>
<div className="flex justify-center mt-2"> <div className="mt-2 flex justify-center">
<Link to="/" className="text-sm text-default-500 hover:text-primary transition-colors flex items-center gap-1"> <RouterLink
<span> </span> to="/"
</Link> className="text-default-500 hover:text-foreground text-sm"
>
</RouterLink>
</div> </div>
</Card.Footer> </Card.Footer>
</Form> </Form>
</Card> </Card>
<p className="mt-4 text-sm text-gray-500">
{' '}
<RouterLink to="/register" className="text-primary hover:underline">
</RouterLink>
</p>
</div> </div>
); )
} }

View File

@@ -1,57 +1,63 @@
import { useParams, Link as RouterLink } from 'react-router-dom'; import { useParams, Link as RouterLink } from 'react-router-dom'
import { usePost } from '../../../hooks/useApi'; import { usePost, Comment } from '../../../hooks/useApi'
import { Card, Button, Avatar, Separator, Skeleton } from '@heroui/react'; import { Card, Avatar, Separator, Skeleton } from '@heroui/react'
import { buttonVariants } from '@heroui/styles'; import { buttonVariants } from '@heroui/styles'
export default function PostDetailPage() { export default function PostDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>()
const { post, isLoading, isError } = usePost(id || ''); const { post, isLoading, isError } = usePost(id || '')
if (isLoading) { if (isLoading) {
return ( return (
<div className="pb-20"> <div className="pb-20">
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
<Card className="p-6 max-w-4xl mx-auto space-y-4"> <Card className="mx-auto max-w-4xl space-y-4 p-6">
<Skeleton className="h-8 w-3/4 rounded-lg" /> <Skeleton className="h-8 w-3/4 rounded-lg" />
<Skeleton className="h-4 w-1/4 rounded-lg" /> <Skeleton className="h-4 w-1/4 rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" /> <Skeleton className="h-40 w-full rounded-lg" />
</Card> </Card>
</main> </main>
</div> </div>
); )
} }
if (isError || !post) { if (isError || !post) {
return ( return (
<div className="pb-20"> <div className="pb-20">
<main className="container mx-auto px-4 py-8 text-center"> <main className="container mx-auto px-4 py-8 text-center">
<h1 className="text-2xl font-bold mb-4"></h1> <h1 className="mb-4 text-2xl font-bold"></h1>
<RouterLink to="/posts" className={buttonVariants({ variant: "secondary" })}> <RouterLink
to="/posts"
className={buttonVariants({ variant: 'secondary' })}
>
</RouterLink> </RouterLink>
</main> </main>
</div> </div>
); )
} }
return ( return (
<div className="pb-20"> <div className="pb-20">
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto"> <div className="mx-auto max-w-4xl">
<div className="mb-4"> <div className="mb-4">
<RouterLink to="/posts" className="text-default-500 hover:text-foreground"> <RouterLink
to="/posts"
className="text-default-500 hover:text-foreground"
>
&larr; &larr;
</RouterLink> </RouterLink>
</div> </div>
<Card className="p-6 mb-8"> <Card className="mb-8 p-6">
<Card.Header className="flex flex-col items-start gap-2"> <Card.Header className="flex flex-col items-start gap-2">
<h1 className="text-3xl font-bold">{post.title}</h1> <h1 className="text-3xl font-bold">{post.title}</h1>
<div className="flex items-center gap-2 text-small text-default-500"> <div className="text-small text-default-500 flex items-center gap-2">
<Avatar className="w-6 h-6"> <Avatar className="h-6 w-6">
<Avatar.Fallback> <Avatar.Fallback>
{(post.user?.name || 'U').slice(0, 2).toUpperCase()} {(post.user?.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback> </Avatar.Fallback>
</Avatar> </Avatar>
<span>{post.user?.name || '匿名用户'}</span> <span>{post.user?.name || '匿名用户'}</span>
<span></span> <span></span>
@@ -60,7 +66,7 @@ export default function PostDetailPage() {
</Card.Header> </Card.Header>
<Separator className="my-4" /> <Separator className="my-4" />
<Card.Content> <Card.Content>
<div className="whitespace-pre-wrap text-lg leading-relaxed"> <div className="text-lg leading-relaxed whitespace-pre-wrap">
{post.content} {post.content}
</div> </div>
</Card.Content> </Card.Content>
@@ -68,13 +74,19 @@ export default function PostDetailPage() {
{post.comments && post.comments.length > 0 && ( {post.comments && post.comments.length > 0 && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-xl font-bold"> ({post.comments.length})</h3> <h3 className="text-xl font-bold">
{post.comments.map((comment) => ( ({post.comments.length})
</h3>
{post.comments.map((comment: Comment) => (
<Card key={comment.id} className="p-4"> <Card key={comment.id} className="p-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-small text-default-500"> <div className="text-small text-default-500 flex items-center gap-2">
<span className="font-semibold text-foreground">{comment.user?.name || '匿名用户'}</span> <span className="text-foreground font-semibold">
<span>{new Date(comment.createdAt).toLocaleString()}</span> {comment.user?.name || '匿名用户'}
</span>
<span>
{new Date(comment.createdAt).toLocaleString()}
</span>
</div> </div>
<p>{comment.content}</p> <p>{comment.content}</p>
</div> </div>
@@ -85,5 +97,5 @@ export default function PostDetailPage() {
</div> </div>
</main> </main>
</div> </div>
); )
} }

View File

@@ -1,76 +1,98 @@
import { useState } from 'react'; import { useState } from 'react'
import { usePosts } from '../../hooks/useApi'; import { usePosts, Post } from '../../hooks/useApi'
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom'
import { Card, Button, Input, TextArea, Form, Link, toast, TextField, Label, FieldError } from '@heroui/react'; import {
import { useSession } from '../../lib/auth-client'; Card,
import { api } from '../../api/client'; Button,
Input,
TextArea,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { useSession } from '../../lib/auth-client'
import { api } from '../../api/client'
export default function PostsPage() { export default function PostsPage() {
const { posts, isLoading, mutate } = usePosts(); const { posts, isLoading, mutate } = usePosts()
const { data: session } = useSession(); const { data: session } = useSession()
const [title, setTitle] = useState(''); const [title, setTitle] = useState('')
const [content, setContent] = useState(''); const [content, setContent] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
if (!session) return; if (!session) return
setIsSubmitting(true); setIsSubmitting(true)
try { try {
await api.posts.post({ await api.posts.post({
title, title,
content, content,
userId: session.user.id, userId: session.user.id,
published: true published: true,
}); })
setTitle(''); setTitle('')
setContent(''); setContent('')
mutate(); // Refresh list mutate() // Refresh list
} catch (err) { } catch (err) {
console.error(err); console.error(err)
toast.danger('发布失败'); toast.danger('发布失败')
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false)
} }
}; }
return ( return (
<div className="pb-20"> <div className="pb-20">
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col gap-8 md:flex-row">
<div className="flex-1"> <div className="flex-1">
<h2 className="text-2xl font-bold mb-6"></h2> <h2 className="mb-6 text-2xl font-bold"></h2>
{isLoading ? ( {isLoading ? (
<div>...</div> <div>...</div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{posts?.map((post: any) => ( {posts?.map((post: Post) => (
<Card key={post.id} className="p-4"> <Card key={post.id} className="p-4">
<Card.Header> <Card.Header>
<div className="flex flex-col"> <div className="flex flex-col">
<RouterLink to={`/posts/${post.id}`} className="text-md font-bold text-foreground hover:text-primary"> <RouterLink
{post.title} to={`/posts/${post.id}`}
</RouterLink> className="text-md text-foreground hover:text-primary font-bold"
<p className="text-small text-default-500"> >
{post.user?.name || '匿名用户'} - {new Date(post.createdAt).toLocaleDateString()} {post.title}
</p> </RouterLink>
</div> <p className="text-small text-default-500">
{post.user?.name || '匿名用户'} -{' '}
{new Date(post.createdAt).toLocaleDateString()}
</p>
</div>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<p className="whitespace-pre-wrap line-clamp-3">{post.content}</p> <p className="line-clamp-3 whitespace-pre-wrap">
{post.content}
</p>
</Card.Content> </Card.Content>
</Card> </Card>
))} ))}
{!posts?.length && <div className="text-center text-gray-500"></div>} {!posts?.length && (
<div className="text-center text-gray-500"></div>
)}
</div> </div>
)} )}
</div> </div>
{session && ( {session && (
<div className="w-full md:w-80"> <div className="w-full md:w-80">
<Card className="p-4 sticky top-4"> <Card className="sticky top-4 p-4">
<h3 className="text-xl font-bold mb-4"></h3> <h3 className="mb-4 text-xl font-bold"></h3>
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4"> <Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField <TextField
isRequired isRequired
name="title" name="title"
@@ -103,5 +125,5 @@ export default function PostsPage() {
</div> </div>
</main> </main>
</div> </div>
); )
} }

View File

@@ -1,42 +1,57 @@
import { useState } from 'react'; import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link as RouterLink } from 'react-router-dom'
import { Card, Input, Button, Form, toast, TextField, Label, FieldError } from '@heroui/react'; import {
import { signUp } from '../../lib/auth-client'; Card,
Input,
Button,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { signUp } from '../../lib/auth-client'
export default function RegisterPage() { export default function RegisterPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('')
const [password, setPassword] = useState(''); const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const navigate = useNavigate(); const navigate = useNavigate()
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => { const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
setLoading(true); setLoading(true)
try { try {
await signUp.email({ await signUp.email(
email, {
password, email,
name: email.split('@')[0], // Default name password,
}, { name: email.split('@')[0], // Default name
onSuccess: () => {
navigate('/');
}, },
onError: (ctx) => { {
toast.danger(ctx.error.message); onSuccess: () => {
} navigate('/')
}); },
onError: (ctx) => {
toast.danger(ctx.error.message)
},
},
)
} catch (err) { } catch (err) {
console.error(err); console.error(err)
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
return ( return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] p-4"> <div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<Card.Header> <Card.Header>
<Card.Title className="text-2xl font-bold text-center w-full"> HLAE </Card.Title> <Card.Title className="w-full text-center text-2xl font-bold">
HLAE
</Card.Title>
</Card.Header> </Card.Header>
<Form validationBehavior="native" onSubmit={handleRegister}> <Form validationBehavior="native" onSubmit={handleRegister}>
<Card.Content className="flex flex-col gap-6"> <Card.Content className="flex flex-col gap-6">
@@ -64,21 +79,42 @@ export default function RegisterPage() {
<FieldError /> <FieldError />
</TextField> </TextField>
</Card.Content> </Card.Content>
<Card.Footer className="flex flex-col gap-3 mt-2"> <Card.Footer className="mt-2 flex flex-col gap-3">
<Button type="submit" isPending={loading} fullWidth className="font-bold"> <Button
type="submit"
isPending={loading}
fullWidth
className="font-bold"
>
</Button> </Button>
<Button as={Link} to="/login" variant="tertiary" fullWidth className="font-bold"> <RouterLink
to="/login"
className={buttonVariants({
variant: 'tertiary',
fullWidth: true,
className: 'font-bold',
})}
>
</Button> </RouterLink>
<div className="flex justify-center mt-2"> <div className="mt-2 flex justify-center">
<Link to="/" className="text-sm text-default-500 hover:text-primary transition-colors flex items-center gap-1"> <RouterLink
<span> </span> to="/"
</Link> className="text-default-500 hover:text-foreground text-sm"
>
</RouterLink>
</div> </div>
</Card.Footer> </Card.Footer>
</Form> </Form>
</Card> </Card>
<p className="mt-4 text-sm text-gray-500">
{' '}
<RouterLink to="/login" className="text-primary hover:underline">
</RouterLink>
</p>
</div> </div>
); )
} }

View File

@@ -1,42 +1,57 @@
import { useState } from 'react'; import { useState } from 'react'
import { Modal, Button, Input, Form, toast, TextField, Label, FieldError } from "@heroui/react"; import {
import { api } from '../api/client'; Modal,
import { useSWRConfig } from 'swr'; Button,
Input,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { api } from '../api/client'
import { useSWRConfig } from 'swr'
export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean, onOpenChange: (isOpen: boolean) => void }) { export function CreateResourceModal({
const [title, setTitle] = useState(''); isOpen,
const [description, setDescription] = useState(''); onOpenChange,
const [url, setUrl] = useState(''); }: {
const [icon, setIcon] = useState(''); isOpen: boolean
const [category, setCategory] = useState(''); onOpenChange: (isOpen: boolean) => void
const [loading, setLoading] = useState(false); }) {
const { mutate } = useSWRConfig(); const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = useState('')
const [category, setCategory] = useState('')
const [loading, setLoading] = useState(false)
const { mutate } = useSWRConfig()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
setLoading(true); setLoading(true)
try { try {
await api.resources.post({ await api.resources.post({
title, title,
description, description,
url, url,
icon, icon,
category category,
}); })
mutate('/resources'); mutate('/resources')
onOpenChange(false); onOpenChange(false)
setTitle(''); setTitle('')
setDescription(''); setDescription('')
setUrl(''); setUrl('')
setIcon(''); setIcon('')
setCategory(''); setCategory('')
} catch (err) { } catch (err) {
console.error(err); console.error(err)
toast.danger('创建失败'); toast.danger('创建失败')
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -48,7 +63,11 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
<Modal.CloseTrigger /> <Modal.CloseTrigger />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4"> <Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField <TextField
isRequired isRequired
name="title" name="title"
@@ -70,22 +89,13 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
<FieldError /> <FieldError />
</TextField> </TextField>
<TextField <TextField isRequired name="url" value={url} onChange={setUrl}>
isRequired
name="url"
value={url}
onChange={setUrl}
>
<Label></Label> <Label></Label>
<Input placeholder="https://..." variant="secondary" /> <Input placeholder="https://..." variant="secondary" />
<FieldError /> <FieldError />
</TextField> </TextField>
<TextField <TextField name="icon" value={icon} onChange={setIcon}>
name="icon"
value={icon}
onChange={setIcon}
>
<Label> URL</Label> <Label> URL</Label>
<Input placeholder="如: /icon/hlae.png" variant="secondary" /> <Input placeholder="如: /icon/hlae.png" variant="secondary" />
<FieldError /> <FieldError />
@@ -115,5 +125,5 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
); )
} }

View File

@@ -1,33 +1,37 @@
import { Card } from "@heroui/react"; import { Card } from '@heroui/react'
import { tv, type VariantProps } from "tailwind-variants"; import { tv, type VariantProps } from 'tailwind-variants'
import React from "react"; import React from 'react'
// Define custom styles // Define custom styles
export const customStyles = tv({ const customStyles = tv({
variants: { variants: {
variant: { variant: {
glass: "bg-white/70 backdrop-blur-md border-white/20 shadow-xl dark:bg-black/70 dark:border-white/10", glass:
neon: "border border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] bg-gray-900/90 text-white", 'bg-white/70 backdrop-blur-md border-white/20 shadow-xl dark:bg-black/70 dark:border-white/10',
minimal: "border-none shadow-none bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors", neon: 'border border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] bg-gray-900/90 text-white',
modern: "bg-default-50/40 hover:bg-default-100/60 transition-all duration-500 border-none shadow-sm hover:shadow-xl hover:-translate-y-1 rounded-3xl", minimal:
'border-none shadow-none bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors',
modern:
'bg-default-50/40 hover:bg-default-100/60 transition-all duration-500 border-none shadow-sm hover:shadow-xl hover:-translate-y-1 rounded-3xl',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "modern", variant: 'modern',
} },
}); })
type CustomCardProps = React.ComponentProps<typeof Card> & VariantProps<typeof customStyles>; type CustomCardProps = React.ComponentProps<typeof Card> &
VariantProps<typeof customStyles>
// Create the custom component // Create the custom component
export function CustomCard({ className, variant, ...props }: CustomCardProps) { export function CustomCard({ className, variant, ...props }: CustomCardProps) {
// Use custom styles and merge with className // Use custom styles and merge with className
return <Card className={customStyles({ variant, className })} {...props} />; return <Card className={customStyles({ variant, className })} {...props} />
} }
// Attach subcomponents to the custom component for easier usage // Attach subcomponents to the custom component for easier usage
CustomCard.Header = Card.Header; CustomCard.Header = Card.Header
CustomCard.Content = Card.Content; CustomCard.Content = Card.Content
CustomCard.Footer = Card.Footer; CustomCard.Footer = Card.Footer
CustomCard.Title = Card.Title; CustomCard.Title = Card.Title
CustomCard.Description = Card.Description; CustomCard.Description = Card.Description

View File

@@ -1,72 +1,83 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react'
import { Modal, Button, Input, Form, toast, TextField, Label, FieldError } from "@heroui/react"; import {
import { api } from '../api/client'; Modal,
import { useSWRConfig } from 'swr'; Button,
import { Resource } from '../hooks/useApi'; Input,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { api } from '../api/client'
import { useSWRConfig } from 'swr'
import { Resource } from '../hooks/useApi'
export function EditResourceModal({ export function EditResourceModal({
isOpen, isOpen,
onOpenChange, onOpenChange,
resource resource,
}: { }: {
isOpen: boolean, isOpen: boolean
onOpenChange: (isOpen: boolean) => void, onOpenChange: (isOpen: boolean) => void
resource: Resource | null resource: Resource | null
}) { }) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('')
const [description, setDescription] = useState(''); const [description, setDescription] = useState('')
const [url, setUrl] = useState(''); const [url, setUrl] = useState('')
const [icon, setIcon] = useState(''); const [icon, setIcon] = useState('')
const [category, setCategory] = useState(''); const [category, setCategory] = useState('')
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig()
useEffect(() => { useEffect(() => {
if (resource) { if (resource) {
setTitle(resource.title); setTitle(resource.title)
setDescription(resource.description || ''); setDescription(resource.description || '')
setUrl(resource.url); setUrl(resource.url)
setIcon(resource.icon || ''); setIcon(resource.icon || '')
setCategory(resource.category || ''); setCategory(resource.category || '')
} }
}, [resource]); }, [resource])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
if (!resource) return; if (!resource) return
setLoading(true); setLoading(true)
try { try {
await api.resources({ id: resource.id }).put({ // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (api.resources as any)[resource.id].put({
title, title,
description, description,
url, url,
icon, icon,
category category,
}); })
mutate('/resources'); mutate('/resources')
onOpenChange(false); onOpenChange(false)
} catch (err) { } catch (err) {
console.error(err); console.error(err)
toast.danger('更新失败'); toast.danger('更新失败')
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
const handleDelete = async () => { const handleDelete = async () => {
if (!resource || !confirm('确定要删除这个资源吗?')) return; if (!resource || !confirm('确定要删除这个资源吗?')) return
setLoading(true); setLoading(true)
try { try {
await api.resources({ id: resource.id }).delete(); // eslint-disable-next-line @typescript-eslint/no-explicit-any
mutate('/resources'); await (api.resources as any)[resource.id].delete()
onOpenChange(false); mutate('/resources')
onOpenChange(false)
} catch (err) { } catch (err) {
console.error(err); console.error(err)
toast.danger('删除失败'); toast.danger('删除失败')
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -78,7 +89,11 @@ export function EditResourceModal({
<Modal.CloseTrigger /> <Modal.CloseTrigger />
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4"> <Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField <TextField
isRequired isRequired
name="title" name="title"
@@ -100,22 +115,13 @@ export function EditResourceModal({
<FieldError /> <FieldError />
</TextField> </TextField>
<TextField <TextField isRequired name="url" value={url} onChange={setUrl}>
isRequired
name="url"
value={url}
onChange={setUrl}
>
<Label></Label> <Label></Label>
<Input placeholder="https://..." variant="secondary" /> <Input placeholder="https://..." variant="secondary" />
<FieldError /> <FieldError />
</TextField> </TextField>
<TextField <TextField name="icon" value={icon} onChange={setIcon}>
name="icon"
value={icon}
onChange={setIcon}
>
<Label> URL</Label> <Label> URL</Label>
<Input placeholder="如: /icon/hlae.png" variant="secondary" /> <Input placeholder="如: /icon/hlae.png" variant="secondary" />
<FieldError /> <FieldError />
@@ -131,8 +137,12 @@ export function EditResourceModal({
<FieldError /> <FieldError />
</TextField> </TextField>
<div className="flex justify-between gap-2 mt-4"> <div className="mt-4 flex justify-between gap-2">
<Button variant="danger" onPress={handleDelete} isPending={loading}> <Button
variant="danger"
onPress={handleDelete}
isPending={loading}
>
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -150,5 +160,5 @@ export function EditResourceModal({
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
); )
} }

View File

@@ -1,51 +1,54 @@
import { Button } from '@heroui/react'; import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'; import { buttonVariants } from '@heroui/styles'
export const Hero = () => { export const Hero = () => {
return ( return (
<section className="flex flex-col items-center justify-center py-20 text-center"> <section className="flex flex-col items-center justify-center py-20 text-center">
<h1 className="text-6xl font-extrabold tracking-tight mb-8"> <h1 className="mb-8 text-6xl font-extrabold tracking-tight">
HLAE中文站 HLAE中文站
</h1> </h1>
<p className="text-2xl text-default-600 mb-12 max-w-3xl leading-relaxed"> <p className="text-default-600 mb-12 max-w-3xl text-2xl leading-relaxed">
<span className="text-[#FF6B00] font-bold">CS</span> <span className="font-bold text-[#FF6B00]">CS</span>
<span className="text-brand font-bold mx-1">HLAE</span>
<span className="text-brand mx-1 font-bold">HLAE</span>
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
<Button <a
as="a"
href="https://www.advancedfx.org/" href="https://www.advancedfx.org/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-brand text-white font-medium px-10 h-14 rounded-full text-lg shadow-lg shadow-brand/20 hover:opacity-90 transition-opacity" className={buttonVariants({
startContent={<WrenchScrewdriverIcon className="w-6 h-6" />} className:
'bg-brand shadow-brand/20 flex h-14 items-center gap-2 rounded-full px-10 text-lg font-medium text-white shadow-lg transition-opacity hover:opacity-90',
})}
> >
<WrenchScrewdriverIcon className="h-6 w-6" />
</Button> </a>
<Button <a
as="a"
href="https://github.com/purp1e/hlae-site" href="https://github.com/purp1e/hlae-site"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="bg-default-100 text-default-900 font-medium px-10 h-14 rounded-full text-lg hover:bg-default-200 transition-colors" className={buttonVariants({
startContent={ className:
<svg 'bg-default-100 text-default-900 hover:bg-default-200 flex h-14 items-center gap-2 rounded-full px-10 text-lg font-medium transition-colors',
className="w-6 h-6" })}
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
}
> >
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</Button> </a>
</div> </div>
</section> </section>
); )
}; }

View File

@@ -1,51 +1,63 @@
import { import { Button, Avatar, Dropdown, Label } from '@heroui/react'
Link, import { buttonVariants } from '@heroui/styles'
Button, import { useEffect, useState } from 'react'
Avatar, import { useSession, signOut } from '../lib/auth-client'
Dropdown, import { SunIcon, MoonIcon } from '@heroicons/react/24/outline'
Label import { Link as RouterLink } from 'react-router-dom'
} from "@heroui/react";
import { linkVariants, buttonVariants } from "@heroui/styles";
import { useEffect, useState } from "react";
import { useSession, signOut } from "../lib/auth-client";
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
import { Link as RouterLink } from "react-router-dom";
export function SiteNavbar() { export function SiteNavbar() {
const { data: session } = useSession(); const { data: session } = useSession()
const [theme, setTheme] = useState<"light" | "dark">("light"); const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('dark')
? 'dark'
: 'light'
}
return 'light'
})
useEffect(() => { useEffect(() => {
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light"; // Sync theme on mount if needed, but the state is already initialized
setTheme(currentTheme); }, [])
}, []);
const toggleTheme = () => { const toggleTheme = () => {
const nextTheme = theme === "dark" ? "light" : "dark"; const nextTheme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(nextTheme); document.documentElement.classList.add(nextTheme)
document.documentElement.setAttribute("data-theme", nextTheme); document.documentElement.setAttribute('data-theme', nextTheme)
localStorage.setItem("theme", nextTheme); localStorage.setItem('theme', nextTheme)
setTheme(nextTheme); setTheme(nextTheme)
}; }
return ( return (
<nav className="flex items-center justify-between px-8 py-4 bg-background/70 backdrop-blur-md border-b border-default-100 sticky top-0 z-50 w-full"> <nav className="bg-background/70 border-default-100 sticky top-0 z-50 flex w-full items-center justify-between border-b px-8 py-4 backdrop-blur-md">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<RouterLink to="/" className="font-bold text-inherit text-xl no-underline text-foreground"> <RouterLink
to="/"
className="text-foreground text-xl font-bold text-inherit no-underline"
>
HLAE中文站 HLAE中文站
</RouterLink> </RouterLink>
</div> </div>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<div className="hidden sm:flex gap-8 items-center"> <div className="hidden items-center gap-8 sm:flex">
<RouterLink to="/" className="text-foreground/80 hover:text-foreground font-medium transition-colors"> <RouterLink
to="/"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink> </RouterLink>
<RouterLink to="/demo" className="text-foreground/80 hover:text-foreground font-medium transition-colors"> <RouterLink
to="/demo"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink> </RouterLink>
<RouterLink to="/about" className="text-foreground/80 hover:text-foreground font-medium transition-colors"> <RouterLink
to="/about"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink> </RouterLink>
</div> </div>
@@ -53,14 +65,14 @@ export function SiteNavbar() {
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
isIconOnly isIconOnly
variant="light" variant="ghost"
onPress={toggleTheme} onPress={toggleTheme}
className="text-foreground/80" className="text-foreground/80"
> >
{theme === "dark" ? ( {theme === 'dark' ? (
<SunIcon className="w-5 h-5" /> <SunIcon className="h-5 w-5" />
) : ( ) : (
<MoonIcon className="w-5 h-5" /> <MoonIcon className="h-5 w-5" />
)} )}
</Button> </Button>
@@ -70,11 +82,11 @@ export function SiteNavbar() {
<Button isIconOnly variant="tertiary" className="rounded-full"> <Button isIconOnly variant="tertiary" className="rounded-full">
<Avatar size="sm" color="accent"> <Avatar size="sm" color="accent">
<Avatar.Image <Avatar.Image
alt={session.user.name || "用户头像"} alt={session.user.name || '用户头像'}
src={session.user.image || ""} src={session.user.image || ''}
/> />
<Avatar.Fallback> <Avatar.Fallback>
{(session.user.name || "U").slice(0, 2).toUpperCase()} {(session.user.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback> </Avatar.Fallback>
</Avatar> </Avatar>
</Button> </Button>
@@ -82,17 +94,30 @@ export function SiteNavbar() {
<Dropdown.Popover> <Dropdown.Popover>
<Dropdown.Menu aria-label="Profile Actions"> <Dropdown.Menu aria-label="Profile Actions">
<Dropdown.Item id="profile" textValue="Profile"> <Dropdown.Item id="profile" textValue="Profile">
<Label className="font-semibold"> {session.user.email}</Label> <Label className="font-semibold">
{session.user.email}
</Label>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item id="logout" textValue="Logout" onPress={() => signOut()}> <Dropdown.Item
id="logout"
textValue="Logout"
variant="danger"
onPress={() => signOut()}
>
<Label className="text-danger">退</Label> <Label className="text-danger">退</Label>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown.Popover> </Dropdown.Popover>
</Dropdown> </Dropdown>
) : ( ) : (
<div className="flex gap-4 items-center"> <div className="flex items-center gap-4">
<RouterLink to="/login" className={buttonVariants({ variant: "light", className: "text-foreground font-medium" })}> <RouterLink
to="/login"
className={buttonVariants({
variant: 'ghost',
className: 'text-foreground font-medium',
})}
>
</RouterLink> </RouterLink>
</div> </div>
@@ -100,5 +125,5 @@ export function SiteNavbar() {
</div> </div>
</div> </div>
</nav> </nav>
); )
} }

View File

@@ -1,9 +1,9 @@
@import "tailwindcss"; @import 'tailwindcss';
@import "@heroui/styles"; @import '@heroui/styles';
@theme { @theme {
--color-brand: #C14B4B; --color-brand: #c14b4b;
--color-brand-foreground: #FFFFFF; --color-brand-foreground: #ffffff;
--radius-2xl: 1.25rem; --radius-2xl: 1.25rem;
--radius-3xl: 1.5rem; --radius-3xl: 1.5rem;
@@ -22,6 +22,6 @@
} }
body { body {
@apply bg-background text-foreground antialiased selection:bg-brand/20 selection:text-brand; @apply bg-background text-foreground selection:bg-brand/20 selection:text-brand antialiased;
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
} }

View File

@@ -1,47 +1,89 @@
import useSWR from 'swr'; import useSWR from 'swr'
import { api } from '../api/client'; import { api } from '../api/client'
export interface Resource {
id: string
title: string
description: string | null
url: string
icon: string | null
category: string | null
createdAt: string | Date
updatedAt: string | Date
}
export interface Comment {
id: string
content: string
userId: string
postId: string
createdAt: string | Date
updatedAt: string | Date
user?: {
name: string | null
image?: string | null
}
}
export interface Post {
id: string
title: string
content: string
userId: string
createdAt: string | Date
updatedAt: string | Date
user?: {
name: string | null
image?: string | null
}
comments?: Comment[]
}
export function useResources() { export function useResources() {
const { data, error, isLoading, mutate } = useSWR('/resources', async () => { const { data, error, isLoading, mutate } = useSWR('/resources', async () => {
const { data, error } = await api.resources.get(); const { data, error } = await api.resources.get()
if (error) throw error; if (error) throw error
return data; return data
}); })
return { return {
resources: data, resources: data,
isLoading, isLoading,
isError: error, isError: error,
mutate mutate,
}; }
} }
export function usePosts() { export function usePosts() {
const { data, error, isLoading, mutate } = useSWR('/posts', async () => { const { data, error, isLoading, mutate } = useSWR('/posts', async () => {
const { data, error } = await api.posts.get(); const { data, error } = await api.posts.get()
if (error) throw error; if (error) throw error
return data; return data
}); })
return { return {
posts: data, posts: data,
isLoading, isLoading,
isError: error, isError: error,
mutate mutate,
}; }
} }
export function usePost(id: string) { export function usePost(id: string) {
const { data, error, isLoading, mutate } = useSWR(id ? `/posts/${id}` : null, async () => { const { data, error, isLoading, mutate } = useSWR(
const { data, error } = await api.posts({ id }).get(); id ? `/posts/${id}` : null,
if (error) throw error; async () => {
return data; // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); const { data, error } = await (api.posts as any)[id].get()
if (error) throw error
return data
},
)
return { return {
post: data, post: data,
isLoading, isLoading,
isError: error, isError: error,
mutate mutate,
}; }
} }

View File

@@ -1,17 +1,17 @@
import { Toast } from "@heroui/react"; import { Toast } from '@heroui/react'
import "./globals.css"; import './globals.css'
import { SiteNavbar } from "./components/SiteNavbar"; import { SiteNavbar } from './components/SiteNavbar'
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode
}) { }) {
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="bg-background text-foreground min-h-screen">
<Toast.Provider /> <Toast.Provider />
<SiteNavbar /> <SiteNavbar />
<main className="min-h-screen h-full">{children}</main> <main className="h-full min-h-screen">{children}</main>
</div> </div>
); )
} }

View File

@@ -1,7 +1,7 @@
import { createAuthClient } from 'better-auth/react'; import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL, baseURL: import.meta.env.VITE_API_URL,
}); })
export const { useSession, signIn, signUp, signOut } = authClient; export const { useSession, signIn, signUp, signOut } = authClient

View File

@@ -1,8 +1,3 @@
export function Providers( { children }: { children: React.ReactNode } ) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return <>{children}</>
<>
{children}
</>
);
} }

View File

@@ -1,29 +1,32 @@
import { StrictMode, Suspense, useEffect } from "react"; import { StrictMode, Suspense, useEffect } from 'react'
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client'
import { BrowserRouter, useRoutes } from "react-router-dom"; import { BrowserRouter, useRoutes } from 'react-router-dom'
import { Providers } from "./providers"; import { Providers } from './providers'
import RootLayout from "./layout"; import RootLayout from './layout'
import routes from "~react-pages"; import routes from '~react-pages'
function App() { export function App() {
useEffect(() => { useEffect(() => {
const storedTheme = localStorage.getItem("theme"); const storedTheme = localStorage.getItem('theme')
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const systemPrefersDark = window.matchMedia(
const theme = storedTheme === "dark" || storedTheme === "light" '(prefers-color-scheme: dark)',
? storedTheme ).matches
: systemPrefersDark const theme =
? "dark" storedTheme === 'dark' || storedTheme === 'light'
: "light"; ? storedTheme
: systemPrefersDark
? 'dark'
: 'light'
document.documentElement.classList.remove("light", "dark"); document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme); document.documentElement.classList.add(theme)
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute('data-theme', theme)
}, []); }, [])
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>; return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>
} }
const app = createRoot(document.getElementById("root")!); const app = createRoot(document.getElementById('root')!)
app.render( app.render(
<StrictMode> <StrictMode>
@@ -35,4 +38,4 @@ app.render(
</Providers> </Providers>
</BrowserRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
); )

11
web/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite-env.d.ts", "bun-env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,35 +1,32 @@
import { defineConfig } from "vite"; import { defineConfig } from 'vite'
import path from "path"; import path from 'path'
import react from "@vitejs/plugin-react"; import react from '@vitejs/plugin-react'
import Pages from "vite-plugin-pages"; import Pages from 'vite-plugin-pages'
export default defineConfig({ export default defineConfig({
root: __dirname, root: __dirname,
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "src"), '@': path.resolve(__dirname, 'src'),
}, },
}, },
plugins: [ plugins: [react(), Pages({ dirs: ['src/app'] })],
react(),
Pages({ dirs: ["src/app"] }),
],
build: { build: {
outDir: "dist", outDir: 'dist',
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
// React core // React core
"react-vendor": [ 'react-vendor': [
"react", 'react',
"react-dom", 'react-dom',
"react-router", 'react-router',
"react-router-dom", 'react-router-dom',
], ],
// UI libraries // UI libraries
"ui-vendor": ["@heroui/react"], 'ui-vendor': ['@heroui/react'],
}, },
}, },
}, },
}, },
}); })