feat(ts): 实现 Polaris 可交互首页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:53:28 +08:00
co-authored by Claude Opus 4.8
parent 7eaa9e6f80
commit 9754d61ffe
71 changed files with 15613 additions and 46 deletions
@@ -0,0 +1,111 @@
"use client"
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
import { useState } from "react"
import {
getIconCandidates,
type IconCandidate,
} from "@/components/shared/icon-candidates"
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import { cn } from "@/lib/utils"
import type { NavLink } from "@/types/navigation"
type SiteIconProps = {
link: Pick<NavLink, "title" | "url" | "icon">
size?: number
className?: string
}
type SiteIconContentProps = SiteIconProps & {
candidates: IconCandidate[]
}
function SiteIconContent({
link,
candidates,
size = 36,
className,
}: SiteIconContentProps) {
const [candidateIndex, setCandidateIndex] = useState(0)
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null)
const candidate = candidates[candidateIndex]
const initialCandidate = candidates.at(-1)
const initialValue =
initialCandidate?.kind === "initial" ? initialCandidate.value : "?"
const wrapperClassName = cn("site-icon", className)
const style = { width: size, height: size }
if (candidate.kind === "simple") {
const icon = SIMPLE_ICON_MAP[candidate.slug]
return (
<span className={wrapperClassName} style={style} aria-hidden="true">
<svg
viewBox="0 0 24 24"
role="presentation"
width={size}
height={size}
style={{ color: `#${icon.hex}` }}
>
<path d={icon.path} fill="currentColor" />
</svg>
</span>
)
}
if (candidate.kind === "image") {
const imageLoaded = loadedImageSrc === candidate.src
return (
<span
className={cn(wrapperClassName, !imageLoaded && "site-icon--initial")}
style={style}
data-initial={!imageLoaded ? initialValue : undefined}
aria-hidden="true"
>
<img
className={cn(
"site-icon__image",
!imageLoaded && "site-icon__image--loading",
)}
src={candidate.src}
alt={`${link.title} 图标`}
width={size}
height={size}
onLoad={() => setLoadedImageSrc(candidate.src)}
onError={() =>
setCandidateIndex((index) =>
Math.min(index + 1, candidates.length - 1),
)
}
/>
</span>
)
}
return (
<span
className={cn(wrapperClassName, "site-icon--initial")}
style={style}
aria-hidden="true"
>
{candidate.value}
</span>
)
}
export function SiteIcon({ link, size = 36, className }: SiteIconProps) {
const candidates = getIconCandidates(link)
const candidateKey = JSON.stringify([link.icon ?? "", link.title, link.url])
// 链接变化时通过 key 重建候选状态,避免 effect 中同步 setState 和旧链接的失败记录泄漏。
return (
<SiteIconContent
key={candidateKey}
link={link}
candidates={candidates}
size={size}
className={className}
/>
)
}