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