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
+635
View File
@@ -0,0 +1,635 @@
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--text-primary);
--color-primary: var(--primary);
--color-primary-foreground: #ffffff;
--color-muted-foreground: var(--text-secondary);
--color-border: var(--border-subtle);
--radius-lg: var(--radius-card);
--radius-xl: var(--radius-panel);
}
:root {
color-scheme: light;
--background: #dcebf5;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-soft: rgba(59, 130, 246, 0.1);
--surface-sidebar: rgba(255, 255, 255, 0.68);
--surface-search: rgba(255, 255, 255, 0.72);
--surface-card: rgba(255, 255, 255, 0.62);
--surface-card-hover: rgba(255, 255, 255, 0.78);
--border-glass: rgba(255, 255, 255, 0.58);
--border-subtle: rgba(15, 23, 42, 0.08);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.1);
--shadow-medium: 0 20px 60px rgba(15, 23, 42, 0.12);
--shadow-panel: 0 24px 80px rgba(15, 23, 42, 0.1);
--radius-card: 20px;
--radius-panel: 24px;
--sidebar-expanded: 240px;
--sidebar-collapsed: 72px;
--layout-gap: 20px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
isolation: isolate;
margin: 0;
overflow-x: hidden;
color: var(--text-primary);
background:
radial-gradient(
circle at 12% 15%,
rgba(255, 245, 239, 0.92) 0,
transparent 34%
),
radial-gradient(
circle at 82% 14%,
rgba(194, 222, 246, 0.92) 0,
transparent 38%
),
radial-gradient(
circle at 48% 82%,
rgba(226, 239, 247, 0.72) 0,
transparent 44%
),
linear-gradient(135deg, #eef5fa 0%, #dceaf4 48%, #cbdff0 100%);
font-family:
Inter,
"SF Pro Display",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
sans-serif;
}
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
pointer-events: none;
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.2),
rgba(255, 255, 255, 0.08)
),
repeating-linear-gradient(
165deg,
transparent 0 84px,
rgba(255, 255, 255, 0.08) 85px 86px
);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
button,
input,
a {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.34);
outline-offset: 3px;
}
.glass-panel {
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
}
.app-shell {
--sidebar-width: var(--sidebar-expanded);
min-height: 100svh;
}
.app-shell[data-sidebar-collapsed="true"] {
--sidebar-width: var(--sidebar-collapsed);
}
.app-shell:not([data-hydrated="true"]) *,
.app-shell:not([data-hydrated="true"]) *::before,
.app-shell:not([data-hydrated="true"]) *::after {
transition: none !important;
}
.sidebar {
position: fixed;
inset: 20px auto 20px 20px;
z-index: 20;
display: flex;
width: var(--sidebar-width);
overflow: visible;
flex-direction: column;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-sidebar);
box-shadow: var(--shadow-panel);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
transition: width 180ms ease;
}
.sidebar__header {
position: relative;
display: flex;
min-height: 72px;
align-items: center;
gap: 10px;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.46);
}
.sidebar__brand {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
color: var(--text-primary);
text-decoration: none;
}
.sidebar__brand img {
display: block;
flex: none;
}
.sidebar__site-name {
overflow: hidden;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar__header > button {
flex: none;
color: var(--text-secondary);
border-radius: 12px;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__header > button:hover {
color: var(--primary-hover);
background: var(--primary-soft);
}
.sidebar__nav {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
gap: 8px;
overflow-y: auto;
padding: 12px 12px 16px;
}
.sidebar__categories {
display: flex;
flex-direction: column;
gap: 6px;
}
.sidebar__nav-item {
display: flex;
width: 100%;
height: 48px;
min-height: 48px;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 14px;
color: #334155;
text-decoration: none;
border: 0;
border-radius: 16px;
background: transparent;
transition:
color 180ms ease,
background 180ms ease;
}
.sidebar__nav-item svg {
width: 20px;
height: 20px;
flex: none;
}
.sidebar__nav-item--active {
color: var(--primary-hover);
background: var(--primary-soft);
font-weight: 650;
}
.sidebar__nav-item[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.82;
}
.sidebar__nav-item[aria-disabled="true"]:hover {
color: #334155;
background: rgba(255, 255, 255, 0.28);
}
.sidebar__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header {
justify-content: center;
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__brand {
justify-content: center;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__header > button {
position: absolute;
top: 22px;
right: -14px;
width: 28px;
height: 28px;
border: 1px solid var(--border-glass);
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav {
padding-inline: 10px;
}
.app-shell[data-sidebar-collapsed="true"] .sidebar__nav-item {
justify-content: center;
padding-inline: 0;
}
.app-shell__content {
min-height: 100svh;
margin-left: calc(var(--sidebar-width) + 40px);
transition: margin-left 180ms ease;
}
.home-content {
display: grid;
min-height: 100svh;
place-items: start center;
padding: clamp(108px, 14vh, 156px) 40px 64px;
}
.home-content__inner {
width: min(100%, 880px);
}
.clock-greeting {
text-align: center;
}
.clock-greeting__time {
min-height: 1.2em;
margin: 0;
color: var(--text-primary);
font-size: clamp(44px, 4.2vw, 56px);
font-weight: 700;
line-height: 1.16;
letter-spacing: -0.035em;
text-wrap: balance;
}
.clock-greeting__date {
margin: 12px 0 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.6;
}
.search-box {
position: relative;
z-index: 10;
width: min(100%, 760px);
margin: 40px auto 0;
}
.search-box__form {
display: flex;
height: 68px;
align-items: center;
border: 1px solid var(--border-glass);
border-radius: var(--radius-panel);
background: var(--surface-search);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
background 180ms ease;
}
.search-box__form:focus-within {
border-color: rgba(59, 130, 246, 0.26);
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 0 0 4px rgba(59, 130, 246, 0.09),
var(--shadow-medium);
}
.search-provider-menu__trigger {
min-width: 126px;
height: 48px;
justify-content: flex-start;
margin-left: 10px;
padding-inline: 14px;
color: #334155;
border-radius: 16px;
}
.search-provider-menu__trigger:hover,
.search-provider-menu__trigger[aria-expanded="true"] {
color: var(--primary-hover);
background: var(--primary-soft);
}
.search-box__icon {
width: 20px;
height: 20px;
flex: none;
margin-left: 10px;
color: var(--text-muted);
}
.search-box__input {
min-width: 0;
height: 100%;
flex: 1;
padding: 0 14px;
color: var(--text-primary);
font-size: 16px;
line-height: 1.5;
border: 0;
outline: 0;
background: transparent;
}
.search-box__input::placeholder {
color: var(--text-muted);
opacity: 1;
}
.search-box__shortcut {
display: inline-flex;
min-width: 50px;
height: 30px;
flex: none;
align-items: center;
justify-content: center;
margin-right: 16px;
padding-inline: 9px;
color: var(--text-muted);
font-family: inherit;
font-size: 12px;
line-height: 1;
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 9px;
background: rgba(255, 255, 255, 0.42);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.68);
}
.local-search-results {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 30;
width: 100%;
max-height: min(520px, calc(100vh - 220px));
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border-glass);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-medium);
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.local-search-results__option {
display: flex;
min-height: 58px;
align-items: center;
gap: 12px;
padding: 9px 12px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid transparent;
border-radius: 14px;
transition:
color 180ms ease,
background 180ms ease,
border-color 180ms ease;
}
.local-search-results__option[aria-selected="true"] {
color: var(--primary-hover);
border-color: rgba(59, 130, 246, 0.08);
background: var(--primary-soft);
}
.local-search-results__copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 3px;
}
.local-search-results__title,
.local-search-results__category {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.local-search-results__title {
font-size: 14px;
font-weight: 650;
}
.local-search-results__category {
color: var(--text-secondary);
font-size: 12px;
}
.local-search-results__empty {
display: grid;
min-height: 96px;
place-items: center;
margin: 0;
color: var(--text-secondary);
font-size: 14px;
}
.quick-links {
width: min(100%, 880px);
margin-top: 44px;
}
.quick-links__title {
margin: 0 0 18px;
color: var(--text-primary);
font-size: 16px;
font-weight: 650;
}
.quick-links__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px 18px;
}
.quick-link-card {
display: flex;
min-width: 0;
height: 76px;
align-items: center;
gap: 14px;
padding: 0 20px;
color: var(--text-primary);
text-decoration: none;
border: 1px solid var(--border-glass);
border-radius: var(--radius-card);
background: var(--surface-card);
box-shadow: var(--shadow-soft);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
transition:
transform 180ms ease,
background 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
}
.quick-link-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.76);
background: var(--surface-card-hover);
box-shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
.quick-link-card > span:last-child {
min-width: 0;
overflow: hidden;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-icon {
position: relative;
display: inline-flex;
overflow: hidden;
flex: none;
align-items: center;
justify-content: center;
border-radius: 11px;
}
.site-icon svg,
.site-icon img {
display: block;
max-width: 100%;
max-height: 100%;
}
.site-icon img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10px;
}
.site-icon__image {
position: absolute;
inset: 0;
}
.site-icon__image--loading {
opacity: 0;
}
.site-icon--initial::before {
content: attr(data-initial);
}
.site-icon--initial {
color: var(--primary-hover);
font-size: 15px;
font-weight: 700;
background: rgba(219, 234, 254, 0.7);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
}
.local-search-results,
.sidebar__nav {
scrollbar-color: rgba(100, 116, 139, 0.32) transparent;
scrollbar-width: thin;
}
@media (max-width: 1199px) {
:root {
--sidebar-expanded: 220px;
}
.home-content {
padding-inline: 28px;
}
.quick-links__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata, Viewport } from "next"
import "./globals.css"
export const metadata: Metadata = {
title: "Polaris — 个人导航起始页",
description: "快速搜索并访问常用网站的个人导航起始页。",
icons: {
icon: "/brand/logo.svg",
},
}
export const viewport: Viewport = {
colorScheme: "light",
themeColor: "#dcebf5",
}
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { HomeContent } from "@/features/home/components/home-content"
import { AppShell } from "@/features/navigation/components/app-shell"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings } from "@/services/settings"
export default function HomePage() {
const categories = getCategories()
const links = getLinks()
const pinnedLinks = getPinnedLinks()
const settings = getSettings()
return (
<AppShell
categories={categories}
siteName={settings.siteName}
defaultCollapsed={settings.sidebar.defaultCollapsed}
>
<HomeContent
categories={categories}
links={links}
pinnedLinks={pinnedLinks}
settings={settings}
/>
</AppShell>
)
}
@@ -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}
/>
)
}
@@ -0,0 +1,50 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost: "hover:bg-primary/10 hover:text-primary",
outline:
"border border-border bg-transparent hover:bg-primary/10 hover:text-primary",
},
size: {
default: "h-9 px-4 py-2",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,194 @@
"use client"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuItem({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2.5 py-1.5 text-xs font-medium text-slate-500 data-[inset]:pl-8",
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-slate-200/80", className)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-lg py-2 pr-2.5 pl-8 text-sm outline-none select-none focus:bg-blue-50 focus:text-blue-700",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-lg px-2.5 py-2 text-sm outline-none select-none focus:bg-blue-50 data-[inset]:pl-8 data-[state=open]:bg-blue-50",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-40 overflow-hidden rounded-xl border border-white/60 bg-white/90 p-1.5 text-slate-900 shadow-xl backdrop-blur-xl",
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
@@ -0,0 +1,48 @@
"use client"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 240,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipContent({
className,
sideOffset = 8,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 rounded-lg bg-slate-900 px-3 py-1.5 text-xs text-white shadow-lg",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="fill-slate-900" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -0,0 +1,51 @@
[
{
"id": "ai",
"title": "AI 工具",
"description": "精选常用 AI 工具与服务,辅助创作、开发和效率提升。",
"icon": "sparkles",
"sort": 10
},
{
"id": "dev",
"title": "开发工具",
"description": "高效的开发工具和资源集合,提升你的开发体验和效率。",
"icon": "code",
"sort": 20
},
{
"id": "design",
"title": "设计资源",
"description": "常用设计工具、素材与灵感资源集合。",
"icon": "palette",
"sort": 30
},
{
"id": "learning",
"title": "学习资料",
"description": "整理常用学习平台、文档、课程和知识资源。",
"icon": "book-open",
"sort": 40
},
{
"id": "efficiency",
"title": "效率工具",
"description": "提升日常工作流效率的工具入口。",
"icon": "zap",
"sort": 50
},
{
"id": "media",
"title": "娱乐媒体",
"description": "常用影音、社区和内容平台。",
"icon": "circle-play",
"sort": 60
},
{
"id": "life",
"title": "生活服务",
"description": "日常生活、服务查询和实用网站入口。",
"icon": "coffee",
"sort": 70
}
]
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest"
import categories from "@/data/categories.json"
import links from "@/data/links.json"
import settings from "@/data/settings.json"
import { SEARCH_PROVIDERS } from "@/types/search"
const hasUniqueValues = (values: string[]) =>
new Set(values).size === values.length
describe("Polaris 静态数据", () => {
it("包含 7 个唯一且结构完整的分类", () => {
expect(categories).toHaveLength(7)
expect(hasUniqueValues(categories.map(({ id }) => id))).toBe(true)
for (const category of categories) {
expect(category).toEqual({
id: expect.any(String),
title: expect.any(String),
description: expect.any(String),
icon: expect.any(String),
sort: expect.any(Number),
})
expect(category.id).not.toBe("")
expect(category.title).not.toBe("")
expect(Number.isFinite(category.sort)).toBe(true)
}
})
it("包含 20 条合法链接且分类引用均存在", () => {
const categoryIds = new Set(categories.map(({ id }) => id))
expect(links).toHaveLength(20)
expect(hasUniqueValues(links.map(({ id }) => id))).toBe(true)
for (const link of links) {
expect(link.id).toEqual(expect.any(String))
expect(link.title).toEqual(expect.any(String))
expect(link.description).toEqual(expect.any(String))
expect(link.url).toEqual(expect.any(String))
expect(link.categoryId).toEqual(expect.any(String))
expect(link.pinned).toEqual(expect.any(Boolean))
expect(link.keywords).toEqual(expect.any(Array))
expect(link.sort).toEqual(expect.any(Number))
expect(link.id).not.toBe("")
expect(link.title).not.toBe("")
expect(() => new URL(link.url)).not.toThrow()
// 跨分类常用链接仍必须归属于真实分类,避免未来 API 出现悬空外键。
expect(categoryIds.has(link.categoryId)).toBe(true)
expect(
link.keywords.every((keyword) => typeof keyword === "string"),
).toBe(true)
expect(Number.isFinite(link.sort)).toBe(true)
if (link.icon !== undefined) expect(typeof link.icon).toBe("string")
if (!link.pinned) expect(link.pinnedSort).toBeUndefined()
}
})
it("8 条常用链接拥有唯一 pinnedSort", () => {
const pinned = links.filter(({ pinned }) => pinned)
const pinnedSorts = pinned.map(({ pinnedSort }) => pinnedSort)
expect(pinned).toHaveLength(8)
expect(pinnedSorts.every(Number.isFinite)).toBe(true)
expect(new Set(pinnedSorts).size).toBe(8)
})
it("设置包含完整且合法的嵌套契约", () => {
expect(settings).toEqual({
siteName: "Polaris",
theme: "light-glass",
search: {
defaultProvider: "google",
enabledProviders: [...SEARCH_PROVIDERS],
},
background: {
type: "gradient",
url: null,
blur: expect.any(Number),
overlay: expect.any(Number),
},
sidebar: {
defaultCollapsed: expect.any(Boolean),
},
})
expect(SEARCH_PROVIDERS).toContain(settings.search.defaultProvider)
expect(Number.isFinite(settings.background.blur)).toBe(true)
expect(Number.isFinite(settings.background.overlay)).toBe(true)
})
})
+230
View File
@@ -0,0 +1,230 @@
[
{
"id": "github",
"title": "GitHub",
"description": "全球最大的代码托管平台",
"url": "https://github.com",
"icon": "si:github",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 10,
"keywords": ["代码", "仓库", "开源", "git"],
"sort": 10
},
{
"id": "gitlab",
"title": "GitLab",
"description": "一体化 DevOps 平台",
"url": "https://gitlab.com",
"icon": "si:gitlab",
"categoryId": "dev",
"pinned": false,
"keywords": ["代码", "仓库", "devops", "ci", "cd"],
"sort": 20
},
{
"id": "bitbucket",
"title": "Bitbucket",
"description": "代码托管和协作开发",
"url": "https://bitbucket.org",
"icon": "si:bitbucket",
"categoryId": "dev",
"pinned": false,
"keywords": ["代码", "仓库", "git"],
"sort": 30
},
{
"id": "vscode",
"title": "VS Code",
"description": "强大的代码编辑器",
"url": "https://code.visualstudio.com",
"icon": "si:vscode",
"categoryId": "dev",
"pinned": false,
"keywords": ["编辑器", "ide", "代码"],
"sort": 40
},
{
"id": "jetbrains",
"title": "JetBrains",
"description": "智能开发工具套件",
"url": "https://www.jetbrains.com",
"icon": "si:jetbrains",
"categoryId": "dev",
"pinned": false,
"keywords": ["ide", "开发工具", "编辑器"],
"sort": 50
},
{
"id": "goland",
"title": "GoLand",
"description": "Go 语言 IDE",
"url": "https://www.jetbrains.com/go/",
"icon": "si:goland",
"categoryId": "dev",
"pinned": false,
"keywords": ["go", "golang", "ide"],
"sort": 60
},
{
"id": "postman",
"title": "Postman",
"description": "API 开发和测试工具",
"url": "https://www.postman.com",
"icon": "si:postman",
"categoryId": "dev",
"pinned": false,
"keywords": ["api", "接口", "测试"],
"sort": 70
},
{
"id": "docker",
"title": "Docker",
"description": "容器化平台",
"url": "https://www.docker.com",
"icon": "si:docker",
"categoryId": "dev",
"pinned": false,
"keywords": ["容器", "部署", "镜像"],
"sort": 80
},
{
"id": "kubernetes",
"title": "Kubernetes",
"description": "容器编排平台",
"url": "https://kubernetes.io",
"icon": "si:kubernetes",
"categoryId": "dev",
"pinned": false,
"keywords": ["k8s", "容器", "编排", "云原生"],
"sort": 90
},
{
"id": "vercel",
"title": "Vercel",
"description": "前端部署与托管平台",
"url": "https://vercel.com",
"icon": "si:vercel",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 70,
"keywords": ["部署", "前端", "托管"],
"sort": 100
},
{
"id": "netlify",
"title": "Netlify",
"description": "现代化的开发平台",
"url": "https://www.netlify.com",
"icon": "si:netlify",
"categoryId": "dev",
"pinned": false,
"keywords": ["部署", "前端", "托管"],
"sort": 110
},
{
"id": "render",
"title": "Render",
"description": "云应用托管平台",
"url": "https://render.com",
"icon": "si:render",
"categoryId": "dev",
"pinned": false,
"keywords": ["部署", "后端", "托管", "云服务"],
"sort": 120
},
{
"id": "npm",
"title": "npm",
"description": "JavaScript 包管理器",
"url": "https://www.npmjs.com",
"icon": "si:npm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 130
},
{
"id": "pnpm",
"title": "pnpm",
"description": "快速的包管理器",
"url": "https://pnpm.io",
"icon": "si:pnpm",
"categoryId": "dev",
"pinned": false,
"keywords": ["javascript", "node", "包管理"],
"sort": 140
},
{
"id": "figma",
"title": "Figma",
"description": "界面设计协作工具",
"url": "https://www.figma.com",
"icon": "si:figma",
"categoryId": "dev",
"pinned": true,
"pinnedSort": 50,
"keywords": ["设计", "ui", "协作"],
"sort": 150
},
{
"id": "chatgpt",
"title": "ChatGPT",
"description": "智能对话与创作助手",
"url": "https://chatgpt.com",
"icon": "si:openai",
"categoryId": "ai",
"pinned": true,
"pinnedSort": 20,
"keywords": ["ai", "人工智能", "对话", "写作"],
"sort": 10
},
{
"id": "gmail",
"title": "Gmail",
"description": "Google 电子邮件服务",
"url": "https://mail.google.com",
"icon": "si:gmail",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 30,
"keywords": ["邮件", "邮箱", "google", "mail"],
"sort": 10
},
{
"id": "notion",
"title": "Notion",
"description": "一体化知识与协作空间",
"url": "https://www.notion.so",
"icon": "si:notion",
"categoryId": "efficiency",
"pinned": true,
"pinnedSort": 40,
"keywords": ["笔记", "知识库", "协作", "文档"],
"sort": 20
},
{
"id": "youtube",
"title": "YouTube",
"description": "全球视频内容平台",
"url": "https://www.youtube.com",
"icon": "si:youtube",
"categoryId": "media",
"pinned": true,
"pinnedSort": 60,
"keywords": ["视频", "影音", "直播", "google"],
"sort": 10
},
{
"id": "bilibili",
"title": "Bilibili",
"description": "年轻人的视频社区",
"url": "https://www.bilibili.com",
"icon": "si:bilibili",
"categoryId": "media",
"pinned": true,
"pinnedSort": 80,
"keywords": ["哔哩哔哩", "视频", "动漫", "社区"],
"sort": 20
}
]
@@ -0,0 +1,17 @@
{
"siteName": "Polaris",
"theme": "light-glass",
"search": {
"defaultProvider": "google",
"enabledProviders": ["google", "bing", "github", "local"]
},
"background": {
"type": "gradient",
"url": null,
"blur": 8,
"overlay": 0.32
},
"sidebar": {
"defaultCollapsed": false
}
}
@@ -0,0 +1,60 @@
import { act, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClockGreeting } from "./clock-greeting"
describe("ClockGreeting", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 20, 21, 56, 30))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it("挂载后显示当前问候、时间和日期", () => {
render(<ClockGreeting />)
expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56")
expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument()
})
it("在下一分钟边界刷新", () => {
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("21:57")
})
it("延迟执行后重新对齐下一个真实分钟边界", () => {
const setTimeoutSpy = vi.spyOn(window, "setTimeout")
vi.setSystemTime(new Date(2026, 7, 20, 9, 6, 30))
render(<ClockGreeting />)
const delayedTick = setTimeoutSpy.mock.calls[0]?.[0]
expect(typeof delayedTick).toBe("function")
if (typeof delayedTick !== "function") throw new Error("Missing clock tick")
vi.setSystemTime(new Date(2026, 7, 20, 9, 7, 12))
act(() => delayedTick())
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 48_000)
})
it("跨午夜同步更新日期与问候语", () => {
vi.setSystemTime(new Date(2026, 7, 20, 23, 59, 30))
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("夜深了,00:00")
expect(screen.getByText("2026 年 8 月 21 日 星期五")).toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
"use client"
import { useClock } from "@/features/home/hooks/use-clock"
import {
formatDate,
formatTime,
getGreeting,
} from "@/features/home/lib/date-time"
export function ClockGreeting() {
const now = useClock()
if (!now) {
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer">
正在同步时间…
</p>
<p className="clock-greeting__date" aria-hidden="true">
&nbsp;
</p>
</section>
)
}
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer" aria-live="off">
{getGreeting(now.getHours())},{formatTime(now)}
</p>
<p className="clock-greeting__date">{formatDate(now)}</p>
</section>
)
}
@@ -0,0 +1,34 @@
import { ClockGreeting } from "@/features/home/components/clock-greeting"
import { QuickLinks } from "@/features/home/components/quick-links"
import { SearchBox } from "@/features/home/components/search-box"
import type { Category, NavLink } from "@/types/navigation"
import type { AppSettings } from "@/types/settings"
type HomeContentProps = {
categories: Category[]
links: NavLink[]
pinnedLinks: NavLink[]
settings: AppSettings
}
export function HomeContent({
categories,
links,
pinnedLinks,
settings,
}: HomeContentProps) {
return (
<main className="home-content">
<div className="home-content__inner">
<ClockGreeting />
<SearchBox
links={links}
categories={categories}
defaultProvider={settings.search.defaultProvider}
enabledProviders={settings.search.enabledProviders}
/>
<QuickLinks links={pinnedLinks} />
</div>
</main>
)
}
@@ -0,0 +1,53 @@
import type { MutableRefObject } from "react"
import { SiteIcon } from "@/components/shared/site-icon"
import type { LocalSearchResult } from "@/types/search"
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: MutableRefObject<Array<HTMLAnchorElement | null>>
onActiveIndexChange: (index: number) => void
}
export function LocalSearchResults({
listboxId,
results,
activeIndex,
optionRefs,
onActiveIndexChange,
}: LocalSearchResultsProps) {
return (
<div className="local-search-results" role="listbox" id={listboxId}>
{results.length === 0 ? (
<p className="local-search-results__empty">没有匹配的网站</p>
) : (
results.map(({ link, categoryTitle }, index) => (
<a
key={link.id}
ref={(element) => {
optionRefs.current[index] = element
}}
id={`${listboxId}-option-${index}`}
className="local-search-results__option"
role="option"
aria-selected={index === activeIndex}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onMouseMove={() => onActiveIndexChange(index)}
>
<SiteIcon link={link} size={32} />
<span className="local-search-results__copy">
<span className="local-search-results__title">{link.title}</span>
<span className="local-search-results__category">
{categoryTitle}
</span>
</span>
</a>
))
)}
</div>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { QuickLinks } from "./quick-links"
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: [],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "AI 助手",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: [],
sort: 10,
},
]
describe("QuickLinks", () => {
it("按输入顺序渲染安全的新标签页链接", () => {
render(<QuickLinks links={links} />)
const renderedLinks = screen.getAllByRole("link")
expect(renderedLinks.map((link) => link.textContent)).toEqual([
"GitHub",
"ChatGPT",
])
for (const link of renderedLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("只展示图标和名称,不展示描述", () => {
render(<QuickLinks links={links} />)
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,26 @@
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
<section className="quick-links" aria-labelledby="quick-links-title">
<h2 id="quick-links-title" className="quick-links__title">
常用链接
</h2>
<div className="quick-links__grid">
{links.map((link) => (
<a
key={link.id}
className="quick-link-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon link={link} size={38} />
<span>{link.title}</span>
</a>
))}
</div>
</section>
)
}
@@ -0,0 +1,172 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { ComponentProps } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { SearchBox } from "./search-box"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 10,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 20,
},
]
function renderSearchBox(
overrides: Partial<ComponentProps<typeof SearchBox>> = {},
) {
const onExternalNavigate = vi.fn()
const renderResult = render(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
onExternalNavigate={onExternalNavigate}
{...overrides}
/>,
)
return { onExternalNavigate, ...renderResult }
}
describe("SearchBox", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("挂载后自动聚焦,并响应 Ctrl+K 与 Meta+K", async () => {
renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await waitFor(() => expect(input).toHaveFocus())
input.blur()
fireEvent.keyDown(window, { key: "k", ctrlKey: true })
expect(input).toHaveFocus()
input.blur()
fireEvent.keyDown(window, { key: "k", metaKey: true })
expect(input).toHaveFocus()
})
it("Google 模式回车只执行当前页外部导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await user.type(input, "Next.js 中文{Enter}")
const url = new URL(onExternalNavigate.mock.calls[0][0])
expect(url.hostname).toBe("www.google.com")
expect(url.searchParams.get("q")).toBe("Next.js 中文")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("空白查询不导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
await user.type(screen.getByRole("combobox"), " {Enter}")
expect(onExternalNavigate).not.toHaveBeenCalled()
})
it("切换 Local 后实时显示结果并持久化 Provider", async () => {
const user = userEvent.setup()
renderSearchBox()
await user.click(
screen.getByRole("button", { name: "选择搜索方式,当前为 Google" }),
)
await user.click(screen.getByRole("menuitemradio", { name: "本地" }))
await user.type(screen.getByRole("combobox"), "git")
expect(screen.getByRole("listbox")).toBeInTheDocument()
expect(screen.getAllByRole("option")).toHaveLength(2)
expect(window.localStorage.getItem("polaris.search.provider")).toBe("local")
})
it("Local 模式支持上下选择、Enter 与 Esc", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
const input = screen.getByRole("combobox")
await user.type(input, "git")
const options = screen.getAllByRole("option")
expect(options[0]).toHaveAttribute("aria-selected", "true")
expect(options[0]).toHaveAttribute("target", "_blank")
expect(options[0]).toHaveAttribute("rel", "noopener noreferrer")
await user.keyboard("{ArrowUp}")
expect(options[1]).toHaveAttribute("aria-selected", "true")
await user.keyboard("{ArrowDown}")
expect(options[0]).toHaveAttribute("aria-selected", "true")
fireEvent.mouseMove(options[1])
expect(options[1]).toHaveAttribute("aria-selected", "true")
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})
await user.keyboard("{Enter}")
expect(clickSpy).toHaveBeenCalledOnce()
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("Local 无结果显示空状态", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
await user.type(screen.getByRole("combobox"), "不存在的网站")
expect(screen.getByText("没有匹配的网站")).toBeInTheDocument()
})
it("恢复合法 Provider,非法或未启用值回退默认 Provider", async () => {
window.localStorage.setItem("polaris.search.provider", "local")
const { unmount } = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 本地/ }),
).toBeInTheDocument(),
)
unmount()
window.localStorage.setItem("polaris.search.provider", "invalid")
const secondRender = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
secondRender.unmount()
window.localStorage.setItem("polaris.search.provider", "local")
renderSearchBox({ enabledProviders: ["google", "bing"] })
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
})
})
@@ -0,0 +1,217 @@
"use client"
import { Search } from "lucide-react"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent,
} from "react"
import { LocalSearchResults } from "@/features/home/components/local-search-results"
import { SearchProviderMenu } from "@/features/home/components/search-provider-menu"
import { searchLocalLinks } from "@/features/home/lib/local-search"
import {
parseStoredProvider,
SEARCH_PROVIDER_STORAGE_KEY,
} from "@/features/home/lib/provider-preference"
import { buildSearchUrl } from "@/features/home/lib/search-providers"
import type { Category, NavLink } from "@/types/navigation"
import type { SearchProvider } from "@/types/search"
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
function readStoredProvider(
defaultProvider: SearchProvider,
enabledProviders: SearchProvider[],
): SearchProvider {
try {
const storedProvider = parseStoredProvider(
window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY),
defaultProvider,
)
return enabledProviders.includes(storedProvider)
? storedProvider
: defaultProvider
} catch {
return defaultProvider
}
}
export function SearchBox({
links,
categories,
defaultProvider,
enabledProviders,
onExternalNavigate,
}: SearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([])
const listboxId = useId()
const [query, setQuery] = useState("")
const [selectedProvider, setSelectedProvider] =
useState<SearchProvider | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
// Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。
const restoredProvider = hydrated
? readStoredProvider(defaultProvider, enabledProviders)
: defaultProvider
const provider = selectedProvider ?? restoredProvider
const results = useMemo(
() =>
provider === "local" ? searchLocalLinks(query, links, categories, 8) : [],
[categories, links, provider, query],
)
const panelOpen = provider === "local" && query.trim().length > 0
const resolvedActiveIndex =
results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0
const activeOptionId =
panelOpen && results.length > 0
? `${listboxId}-option-${resolvedActiveIndex}`
: undefined
const shortcutHint =
hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K"
useEffect(() => {
inputRef.current?.focus()
const focusSearch = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener("keydown", focusSearch)
return () => window.removeEventListener("keydown", focusSearch)
}, [])
const changeProvider = (nextProvider: SearchProvider) => {
setSelectedProvider(nextProvider)
setActiveIndex(0)
try {
window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider)
} catch {
// Provider 已保留在当前组件状态,存储受限时不影响本次交互。
}
}
const openActiveLocalResult = () => {
if (results.length === 0) return
// 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。
optionRefs.current[resolvedActiveIndex]?.click()
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。
if (provider === "local") {
openActiveLocalResult()
return
}
const url = buildSearchUrl(provider, query)
if (!url) return
if (onExternalNavigate) onExternalNavigate(url)
else window.location.assign(url)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setQuery("")
setActiveIndex(0)
return
}
if (provider !== "local") return
if (event.key === "Enter") {
event.preventDefault()
openActiveLocalResult()
return
}
if (results.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => (index + 1) % results.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => (index - 1 + results.length) % results.length)
}
}
return (
<div className="search-box">
<form className="search-box__form" role="search" onSubmit={submitSearch}>
<SearchProviderMenu
provider={provider}
enabledProviders={enabledProviders}
onProviderChange={changeProvider}
/>
<Search className="search-box__icon" aria-hidden="true" />
<input
ref={inputRef}
className="search-box__input"
role="combobox"
aria-label="搜索网站、书签或关键词"
aria-autocomplete="list"
aria-expanded={panelOpen}
aria-controls={panelOpen ? listboxId : undefined}
aria-activedescendant={activeOptionId}
placeholder="搜索网站、书签或关键词..."
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={handleInputKeyDown}
/>
<kbd className="search-box__shortcut" aria-hidden="true">
{shortcutHint}
</kbd>
</form>
{panelOpen ? (
<LocalSearchResults
listboxId={listboxId}
results={results}
activeIndex={resolvedActiveIndex}
optionRefs={optionRefs}
onActiveIndexChange={setActiveIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,85 @@
"use client"
import {
Database,
GitFork,
Globe2,
Search,
type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
const PROVIDER_LABELS: Record<SearchProvider, string> = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
const PROVIDER_ICONS: Record<SearchProvider, LucideIcon> = {
google: Globe2,
bing: Search,
github: GitFork,
local: Database,
}
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
export function SearchProviderMenu({
provider,
enabledProviders,
onProviderChange,
}: SearchProviderMenuProps) {
const CurrentIcon = PROVIDER_ICONS[provider]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="search-provider-menu__trigger"
aria-label={`选择搜索方式,当前为 ${PROVIDER_LABELS[provider]}`}
>
<CurrentIcon aria-hidden="true" />
<span>{PROVIDER_LABELS[provider]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={provider}
onValueChange={(value) => {
if (isSearchProvider(value)) onProviderChange(value)
}}
>
{enabledProviders.map((enabledProvider) => {
const Icon = PROVIDER_ICONS[enabledProvider]
return (
<DropdownMenuRadioItem
key={enabledProvider}
value={enabledProvider}
>
<Icon aria-hidden="true" />
{PROVIDER_LABELS[enabledProvider]}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,61 @@
"use client"
import { useSyncExternalStore } from "react"
import { getDelayUntilNextMinute } from "@/features/home/lib/date-time"
type Listener = () => void
const listeners = new Set<Listener>()
let currentTime: Date | null = null
let timeoutId: number | undefined
function emitCurrentTime(): Date {
const nextTime = new Date()
currentTime = nextTime
listeners.forEach((listener) => listener())
return nextTime
}
function scheduleNextTick() {
const observedTime = emitCurrentTime()
// 每次 tick 都重新对齐;标签页休眠或主线程阻塞后不会把延迟永久带入后续分钟。
timeoutId = window.setTimeout(
scheduleNextTick,
getDelayUntilNextMinute(observedTime),
)
}
function startClock() {
scheduleNextTick()
}
function stopClock() {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
timeoutId = undefined
currentTime = null
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) startClock()
return () => {
listeners.delete(listener)
if (listeners.size === 0) stopClock()
}
}
function getSnapshot() {
return currentTime
}
function getServerSnapshot() {
return null
}
export function useClock(): Date | null {
// 服务端与 hydration 首帧固定为空;订阅建立后再读取浏览器时间,避免首屏不一致。
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import {
formatDate,
formatTime,
getDelayUntilNextMinute,
getGreeting,
} from "./date-time"
describe("首页日期与问候", () => {
it.each([
[0, "夜深了"],
[4, "夜深了"],
[5, "早上好"],
[11, "早上好"],
[12, "中午好"],
[13, "中午好"],
[14, "下午好"],
[17, "下午好"],
[18, "晚上好"],
[23, "晚上好"],
])("%i 时显示 %s", (hour, expected) => {
expect(getGreeting(hour)).toBe(expected)
})
it("拒绝无效小时", () => {
expect(() => getGreeting(-1)).toThrow("hour must be between 0 and 23")
expect(() => getGreeting(24)).toThrow("hour must be between 0 and 23")
})
it("格式化中文日期与两位时间", () => {
const date = new Date(2026, 7, 20, 9, 6, 30)
expect(formatTime(date)).toBe("09:06")
expect(formatDate(date)).toBe("2026 年 8 月 20 日 星期四")
})
it("计算到下一分钟边界的延迟", () => {
expect(getDelayUntilNextMinute(new Date(2026, 7, 20, 9, 6, 30, 250))).toBe(
29_750,
)
})
})
@@ -0,0 +1,34 @@
const WEEKDAYS = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
] as const
export function getGreeting(hour: number): string {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RangeError("hour must be between 0 and 23")
}
if (hour < 5) return "夜深了"
if (hour < 12) return "早上好"
if (hour < 14) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
}
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
export function formatDate(date: Date): string {
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${WEEKDAYS[date.getDay()]}`
}
export function getDelayUntilNextMinute(date: Date): number {
return 60_000 - (date.getSeconds() * 1_000 + date.getMilliseconds())
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { searchLocalLinks } from "./local-search"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
{ id: "ai", title: "AI 工具", description: "", icon: "sparkles", sort: 20 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 20,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "智能对话与写作",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: ["人工智能", "写作"],
sort: 10,
},
]
describe("Local 搜索", () => {
it("标题完全匹配优先于标题包含和关键词", () => {
expect(searchLocalLinks("github", links, categories)[0].link.id).toBe(
"github",
)
})
it("匹配关键词、描述、分类标题和分类 ID", () => {
expect(searchLocalLinks("人工智能", links, categories)[0].link.id).toBe(
"chatgpt",
)
expect(searchLocalLinks("DevOps", links, categories)[0].link.id).toBe(
"gitlab",
)
expect(
searchLocalLinks("开发工具", links, categories).map(
({ link }) => link.id,
),
).toEqual(["gitlab", "github"])
expect(searchLocalLinks("ai", links, categories)[0].link.id).toBe("chatgpt")
})
it("同分时按 sort 再按标题稳定排序", () => {
expect(
searchLocalLinks("git", links, categories).map(({ link }) => link.id),
).toEqual(["gitlab", "github"])
const titleTies: NavLink[] = [
{
...links[0],
id: "beta",
title: "贝塔",
keywords: ["工具"],
sort: 10,
},
{
...links[0],
id: "alpha",
title: "阿尔法",
keywords: ["工具"],
sort: 10,
},
]
expect(
searchLocalLinks("工具", titleTies, categories).map(
({ link }) => link.id,
),
).toEqual(["alpha", "beta"])
})
it("规范化大小写、全角字符和首尾空白并限制结果数量", () => {
expect(
searchLocalLinks(" GitHub ", links, categories, 1).map(
({ link }) => link.id,
),
).toEqual(["github"])
})
it("空查询返回空数组", () => {
expect(searchLocalLinks(" ", links, categories)).toEqual([])
})
})
@@ -0,0 +1,74 @@
import type { Category, NavLink } from "@/types/navigation"
import type { LocalSearchResult } from "@/types/search"
function normalize(value: string): string {
return value.normalize("NFKC").trim().toLocaleLowerCase("zh-CN")
}
function textScore(
value: string,
query: string,
exact: number,
prefix: number,
contains: number,
): number {
if (value === query) return exact
if (value.startsWith(query)) return prefix
if (value.includes(query)) return contains
return 0
}
function scoreLink(
link: NavLink,
category: Category | undefined,
query: string,
): number {
const title = normalize(link.title)
const keywordScore = Math.max(
0,
...link.keywords.map((keyword) =>
textScore(normalize(keyword), query, 300, 260, 240),
),
)
// 各区间不重叠,保证“标题 > 关键词 > 描述 > 分类”的规则不会被字段数量反转。
return Math.max(
textScore(title, query, 500, 400, 350),
keywordScore,
normalize(link.description).includes(query) ? 180 : 0,
category ? textScore(normalize(category.title), query, 140, 130, 120) : 0,
category ? textScore(normalize(category.id), query, 100, 90, 80) : 0,
)
}
export function searchLocalLinks(
query: string,
links: NavLink[],
categories: Category[],
limit = 8,
): LocalSearchResult[] {
const normalizedQuery = normalize(query)
if (!normalizedQuery || limit <= 0) return []
const categoryById = new Map(
categories.map((category) => [category.id, category]),
)
return links
.map((link) => {
const category = categoryById.get(link.categoryId)
return {
link,
categoryTitle: category?.title ?? "未分类",
score: scoreLink(link, category, normalizedQuery),
}
})
.filter(({ score }) => score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.link.sort - b.link.sort ||
a.link.title.localeCompare(b.link.title, "zh-CN"),
)
.slice(0, limit)
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { parseStoredProvider } from "./provider-preference"
describe("Provider 持久化值", () => {
it("返回合法存储值", () => {
expect(parseStoredProvider("local", "google")).toBe("local")
})
it("非法或空值回退默认值", () => {
expect(parseStoredProvider("duckduckgo", "google")).toBe("google")
expect(parseStoredProvider(null, "bing")).toBe("bing")
})
})
@@ -0,0 +1,11 @@
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
export const SEARCH_PROVIDER_STORAGE_KEY = "polaris.search.provider"
export function parseStoredProvider(
value: string | null,
fallback: SearchProvider,
): SearchProvider {
return isSearchProvider(value) ? value : fallback
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest"
import { buildSearchUrl, isSearchProvider } from "./search-providers"
describe("外部搜索 Provider", () => {
it.each([
["google", "https://www.google.com/search"],
["bing", "https://www.bing.com/search"],
["github", "https://github.com/search"],
] as const)("为 %s 构建正确 URL", (provider, expectedBase) => {
const result = buildSearchUrl(provider, "Next.js 中文")
const url = new URL(result!)
expect(`${url.origin}${url.pathname}`).toBe(expectedBase)
expect(url.searchParams.get("q")).toBe("Next.js 中文")
})
it("对查询参数分隔符和百分号执行 URL 编码", () => {
const query = "a&next=1 %= #中文"
const url = new URL(buildSearchUrl("google", query)!)
expect(url.searchParams.get("q")).toBe(query)
expect([...url.searchParams.keys()]).toEqual(["q"])
})
it("空白查询不生成 URL", () => {
expect(buildSearchUrl("google", " ")).toBeNull()
})
it("识别合法 Provider", () => {
expect(isSearchProvider("local")).toBe(true)
expect(isSearchProvider("duckduckgo")).toBe(false)
expect(isSearchProvider(null)).toBe(false)
})
})
@@ -0,0 +1,30 @@
import {
SEARCH_PROVIDERS,
type ExternalSearchProvider,
type SearchProvider,
} from "@/types/search"
const SEARCH_ENDPOINTS: Record<ExternalSearchProvider, string> = {
google: "https://www.google.com/search",
bing: "https://www.bing.com/search",
github: "https://github.com/search",
}
export function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
export function buildSearchUrl(
provider: ExternalSearchProvider,
query: string,
): string | null {
const normalizedQuery = query.trim()
if (!normalizedQuery) return null
const url = new URL(SEARCH_ENDPOINTS[provider])
url.searchParams.set("q", normalizedQuery)
return url.toString()
}
@@ -0,0 +1,162 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category } from "@/types/navigation"
import { AppShell } from "./app-shell"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "开发", icon: "code", sort: 10 },
]
describe("AppShell", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("切换侧栏并持久化状态", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
const toggle = screen.getByRole("button", { name: "折叠侧边栏" })
fireEvent.click(toggle)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
expect(window.localStorage.getItem("polaris.sidebar.collapsed")).toBe(
"true",
)
})
it("hydration 后恢复持久化状态", async () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "true")
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
await waitFor(() => {
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-hydrated",
"true",
)
})
})
it("写入失败时内存偏好优先于旧持久化值", () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "false")
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("忽略 sessionStorage 的同名 storage 事件", () => {
window.localStorage.setItem("polaris.sidebar.collapsed", "false")
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
fireEvent(
window,
new StorageEvent("storage", {
key: "polaris.sidebar.collapsed",
storageArea: window.sessionStorage,
}),
)
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("存储不可用时仍保留当前会话内交互", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable")
})
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
fireEvent.click(screen.getByRole("button", { name: "折叠侧边栏" }))
expect(screen.getByTestId("app-shell")).toHaveAttribute(
"data-sidebar-collapsed",
"true",
)
})
it("分类项可聚焦但保持禁用", () => {
render(
<AppShell
categories={categories}
siteName="Polaris"
defaultCollapsed={false}
>
<main>首页内容</main>
</AppShell>,
)
expect(
screen.getByRole("button", {
name: "开发工具,分类页将在下一阶段提供",
}),
).toHaveAttribute("aria-disabled", "true")
expect(
screen.queryByRole("link", { name: /开发工具/ }),
).not.toBeInTheDocument()
})
})
@@ -0,0 +1,127 @@
"use client"
import { useCallback, useSyncExternalStore, type ReactNode } from "react"
import { Sidebar } from "@/features/navigation/components/sidebar"
import {
parseSidebarPreference,
SIDEBAR_STORAGE_KEY,
} from "@/features/navigation/lib/sidebar-preference"
import type { Category } from "@/types/navigation"
type AppShellProps = {
categories: Category[]
siteName: string
defaultCollapsed: boolean
children: ReactNode
}
type Listener = () => void
const listeners = new Set<Listener>()
let volatilePreference: boolean | undefined
function emitPreferenceChange() {
listeners.forEach((listener) => listener())
}
function handleStorage(event: StorageEvent) {
if (event.storageArea !== window.localStorage) return
if (event.key !== null && event.key !== SIDEBAR_STORAGE_KEY) return
volatilePreference = undefined
emitPreferenceChange()
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) window.addEventListener("storage", handleStorage)
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
window.removeEventListener("storage", handleStorage)
volatilePreference = undefined
}
}
}
function readPreference(fallback: boolean): boolean {
if (volatilePreference !== undefined) return volatilePreference
try {
return parseSidebarPreference(
window.localStorage.getItem(SIDEBAR_STORAGE_KEY),
fallback,
)
} catch {
return fallback
}
}
function writePreference(value: boolean) {
volatilePreference = value
try {
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(value))
volatilePreference = undefined
} catch {
// 存储不可用时仍保留当前会话内交互,不让隐私模式破坏页面。
}
emitPreferenceChange()
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
export function AppShell({
categories,
siteName,
defaultCollapsed,
children,
}: AppShellProps) {
const getSnapshot = useCallback(
() => readPreference(defaultCollapsed),
[defaultCollapsed],
)
const getServerSnapshot = useCallback(
() => defaultCollapsed,
[defaultCollapsed],
)
const collapsed = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot,
)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
const toggleSidebar = () => writePreference(!collapsed)
return (
<div
className="app-shell"
data-testid="app-shell"
data-hydrated={hydrated}
data-sidebar-collapsed={collapsed}
>
<Sidebar
categories={categories}
siteName={siteName}
collapsed={collapsed}
onToggle={toggleSidebar}
/>
<div className="app-shell__content">{children}</div>
</div>
)
}
@@ -0,0 +1,25 @@
import {
BookOpen,
CirclePlay,
Code2,
Coffee,
Palette,
Sparkles,
Zap,
type LucideIcon,
} from "lucide-react"
const CATEGORY_ICONS: Readonly<Record<string, LucideIcon>> = {
sparkles: Sparkles,
code: Code2,
palette: Palette,
"book-open": BookOpen,
zap: Zap,
"circle-play": CirclePlay,
coffee: Coffee,
}
export function CategoryIcon({ name }: { name: string }) {
const Icon = CATEGORY_ICONS[name] ?? Sparkles
return <Icon aria-hidden="true" strokeWidth={1.8} />
}
@@ -0,0 +1,105 @@
import { Home, PanelLeftClose, PanelLeftOpen } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { CategoryIcon } from "@/features/navigation/components/category-icon"
import { cn } from "@/lib/utils"
import type { Category } from "@/types/navigation"
type SidebarProps = {
categories: Category[]
siteName: string
collapsed: boolean
onToggle: () => void
}
export function Sidebar({
categories,
siteName,
collapsed,
onToggle,
}: SidebarProps) {
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose
const toggleLabel = collapsed ? "展开侧边栏" : "折叠侧边栏"
return (
<TooltipProvider>
<aside className="sidebar" aria-label="主导航">
<div className="sidebar__header">
<Link
className="sidebar__brand"
href="/"
aria-label={`${siteName} 首页`}
>
<Image
src="/brand/logo.svg"
alt=""
width={40}
height={40}
priority
/>
<span className={cn("sidebar__site-name", collapsed && "sr-only")}>
{siteName}
</span>
</Link>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={toggleLabel}
onClick={onToggle}
>
<ToggleIcon aria-hidden="true" />
</Button>
</div>
<nav className="sidebar__nav" aria-label="站点导航">
<Link
className="sidebar__nav-item sidebar__nav-item--active"
href="/"
aria-current="page"
>
<Home aria-hidden="true" />
<span className={cn("sidebar__label", collapsed && "sr-only")}>
首页
</span>
</Link>
<div className="sidebar__categories">
{/* 本次只验收首页;分类页完成前保持可见但禁用,避免用户进入 404。 */}
{categories.map((category) => (
<Tooltip key={category.id}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
className="sidebar__nav-item"
aria-disabled="true"
aria-label={`${category.title},分类页将在下一阶段提供`}
>
<CategoryIcon name={category.icon} />
<span
className={cn("sidebar__label", collapsed && "sr-only")}
>
{category.title}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
分类页将在下一阶段提供
</TooltipContent>
</Tooltip>
))}
</div>
</nav>
</aside>
</TooltipProvider>
)
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest"
import { parseSidebarPreference } from "./sidebar-preference"
describe("侧栏持久化值", () => {
it("解析显式布尔字符串", () => {
expect(parseSidebarPreference("true", false)).toBe(true)
expect(parseSidebarPreference("false", true)).toBe(false)
})
it("损坏或缺失值回退设置默认值", () => {
expect(parseSidebarPreference("collapsed", false)).toBe(false)
expect(parseSidebarPreference(null, true)).toBe(true)
})
})
@@ -0,0 +1,10 @@
export const SIDEBAR_STORAGE_KEY = "polaris.sidebar.collapsed"
export function parseSidebarPreference(
value: string | null,
fallback: boolean,
): boolean {
if (value === "true") return true
if (value === "false") return false
return fallback
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,10 @@
import categoriesData from "@/data/categories.json"
import type { Category } from "@/types/navigation"
const categories = categoriesData satisfies Category[]
export function getCategories(): Category[] {
return categories
.map((category) => ({ ...category }))
.sort((a, b) => a.sort - b.sort)
}
@@ -0,0 +1,25 @@
import linksData from "@/data/links.json"
import type { NavLink } from "@/types/navigation"
const links = linksData satisfies NavLink[]
function cloneLink(link: NavLink): NavLink {
return { ...link, keywords: [...link.keywords] }
}
export function getLinks(): NavLink[] {
return links.map(cloneLink)
}
export function getPinnedLinks(limit = 8): NavLink[] {
// 首页顺序必须独立于分类内 sort,否则 Vercel 与 Figma 无法同时满足两处设计。
return links
.filter((link) => link.pinned)
.map(cloneLink)
.sort(
(a, b) =>
(a.pinnedSort ?? Number.MAX_SAFE_INTEGER) -
(b.pinnedSort ?? Number.MAX_SAFE_INTEGER),
)
.slice(0, limit)
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest"
import { getCategories } from "@/services/categories"
import { getLinks, getPinnedLinks } from "@/services/links"
import { getSettings, parseSettings } from "@/services/settings"
describe("静态数据 service", () => {
it("按 sort 返回分类并提供新数组", () => {
const first = getCategories()
const second = getCategories()
expect(first.map(({ id }) => id)).toEqual([
"ai",
"dev",
"design",
"learning",
"efficiency",
"media",
"life",
])
expect(first).not.toBe(second)
})
it("按 pinnedSort 返回首页 8 个常用链接", () => {
expect(getPinnedLinks().map(({ id }) => id)).toEqual([
"github",
"chatgpt",
"gmail",
"notion",
"figma",
"youtube",
"vercel",
"bilibili",
])
})
it("限制常用链接数量且不修改原数据", () => {
const allLinks = getLinks()
expect(getPinnedLinks(3)).toHaveLength(3)
expect(getLinks()).toEqual(allLinks)
})
it("返回设置对象及嵌套集合的独立副本", () => {
const first = getSettings()
first.search.enabledProviders.pop()
expect(first).not.toBe(getSettings())
expect(getSettings().search.enabledProviders).toHaveLength(4)
})
it.each([
["非法主题", { ...getSettings(), theme: "dark" }],
[
"非法背景值",
{
...getSettings(),
background: { ...getSettings().background, blur: "8" },
},
],
[
"非法侧栏值",
{
...getSettings(),
sidebar: { defaultCollapsed: "false" },
},
],
[
"非法 Provider",
{
...getSettings(),
search: {
...getSettings().search,
defaultProvider: "duckduckgo",
},
},
],
["缺少站点名", { ...getSettings(), siteName: undefined }],
])("拒绝%s", (_label, value) => {
expect(() => parseSettings(value)).toThrow("Invalid Polaris settings")
})
})
@@ -0,0 +1,79 @@
import settingsData from "@/data/settings.json"
import { SEARCH_PROVIDERS, type SearchProvider } from "@/types/search"
import type { AppSettings } from "@/types/settings"
function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function invalidSettings(): never {
throw new TypeError("Invalid Polaris settings")
}
export function parseSettings(value: unknown): AppSettings {
if (!isRecord(value)) invalidSettings()
const search = value.search
const background = value.background
const sidebar = value.sidebar
if (
typeof value.siteName !== "string" ||
value.theme !== "light-glass" ||
!isRecord(search) ||
!isSearchProvider(search.defaultProvider) ||
!Array.isArray(search.enabledProviders) ||
!search.enabledProviders.every(isSearchProvider) ||
!isRecord(background) ||
(background.type !== "gradient" && background.type !== "image") ||
(background.url !== null && typeof background.url !== "string") ||
typeof background.blur !== "number" ||
!Number.isFinite(background.blur) ||
typeof background.overlay !== "number" ||
!Number.isFinite(background.overlay) ||
!isRecord(sidebar) ||
typeof sidebar.defaultCollapsed !== "boolean"
) {
invalidSettings()
}
// JSON 是外部数据边界;在模块加载时完整校验,避免强制断言掩盖配置拼写或类型错误。
return {
siteName: value.siteName,
theme: value.theme,
search: {
defaultProvider: search.defaultProvider,
enabledProviders: [...search.enabledProviders],
},
background: {
type: background.type,
url: background.url,
blur: background.blur,
overlay: background.overlay,
},
sidebar: {
defaultCollapsed: sidebar.defaultCollapsed,
},
}
}
const settings = parseSettings(settingsData)
export function getSettings(): AppSettings {
return {
...settings,
search: {
...settings.search,
enabledProviders: [...settings.search.enabledProviders],
},
background: { ...settings.background },
sidebar: { ...settings.sidebar },
}
}
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest"
import { cleanup } from "@testing-library/react"
import { afterEach } from "vitest"
afterEach(() => {
cleanup()
})
@@ -0,0 +1,21 @@
export type Category = {
id: string
title: string
description: string
icon: string
sort: number
}
export type NavLink = {
id: string
title: string
description: string
url: string
icon?: string
categoryId: string
pinned: boolean
/** 首页顺序与分类内顺序职责不同,避免复用 sort 造成顺序冲突。 */
pinnedSort?: number
keywords: string[]
sort: number
}
+12
View File
@@ -0,0 +1,12 @@
import type { NavLink } from "@/types/navigation"
export const SEARCH_PROVIDERS = ["google", "bing", "github", "local"] as const
export type SearchProvider = (typeof SEARCH_PROVIDERS)[number]
export type ExternalSearchProvider = Exclude<SearchProvider, "local">
export type LocalSearchResult = {
link: NavLink
categoryTitle: string
score: number
}
@@ -0,0 +1,19 @@
import type { SearchProvider } from "@/types/search"
export type AppSettings = {
siteName: string
theme: "light-glass"
search: {
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
}
background: {
type: "gradient" | "image"
url: string | null
blur: number
overlay: number
}
sidebar: {
defaultCollapsed: boolean
}
}