feat: 新增门户功能并重构资源展示组件

- 新增门户(Portal)数据模型与后端 API 端点
- 新增个人资料页面,支持用户更新昵称
- 重构前端资源卡片组件,支持显示 GitHub 版本信息与加速下载链接
- 在登录/注册页面添加 GitHub OAuth 支持
- 更新环境变量示例文件,添加前后端配置项
- 优化导航栏响应式设计,添加移动端菜单
- 添加页脚组件,包含备案信息
- 更新 Prisma 数据模型,适配 Better Auth 并添加种子数据
- 统一前后端 API URL 配置,支持环境变量覆盖
This commit is contained in:
2026-03-11 16:50:28 +08:00
parent 44b03672f0
commit 6adadce2d6
25 changed files with 1207 additions and 436 deletions

10
backend/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/hlae_site"
# Better Auth
BETTER_AUTH_SECRET="your-secret-here"
BETTER_AUTH_URL="http://localhost:3001"
# GitHub OAuth (Optional)
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""

View File

@@ -11,49 +11,54 @@ datasource db {
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String
email String? @unique email String @unique
emailVerified DateTime? emailVerified Boolean
phone String? @unique
phoneVerified DateTime?
image String? image String?
createdAt DateTime @default(now()) createdAt DateTime
updatedAt DateTime @updatedAt updatedAt DateTime
accounts Account[]
sessions Session[] sessions Session[]
accounts Account[]
posts Post[] posts Post[]
comments Comment[] comments Comment[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
} }
model Session { model Session {
id String @id @default(cuid()) id String @id @default(cuid())
sessionToken String @unique @map("session_token") expiresAt DateTime
userId String @map("user_id") token String @unique
expires DateTime createdAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade) updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
@@map("sessions") model Account {
id String @id @default(cuid())
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?
} }
model Post { model Post {
@@ -66,21 +71,36 @@ model Post {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comments Comment[] comments Comment[]
@@map("posts")
} }
model Resource { model Resource {
id String @id @default(cuid())
title String
description String?
url String
icon String?
image String?
category String?
githubRepo String?
downloadCdn String?
downloadOriginal String?
version String?
background Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Portal {
id String @id @default(cuid()) id String @id @default(cuid())
title String title String
description String?
url String url String
description String?
icon String? icon String?
category String? background Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("resources")
} }
model Comment { model Comment {
@@ -92,15 +112,4 @@ model Comment {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@map("comments")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verification_tokens")
} }

246
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,246 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
const connectionString = process.env.DATABASE_URL;
const pool = new pg.Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const portalData = [
{
title: '中文论坛',
url: 'https://forum.hlae.site',
description: 'HLAE中文交流社区',
icon: 'pi pi-comments',
background: true,
},
{
title: '官方Wiki',
url: 'https://github.com/advancedfx/advancedfx/wiki',
description: '权威,但是英文 orz',
icon: 'pi pi-book',
background: true,
},
{
title: '新版文档',
url: 'https://doc.hlae.site',
description: '新版 advancedfx 文档,建设中',
icon: 'pi pi-bookmark',
background: true,
},
{
title: '官方Discord',
url: 'https://discord.gg/NGp8qhN',
description: '和开发者近距离交流',
icon: 'pi pi-discord',
background: true,
},
{
title: '问题与建议提交',
url: 'https://github.com/advancedfx/advancedfx/issues',
description: 'tnnd 为什么不更新',
icon: 'pi pi-question-circle',
background: true,
},
{
title: 'HUD生成器',
url: 'https://hud.hlae.site',
description: '击杀信息和准星生成工具',
icon: 'pi pi-wrench',
background: true,
},
{
title: '击杀信息生成',
url: '/hud/deathmsg',
description: 'CS2 · CS 击杀信息生成工具(测试)',
icon: 'pi pi-sliders-h',
background: true,
},
{
title: 'HLTV',
url: 'https://hltv.org/',
description: 'CSGO新闻、数据、录像',
icon: 'pi pi-video',
background: true,
},
]
const resourceData = [
{
title: 'HLAE',
githubRepo: 'advancedfx/advancedfx',
url: 'https://github.com/advancedfx/advancedfx',
description: '起源游戏影片制作工具',
background: false,
downloadCdn: 'https://api.upup.cool/get/hlae',
downloadOriginal: 'https://github.com/advancedfx/advancedfx/releases/latest',
image: '/icon/hlae.png',
},
{
title: 'FFmpeg',
githubRepo: 'GyanD/codexffmpeg',
url: 'https://ffmpeg.org',
description: '免费开源的全能媒体转码工具',
background: false,
downloadCdn: 'https://api.upup.cool/get/ffmpeg',
downloadOriginal: 'https://github.com/GyanD/codexffmpeg/releases/latest',
image: '/icon/ffmpeg.webp',
},
{
title: 'CFG预设 For CS2',
githubRepo: 'Purple-CSGO/CS2-Config-Presets',
url: 'https://cfg.upup.cool/v2',
description: '适用于CS2各场景的Config预设',
background: true,
downloadCdn: 'https://api.upup.cool/get/cs2-cfg',
downloadOriginal: 'https://github.com/Purple-CSGO/CS2-Config-Presets/releases/latest',
icon: 'pi pi-github',
},
{
title: 'CS Demo Manager',
githubRepo: 'akiver/CS-Demo-Manager',
url: 'https://cs-demo-manager.com',
description: 'CS录像分析观看工具',
background: true,
downloadCdn: 'https://api.upup.cool/get/csdm',
downloadOriginal: 'https://github.com/akiver/CS-Demo-Manager/releases/latest',
image: '/icon/csdm.svg',
},
{
title: 'CS工具箱',
githubRepo: 'plsgo/cstb',
url: 'https://cstb.upup.cool',
description: '一个为CS游戏各个方面带来便利的工具集合',
background: true,
downloadCdn: 'https://api.upup.cool/get/cstb',
downloadOriginal: 'https://github.com/plsgo/cstb/releases/latest',
image: '/icon/cstb.png',
},
{
title: 'HLAE Studio',
githubRepo: 'One-Studio/HLAE-Studio',
url: 'https://github.com/One-Studio/HLAE-Studio',
description: 'HLAE环境配置更新工具',
version: 'v1.1.0',
background: true,
downloadCdn: 'https://api.upup.cool/get/hlae-studio',
downloadOriginal: 'https://github.com/One-Studio/HLAE-Studio/releases/latest',
image: '/icon/hlae-studio.png',
},
{
title: 'CFG预设',
githubRepo: 'Purple-CSGO/CSGO-Config-Presets',
url: 'https://cfg.upup.cool',
description: '适用于CSGO各场景的Config预设',
background: true,
downloadCdn: 'https://api.upup.cool/get/csgo-cfg',
downloadOriginal: 'https://github.com/Purple-CSGO/CSGO-Config-Presets/releases/latest',
icon: 'pi pi-github',
},
{
title: 'nskinz',
githubRepo: 'advancedfx/nSkinz',
url: 'https://github.com/advancedfx/nskinz',
description: 'CSGO皮肤修改替换插件 advancedfx维护版本',
background: true,
downloadCdn: 'https://api.upup.cool/repo/advancedfx/nSkinz/&&&&.zip',
downloadOriginal: 'https://github.com/advancedfx/nSkinz/releases/latest',
icon: 'pi pi-github',
},
{
title: 'MIGI',
url: 'https://zoolsmith.github.io/MIGI3',
description: 'CSGO资源修改工具',
background: false,
downloadCdn: 'https://cdn.upup.cool/https://github.com/ZooLSmith/MIGI3/blob/main/migi.exe',
downloadOriginal: 'https://github.com/ZooLSmith/MIGI3/blob/main/migi.exe',
image: '/icon/migi.png',
},
{
title: 'Voukoder',
url: 'https://www.voukoder.org',
description: 'PR、AE、Vegas等软件的编码导出插件',
background: true,
downloadOriginal: 'https://www.voukoder.org/forum/thread/783-downloads-instructions/',
image: '/icon/voukoder.png',
},
{
title: 'ReShade AFX',
githubRepo: 'advancedfx/ReShade_advancedfx',
url: 'https://github.com/advancedfx/ReShade_advancedfx',
description: '连接HLAE录制的ReShade插件',
background: false,
downloadCdn: 'https://api.upup.cool/get/reshade-afx',
downloadOriginal: 'https://github.com/advancedfx/ReShade_advancedfx/releases/latest',
image: '/icon/reshade.png',
},
{
title: 'ReShade',
url: 'https://reshade.me',
description: '重塑你的游戏画面',
background: false,
downloadOriginal: 'https://reshade.me',
image: '/icon/reshade.png',
},
{
title: 'ShanaEncoder',
url: 'https://shana.pe.kr/shanaencoder_summary',
description: '方便好用的转码压制图形工具',
background: true,
downloadOriginal: 'https://shana.pe.kr/shanaencoder_download',
icon: 'pi pi-github',
},
{
title: 'SRadar',
githubRepo: 'zuoshipinSHEKL/SRadar',
url: 'https://sdr.hlae.cn',
description: 'Shekl制作的简易雷达',
background: true,
downloadCdn: 'https://api.upup.cool/get/sradar',
downloadOriginal: 'https://github.com/zuoshipinSHEKL/SRadar/releases/latest',
icon: 'pi pi-map',
},
]
async function main() {
console.log('Start seeding ...')
await prisma.portal.deleteMany()
await prisma.resource.deleteMany()
for (let i = 0; i < portalData.length; i++) {
const portal = portalData[i]
const p = await prisma.portal.create({
data: {
...portal,
order: i,
},
})
console.log(`Created portal with id: ${p.id}`)
}
for (let i = 0; i < resourceData.length; i++) {
const resource = resourceData[i]
const r = await prisma.resource.create({
data: {
...resource,
order: i,
},
})
console.log(`Created resource with id: ${r.id}`)
}
console.log('Seeding finished.')
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

View File

@@ -7,6 +7,8 @@ export const auth = betterAuth({
provider: 'postgresql', provider: 'postgresql',
}), }),
basePath: '/api/auth', basePath: '/api/auth',
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',
trustedOrigins: ['http://localhost:5173'],
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },

View File

@@ -4,6 +4,7 @@ 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'
import { portals } from './portals'
const app = new Elysia() const app = new Elysia()
.use( .use(
@@ -17,6 +18,7 @@ const app = new Elysia()
.mount(auth.handler) .mount(auth.handler)
.get('/health', () => 'OK') .get('/health', () => 'OK')
.use(resources) .use(resources)
.use(portals)
.use(posts) .use(posts)
.get('/user/:id', async ({ params }) => { .get('/user/:id', async ({ params }) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({

49
backend/src/portals.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Elysia, t } from 'elysia'
import { prisma } from './prisma'
export const portals = new Elysia({ prefix: '/portals' })
.get('/', async () => {
return await prisma.portal.findMany({
orderBy: { order: 'asc' },
})
})
.post(
'/',
async ({ body }) => {
return await prisma.portal.create({
data: body,
})
},
{
body: t.Object({
title: t.String(),
url: t.String(),
description: t.Optional(t.String()),
icon: t.Optional(t.String()),
background: t.Optional(t.Boolean()),
}),
},
)
.put(
'/:id',
async ({ params: { id }, body }) => {
return await prisma.portal.update({
where: { id },
data: body,
})
},
{
body: t.Object({
title: t.Optional(t.String()),
url: t.Optional(t.String()),
description: t.Optional(t.String()),
icon: t.Optional(t.String()),
background: t.Optional(t.Boolean()),
}),
},
)
.delete('/:id', async ({ params: { id } }) => {
return await prisma.portal.delete({
where: { id },
})
})

View File

@@ -4,7 +4,7 @@ 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: { order: 'asc' },
}) })
}) })
.post( .post(
@@ -21,6 +21,12 @@ export const resources = new Elysia({ prefix: '/resources' })
url: t.String(), url: t.String(),
icon: t.Optional(t.String()), icon: t.Optional(t.String()),
category: t.Optional(t.String()), category: t.Optional(t.String()),
githubRepo: t.Optional(t.String()),
downloadCdn: t.Optional(t.String()),
downloadOriginal: t.Optional(t.String()),
version: t.Optional(t.String()),
background: t.Optional(t.Boolean()),
image: t.Optional(t.String()),
}), }),
}, },
) )
@@ -39,6 +45,12 @@ export const resources = new Elysia({ prefix: '/resources' })
url: t.Optional(t.String()), url: t.Optional(t.String()),
icon: t.Optional(t.String()), icon: t.Optional(t.String()),
category: t.Optional(t.String()), category: t.Optional(t.String()),
githubRepo: t.Optional(t.String()),
downloadCdn: t.Optional(t.String()),
downloadOriginal: t.Optional(t.String()),
version: t.Optional(t.String()),
background: t.Optional(t.Boolean()),
image: t.Optional(t.String()),
}), }),
}, },
) )

