feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user