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

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

View File

@@ -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')

View File

@@ -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>

View File

@@ -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({

View File

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

View File

@@ -88,6 +88,31 @@ export default function RegisterPage() {
>
</Button>
<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
View File

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

View File

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

View File

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

View File

@@ -1,41 +1,31 @@
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'
import { buttonVariants } from '@heroui/styles'
export const Hero = () => {
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"

View File

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

View File

@@ -1,25 +1,31 @@
import { Button, Avatar, Dropdown, Label } from '@heroui/react'
import { 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>
</>
</>
)
}

View File

@@ -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()

View File

@@ -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>
)
}

View File

@@ -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
View File

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