feat: 新增门户功能并重构资源展示组件
- 新增门户(Portal)数据模型与后端 API 端点 - 新增个人资料页面,支持用户更新昵称 - 重构前端资源卡片组件,支持显示 GitHub 版本信息与加速下载链接 - 在登录/注册页面添加 GitHub OAuth 支持 - 更新环境变量示例文件,添加前后端配置项 - 优化导航栏响应式设计,添加移动端菜单 - 添加页脚组件,包含备案信息 - 更新 Prisma 数据模型,适配 Better Auth 并添加种子数据 - 统一前后端 API URL 配置,支持环境变量覆盖
This commit is contained in:
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>
|
||||
) : (
|
||||
<RouterLink
|
||||
key={portal.title}
|
||||
to={portal.url}
|
||||
className="block text-inherit no-underline"
|
||||
>
|
||||
{Content}
|
||||
</RouterLink>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isPortalsLoading ? (
|
||||
<div className="py-20 text-center">加载中...</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)
|
||||
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>
|
||||
)}
|
||||
<ResourceCardList
|
||||
onEdit={(resource) => {
|
||||
setSelectedResource(resource)
|
||||
setIsEditModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
)}
|
||||
</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>
|
||||
{session ? (
|
||||
<Dropdown>
|
||||
<Dropdown.Trigger>
|
||||
<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.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>
|
||||
)}
|
||||
</Dropdown>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<Button size="sm" isIconOnly variant="ghost" className="sm:hidden" onPress={() => setIsMenuOpen(true)} aria-label="打开菜单">
|
||||
<Menu size={20} />
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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