Files
Ant-Browser/frontend/src/modules/profile/ProfilePage.tsx
T
Ant Browser Release Bot 6f58a6c19a publish: 1.0.0 snapshot (bad2ec1)
channel: master
version: 1.0.0
source-ref: master
published-at-utc: 2026-03-13T15:19:28Z
2026-03-13 23:19:29 +08:00

251 lines
8.6 KiB
TypeScript

import {
Github,
Mail,
Globe,
BookOpen,
MessageSquare,
Calendar,
MapPin,
Coffee,
Terminal,
ExternalLink,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Badge, Button, Card } from '../../shared/components'
import { createDefaultProfilePageData, loadProfilePageData } from './api'
import type { IconKey, ProfilePageData } from './types'
const ICON_MAP = {
'book-open': BookOpen,
globe: Globe,
'message-square': MessageSquare,
github: Github,
mail: Mail,
'external-link': ExternalLink,
}
const CHANNEL_ICON_CLASS: Partial<Record<IconKey, string>> = {
'book-open': 'text-[#1e80ff]',
globe: 'text-[#0f766e]',
'message-square': 'text-[#16a34a]',
github: 'text-[var(--color-text-primary)]',
mail: 'text-[var(--color-accent)]',
}
export function ProfilePage() {
const navigate = useNavigate()
const [clickCount, setClickCount] = useState(0)
const [pageData, setPageData] = useState<ProfilePageData>(() => createDefaultProfilePageData())
useEffect(() => {
let active = true
const syncProfile = async () => {
const data = await loadProfilePageData()
if (!active) return
setPageData(data)
}
void syncProfile()
return () => {
active = false
}
}, [])
const handleAuthorClick = () => {
const newCount = clickCount + 1
setClickCount(newCount)
if (newCount >= 5) {
navigate('/admin/keygen')
setClickCount(0)
}
}
const openExternal = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
const authorInfo = pageData.author
const projectInfo = pageData.project
const metaItems = [
{
label: authorInfo.location,
icon: MapPin,
},
{
label: `加入于 ${authorInfo.joinDate}`,
icon: Calendar,
},
].filter((item) => item.label.trim())
return (
<div className="mx-auto max-w-5xl space-y-6 animate-fade-in">
<Card padding="lg" className="rounded-[26px]">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-5 sm:flex-row sm:items-start">
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-[20px] bg-[#1f2d46] text-[34px] font-bold tracking-[0.08em] text-white shadow-sm">
{authorInfo.initial}
</div>
<div className="min-w-0 space-y-4">
<div className="space-y-1">
<h1
className="cursor-pointer select-none text-[34px] font-bold leading-none tracking-tight text-[var(--color-text-primary)] sm:text-[38px]"
onClick={handleAuthorClick}
title={clickCount > 0 ? `再点 ${5 - clickCount} 次进入开发者模式` : ''}
>
{authorInfo.name}
</h1>
<p className="text-base text-[var(--color-text-muted)]">{authorInfo.title}</p>
</div>
<p className="max-w-3xl text-[15px] leading-8 text-[var(--color-text-secondary)]">
{authorInfo.bio}
</p>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3 text-sm text-[var(--color-text-muted)]">
{metaItems.map(({ label, icon: Icon }) => (
<span key={label} className="inline-flex items-center gap-1.5">
<Icon className="h-4 w-4" />
{label}
</span>
))}
{authorInfo.website ? (
<button
type="button"
className="inline-flex items-center gap-1.5 text-[var(--color-text-primary)] transition-colors hover:text-[var(--color-accent)]"
onClick={() => openExternal(authorInfo.website)}
>
<Globe className="h-4 w-4" />
{stripProtocol(authorInfo.website)}
</button>
) : null}
</div>
</div>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
{authorInfo.github ? (
<Button
variant="ghost"
className="h-11 rounded-2xl border border-transparent px-4 text-[var(--color-text-primary)] hover:border-[var(--color-border-default)] hover:bg-[var(--color-bg-muted)]"
onClick={() => openExternal(authorInfo.github)}
>
<Github className="h-4 w-4" />
GitHub
</Button>
) : null}
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
{authorInfo.channels.map((channel) => {
const Icon = getIcon(channel.icon)
const iconClassName = CHANNEL_ICON_CLASS[channel.icon || 'globe'] || 'text-[var(--color-text-primary)]'
const content = (
<Card
className="h-full rounded-[22px] border-[var(--color-border-default)] transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--color-border-strong)] hover:shadow-[var(--shadow-md)]"
padding="lg"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-5">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--color-bg-muted)]">
<Icon className={`h-5 w-5 ${iconClassName}`} />
</div>
<div className="space-y-1 text-left">
<p className="text-[28px] font-bold leading-none text-[var(--color-text-primary)]">
{channel.name}
</p>
<p className="text-sm text-[var(--color-text-muted)]">{channel.description}</p>
<p className="text-xs text-[var(--color-text-secondary)]">{channel.detail}</p>
</div>
</div>
{channel.href ? <ExternalLink className="h-4 w-4 shrink-0 text-[var(--color-text-muted)]" /> : null}
</div>
</Card>
)
if (!channel.href) {
return <div key={channel.name}>{content}</div>
}
return (
<a
key={channel.name}
href={channel.href}
target="_blank"
rel="noopener noreferrer"
className="block h-full"
>
{content}
</a>
)
})}
</div>
<Card
title="技术栈"
actions={<Terminal className="h-4 w-4 text-[var(--color-text-muted)]" />}
className="rounded-[24px]"
padding="lg"
>
<div className="flex flex-wrap gap-x-8 gap-y-4 text-[15px] font-semibold text-[var(--color-text-primary)]">
{authorInfo.skills.map((skill) => (
<span key={skill}>{skill}</span>
))}
</div>
</Card>
<Card
title="关于本项目"
actions={<Coffee className="h-4 w-4 text-[var(--color-text-muted)]" />}
className="rounded-[24px]"
padding="lg"
>
<div className="space-y-4 text-[15px] leading-8 text-[var(--color-text-secondary)]">
<p>
<Badge className="mr-1 rounded-xl px-3 py-1">{projectInfo.introBadge}</Badge>
{projectInfo.introText}
</p>
<div className="flex flex-wrap gap-2">
{projectInfo.techStack.map((item) => (
<Badge key={item} className="rounded-xl px-3 py-1">
{item}
</Badge>
))}
</div>
<p>{projectInfo.description}</p>
<div className="flex flex-wrap items-center gap-3 pt-2">
{projectInfo.actions.map((action) => {
const Icon = getIcon(action.icon)
return (
<Button
key={action.label}
variant="ghost"
className="h-10 rounded-xl border border-[var(--color-border-default)] px-3 text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-muted)]"
onClick={() => openExternal(action.href)}
>
<Icon className="h-4 w-4" />
{action.label}
<ExternalLink className="h-3 w-3" />
</Button>
)
})}
</div>
</div>
</Card>
</div>
)
}
function getIcon(icon?: IconKey) {
return ICON_MAP[icon || 'globe'] || Globe
}
function stripProtocol(value: string): string {
return value.replace(/^https?:\/\//, '').replace(/\/$/, '')
}