feat: 新增门户功能并重构资源展示组件
- 新增门户(Portal)数据模型与后端 API 端点 - 新增个人资料页面,支持用户更新昵称 - 重构前端资源卡片组件,支持显示 GitHub 版本信息与加速下载链接 - 在登录/注册页面添加 GitHub OAuth 支持 - 更新环境变量示例文件,添加前后端配置项 - 优化导航栏响应式设计,添加移动端菜单 - 添加页脚组件,包含备案信息 - 更新 Prisma 数据模型,适配 Better Auth 并添加种子数据 - 统一前后端 API URL 配置,支持环境变量覆盖
This commit is contained in:
10
backend/.env.example
Normal file
10
backend/.env.example
Normal 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=""
|
||||
@@ -11,49 +11,54 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
phone String? @unique
|
||||
phoneVerified DateTime?
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
accounts Account[]
|
||||
createdAt DateTime
|
||||
updatedAt DateTime
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
posts Post[]
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
expiresAt DateTime
|
||||
token String @unique
|
||||
createdAt DateTime
|
||||
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 {
|
||||
@@ -66,8 +71,6 @@ model Post {
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
comments Comment[]
|
||||
|
||||
@@map("posts")
|
||||
}
|
||||
|
||||
model Resource {
|
||||
@@ -76,11 +79,28 @@ model Resource {
|
||||
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
|
||||
}
|
||||
|
||||
@@map("resources")
|
||||
model Portal {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
url String
|
||||
description String?
|
||||
icon String?
|
||||
background Boolean @default(false)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -92,15 +112,4 @@ model Comment {
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], 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
246
backend/prisma/seed.ts
Normal 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)
|
||||
})
|
||||
@@ -7,6 +7,8 @@ export const auth = betterAuth({
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
basePath: '/api/auth',
|
||||
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001',
|
||||
trustedOrigins: ['http://localhost:5173'],
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { auth } from './auth'
|
||||
import { prisma } from './prisma'
|
||||
import { resources } from './resources'
|
||||
import { posts } from './posts'
|
||||
import { portals } from './portals'
|
||||
|
||||
const app = new Elysia()
|
||||
.use(
|
||||
@@ -17,6 +18,7 @@ const app = new Elysia()
|
||||
.mount(auth.handler)
|
||||
.get('/health', () => 'OK')
|
||||
.use(resources)
|
||||
.use(portals)
|
||||
.use(posts)
|
||||
.get('/user/:id', async ({ params }) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
49
backend/src/portals.ts
Normal file
49
backend/src/portals.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { prisma } from './prisma'
|
||||
export const resources = new Elysia({ prefix: '/resources' })
|
||||
.get('/', async () => {
|
||||
return await prisma.resource.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
})
|
||||
.post(
|
||||
@@ -21,6 +21,12 @@ export const resources = new Elysia({ prefix: '/resources' })
|
||||
url: t.String(),
|
||||
icon: 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()),
|
||||
icon: 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()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
50
bun.lock
50
bun.lock
@@ -49,30 +49,34 @@
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.0.0",
|
||||
"@elysiajs/eden": "^1.4.8",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@heroui/react": "^3.0.0-beta.8",
|
||||
"@heroui/styles": "^3.0.0-beta.8",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"ahooks": "^3.9.6",
|
||||
"better-auth": "^1.5.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"postcss": "^8.5.8",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-router": "^7.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite-plugin-pages": "^0.32.0",
|
||||
"vite-plugin-pages": "^0.32.5",
|
||||
"zustand": "^5.0.11",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"backend": "workspace:*",
|
||||
"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/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/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/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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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/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
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL="http://localhost:3001"
|
||||
@@ -12,29 +12,33 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/eden": "^1.0.0",
|
||||
"@elysiajs/eden": "^1.4.8",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@heroui/react": "^3.0.0-beta.8",
|
||||
"@heroui/styles": "^3.0.0-beta.8",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"ahooks": "^3.9.6",
|
||||
"better-auth": "^1.5.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"postcss": "^8.5.8",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-router": "^7.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "^7.13.1",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite-plugin-pages": "^0.32.0",
|
||||
"vite-plugin-pages": "^0.32.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"backend": "workspace:*",
|
||||
"eslint": "^10.0.3",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ import { edenTreaty } from '@elysiajs/eden'
|
||||
import type { App } from 'backend'
|
||||
|
||||
// 创建 Eden 客户端,自动推断类型
|
||||
export const api = edenTreaty<App>('http://localhost:3001')
|
||||
export const api = edenTreaty<App>(import.meta.env.VITE_API_URL || 'http://localhost:3001')
|
||||
|
||||
@@ -1,77 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { useResources, Resource } from '../hooks/useApi'
|
||||
import { CustomCard } from '../components/CustomCard'
|
||||
import { Resource, usePortals } from '../hooks/useApi'
|
||||
import { Hero } from '../components/Hero'
|
||||
import { Button, Chip } from '@heroui/react'
|
||||
import { buttonVariants } from '@heroui/styles'
|
||||
import { Button } from '@heroui/react'
|
||||
import { CreateResourceModal } from '../components/CreateResourceModal'
|
||||
import { EditResourceModal } from '../components/EditResourceModal'
|
||||
import { useSession } from '../lib/auth-client'
|
||||
import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
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',
|
||||
},
|
||||
]
|
||||
import { Card } from '../components/Card'
|
||||
import { ResourceCardList } from '../components/ResourceCardList'
|
||||
|
||||
export default function HomePage() {
|
||||
const { resources, isLoading } = useResources()
|
||||
const { portals, isLoading: isPortalsLoading } = usePortals()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(
|
||||
@@ -86,46 +24,22 @@ export default function HomePage() {
|
||||
{/* Portals Section */}
|
||||
<section className="mb-20">
|
||||
<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">
|
||||
{PORTALS.map((portal) => {
|
||||
const isExternal = portal.url.startsWith('http')
|
||||
const Content = (
|
||||
<CustomCard className="group h-full cursor-pointer p-6">
|
||||
<div className="flex gap-4">
|
||||
<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="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-bold">{portal.title}</h3>
|
||||
<p className="text-default-500 line-clamp-1 text-sm">
|
||||
{portal.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CustomCard>
|
||||
)
|
||||
|
||||
return isExternal ? (
|
||||
<a
|
||||
key={portal.title}
|
||||
href={portal.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-inherit no-underline"
|
||||
>
|
||||
{Content}
|
||||
</a>
|
||||
{isPortalsLoading ? (
|
||||
<div className="py-20 text-center">加载中...</div>
|
||||
) : (
|
||||
<RouterLink
|
||||
key={portal.title}
|
||||
to={portal.url}
|
||||
className="block text-inherit no-underline"
|
||||
>
|
||||
{Content}
|
||||
</RouterLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ul className="grid w-full grid-cols-1 items-center justify-center gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{portals?.map((portal) => (
|
||||
<Card
|
||||
key={portal.id}
|
||||
title={portal.title}
|
||||
url={portal.url}
|
||||
description={portal.description}
|
||||
icon={portal.icon}
|
||||
background={portal.background}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Resources Section */}
|
||||
@@ -135,7 +49,7 @@ export default function HomePage() {
|
||||
{session && (
|
||||
<Button
|
||||
onPress={() => setIsModalOpen(true)}
|
||||
variant="tertiary"
|
||||
variant="flat"
|
||||
className="absolute right-0"
|
||||
>
|
||||
添加资源
|
||||
@@ -153,96 +67,12 @@ export default function HomePage() {
|
||||
resource={selectedResource}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-20">加载中...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{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)
|
||||
<ResourceCardList
|
||||
onEdit={(resource) => {
|
||||
setSelectedResource(resource)
|
||||
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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,30 @@ export default function LoginPage() {
|
||||
>
|
||||
登录
|
||||
</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
|
||||
to="/register"
|
||||
className={buttonVariants({
|
||||
|
||||
142
web/src/app/profile/index.tsx
Normal file
142
web/src/app/profile/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -88,6 +88,31 @@ export default function RegisterPage() {
|
||||
>
|
||||
注册
|
||||
</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
|
||||
to="/login"
|
||||
className={buttonVariants({
|
||||
|
||||
156
web/src/components/Card.tsx
Normal file
156
web/src/components/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
37
web/src/components/Footer.tsx
Normal file
37
web/src/components/Footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +1,31 @@
|
||||
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'
|
||||
import { buttonVariants } from '@heroui/styles'
|
||||
|
||||
export const Hero = () => {
|
||||
return (
|
||||
<section className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<h1 className="mb-8 text-6xl font-extrabold tracking-tight">
|
||||
HLAE中文站
|
||||
</h1>
|
||||
<p className="text-default-600 mb-12 max-w-3xl text-2xl leading-relaxed">
|
||||
<span className="font-bold text-[#FF6B00]">CS</span>
|
||||
<section className="flex flex-col items-center justify-center pt-40 pb-20 text-center">
|
||||
<h1 className="font-bold text-5xl text-zinc-950 dark:text-zinc-100">HLAE中文站</h1>
|
||||
|
||||
<p className="text-[#666] dark:text-[#bbb] py-8 text-xl tracking-widest">
|
||||
<span className="text-orange-600 font-bold">CS</span>
|
||||
等起源引擎游戏的影片制作工具
|
||||
<span className="text-brand mx-1 font-bold">HLAE</span>的中文门户网站
|
||||
<span className="text-[#CA4940] font-bold">HLAE</span>
|
||||
的中文门户网站
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<a
|
||||
href="https://www.advancedfx.org/"
|
||||
href="https://advancedfx.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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',
|
||||
})}
|
||||
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"
|
||||
>
|
||||
<WrenchScrewdriverIcon className="h-6 w-6" />
|
||||
<img src="/icon/hlae.svg" alt="HLAE Logo" className="h-6 w-6" />
|
||||
官方网站
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/purp1e/hlae-site"
|
||||
href="https://github.com/Purple-CSGO/hlae-next-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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',
|
||||
})}
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
className="mr-1 h-6 w-6 p-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
|
||||
93
web/src/components/ResourceCardList.tsx
Normal file
93
web/src/components/ResourceCardList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
import { Button, Avatar, Dropdown, Label } from '@heroui/react'
|
||||
import { buttonVariants } from '@heroui/styles'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useScroll } from 'ahooks'
|
||||
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 { 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() {
|
||||
const scroll = useScroll(isBrowser() ? document : null)
|
||||
const location = useLocation()
|
||||
const pathname = location.pathname
|
||||
const { data: session } = useSession()
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
? 'dark'
|
||||
: 'light'
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null
|
||||
if (savedTheme) return savedTheme
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
return 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Sync theme on mount if needed, but the state is already initialized
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
const nextTheme = theme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
@@ -29,101 +35,168 @@ export function SiteNavbar() {
|
||||
setTheme(nextTheme)
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ name: '主页', href: '/' },
|
||||
{ name: '击杀生成', href: '/demo' },
|
||||
{ name: '关于', href: '/about' },
|
||||
]
|
||||
|
||||
const shouldShow = pathname !== '/' || (scroll?.top || 0) > 140
|
||||
|
||||
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">
|
||||
<RouterLink
|
||||
to="/"
|
||||
className="text-foreground text-xl font-bold text-inherit no-underline"
|
||||
>
|
||||
HLAE中文站
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="hidden items-center gap-8 sm:flex">
|
||||
<RouterLink
|
||||
to="/"
|
||||
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
主页
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/demo"
|
||||
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
击杀生成
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/about"
|
||||
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
|
||||
>
|
||||
关于
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
isIconOnly
|
||||
variant="ghost"
|
||||
onPress={toggleTheme}
|
||||
className="text-foreground/80"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<SunIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<MoonIcon className="h-5 w-5" />
|
||||
<>
|
||||
<nav
|
||||
className={twMerge(
|
||||
'sticky top-0 z-30 w-full bg-white/75 dark:bg-black/70 backdrop-blur-xl transition-all duration-300',
|
||||
shouldShow
|
||||
? 'opacity-100 translate-y-0 border-b border-zinc-100 dark:border-zinc-800 h-auto'
|
||||
: 'opacity-0 h-0 -translate-y-1/4 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<ul className="hidden sm:flex items-center gap-6">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={twMerge(
|
||||
"font-semibold text-zinc-900 dark:text-zinc-100 text-md hover:text-primary transition-colors",
|
||||
pathname === item.href && "text-primary"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<Button size="sm" isIconOnly variant="ghost" onPress={toggleTheme}>
|
||||
{theme == 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</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>
|
||||
<Button isIconOnly variant="ghost" className="rounded-full">
|
||||
<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>
|
||||
</Button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu aria-label="Profile Actions">
|
||||
<Dropdown.Item id="profile" textValue="Profile">
|
||||
<Label className="font-semibold">
|
||||
登录为 {session.user.email}
|
||||
</Label>
|
||||
<Dropdown.Item key="profile" textValue="Profile" href="/profile">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{session.user.name || '个人资料'}</span>
|
||||
<span className="text-tiny text-default-500">{session.user.email}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
id="logout"
|
||||
textValue="Logout"
|
||||
variant="danger"
|
||||
onPress={() => signOut()}
|
||||
>
|
||||
<Label className="text-danger">退出登录</Label>
|
||||
<Dropdown.Item key="logout" textValue="Logout" variant="danger" onPress={() => signOut()}>
|
||||
退出登录
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<RouterLink
|
||||
to="/login"
|
||||
className={buttonVariants({
|
||||
variant: 'ghost',
|
||||
className: 'text-foreground font-medium',
|
||||
})}
|
||||
>
|
||||
登录
|
||||
</RouterLink>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/login">
|
||||
<Button size="sm" variant="ghost">登录</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button size="sm" variant="primary">注册</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<Button size="sm" isIconOnly variant="ghost" className="sm:hidden" onPress={() => setIsMenuOpen(true)} aria-label="打开菜单">
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,25 @@ export interface Resource {
|
||||
url: string
|
||||
icon: 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
|
||||
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() {
|
||||
const { data, error, isLoading, mutate } = useSWR('/posts', async () => {
|
||||
const { data, error } = await api.posts.get()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Toast } from '@heroui/react'
|
||||
import 'primeicons/primeicons.css'
|
||||
import './globals.css'
|
||||
import { SiteNavbar } from './components/SiteNavbar'
|
||||
import Footer from './components/Footer'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -11,7 +13,8 @@ export default function RootLayout({
|
||||
<div className="bg-background text-foreground min-h-screen">
|
||||
<Toast.Provider />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export const authClient = createAuthClient({
|
||||
})
|
||||
|
||||
export const { useSession, signIn, signUp, signOut } = authClient
|
||||
export const user = authClient.user
|
||||
|
||||
37
web/src/lib/github.ts
Normal file
37
web/src/lib/github.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user