129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
"use client"
|
|
|
|
/* eslint-disable @next/next/no-img-element -- favicon 来源由数据动态决定,静态 Image 配置无法预枚举域名。 */
|
|
import { useEffect, useRef, 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 imageRef = useRef<HTMLImageElement | 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 }
|
|
|
|
// 缓存命中的图片可能在 React 附加 onLoad/onError(如水合)之前就完成或失败,
|
|
// 之后不再派发任何事件;挂载后按 complete/naturalWidth 补偿判定,
|
|
// 避免透明图片永久停留在首字母占位,也避免坏图卡住候选链。
|
|
// 候选推进有末位钳制,末位为首字母分支,不会形成无限循环。
|
|
useEffect(() => {
|
|
if (candidate.kind !== "image") return
|
|
const image = imageRef.current
|
|
if (!image?.complete) return
|
|
if (image.naturalWidth > 0) {
|
|
setLoadedImageSrc(candidate.src)
|
|
} else {
|
|
setCandidateIndex((index) => Math.min(index + 1, candidates.length - 1))
|
|
}
|
|
}, [candidate, candidates.length])
|
|
|
|
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}
|
|
ref={imageRef}
|
|
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}
|
|
/>
|
|
)
|
|
}
|