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