View File

@@ -49,30 +49,34 @@
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@elysiajs/eden": "^1.0.0", "@elysiajs/eden": "^1.4.8",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@heroui/react": "^3.0.0-beta.8", "@heroui/react": "^3.0.0-beta.8",
"@heroui/styles": "^3.0.0-beta.8", "@heroui/styles": "^3.0.0-beta.8",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"ahooks": "^3.9.6",
"better-auth": "^1.5.4", "better-auth": "^1.5.4",
"lucide-react": "^0.577.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"react": "^19", "primeicons": "^7.0.0",
"react-dom": "^19", "react": "^19.2.4",
"react-router": "^7.0.0", "react-dom": "^19.2.4",
"react-router-dom": "^7.0.0", "react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"swr": "^2.4.1", "swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vite-plugin-pages": "^0.32.0", "vite-plugin-pages": "^0.32.5",
"zustand": "^5.0.11", "zustand": "^5.0.11",
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19", "@types/react": "^19.2.14",
"@types/react-dom": "^19", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.7.0",
"backend": "workspace:*", "backend": "workspace:*",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"vite": "^5.0.0", "vite": "^5.4.21",
}, },
}, },
}, },
@@ -111,6 +115,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -693,6 +699,8 @@
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/js-cookie": ["@types/js-cookie@3.0.6", "https://registry.npmmirror.com/@types/js-cookie/-/js-cookie-3.0.6.tgz", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -755,6 +763,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ahooks": ["ahooks@3.9.6", "https://registry.npmmirror.com/ahooks/-/ahooks-3.9.6.tgz", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Mr7f05swd5SmKlR9SZo5U6M0LsL4ErweLzpdgXjA1JPmnZ78Vr6wzx0jUtvoxrcqGKYnX0Yjc02iEASVxHFPjQ=="],
"ajv": ["ajv@6.14.0", "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "ajv": ["ajv@6.14.0", "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -859,6 +869,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"dayjs": ["dayjs@1.11.19", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
@@ -1071,6 +1083,8 @@
"internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], "internal-slot": ["internal-slot@1.1.0", "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"intersection-observer": ["intersection-observer@0.12.2", "https://registry.npmmirror.com/intersection-observer/-/intersection-observer-0.12.2.tgz", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="],
"intl-messageformat": ["intl-messageformat@10.7.18", "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-10.7.18.tgz", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], "intl-messageformat": ["intl-messageformat@10.7.18", "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-10.7.18.tgz", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-array-buffer": ["is-array-buffer@3.0.5", "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@@ -1139,6 +1153,8 @@
"js-base64": ["js-base64@3.7.8", "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], "js-base64": ["js-base64@3.7.8", "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"js-cookie": ["js-cookie@3.0.5", "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.5.tgz", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -1201,6 +1217,8 @@
"lru.min": ["lru.min@1.1.4", "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], "lru.min": ["lru.min@1.1.4", "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
"lucide-react": ["lucide-react@0.577.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.577.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -1325,6 +1343,8 @@
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
"primeicons": ["primeicons@7.0.0", "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz", {}, "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw=="],
"prisma": ["prisma@7.4.2", "https://registry.npmmirror.com/prisma/-/prisma-7.4.2.tgz", { "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", "@prisma/engines": "7.4.2", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA=="], "prisma": ["prisma@7.4.2", "https://registry.npmmirror.com/prisma/-/prisma-7.4.2.tgz", { "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", "@prisma/engines": "7.4.2", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA=="],
"promise-limit": ["promise-limit@2.7.0", "https://registry.npmmirror.com/promise-limit/-/promise-limit-2.7.0.tgz", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], "promise-limit": ["promise-limit@2.7.0", "https://registry.npmmirror.com/promise-limit/-/promise-limit-2.7.0.tgz", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="],
@@ -1351,6 +1371,8 @@
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-fast-compare": ["react-fast-compare@3.2.2", "https://registry.npmmirror.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],
"react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "react-is": ["react-is@16.13.1", "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -1373,6 +1395,8 @@
"require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
"resolve": ["resolve@2.0.0-next.6", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], "resolve": ["resolve@2.0.0-next.6", "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
"retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
@@ -1397,6 +1421,8 @@
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"screenfull": ["screenfull@5.2.0", "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="],
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"seq-queue": ["seq-queue@0.0.5", "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], "seq-queue": ["seq-queue@0.0.5", "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
@@ -1465,7 +1491,7 @@
"swr": ["swr@2.4.1", "https://registry.npmmirror.com/swr/-/swr-2.4.1.tgz", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], "swr": ["swr@2.4.1", "https://registry.npmmirror.com/swr/-/swr-2.4.1.tgz", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.5.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwind-variants": ["tailwind-variants@3.2.2", "https://registry.npmmirror.com/tailwind-variants/-/tailwind-variants-3.2.2.tgz", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], "tailwind-variants": ["tailwind-variants@3.2.2", "https://registry.npmmirror.com/tailwind-variants/-/tailwind-variants-3.2.2.tgz", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
@@ -1575,6 +1601,8 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@heroui/react/tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-7.4.2.tgz", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-7.4.2.tgz", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-7.4.2.tgz", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-7.4.2.tgz", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="],

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL="http://localhost:3001"

View File

@@ -12,29 +12,33 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@elysiajs/eden": "^1.0.0", "@elysiajs/eden": "^1.4.8",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@heroui/react": "^3.0.0-beta.8", "@heroui/react": "^3.0.0-beta.8",
"@heroui/styles": "^3.0.0-beta.8", "@heroui/styles": "^3.0.0-beta.8",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"ahooks": "^3.9.6",
"better-auth": "^1.5.4", "better-auth": "^1.5.4",
"lucide-react": "^0.577.0",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"react": "^19", "primeicons": "^7.0.0",
"react-dom": "^19", "react": "^19.2.4",
"react-router": "^7.0.0", "react-dom": "^19.2.4",
"react-router-dom": "^7.0.0", "react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"swr": "^2.4.1", "swr": "^2.4.1",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vite-plugin-pages": "^0.32.0", "vite-plugin-pages": "^0.32.5",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19", "@types/react": "^19.2.14",
"@types/react-dom": "^19", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.7.0",
"backend": "workspace:*", "backend": "workspace:*",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"vite": "^5.0.0" "vite": "^5.4.21"
} }
} }

View File

@@ -2,4 +2,4 @@ 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>(import.meta.env.VITE_API_URL || 'http://localhost:3001')

View File

@@ -1,77 +1,15 @@
import { useState } from 'react' import { useState } from 'react'
import { useResources, Resource } from '../hooks/useApi' import { Resource, usePortals } from '../hooks/useApi'
import { CustomCard } from '../components/CustomCard'
import { Hero } from '../components/Hero' import { Hero } from '../components/Hero'
import { Button, Chip } from '@heroui/react' import { Button } from '@heroui/react'
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 { Card } from '../components/Card'
ChatBubbleLeftEllipsisIcon, import { ResourceCardList } from '../components/ResourceCardList'
BookOpenIcon,
BookmarkIcon,
QuestionMarkCircleIcon,
WrenchScrewdriverIcon,
ListBulletIcon,
VideoCameraIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline'
import { Link as RouterLink } from 'react-router-dom'
const PORTALS = [
{
title: '中文论坛',
description: 'HLAE中文交流社区',
icon: ChatBubbleLeftEllipsisIcon,
url: 'https://bbs.hlae.site',
},
{
title: '官方Wiki',
description: '权威,但是英文 orz',
icon: BookOpenIcon,
url: 'https://github.com/advancedfx/advancedfx/wiki',
},
{
title: '新版文档',
description: '新版 advancedfx 文档,建设中',
icon: BookmarkIcon,
url: 'https://docs.hlae.site',
},
{
title: '官方Discord',
description: '和开发者近距离交流',
icon: UserGroupIcon,
url: 'https://discord.gg/advancedfx',
},
{
title: '问题与建议提交',
description: 'tnnd 为什么不更新',
icon: QuestionMarkCircleIcon,
url: 'https://github.com/advancedfx/advancedfx/issues',
},
{
title: 'HUD生成器',
description: '击杀信息和准星生成工具',
icon: WrenchScrewdriverIcon,
url: '/hud-generator',
},
{
title: '击杀信息生成',
description: 'CS2 · CS 击杀信息生成工具(测试)',
icon: ListBulletIcon,
url: '/demo',
},
{
title: 'HLTV',
description: 'CSGO新闻、数据、录像',
icon: VideoCameraIcon,
url: 'https://www.hltv.org',
},
]
export default function HomePage() { export default function HomePage() {
const { resources, isLoading } = useResources() const { portals, isLoading: isPortalsLoading } = usePortals()
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const [selectedResource, setSelectedResource] = useState<Resource | null>( const [selectedResource, setSelectedResource] = useState<Resource | null>(
@@ -86,46 +24,22 @@ export default function HomePage() {
{/* Portals Section */} {/* Portals Section */}
<section className="mb-20"> <section className="mb-20">
<h2 className="mb-12 text-center text-3xl font-bold"></h2> <h2 className="mb-12 text-center text-3xl font-bold"></h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> {isPortalsLoading ? (
{PORTALS.map((portal) => { <div className="py-20 text-center">...</div>
const isExternal = portal.url.startsWith('http') ) : (
const Content = ( <ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
<CustomCard className="group h-full cursor-pointer p-6"> {portals?.map((portal) => (
<div className="flex gap-4"> <Card
<div className="bg-default-100 text-default-600 group-hover:bg-primary-50 group-hover:text-primary rounded-2xl p-3 transition-colors"> key={portal.id}
<portal.icon className="h-6 w-6" /> title={portal.title}
</div> url={portal.url}
<div className="flex flex-col gap-1"> description={portal.description}
<h3 className="text-lg font-bold">{portal.title}</h3> icon={portal.icon}
<p className="text-default-500 line-clamp-1 text-sm"> background={portal.background}
{portal.description} />
</p> ))}
</div> </ul>
</div> )}
</CustomCard>
)
return isExternal ? (
<a
key={portal.title}
href={portal.url}
target="_blank"
rel="noopener noreferrer"
className="block text-inherit no-underline"
>
{Content}
</a>
) : (
<RouterLink
key={portal.title}
to={portal.url}
className="block text-inherit no-underline"
>
{Content}
</RouterLink>
)
})}
</div>
</section> </section>
{/* Resources Section */} {/* Resources Section */}
@@ -135,7 +49,7 @@ export default function HomePage() {
{session && ( {session && (
<Button <Button
onPress={() => setIsModalOpen(true)} onPress={() => setIsModalOpen(true)}
variant="tertiary" variant="flat"
className="absolute right-0" className="absolute right-0"
> >
@@ -153,96 +67,12 @@ export default function HomePage() {
resource={selectedResource} resource={selectedResource}
/> />
{isLoading ? ( <ResourceCardList
<div className="flex justify-center py-20">...</div> onEdit={(resource) => {
) : ( setSelectedResource(resource)
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> setIsEditModalOpen(true)
{resources?.map((res) => ( }}
<CustomCard key={res.id} className="p-6"> />
<div className="mb-4 flex gap-4">
<div className="bg-default-100 flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl">
{res.icon ? (
<img
alt={res.title}
src={res.icon}
className="h-10 w-10 object-contain"
/>
) : (
<div className="text-default-400 text-2xl font-bold">
{res.title[0]}
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-lg font-bold">
{res.title}
</h3>
{res.category && (
<Chip
size="sm"
variant="soft"
className="h-5 text-[10px]"
>
{res.category}
</Chip>
)}
</div>
<p className="text-default-500 line-clamp-2 text-sm">
{res.description}
</p>
</div>
</div>
<div className="mt-auto flex gap-3">
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
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
href={res.url}
target="_blank"
rel="noopener noreferrer"
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>
{session && (
<Button
isIconOnly
variant="secondary"
onPress={() => {
setSelectedResource(res)
setIsEditModalOpen(true)
}}
className="flex-shrink-0"
>
<WrenchScrewdriverIcon className="h-4 w-4" />
</Button>
)}
</div>
</CustomCard>
))}
{!resources?.length && (
<div className="text-default-400 col-span-full py-20 text-center">
</div>
)}
</div>
)}
</section> </section>
</main> </main>
</div> </div>

View File

@@ -87,6 +87,30 @@ export default function LoginPage() {
> >
</Button> </Button>
<div className="flex w-full items-center gap-2">
<div className="bg-default-200 h-px flex-1" />
<span className="text-default-500 text-xs"></span>
<div className="bg-default-200 h-px flex-1" />
</div>
<Button
variant="outline"
fullWidth
onPress={async () => {
await signIn.social({
provider: 'github',
callbackURL: window.location.origin,
})
}}
>
<svg
viewBox="0 0 24 24"
className="mr-2 h-5 w-5"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
GitHub
</Button>
<RouterLink <RouterLink
to="/register" to="/register"
className={buttonVariants({ className={buttonVariants({

View File

@@ -0,0 +1,142 @@
import { useSession, signOut, user } from '../../lib/auth-client'
import {
Avatar,
Button,
Card,
Input,
Form,
TextField,
Label,
FieldError,
toast,
} from '@heroui/react'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
export default function ProfilePage() {
const { data: session, isPending } = useSession()
const navigate = useNavigate()
const [name, setName] = useState('')
const [isEditing, setIsEditing] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (session?.user) {
setName(session.user.name || '')
} else if (!isPending && !session) {
navigate('/login')
}
}, [session, isPending, navigate])
if (isPending || !session) {
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center">
...
</div>
)
}
const handleUpdateProfile = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
try {
await user.update({
name,
})
toast.success('个人信息更新成功')
setIsEditing(false)
} catch (err) {
console.error(err)
toast.danger('更新失败')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<Card.Header>
<Card.Title className="text-2xl font-bold"></Card.Title>
</Card.Header>
<Card.Content className="flex flex-col gap-8 md:flex-row">
<div className="flex flex-col items-center gap-4">
<Avatar className="h-32 w-32 text-4xl" color="accent">
<Avatar.Image
src={session.user.image || ''}
alt={session.user.name || 'User'}
/>
<Avatar.Fallback>
{(session.user.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar>
<Button
variant="outline"
color="danger"
onPress={() => signOut()}
fullWidth
>
退
</Button>
</div>
<div className="flex-1 space-y-6">
<Form validationBehavior="native" onSubmit={handleUpdateProfile}>
<div className="space-y-4">
<TextField isReadOnly>
<Label></Label>
<Input value={session.user.email} variant="flat" />
</TextField>
<TextField
name="name"
value={name}
onChange={setName}
isReadOnly={!isEditing}
>
<Label></Label>
<Input variant={isEditing ? 'secondary' : 'flat'} />
<FieldError />
</TextField>
<TextField isReadOnly>
<Label>ID</Label>
<Input value={session.user.id} variant="flat" />
</TextField>
</div>
<div className="mt-6 flex justify-end gap-3">
{isEditing ? (
<>
<Button
type="button"
variant="ghost"
onPress={() => {
setIsEditing(false)
setName(session.user.name || '')
}}
isDisabled={loading}
>
</Button>
<Button type="submit" isPending={loading}>
</Button>
</>
) : (
<Button
type="button"
variant="solid"
onPress={() => setIsEditing(true)}
>
</Button>
)}
</div>
</Form>
</div>
</Card.Content>
</Card>
</div>
)
}

View File

@@ -88,6 +88,31 @@ export default function RegisterPage() {
> >
</Button> </Button>
<div className="flex w-full items-center gap-2">
<div className="bg-default-200 h-px flex-1" />
<span className="text-default-500 text-xs"></span>
<div className="bg-default-200 h-px flex-1" />
</div>
<Button
type="button"
variant="outline"
fullWidth
onPress={async () => {
await signUp.social({
provider: 'github',
callbackURL: window.location.origin,
})
}}
>
<svg
viewBox="0 0 24 24"
className="mr-2 h-5 w-5"
fill="currentColor"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
GitHub
</Button>
<RouterLink <RouterLink
to="/login" to="/login"
className={buttonVariants({ className={buttonVariants({

156
web/src/components/Card.tsx Normal file
View File

@@ -0,0 +1,156 @@
import { twMerge } from 'tailwind-merge'
import { Tooltip } from '@heroui/react'
import { Link } from 'react-router-dom'
import React from 'react'
export interface CardProps {
title: string
githubRepo?: string | null
url: string
description: string | null
icon?: string | null
version?: string | null
background?: boolean
downloadCdn?: string | null
downloadOriginal?: string | null
image?: string | null
className?: string
children?: React.ReactNode
onVersionClick?: (e: React.MouseEvent) => void
action?: React.ReactNode
}
const H4 = ({
className,
children,
}: {
className?: string
children: React.ReactNode
}) => <h4 className={twMerge('text-lg font-bold', className)}>{children}</h4>
const LinkComponent = ({
children,
className,
url,
}: {
children: React.ReactNode
className?: string
url: string
}) => {
const isExternal = url.startsWith('http')
if (isExternal) {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
)
}
return (
<Link to={url} className={className}>
{children}
</Link>
)
}
export function Card({
title,
url,
description,
icon,
version,
background,
downloadCdn,
downloadOriginal,
image,
className,
children,
onVersionClick,
action,
}: CardProps) {
return (
<li className="group relative flex h-full flex-col gap-3.5 rounded-xl bg-zinc-100 bg-opacity-90 p-5 transition duration-200 hover:brightness-[.98] dark:bg-zinc-800 dark:hover:brightness-110">
<div className="flex items-center gap-1">
<LinkComponent url={url} className="mr-2 flex h-12 w-12 shrink-0 items-center justify-center rounded-xl">
<div
className={twMerge(
'flex h-12 w-12 shrink-0 items-center justify-center rounded-xl',
background && 'bg-zinc-200 dark:bg-zinc-700',
)}
>
{icon && <i className={twMerge('text-2xl', icon)} />}
{image && !icon && (
<img
src={image}
alt={title}
width={48}
height={48}
className={twMerge('h-12 w-12 rounded-xl', className)}
loading="lazy"
/>
)}
</div>
</LinkComponent>
<div className="flex flex-col gap-1">
<LinkComponent url={url}>
<H4 className="text-zinc-950 cursor-pointer underline-offset-4 hover:underline dark:text-zinc-200">
{title}
</H4>
</LinkComponent>
{version && (
<Tooltip
content="查看更新日志"
delay={500}
closeDelay={800}
placement="right"
>
<div
onClick={onVersionClick}
className={`flex max-h-fit max-w-fit items-center rounded-lg bg-black/5 px-1.5 text-xs tracking-wider text-zinc-500 transition dark:border-white/10 dark:bg-white/10 dark:text-zinc-400 dark:hover:text-zinc-200 ${
onVersionClick
? 'cursor-pointer underline-offset-2 hover:underline'
: ''
}`}
>
{version}
</div>
</Tooltip>
)}
</div>
</div>
<p className="text-zinc-500 flex-grow dark:text-[#9e9e9e]">
{description}
</p>
<div className="relative flex flex-wrap gap-2">
{downloadCdn && (
<a
href={downloadCdn}
target="_blank"
rel="noopener noreferrer"
className="bg-[#ca4940] rounded-lg px-3 py-1 text-white transition hover:brightness-90 active:scale-95 dark:hover:brightness-110"
>
</a>
)}
{downloadOriginal && (
<a
href={downloadOriginal}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg bg-zinc-300 px-3 py-1 text-zinc-600 transition hover:brightness-95 active:scale-95 dark:bg-white/10 dark:text-zinc-200 dark:hover:brightness-110"
>
</a>
)}
{children}
</div>
{action}
</li>
)
}

View File

@@ -1,37 +0,0 @@
import { Card } from '@heroui/react'
import { tv, type VariantProps } from 'tailwind-variants'
import React from 'react'
// Define custom styles
const customStyles = tv({
variants: {
variant: {
glass:
'bg-white/70 backdrop-blur-md border-white/20 shadow-xl dark:bg-black/70 dark:border-white/10',
neon: 'border border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] bg-gray-900/90 text-white',
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: {
variant: 'modern',
},
})
type CustomCardProps = React.ComponentProps<typeof Card> &
VariantProps<typeof customStyles>
// Create the custom component
export function CustomCard({ className, variant, ...props }: CustomCardProps) {
// Use custom styles and merge with className
return <Card className={customStyles({ variant, className })} {...props} />
}
// Attach subcomponents to the custom component for easier usage
CustomCard.Header = Card.Header
CustomCard.Content = Card.Content
CustomCard.Footer = Card.Footer
CustomCard.Title = Card.Title
CustomCard.Description = Card.Description

View File

@@ -0,0 +1,37 @@
export default function Footer() {
return (
<footer className="w-full px-5 py-8 border-t bg-zinc-100 dark:bg-zinc-950/80 border-zinc-200 dark:border-zinc-700/50 text-zinc-700">
<div className="flex flex-col items-center justify-between max-w-5xl gap-3 mx-auto">
<span className="font-medium tracking-wider text-zinc-500">
Presented by{' '}
<a href="https://github.com/Purple-CSGO" className="font-bold text-zinc-700">
Purple-CSGO
</a>{' '}
©{new Date().getFullYear()}
</span>
<Beian record="皖公网安备34012302000653" icp="皖ICP备20002252号-2" />
</div>
</footer>
)
}
type BeianProps = {
record?: string
icp?: string
}
function Beian({ record, icp }: BeianProps) {
return (
<div className="text-sm tracking-wider items-center font-medium text-zinc-700 underline-offset-2 flex flex-col sm:flex-row gap-2">
<a href="https://beian.mps.gov.cn/#/query/webSearch?code=34012302000653" rel="noreferrer" className="flex gap-3 hover:underline" target="_blank">
<img src="/icon/beian.png" alt="beian" className="w-4 h-4" />
{record}
</a>
<span className="hidden sm:block">|</span>
<a href="http://beian.miit.gov.cn/" target="_blank" className="hover:underline">
{icp}
</a>
</div>
)
}

View File

@@ -1,41 +1,31 @@
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 pt-40 pb-20 text-center">
<h1 className="mb-8 text-6xl font-extrabold tracking-tight"> <h1 className="font-bold text-5xl text-zinc-950 dark:text-zinc-100">HLAE中文站</h1>
HLAE中文站
</h1> <p className="text-[#666] dark:text-[#bbb] py-8 text-xl tracking-widest">
<p className="text-default-600 mb-12 max-w-3xl text-2xl leading-relaxed"> <span className="text-orange-600 font-bold">CS</span>
<span className="font-bold text-[#FF6B00]">CS</span>
<span className="text-brand mx-1 font-bold">HLAE</span> <span className="text-[#CA4940] font-bold">HLAE</span>
</p> </p>
<div className="flex gap-6">
<div className="flex flex-row gap-4">
<a <a
href="https://www.advancedfx.org/" href="https://advancedfx.org"
target="_blank" target="_blank"
rel="noopener noreferrer" className="bg-[#CA4940] hover:bg-[#B33B32] text-[#fff] flex flex-row items-center justify-center rounded-full py-2 pr-4 pl-3 font-semibold transition duration-200 active:scale-95"
className={buttonVariants({
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" /> <img src="/icon/hlae.svg" alt="HLAE Logo" className="h-6 w-6" />
</a> </a>
<a <a
href="https://github.com/purp1e/hlae-site" href="https://github.com/Purple-CSGO/hlae-next-site"
target="_blank" target="_blank"
rel="noopener noreferrer" className="dark:bg-gray-200 bg-gray-100 hover:brightness-90 text-[#333] flex flex-row items-center justify-center rounded-full py-2 pr-4 pl-3 font-semibold transition duration-200 active:scale-95"
className={buttonVariants({
className:
'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',
})}
> >
<svg <svg
className="h-6 w-6" className="mr-1 h-6 w-6 p-0.5"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
aria-hidden="true" aria-hidden="true"

View File

@@ -0,0 +1,93 @@
import useSWR from 'swr'
import { Card, CardProps } from './Card'
import { useResources, Resource } from '../hooks/useApi'
import { fetchResourceReleaseData, LatestRelease } from '../lib/github'
import { useSession } from '../lib/auth-client'
import { Button } from '@heroui/react'
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'
interface ResourceCardListProps {
onEdit?: (resource: Resource) => void
}
export function ResourceCardList({ onEdit }: ResourceCardListProps) {
const { resources, isLoading } = useResources()
const { data: session } = useSession()
const repos =
resources
?.map((item) => item.githubRepo)
.filter((repo): repo is string => !!repo && repo.includes('/')) || []
const { data: releaseData } = useSWR(
repos.length > 0 ? ['resource-releases', repos] : null,
([, repos]) => fetchResourceReleaseData(repos),
{
revalidateOnFocus: false,
dedupingInterval: 180000, // 3 mins
},
)
const repoReleaseMap = new Map<string, LatestRelease>()
if (releaseData) {
releaseData.results.forEach((result) => {
if (result.success && result.latest_release) {
repoReleaseMap.set(result.repo, result.latest_release)
}
})
}
if (isLoading) {
return <div className="py-20 text-center">...</div>
}
if (!resources?.length) {
return <div className="col-span-full py-20 text-center text-default-400"></div>
}
return (
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
{resources.map((item) => {
const releaseInfo = item.githubRepo
? repoReleaseMap.get(item.githubRepo)
: undefined
const cardProps: CardProps = {
title: item.title,
url: item.url,
description: item.description,
icon: item.icon,
background: item.background,
githubRepo: item.githubRepo,
downloadCdn: item.downloadCdn,
downloadOriginal: item.downloadOriginal,
image: item.image,
version: releaseInfo?.latest_version || item.version,
onVersionClick: releaseInfo
? (e) => {
e.preventDefault()
window.open(
`https://github.com/${item.githubRepo}/releases/tag/${releaseInfo.latest_version}`,
'_blank',
)
}
: undefined,
action:
session && onEdit ? (
<Button
isIconOnly
variant="flat"
size="sm"
onPress={() => onEdit(item)}
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity group-hover:opacity-100"
>
<WrenchScrewdriverIcon className="h-4 w-4" />
</Button>
) : undefined,
}
return <Card {...cardProps} key={item.id} />
})}
</ul>
)
}

View File

@@ -1,25 +1,31 @@
import { Button, Avatar, Dropdown, Label } from '@heroui/react' import { Link, useLocation } from 'react-router-dom'
import { buttonVariants } from '@heroui/styles' import { useScroll } from 'ahooks'
import { useEffect, useState } from 'react' import { twMerge } from 'tailwind-merge'
import { Moon, Sun, Menu, X } from 'lucide-react'
import { Button, Avatar, Dropdown } from '@heroui/react'
import { useState } from 'react'
import { useSession, signOut } from '../lib/auth-client' import { useSession, signOut } from '../lib/auth-client'
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline'
import { Link as RouterLink } from 'react-router-dom' function isBrowser() {
return !!(typeof window !== 'undefined' && window.document && window.document.createElement)
}
export function SiteNavbar() { export function SiteNavbar() {
const scroll = useScroll(isBrowser() ? document : null)
const location = useLocation()
const pathname = location.pathname
const { data: session } = useSession() const { data: session } = useSession()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [theme, setTheme] = useState<'light' | 'dark'>(() => { const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('dark') const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
? 'dark' if (savedTheme) return savedTheme
: 'light' return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
} }
return 'light' return 'light'
}) })
useEffect(() => {
// Sync theme on mount if needed, but the state is already initialized
}, [])
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')
@@ -29,101 +35,168 @@ export function SiteNavbar() {
setTheme(nextTheme) setTheme(nextTheme)
} }
const menuItems = [
{ name: '主页', href: '/' },
{ name: '击杀生成', href: '/demo' },
{ name: '关于', href: '/about' },
]
const shouldShow = pathname !== '/' || (scroll?.top || 0) > 140
return ( return (
<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"> <nav
<RouterLink className={twMerge(
to="/" 'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl transition-all duration-300',
className="text-foreground text-xl font-bold text-inherit no-underline" shouldShow
> ? 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
HLAE中文站 : 'opacity-0 h-0 -translate-y-1/4 pointer-events-none'
</RouterLink> )}
</div> >
<div className="flex items-center justify-between w-full max-w-screen-lg gap-4 px-8 py-4 mx-auto">
<Link to="/">
<h4 className="font-bold whitespace-nowrap text-xl text-foreground">HLAE中文站</h4>
</Link>
<div className="flex items-center gap-8"> <ul className="hidden sm:flex items-center gap-6">
<div className="hidden items-center gap-8 sm:flex"> {menuItems.map((item) => (
<RouterLink <Link
to="/" key={item.href}
className="text-foreground/80 hover:text-foreground font-medium transition-colors" to={item.href}
> className={twMerge(
"font-semibold text-zinc-900 dark:text-zinc-100 text-md hover:text-primary transition-colors",
</RouterLink> pathname === item.href && "text-primary"
<RouterLink )}
to="/demo" >
className="text-foreground/80 hover:text-foreground font-medium transition-colors" {item.name}
> </Link>
))}
</RouterLink>
<RouterLink <Button size="sm" isIconOnly variant="ghost" onPress={toggleTheme}>
to="/about" {theme == 'dark' ? <Sun size={20} /> : <Moon size={20} />}
className="text-foreground/80 hover:text-foreground font-medium transition-colors" </Button>
>
</RouterLink>
</div>
<div className="flex items-center gap-4"> {session ? (
<Button <Dropdown>
isIconOnly <Dropdown.Trigger>
variant="ghost" <Button isIconOnly variant="ghost" className="rounded-full">
onPress={toggleTheme} <Avatar size="sm">
className="text-foreground/80" <Avatar.Image src={session.user.image || ''} alt={session.user.name || 'User'} />
> <Avatar.Fallback>{(session.user.name || 'U').slice(0, 2).toUpperCase()}</Avatar.Fallback>
{theme === 'dark' ? ( </Avatar>
<SunIcon className="h-5 w-5" /> </Button>
) : ( </Dropdown.Trigger>
<MoonIcon className="h-5 w-5" />
)}
</Button>
{session ? (
<Dropdown>
<Dropdown.Trigger>
<Button isIconOnly variant="tertiary" className="rounded-full">
<Avatar size="sm" color="accent">
<Avatar.Image
alt={session.user.name || '用户头像'}
src={session.user.image || ''}
/>
<Avatar.Fallback>
{(session.user.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar>
</Button>
</Dropdown.Trigger>
<Dropdown.Popover>
<Dropdown.Menu aria-label="Profile Actions"> <Dropdown.Menu aria-label="Profile Actions">
<Dropdown.Item id="profile" textValue="Profile"> <Dropdown.Item key="profile" textValue="Profile" href="/profile">
<Label className="font-semibold"> <div className="flex flex-col">
{session.user.email} <span className="font-semibold">{session.user.name || '个人资料'}</span>
</Label> <span className="text-tiny text-default-500">{session.user.email}</span>
</div>
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item key="logout" textValue="Logout" variant="danger" onPress={() => signOut()}>
id="logout" 退
textValue="Logout"
variant="danger"
onPress={() => signOut()}
>
<Label className="text-danger">退</Label>
</Dropdown.Item> </Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown.Popover> </Dropdown>
</Dropdown> ) : (
) : ( <div className="flex items-center gap-2">
<div className="flex items-center gap-4"> <Link to="/login">
<RouterLink <Button size="sm" variant="ghost"></Button>
to="/login" </Link>
className={buttonVariants({ <Link to="/register">
variant: 'ghost', <Button size="sm" variant="primary"></Button>
className: 'text-foreground font-medium', </Link>
})} </div>
> )}
</ul>
</RouterLink>
</div> <Button size="sm" isIconOnly variant="ghost" className="sm:hidden" onPress={() => setIsMenuOpen(true)} aria-label="打开菜单">
)} <Menu size={20} />
</Button>
</div> </div>
</div> </nav>
</nav>
{/* Mobile Menu */}
<>
<div
className={twMerge(
'fixed inset-0 z-40 bg-black/50 backdrop-blur-sm sm:hidden transition-opacity duration-300',
isMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
)}
onClick={() => setIsMenuOpen(false)}
aria-hidden={!isMenuOpen}
/>
<div
className={twMerge(
'fixed top-0 right-0 z-50 w-64 h-full bg-white dark:bg-zinc-900 shadow-xl sm:hidden transition-transform duration-300 ease-in-out',
isMenuOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-zinc-800">
<h4 className="font-bold whitespace-nowrap text-lg"></h4>
<Button size="sm" isIconOnly variant="ghost" onPress={() => setIsMenuOpen(false)} aria-label="关闭菜单">
<X size={20} />
</Button>
</div>
<nav className="flex-1 overflow-y-auto p-6">
<ul className="flex flex-col gap-4">
{menuItems.map((item, index) => (
<li key={`${item.href}-${index}`}>
<Link
to={item.href}
className={twMerge(
'block font-semibold text-zinc-900 dark:text-zinc-100 text-lg py-2 transition-colors',
pathname === item.href && 'text-primary'
)}
onClick={() => setIsMenuOpen(false)}
>
{item.name}
</Link>
</li>
))}
<li>
<Button size="sm" variant="ghost" isIconOnly onPress={toggleTheme} aria-label="切换主题">
{theme == 'dark' ? <Sun size={20} /> : <Moon size={20} />}
</Button>
</li>
{session ? (
<>
<div className="h-px bg-zinc-200 dark:bg-zinc-800 my-2"></div>
<li>
<Link to="/profile" className="flex items-center gap-2 py-2" onClick={() => setIsMenuOpen(false)}>
<Avatar size="sm">
<Avatar.Image src={session.user.image || ''} alt={session.user.name || 'User'} />
<Avatar.Fallback>{(session.user.name || 'U').slice(0, 2).toUpperCase()}</Avatar.Fallback>
</Avatar>
<span className="font-semibold">{session.user.name}</span>
</Link>
</li>
<li>
<Button size="sm" variant="danger" onPress={() => { signOut(); setIsMenuOpen(false); }} fullWidth>退</Button>
</li>
</>
) : (
<>
<div className="h-px bg-zinc-200 dark:bg-zinc-800 my-2"></div>
<li>
<Link to="/login" onClick={() => setIsMenuOpen(false)}>
<Button fullWidth variant="ghost"></Button>
</Link>
</li>
<li>
<Link to="/register" onClick={() => setIsMenuOpen(false)}>
<Button fullWidth variant="primary"></Button>
</Link>
</li>
</>
)}
</ul>
</nav>
</div>
</div>
</>
</>
) )
} }

View File

@@ -8,6 +8,25 @@ export interface Resource {
url: string url: string
icon: string | null icon: string | null
category: string | null category: string | null
githubRepo: string | null
downloadCdn: string | null
downloadOriginal: string | null
version: string | null
background: boolean
image: string | null
order: number
createdAt: string | Date
updatedAt: string | Date
}
export interface Portal {
id: string
title: string
url: string
description: string | null
icon: string | null
background: boolean
order: number
createdAt: string | Date createdAt: string | Date
updatedAt: string | Date updatedAt: string | Date
} }
@@ -54,6 +73,21 @@ export function useResources() {
} }
} }
export function usePortals() {
const { data, error, isLoading, mutate } = useSWR('/portals', async () => {
const { data, error } = await api.portals.get()
if (error) throw error
return data
})
return {
portals: data,
isLoading,
isError: error,
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()

View File

@@ -1,6 +1,8 @@
import { Toast } from '@heroui/react' import { Toast } from '@heroui/react'
import 'primeicons/primeicons.css'
import './globals.css' import './globals.css'
import { SiteNavbar } from './components/SiteNavbar' import { SiteNavbar } from './components/SiteNavbar'
import Footer from './components/Footer'
export default function RootLayout({ export default function RootLayout({
children, children,
@@ -11,7 +13,8 @@ export default function RootLayout({
<div className="bg-background text-foreground min-h-screen"> <div className="bg-background text-foreground min-h-screen">
<Toast.Provider /> <Toast.Provider />
<SiteNavbar /> <SiteNavbar />
<main className="h-full min-h-screen">{children}</main> <main className="h-full min-h-screen mx-auto max-w-5xl">{children}</main>
<Footer />
</div> </div>
) )
} }

View File

@@ -5,3 +5,4 @@ export const authClient = createAuthClient({
}) })
export const { useSession, signIn, signUp, signOut } = authClient export const { useSession, signIn, signUp, signOut } = authClient
export const user = authClient.user

37
web/src/lib/github.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface LatestRelease {
repo: string
latest_version: string
changelog: string
published_at: string
attachments: string[]
}
export interface ApiResponse {
results: Array<{
repo: string
success: boolean
latest_release?: LatestRelease
error?: string
}>
}
export async function fetchResourceReleaseData(
repos: string[],
): Promise<ApiResponse> {
const response = await fetch('https://gh-info.okk.cool/repos/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
repos: repos,
fields: ['latest_release'],
}),
})
if (!response.ok) {
throw new Error(`API 请求失败: ${response.status}`)
}
return await response.json()
}