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,18 @@
import { createElement, type ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
type GlassPanelProps = ComponentPropsWithoutRef<"div"> & {
as?: "div" | "section" | "aside"
}
export function GlassPanel({
as = "div",
className,
...props
}: GlassPanelProps) {
return createElement(as, {
className: cn("glass-panel", className),
...props,
})
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { getIconCandidates } from "./icon-candidates"
const baseLink: NavLink = {
id: "example",
title: "Example",
description: "Example site",
url: "https://example.com/path",
categoryId: "dev",
pinned: false,
keywords: [],
sort: 10,
}
describe("站点图标候选链", () => {
it("local 图标后依次回退 favicon 和文字", () => {
expect(getIconCandidates({ ...baseLink, icon: "local:custom" })).toEqual([
{ kind: "image", src: "/icons/custom.svg" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("不允许 local 图标名越出 icons 目录", () => {
expect(
getIconCandidates({ ...baseLink, icon: "local:../brand/logo" })[0],
).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("已知 simple icon 优先使用本地 SVG 数据", () => {
expect(
getIconCandidates({ ...baseLink, icon: "si:github" })[0],
).toMatchObject({
kind: "simple",
slug: "github",
})
})
it("未知 simple icon 跳过无效映射", () => {
expect(getIconCandidates({ ...baseLink, icon: "si:not-real" })[0]).toEqual({
kind: "image",
src: "https://example.com/favicon.ico",
})
})
it("去重写法不同但语义相同的 favicon URL", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://example.com:443/favicon.ico",
}),
).toEqual([
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
it("显式 URL 图标失败后仍回退 favicon 和文字", () => {
expect(
getIconCandidates({
...baseLink,
icon: "url:https://cdn.example.com/icon.png",
}),
).toEqual([
{ kind: "image", src: "https://cdn.example.com/icon.png" },
{ kind: "image", src: "https://example.com/favicon.ico" },
{ kind: "initial", value: "E" },
])
})
})
@@ -0,0 +1,62 @@
import { SIMPLE_ICON_MAP } from "@/components/shared/simple-icon-map"
import type { NavLink } from "@/types/navigation"
const LOCAL_ICON_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
export type IconCandidate =
| { kind: "simple"; slug: string }
| { kind: "image"; src: string }
| { kind: "initial"; value: string }
function normalizeImageUrl(value: string): string | null {
try {
return new URL(value).toString()
} catch {
return null
}
}
function getFaviconUrl(url: string): string | null {
try {
return new URL("/favicon.ico", url).toString()
} catch {
return null
}
}
export function getIconCandidates(
link: Pick<NavLink, "title" | "url" | "icon">,
): IconCandidate[] {
const candidates: IconCandidate[] = []
const icon = link.icon?.trim()
if (icon?.startsWith("local:")) {
const name = icon.slice("local:".length)
// 本地图标只接受单个文件名,避免路径段将请求规范化到 /icons/ 之外。
if (LOCAL_ICON_NAME_PATTERN.test(name)) {
candidates.push({ kind: "image", src: `/icons/${name}.svg` })
}
} else if (icon?.startsWith("si:")) {
const slug = icon.slice("si:".length).toLowerCase()
if (SIMPLE_ICON_MAP[slug]) candidates.push({ kind: "simple", slug })
} else if (icon?.startsWith("url:")) {
const src = normalizeImageUrl(icon.slice("url:".length))
if (src) candidates.push({ kind: "image", src })
}
const favicon = getFaviconUrl(link.url)
if (
favicon &&
!candidates.some(
(candidate) => candidate.kind === "image" && candidate.src === favicon,
)
) {
candidates.push({ kind: "image", src: favicon })
}
candidates.push({
kind: "initial",
value: link.title.trim().charAt(0).toUpperCase() || "?",
})
return candidates
}
@@ -0,0 +1,44 @@
import {
siBilibili,
siBitbucket,
siDocker,
siFigma,
siGithub,
siGitlab,
siGmail,
siGoland,
siJetbrains,
siKubernetes,
siNetlify,
siNotion,
siNpm,
siPnpm,
siPostman,
siRender,
siVercel,
siYoutube,
type SimpleIcon,
} from "simple-icons"
export const SIMPLE_ICON_MAP: Readonly<Record<string, SimpleIcon>> = {
bilibili: siBilibili,
bitbucket: siBitbucket,
docker: siDocker,
figma: siFigma,
github: siGithub,
gitlab: siGitlab,
gmail: siGmail,
goland: siGoland,
jetbrains: siJetbrains,
kubernetes: siKubernetes,
netlify: siNetlify,
notion: siNotion,
npm: siNpm,
pnpm: siPnpm,
postman: siPostman,
render: siRender,
vercel: siVercel,
youtube: siYoutube,
}
// 上游 v16 不再提供 OpenAI 与 VS Code;这两项按统一候选链回退到 favicon 和文字。
@@ -0,0 +1,84 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { SiteIcon } from "./site-icon"
describe("SiteIcon", () => {
it("渲染已知 Simple Icon", () => {
const { container } = render(
<SiteIcon
link={{ title: "GitHub", url: "https://github.com", icon: "si:github" }}
size={28}
/>,
)
const icon = container.querySelector("svg")
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute("width", "28")
expect(icon).toHaveAttribute("height", "28")
})
it("远程图片加载完成前显示首字符占位", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
const wrapper = container.querySelector(".site-icon")
const image = container.querySelector("img")
expect(wrapper).toHaveAttribute("data-initial", "E")
expect(image).toHaveClass("site-icon__image--loading")
fireEvent.load(image!)
expect(wrapper).not.toHaveAttribute("data-initial")
expect(image).not.toHaveClass("site-icon__image--loading")
})
it("链接变化后从新候选链的首项重新开始", () => {
const { container, rerender } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
rerender(
<SiteIcon
link={{
title: "Other",
url: "https://other.example.com",
icon: "url:https://cdn.example.com/other.png",
}}
/>,
)
expect(container.querySelector("img")).toHaveAttribute(
"src",
"https://cdn.example.com/other.png",
)
})
it("远程图片连续失败后显示首字符", () => {
const { container } = render(
<SiteIcon
link={{
title: "Example",
url: "https://example.com",
icon: "url:https://cdn.example.com/icon.png",
}}
/>,
)
fireEvent.error(container.querySelector("img")!)
fireEvent.error(container.querySelector("img")!)
expect(screen.getByText("E")).toBeInTheDocument()
})
})
@@ -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}
/>
)
}