diff --git a/packages/blocks/package.json b/packages/blocks/package.json new file mode 100644 index 0000000..c18baf1 --- /dev/null +++ b/packages/blocks/package.json @@ -0,0 +1,44 @@ +{ + "name": "@workspace/blocks", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "format": "prettier --write \"**/*.{ts,tsx}\"", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@base-ui/utils": "^0.3.1", + "@floating-ui/utils": "^0.2.12", + "@hugeicons/core-free-icons": "^4.2.3", + "@hugeicons/react": "^1.1.9", + "@tanstack/react-hotkeys": "^0.10.0", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.18", + "@workspace/ui": "workspace:*", + "lucide-react": "^1.27.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/react": "^19", + "@types/react-dom": "^19", + "jsdom": "^30.0.1", + "typescript": "~6", + "vitest": "^4.1.10" + }, + "exports": { + "./globals.css": "./src/styles/globals.css", + "./appearance": "./src/blocks/appearance/index.ts", + "./chats": "./src/blocks/chats/index.ts", + "./components/*": "./src/components/*.tsx", + "./hooks/*": "./src/hooks/*.ts", + "./lib/*": "./src/lib/*.tsx", + "./layout": "./src/blocks/layout/index.ts", + "./navigation": "./src/blocks/navigation/index.ts", + "./notifications": "./src/blocks/notifications/index.ts" + } +} diff --git a/packages/blocks/src/blocks/appearance/controller.tsx b/packages/blocks/src/blocks/appearance/controller.tsx new file mode 100644 index 0000000..3950108 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/controller.tsx @@ -0,0 +1,88 @@ +import { useCallback, useState } from "react" +import { useHotkey } from "@tanstack/react-hotkeys" +import { useResolvedTheme, useUiState } from "./ui-state" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { + NavigationActionTarget, + navigationActions, +} from "../../lib/command-actions" +import { appearanceDialogHandle } from "./dialog" +import { CompactPreference, ModePreference } from "./preferences" +import { ThemeConfigPanel } from "./theme-config-panel" + +export function AppearanceController() { + const [isOpen, setIsOpen] = useState(false) + const [, setThemeMode] = useUiState("theme-mode") + const resolvedTheme = useResolvedTheme() + + const toggleDialog = useCallback(() => { + if (isOpen) { + appearanceDialogHandle.close() + } else { + appearanceDialogHandle.open(null) + } + }, [isOpen]) + + const toggleTheme = useCallback(() => { + setThemeMode(resolvedTheme === "dark" ? "light" : "dark") + }, [resolvedTheme, setThemeMode]) + + useHotkey("Mod+,", toggleDialog, { + ignoreInputs: true, + preventDefault: true, + stopPropagation: false, + meta: { + name: "界面外观", + description: "打开主题与布局偏好", + }, + }) + + useHotkey("Mod+D", toggleTheme, { + ignoreInputs: true, + preventDefault: true, + stopPropagation: false, + meta: { + name: "切换主题", + description: "在浅色与深色主题之间切换", + }, + }) + + return ( + + appearanceDialogHandle.open(null)} + /> + + + 界面外观 + 主题与布局偏好 + + + + + + + + + + + + + + ) +} diff --git a/packages/blocks/src/blocks/appearance/dialog.tsx b/packages/blocks/src/blocks/appearance/dialog.tsx new file mode 100644 index 0000000..075ede3 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/dialog.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" +import { DialogTrigger } from "@workspace/ui/components/dialog" + +export const appearanceDialogHandle = DialogPrimitive.createHandle() + +export function AppearanceTrigger( + props: Omit, "handle" | "nativeButton"> +) { + return ( + + ) +} diff --git a/packages/blocks/src/blocks/appearance/index.ts b/packages/blocks/src/blocks/appearance/index.ts new file mode 100644 index 0000000..3331a14 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/index.ts @@ -0,0 +1,20 @@ +export { appearanceDialogHandle, AppearanceTrigger } from "./dialog" +export { AppearanceController } from "./controller" +export { + UiStateProvider, + type UiStateProviderProps, + type UiStateChangeHandler, + useResolvedTheme, + useUiState, +} from "./ui-state" +export { + defaultUiState, + isUiStateUpdate, + type ThemeMode, + type UiState, + uiStateDefinitions, + type UiStateKey, + type UiStateUpdate, + type UiStateValue, +} from "./state" +export * from "./theme" diff --git a/packages/blocks/src/blocks/appearance/preferences.tsx b/packages/blocks/src/blocks/appearance/preferences.tsx new file mode 100644 index 0000000..46c263b --- /dev/null +++ b/packages/blocks/src/blocks/appearance/preferences.tsx @@ -0,0 +1,86 @@ +import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select" +import { Switch } from "@workspace/ui/components/switch" + +import type { ThemeMode } from "./state" +import { useUiState } from "./ui-state" + +const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [ + { label: "使用浅色", value: "light" }, + { label: "使用深色", value: "dark" }, + { label: "跟随系统", value: "system" }, +] + +function ModeIcon({ mode }: { mode: ThemeMode | null }) { + if (mode === "light") return + if (mode === "dark") return + return +} + +export function ModePreference() { + const [themeMode, setThemeMode] = useUiState("theme-mode") + const description = { + light: "界面将始终使用浅色主题", + dark: "界面将始终使用深色主题", + system: "界面主题将跟随系统外观设置", + }[themeMode] + + return ( + + 主题模式 + {description} + { + if (mode) setThemeMode(mode) + }} + > + + + {(value: ThemeMode | null) => ( + <> + + {modeItems + .find((item) => item.value === value) + ?.label.slice(-2)} + > + )} + + + + {modeItems.map((item) => ( + + + {item.label} + + ))} + + + + ) +} + +export function CompactPreference() { + const [isCompacted, setIsCompacted] = useUiState("main-compacted") + + return ( + + 紧凑布局 + + 启用后,页面主内容将使用紧凑宽度 + + + + ) +} diff --git a/packages/blocks/src/blocks/appearance/state.test.ts b/packages/blocks/src/blocks/appearance/state.test.ts new file mode 100644 index 0000000..79fa38a --- /dev/null +++ b/packages/blocks/src/blocks/appearance/state.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" +import { defaultUiState, isUiStateUpdate, uiStateDefinitions } from "./state" + +describe("UI state", () => { + it("keeps the main content full width by default", () => { + expect(defaultUiState["main-compacted"]).toBe(false) + }) + + it("serializes and parses the compacted main state", () => { + const definition = uiStateDefinitions["main-compacted"] + + expect(definition.serialize(true)).toBe("1") + expect(definition.serialize(false)).toBe("0") + expect(definition.parse("1")).toBe(true) + expect(definition.parse("0")).toBe(false) + expect(definition.parse(undefined)).toBe(false) + }) + + it("validates compacted main updates", () => { + expect(isUiStateUpdate({ key: "main-compacted", value: true })).toBe(true) + expect(isUiStateUpdate({ key: "main-compacted", value: "true" })).toBe( + false + ) + }) + + it("keeps and persists the sidebar collapsed state", () => { + const definition = uiStateDefinitions["sidebar-collapsed"] + + expect(defaultUiState["sidebar-collapsed"]).toBe(false) + expect(definition.serialize(true)).toBe("1") + expect(definition.parse("1")).toBe(true) + expect(definition.parse("true")).toBe(true) + expect(definition.parse(undefined)).toBe(false) + expect(isUiStateUpdate({ key: "sidebar-collapsed", value: true })).toBe( + true + ) + }) + + it("serializes and parses theme color settings", () => { + const definition = uiStateDefinitions["theme-light"] + const value = { + baseColor: "zinc", + accentColor: "violet", + } as const + + expect(definition.parse(definition.serialize(value))).toEqual(value) + expect(definition.parse("invalid json")).toEqual({ + baseColor: "neutral", + accentColor: "blue", + }) + }) + + it("validates light and dark theme updates", () => { + expect( + isUiStateUpdate({ + key: "theme-dark", + value: { baseColor: "stone", accentColor: "amber" }, + }) + ).toBe(true) + expect( + isUiStateUpdate({ + key: "theme-light", + value: { baseColor: "unknown", accentColor: "blue" }, + }) + ).toBe(false) + }) +}) diff --git a/packages/blocks/src/blocks/appearance/state.ts b/packages/blocks/src/blocks/appearance/state.ts new file mode 100644 index 0000000..0a6772f --- /dev/null +++ b/packages/blocks/src/blocks/appearance/state.ts @@ -0,0 +1,125 @@ +import { accentColors, baseColors, type ThemeColorConfig } from "./theme" + +export type ThemeMode = "light" | "dark" | "system" + +export type UiState = { + "main-compacted": boolean + "sidebar-collapsed": boolean + "theme-mode": ThemeMode + "theme-light": ThemeColorConfig + "theme-dark": ThemeColorConfig +} + +export type UiStateKey = keyof UiState +export type UiStateValue = UiState[K] + +type UiStateDefinition = { + cookie: string + defaultValue: T + parse: (value: string | undefined) => T + serialize: (value: T) => string +} + +const defaultThemeColorConfig: ThemeColorConfig = { + baseColor: "neutral", + accentColor: "blue", +} + +function isThemeColorConfig(value: unknown): value is ThemeColorConfig { + if (!value || typeof value !== "object") return false + + const config = value as { + baseColor?: unknown + accentColor?: unknown + } + + return ( + typeof config.baseColor === "string" && + baseColors.some((color) => color === config.baseColor) && + typeof config.accentColor === "string" && + accentColors.some((color) => color === config.accentColor) + ) +} + +function parseThemeColorConfig(value: string | undefined): ThemeColorConfig { + if (!value) return { ...defaultThemeColorConfig } + + try { + const parsed: unknown = JSON.parse(value) + return isThemeColorConfig(parsed) ? parsed : { ...defaultThemeColorConfig } + } catch { + return { ...defaultThemeColorConfig } + } +} + +export const uiStateDefinitions: { + [K in UiStateKey]: UiStateDefinition +} = { + "main-compacted": { + cookie: "ui-main-compacted", + defaultValue: false, + parse: (value) => value === "1", + serialize: (value) => (value ? "1" : "0"), + }, + "sidebar-collapsed": { + cookie: "ui-sidebar-collapsed", + defaultValue: false, + parse: (value) => value === "true" || value === "1", + serialize: (value) => (value ? "1" : "0"), + }, + "theme-mode": { + cookie: "ui-theme", + defaultValue: "system", + parse: (value) => + value === "light" || value === "dark" || value === "system" + ? value + : "system", + serialize: (value) => value, + }, + "theme-light": { + cookie: "ui-theme-light", + defaultValue: { ...defaultThemeColorConfig }, + parse: parseThemeColorConfig, + serialize: JSON.stringify, + }, + "theme-dark": { + cookie: "ui-theme-dark", + defaultValue: { ...defaultThemeColorConfig }, + parse: parseThemeColorConfig, + serialize: JSON.stringify, + }, +} + +export const defaultUiState: UiState = { + "main-compacted": uiStateDefinitions["main-compacted"].defaultValue, + "sidebar-collapsed": uiStateDefinitions["sidebar-collapsed"].defaultValue, + "theme-mode": uiStateDefinitions["theme-mode"].defaultValue, + "theme-light": uiStateDefinitions["theme-light"].defaultValue, + "theme-dark": uiStateDefinitions["theme-dark"].defaultValue, +} + +export type UiStateUpdate = { + [K in UiStateKey]: { key: K; value: UiState[K] } +}[UiStateKey] + +export function isUiStateUpdate(value: unknown): value is UiStateUpdate { + if (!value || typeof value !== "object") return false + + const update = value as { key?: unknown; value?: unknown } + switch (update.key) { + case "main-compacted": + case "sidebar-collapsed": + return typeof update.value === "boolean" + case "theme-mode": + return ( + update.value === "light" || + update.value === "dark" || + update.value === "system" + ) + case "theme-light": + case "theme-dark": + return isThemeColorConfig(update.value) + default: + return false + } +} diff --git a/packages/blocks/src/blocks/appearance/theme-color-picker.tsx b/packages/blocks/src/blocks/appearance/theme-color-picker.tsx new file mode 100644 index 0000000..98d523d --- /dev/null +++ b/packages/blocks/src/blocks/appearance/theme-color-picker.tsx @@ -0,0 +1,114 @@ +import type { CSSProperties } from "react" +import { cn } from "@workspace/ui/lib/utils" +import { + accentColors, + baseColors, + getTheme, + type AccentColor, + type BaseColor, +} from "./theme" + +type ThemeColor = BaseColor | AccentColor + +type ThemeColorSwatch = { + name: T + title: string + light: { + background: string + foreground: string + } + dark: { + background: string + foreground: string + } +} + +function createSwatches( + colors: readonly T[] +): ThemeColorSwatch[] { + return colors.map((name) => { + const theme = getTheme(name)! + + return { + name, + title: theme.title, + light: { + background: theme.cssVars.light.primary, + foreground: theme.cssVars.light["primary-foreground"], + }, + dark: { + background: theme.cssVars.dark.primary, + foreground: theme.cssVars.dark["primary-foreground"], + }, + } + }) +} + +export const baseColorSwatches = createSwatches(baseColors) +export const accentColorSwatches = createSwatches(accentColors) + +export function ThemeColorPicker({ + swatches, + title, + value, + onChange, +}: { + swatches: readonly ThemeColorSwatch[] + title: string + value: T + onChange: (color: T) => void +}) { + return ( + + + {title} + + + {swatches.map((swatch) => ( + onChange(swatch.name)} + /> + ))} + + + ) +} + +function ThemeColorButton({ + swatch, + active, + onClick, +}: { + swatch: ThemeColorSwatch + active: boolean + onClick: () => void +}) { + const style = { + "--light-bg": swatch.light.background, + "--light-fg": swatch.light.foreground, + "--dark-bg": swatch.dark.background, + "--dark-fg": swatch.dark.foreground, + } as CSSProperties + + return ( + + {swatch.title} + + ) +} diff --git a/packages/blocks/src/blocks/appearance/theme-config-panel.tsx b/packages/blocks/src/blocks/appearance/theme-config-panel.tsx new file mode 100644 index 0000000..f85fccb --- /dev/null +++ b/packages/blocks/src/blocks/appearance/theme-config-panel.tsx @@ -0,0 +1,57 @@ +import { MoonIcon, SunIcon } from "lucide-react" +import { useUiState } from "./ui-state" +import { Badge } from "@workspace/ui/components/badge" +import { + accentColorSwatches, + baseColorSwatches, + ThemeColorPicker, +} from "./theme-color-picker" +import { ThemePreview } from "./theme-preview" +import type { ThemeScheme } from "./theme" + +export function ThemeConfigPanel({ + scheme, + active, + description, +}: { + scheme: ThemeScheme + active: boolean + description: string +}) { + const stateKey = scheme === "light" ? "theme-light" : "theme-dark" + const [config, setConfig] = useUiState(stateKey) + + return ( + + + {scheme === "light" ? ( + + ) : ( + + )} + {scheme === "light" ? "浅色主题" : "深色主题"} + {active && 使用中} + + + {description} + + + setConfig((current) => ({ ...current, baseColor })) + } + /> + + setConfig((current) => ({ ...current, accentColor })) + } + /> + + + ) +} diff --git a/packages/blocks/src/blocks/appearance/theme-preview.tsx b/packages/blocks/src/blocks/appearance/theme-preview.tsx new file mode 100644 index 0000000..021ed04 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/theme-preview.tsx @@ -0,0 +1,103 @@ +import type { CSSProperties } from "react" +import { + getTheme, + getThemeCssVariables, + type ThemeColorConfig, + type ThemeScheme, +} from "./theme" +import { cn } from "@workspace/ui/lib/utils" +import { useUiState } from "./ui-state" + +export function ThemePreview({ + scheme, + config, + className, +}: { + scheme: ThemeScheme + config: ThemeColorConfig + className?: string +}) { + const [isCompacted] = useUiState("main-compacted") + const themeVariables = getThemeCssVariables(scheme, config) + const baseTheme = getTheme(config.baseColor) ?? getTheme("neutral")! + const style = { + ...Object.fromEntries( + Object.entries(themeVariables).map(([key, value]) => [`--${key}`, value]) + ), + "--preview-base": baseTheme.cssVars[scheme].primary, + } as CSSProperties + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/blocks/src/blocks/appearance/theme.test.ts b/packages/blocks/src/blocks/appearance/theme.test.ts new file mode 100644 index 0000000..c1c5223 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/theme.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest" +import { + accentColors, + baseColors, + buildThemeStyleSheet, + getTheme, + getThemeCssVariables, + themes, +} from "./theme" + +describe("appearance themes", () => { + it("defines every selectable base and accent color exactly once", () => { + const selectableColors = [...baseColors, ...accentColors] + + expect(themes.map((theme) => theme.name)).toEqual(selectableColors) + expect(new Set(selectableColors).size).toBe(selectableColors.length) + expect(themes.every((theme) => theme.title.length > 0)).toBe(true) + }) + + it("combines the base palette with the selected accent palette", () => { + const variables = getThemeCssVariables("light", { + baseColor: "stone", + accentColor: "rose", + }) + + expect(variables.background).toBe( + getTheme("stone")?.cssVars.light.background + ) + expect(variables.primary).toBe(getTheme("rose")?.cssVars.light.primary) + }) + + it("builds explicit and system color-scheme rules", () => { + const styleSheet = buildThemeStyleSheet( + { baseColor: "neutral", accentColor: "blue" }, + { baseColor: "zinc", accentColor: "violet" } + ) + + expect(styleSheet).toContain(":root,.ui\\:light{") + expect(styleSheet).toContain(".ui\\:dark{") + expect(styleSheet).toContain("@media (prefers-color-scheme:dark)") + expect(styleSheet).toContain("--background:") + expect(styleSheet).toContain("--primary:") + }) +}) diff --git a/packages/blocks/src/blocks/appearance/theme.ts b/packages/blocks/src/blocks/appearance/theme.ts new file mode 100644 index 0000000..25bf25d --- /dev/null +++ b/packages/blocks/src/blocks/appearance/theme.ts @@ -0,0 +1,58 @@ +import { + themes, + type AccentColor, + type BaseColor, + type ThemeColorConfig, + type ThemeScheme, +} from "./themes" + +export { + accentColors, + baseColors, + themes, + type AccentColor, + type BaseColor, + type Theme, + type ThemeColorConfig, + type ThemeScheme, +} from "./themes" + +export const THEME_STYLE_ELEMENT_ID = "ui-theme-variables" + +export const getTheme = (name: BaseColor | AccentColor) => + themes.find((theme) => theme.name === name) + +export function getThemeCssVariables( + scheme: ThemeScheme, + config: ThemeColorConfig +) { + const base = getTheme(config.baseColor) ?? getTheme("neutral")! + const accent = getTheme(config.accentColor) ?? getTheme("blue")! + + return { + ...base.cssVars[scheme], + ...accent.cssVars[scheme], + } +} + +function serializeThemeVariables(theme: Record): string { + return Object.entries(theme) + .map(([key, value]) => `--${key}:${value}`) + .join(";") +} + +export function buildThemeStyleSheet( + lightConfig: ThemeColorConfig, + darkConfig: ThemeColorConfig +) { + const light = serializeThemeVariables( + getThemeCssVariables("light", lightConfig) + ) + const dark = serializeThemeVariables(getThemeCssVariables("dark", darkConfig)) + + return [ + `:root,.ui\\:light{${light}}`, + `.ui\\:dark{${dark}}`, + `@media (prefers-color-scheme:dark){:root:not(.ui\\:light):not(.ui\\:dark){${dark}}}`, + ].join("") +} diff --git a/packages/blocks/src/blocks/appearance/themes.ts b/packages/blocks/src/blocks/appearance/themes.ts new file mode 100644 index 0000000..7f8c6fd --- /dev/null +++ b/packages/blocks/src/blocks/appearance/themes.ts @@ -0,0 +1,1126 @@ +export type BaseColor = + "neutral" | "stone" | "zinc" | "mauve" | "olive" | "mist" | "taupe" + +export type AccentColor = + | "amber" + | "blue" + | "cyan" + | "emerald" + | "fuchsia" + | "green" + | "indigo" + | "lime" + | "orange" + | "pink" + | "purple" + | "red" + | "rose" + | "sky" + | "teal" + | "violet" + | "yellow" + +export type ThemeScheme = "light" | "dark" + +export type ThemeColorConfig = { + baseColor: BaseColor + accentColor: AccentColor +} + +export const accentColors: AccentColor[] = [ + "amber", + "blue", + "cyan", + "emerald", + "fuchsia", + "green", + "indigo", + "lime", + "orange", + "pink", + "purple", + "red", + "rose", + "sky", + "teal", + "violet", + "yellow", +] + +export const baseColors: BaseColor[] = [ + "neutral", + "stone", + "zinc", + "mauve", + "olive", + "mist", + "taupe", +] + +export type Theme = { + name: BaseColor | AccentColor + title: string + cssVars: { + light: Record + dark: Record + } +} + +export const themes: Theme[] = [ + { + name: "neutral", + title: "中性灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.145 0 0)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.145 0 0)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.145 0 0)", + primary: "oklch(0.205 0 0)", + "primary-foreground": "oklch(0.985 0 0)", + secondary: "oklch(0.97 0 0)", + "secondary-foreground": "oklch(0.205 0 0)", + muted: "oklch(0.97 0 0)", + "muted-foreground": "oklch(0.556 0 0)", + accent: "oklch(0.97 0 0)", + "accent-foreground": "oklch(0.205 0 0)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.922 0 0)", + input: "oklch(0.922 0 0)", + ring: "oklch(0.708 0 0)", + "chart-1": "oklch(0.87 0 0)", + "chart-2": "oklch(0.556 0 0)", + "chart-3": "oklch(0.439 0 0)", + "chart-4": "oklch(0.371 0 0)", + "chart-5": "oklch(0.269 0 0)", + radius: "0.625rem", + sidebar: "oklch(0.985 0 0)", + "sidebar-foreground": "oklch(0.145 0 0)", + "sidebar-primary": "oklch(0.205 0 0)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.97 0 0)", + "sidebar-accent-foreground": "oklch(0.205 0 0)", + "sidebar-border": "oklch(0.922 0 0)", + "sidebar-ring": "oklch(0.708 0 0)", + }, + dark: { + background: "oklch(0.145 0 0)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.205 0 0)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.205 0 0)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.922 0 0)", + "primary-foreground": "oklch(0.205 0 0)", + secondary: "oklch(0.269 0 0)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.269 0 0)", + "muted-foreground": "oklch(0.708 0 0)", + accent: "oklch(0.269 0 0)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.556 0 0)", + "chart-1": "oklch(0.87 0 0)", + "chart-2": "oklch(0.556 0 0)", + "chart-3": "oklch(0.439 0 0)", + "chart-4": "oklch(0.371 0 0)", + "chart-5": "oklch(0.269 0 0)", + sidebar: "oklch(0.205 0 0)", + "sidebar-foreground": "oklch(0.985 0 0)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.269 0 0)", + "sidebar-accent-foreground": "oklch(0.985 0 0)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.556 0 0)", + }, + }, + }, + { + name: "stone", + title: "石灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.147 0.004 49.25)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.147 0.004 49.25)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.147 0.004 49.25)", + primary: "oklch(0.216 0.006 56.043)", + "primary-foreground": "oklch(0.985 0.001 106.423)", + secondary: "oklch(0.97 0.001 106.424)", + "secondary-foreground": "oklch(0.216 0.006 56.043)", + muted: "oklch(0.97 0.001 106.424)", + "muted-foreground": "oklch(0.553 0.013 58.071)", + accent: "oklch(0.97 0.001 106.424)", + "accent-foreground": "oklch(0.216 0.006 56.043)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.923 0.003 48.717)", + input: "oklch(0.923 0.003 48.717)", + ring: "oklch(0.709 0.01 56.259)", + "chart-1": "oklch(0.869 0.005 56.366)", + "chart-2": "oklch(0.553 0.013 58.071)", + "chart-3": "oklch(0.444 0.011 73.639)", + "chart-4": "oklch(0.374 0.01 67.558)", + "chart-5": "oklch(0.268 0.007 34.298)", + radius: "0.625rem", + sidebar: "oklch(0.985 0.001 106.423)", + "sidebar-foreground": "oklch(0.147 0.004 49.25)", + "sidebar-primary": "oklch(0.216 0.006 56.043)", + "sidebar-primary-foreground": "oklch(0.985 0.001 106.423)", + "sidebar-accent": "oklch(0.97 0.001 106.424)", + "sidebar-accent-foreground": "oklch(0.216 0.006 56.043)", + "sidebar-border": "oklch(0.923 0.003 48.717)", + "sidebar-ring": "oklch(0.709 0.01 56.259)", + }, + dark: { + background: "oklch(0.147 0.004 49.25)", + foreground: "oklch(0.985 0.001 106.423)", + card: "oklch(0.216 0.006 56.043)", + "card-foreground": "oklch(0.985 0.001 106.423)", + popover: "oklch(0.216 0.006 56.043)", + "popover-foreground": "oklch(0.985 0.001 106.423)", + primary: "oklch(0.923 0.003 48.717)", + "primary-foreground": "oklch(0.216 0.006 56.043)", + secondary: "oklch(0.268 0.007 34.298)", + "secondary-foreground": "oklch(0.985 0.001 106.423)", + muted: "oklch(0.268 0.007 34.298)", + "muted-foreground": "oklch(0.709 0.01 56.259)", + accent: "oklch(0.268 0.007 34.298)", + "accent-foreground": "oklch(0.985 0.001 106.423)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.553 0.013 58.071)", + "chart-1": "oklch(0.869 0.005 56.366)", + "chart-2": "oklch(0.553 0.013 58.071)", + "chart-3": "oklch(0.444 0.011 73.639)", + "chart-4": "oklch(0.374 0.01 67.558)", + "chart-5": "oklch(0.268 0.007 34.298)", + sidebar: "oklch(0.216 0.006 56.043)", + "sidebar-foreground": "oklch(0.985 0.001 106.423)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.985 0.001 106.423)", + "sidebar-accent": "oklch(0.268 0.007 34.298)", + "sidebar-accent-foreground": "oklch(0.985 0.001 106.423)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.553 0.013 58.071)", + }, + }, + }, + { + name: "zinc", + title: "锌灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.141 0.005 285.823)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.141 0.005 285.823)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.141 0.005 285.823)", + primary: "oklch(0.21 0.006 285.885)", + "primary-foreground": "oklch(0.985 0 0)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + muted: "oklch(0.967 0.001 286.375)", + "muted-foreground": "oklch(0.552 0.016 285.938)", + accent: "oklch(0.967 0.001 286.375)", + "accent-foreground": "oklch(0.21 0.006 285.885)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.92 0.004 286.32)", + input: "oklch(0.92 0.004 286.32)", + ring: "oklch(0.705 0.015 286.067)", + "chart-1": "oklch(0.871 0.006 286.286)", + "chart-2": "oklch(0.552 0.016 285.938)", + "chart-3": "oklch(0.442 0.017 285.786)", + "chart-4": "oklch(0.37 0.013 285.805)", + "chart-5": "oklch(0.274 0.006 286.033)", + radius: "0.625rem", + sidebar: "oklch(0.985 0 0)", + "sidebar-foreground": "oklch(0.141 0.005 285.823)", + "sidebar-primary": "oklch(0.21 0.006 285.885)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.967 0.001 286.375)", + "sidebar-accent-foreground": "oklch(0.21 0.006 285.885)", + "sidebar-border": "oklch(0.92 0.004 286.32)", + "sidebar-ring": "oklch(0.705 0.015 286.067)", + }, + dark: { + background: "oklch(0.141 0.005 285.823)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.21 0.006 285.885)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.21 0.006 285.885)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.92 0.004 286.32)", + "primary-foreground": "oklch(0.21 0.006 285.885)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.274 0.006 286.033)", + "muted-foreground": "oklch(0.705 0.015 286.067)", + accent: "oklch(0.274 0.006 286.033)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.552 0.016 285.938)", + "chart-1": "oklch(0.871 0.006 286.286)", + "chart-2": "oklch(0.552 0.016 285.938)", + "chart-3": "oklch(0.442 0.017 285.786)", + "chart-4": "oklch(0.37 0.013 285.805)", + "chart-5": "oklch(0.274 0.006 286.033)", + sidebar: "oklch(0.21 0.006 285.885)", + "sidebar-foreground": "oklch(0.985 0 0)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.274 0.006 286.033)", + "sidebar-accent-foreground": "oklch(0.985 0 0)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.552 0.016 285.938)", + }, + }, + }, + { + name: "mauve", + title: "紫灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.145 0.008 326)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.145 0.008 326)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.145 0.008 326)", + primary: "oklch(0.212 0.019 322.12)", + "primary-foreground": "oklch(0.985 0 0)", + secondary: "oklch(0.96 0.003 325.6)", + "secondary-foreground": "oklch(0.212 0.019 322.12)", + muted: "oklch(0.96 0.003 325.6)", + "muted-foreground": "oklch(0.542 0.034 322.5)", + accent: "oklch(0.96 0.003 325.6)", + "accent-foreground": "oklch(0.212 0.019 322.12)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.922 0.005 325.62)", + input: "oklch(0.922 0.005 325.62)", + ring: "oklch(0.711 0.019 323.02)", + "chart-1": "oklch(0.865 0.012 325.68)", + "chart-2": "oklch(0.542 0.034 322.5)", + "chart-3": "oklch(0.435 0.029 321.78)", + "chart-4": "oklch(0.364 0.029 323.89)", + "chart-5": "oklch(0.263 0.024 320.12)", + radius: "0.625rem", + sidebar: "oklch(0.985 0 0)", + "sidebar-foreground": "oklch(0.145 0.008 326)", + "sidebar-primary": "oklch(0.212 0.019 322.12)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.96 0.003 325.6)", + "sidebar-accent-foreground": "oklch(0.212 0.019 322.12)", + "sidebar-border": "oklch(0.922 0.005 325.62)", + "sidebar-ring": "oklch(0.711 0.019 323.02)", + }, + dark: { + background: "oklch(0.145 0.008 326)", + foreground: "oklch(0.985 0 0)", + card: "oklch(0.212 0.019 322.12)", + "card-foreground": "oklch(0.985 0 0)", + popover: "oklch(0.212 0.019 322.12)", + "popover-foreground": "oklch(0.985 0 0)", + primary: "oklch(0.922 0.005 325.62)", + "primary-foreground": "oklch(0.212 0.019 322.12)", + secondary: "oklch(0.263 0.024 320.12)", + "secondary-foreground": "oklch(0.985 0 0)", + muted: "oklch(0.263 0.024 320.12)", + "muted-foreground": "oklch(0.711 0.019 323.02)", + accent: "oklch(0.263 0.024 320.12)", + "accent-foreground": "oklch(0.985 0 0)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.542 0.034 322.5)", + "chart-1": "oklch(0.865 0.012 325.68)", + "chart-2": "oklch(0.542 0.034 322.5)", + "chart-3": "oklch(0.435 0.029 321.78)", + "chart-4": "oklch(0.364 0.029 323.89)", + "chart-5": "oklch(0.263 0.024 320.12)", + sidebar: "oklch(0.212 0.019 322.12)", + "sidebar-foreground": "oklch(0.985 0 0)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.985 0 0)", + "sidebar-accent": "oklch(0.263 0.024 320.12)", + "sidebar-accent-foreground": "oklch(0.985 0 0)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.542 0.034 322.5)", + }, + }, + }, + { + name: "olive", + title: "橄榄灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.153 0.006 107.1)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.153 0.006 107.1)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.153 0.006 107.1)", + primary: "oklch(0.228 0.013 107.4)", + "primary-foreground": "oklch(0.988 0.003 106.5)", + secondary: "oklch(0.966 0.005 106.5)", + "secondary-foreground": "oklch(0.228 0.013 107.4)", + muted: "oklch(0.966 0.005 106.5)", + "muted-foreground": "oklch(0.58 0.031 107.3)", + accent: "oklch(0.966 0.005 106.5)", + "accent-foreground": "oklch(0.228 0.013 107.4)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.93 0.007 106.5)", + input: "oklch(0.93 0.007 106.5)", + ring: "oklch(0.737 0.021 106.9)", + "chart-1": "oklch(0.88 0.011 106.6)", + "chart-2": "oklch(0.58 0.031 107.3)", + "chart-3": "oklch(0.466 0.025 107.3)", + "chart-4": "oklch(0.394 0.023 107.4)", + "chart-5": "oklch(0.286 0.016 107.4)", + radius: "0.625rem", + sidebar: "oklch(0.988 0.003 106.5)", + "sidebar-foreground": "oklch(0.153 0.006 107.1)", + "sidebar-primary": "oklch(0.228 0.013 107.4)", + "sidebar-primary-foreground": "oklch(0.988 0.003 106.5)", + "sidebar-accent": "oklch(0.966 0.005 106.5)", + "sidebar-accent-foreground": "oklch(0.228 0.013 107.4)", + "sidebar-border": "oklch(0.93 0.007 106.5)", + "sidebar-ring": "oklch(0.737 0.021 106.9)", + }, + dark: { + background: "oklch(0.153 0.006 107.1)", + foreground: "oklch(0.988 0.003 106.5)", + card: "oklch(0.228 0.013 107.4)", + "card-foreground": "oklch(0.988 0.003 106.5)", + popover: "oklch(0.228 0.013 107.4)", + "popover-foreground": "oklch(0.988 0.003 106.5)", + primary: "oklch(0.93 0.007 106.5)", + "primary-foreground": "oklch(0.228 0.013 107.4)", + secondary: "oklch(0.286 0.016 107.4)", + "secondary-foreground": "oklch(0.988 0.003 106.5)", + muted: "oklch(0.286 0.016 107.4)", + "muted-foreground": "oklch(0.737 0.021 106.9)", + accent: "oklch(0.286 0.016 107.4)", + "accent-foreground": "oklch(0.988 0.003 106.5)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.58 0.031 107.3)", + "chart-1": "oklch(0.88 0.011 106.6)", + "chart-2": "oklch(0.58 0.031 107.3)", + "chart-3": "oklch(0.466 0.025 107.3)", + "chart-4": "oklch(0.394 0.023 107.4)", + "chart-5": "oklch(0.286 0.016 107.4)", + sidebar: "oklch(0.228 0.013 107.4)", + "sidebar-foreground": "oklch(0.988 0.003 106.5)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.988 0.003 106.5)", + "sidebar-accent": "oklch(0.286 0.016 107.4)", + "sidebar-accent-foreground": "oklch(0.988 0.003 106.5)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.58 0.031 107.3)", + }, + }, + }, + { + name: "mist", + title: "雾灰", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.148 0.004 228.8)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.148 0.004 228.8)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.148 0.004 228.8)", + primary: "oklch(0.218 0.008 223.9)", + "primary-foreground": "oklch(0.987 0.002 197.1)", + secondary: "oklch(0.963 0.002 197.1)", + "secondary-foreground": "oklch(0.218 0.008 223.9)", + muted: "oklch(0.963 0.002 197.1)", + "muted-foreground": "oklch(0.56 0.021 213.5)", + accent: "oklch(0.963 0.002 197.1)", + "accent-foreground": "oklch(0.218 0.008 223.9)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.925 0.005 214.3)", + input: "oklch(0.925 0.005 214.3)", + ring: "oklch(0.723 0.014 214.4)", + "chart-1": "oklch(0.872 0.007 219.6)", + "chart-2": "oklch(0.56 0.021 213.5)", + "chart-3": "oklch(0.45 0.017 213.2)", + "chart-4": "oklch(0.378 0.015 216)", + "chart-5": "oklch(0.275 0.011 216.9)", + radius: "0.625rem", + sidebar: "oklch(0.987 0.002 197.1)", + "sidebar-foreground": "oklch(0.148 0.004 228.8)", + "sidebar-primary": "oklch(0.218 0.008 223.9)", + "sidebar-primary-foreground": "oklch(0.987 0.002 197.1)", + "sidebar-accent": "oklch(0.963 0.002 197.1)", + "sidebar-accent-foreground": "oklch(0.218 0.008 223.9)", + "sidebar-border": "oklch(0.925 0.005 214.3)", + "sidebar-ring": "oklch(0.723 0.014 214.4)", + }, + dark: { + background: "oklch(0.148 0.004 228.8)", + foreground: "oklch(0.987 0.002 197.1)", + card: "oklch(0.218 0.008 223.9)", + "card-foreground": "oklch(0.987 0.002 197.1)", + popover: "oklch(0.218 0.008 223.9)", + "popover-foreground": "oklch(0.987 0.002 197.1)", + primary: "oklch(0.925 0.005 214.3)", + "primary-foreground": "oklch(0.218 0.008 223.9)", + secondary: "oklch(0.275 0.011 216.9)", + "secondary-foreground": "oklch(0.987 0.002 197.1)", + muted: "oklch(0.275 0.011 216.9)", + "muted-foreground": "oklch(0.723 0.014 214.4)", + accent: "oklch(0.275 0.011 216.9)", + "accent-foreground": "oklch(0.987 0.002 197.1)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.56 0.021 213.5)", + "chart-1": "oklch(0.872 0.007 219.6)", + "chart-2": "oklch(0.56 0.021 213.5)", + "chart-3": "oklch(0.45 0.017 213.2)", + "chart-4": "oklch(0.378 0.015 216)", + "chart-5": "oklch(0.275 0.011 216.9)", + sidebar: "oklch(0.218 0.008 223.9)", + "sidebar-foreground": "oklch(0.987 0.002 197.1)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.987 0.002 197.1)", + "sidebar-accent": "oklch(0.275 0.011 216.9)", + "sidebar-accent-foreground": "oklch(0.987 0.002 197.1)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.56 0.021 213.5)", + }, + }, + }, + { + name: "taupe", + title: "灰褐", + cssVars: { + light: { + background: "oklch(1 0 0)", + foreground: "oklch(0.147 0.004 49.3)", + card: "oklch(1 0 0)", + "card-foreground": "oklch(0.147 0.004 49.3)", + popover: "oklch(1 0 0)", + "popover-foreground": "oklch(0.147 0.004 49.3)", + primary: "oklch(0.214 0.009 43.1)", + "primary-foreground": "oklch(0.986 0.002 67.8)", + secondary: "oklch(0.96 0.002 17.2)", + "secondary-foreground": "oklch(0.214 0.009 43.1)", + muted: "oklch(0.96 0.002 17.2)", + "muted-foreground": "oklch(0.547 0.021 43.1)", + accent: "oklch(0.96 0.002 17.2)", + "accent-foreground": "oklch(0.214 0.009 43.1)", + destructive: "oklch(0.577 0.245 27.325)", + border: "oklch(0.922 0.005 34.3)", + input: "oklch(0.922 0.005 34.3)", + ring: "oklch(0.714 0.014 41.2)", + "chart-1": "oklch(0.868 0.007 39.5)", + "chart-2": "oklch(0.547 0.021 43.1)", + "chart-3": "oklch(0.438 0.017 39.3)", + "chart-4": "oklch(0.367 0.016 35.7)", + "chart-5": "oklch(0.268 0.011 36.5)", + radius: "0.625rem", + sidebar: "oklch(0.986 0.002 67.8)", + "sidebar-foreground": "oklch(0.147 0.004 49.3)", + "sidebar-primary": "oklch(0.214 0.009 43.1)", + "sidebar-primary-foreground": "oklch(0.986 0.002 67.8)", + "sidebar-accent": "oklch(0.96 0.002 17.2)", + "sidebar-accent-foreground": "oklch(0.214 0.009 43.1)", + "sidebar-border": "oklch(0.922 0.005 34.3)", + "sidebar-ring": "oklch(0.714 0.014 41.2)", + }, + dark: { + background: "oklch(0.147 0.004 49.3)", + foreground: "oklch(0.986 0.002 67.8)", + card: "oklch(0.214 0.009 43.1)", + "card-foreground": "oklch(0.986 0.002 67.8)", + popover: "oklch(0.214 0.009 43.1)", + "popover-foreground": "oklch(0.986 0.002 67.8)", + primary: "oklch(0.922 0.005 34.3)", + "primary-foreground": "oklch(0.214 0.009 43.1)", + secondary: "oklch(0.268 0.011 36.5)", + "secondary-foreground": "oklch(0.986 0.002 67.8)", + muted: "oklch(0.268 0.011 36.5)", + "muted-foreground": "oklch(0.714 0.014 41.2)", + accent: "oklch(0.268 0.011 36.5)", + "accent-foreground": "oklch(0.986 0.002 67.8)", + destructive: "oklch(0.704 0.191 22.216)", + border: "oklch(1 0 0 / 10%)", + input: "oklch(1 0 0 / 15%)", + ring: "oklch(0.547 0.021 43.1)", + "chart-1": "oklch(0.868 0.007 39.5)", + "chart-2": "oklch(0.547 0.021 43.1)", + "chart-3": "oklch(0.438 0.017 39.3)", + "chart-4": "oklch(0.367 0.016 35.7)", + "chart-5": "oklch(0.268 0.011 36.5)", + sidebar: "oklch(0.214 0.009 43.1)", + "sidebar-foreground": "oklch(0.986 0.002 67.8)", + "sidebar-primary": "oklch(0.488 0.243 264.376)", + "sidebar-primary-foreground": "oklch(0.986 0.002 67.8)", + "sidebar-accent": "oklch(0.268 0.011 36.5)", + "sidebar-accent-foreground": "oklch(0.986 0.002 67.8)", + "sidebar-border": "oklch(1 0 0 / 10%)", + "sidebar-ring": "oklch(0.547 0.021 43.1)", + }, + }, + }, + { + name: "amber", + title: "琥珀色", + cssVars: { + light: { + primary: "oklch(0.555 0.163 48.998)", + "primary-foreground": "oklch(0.987 0.022 95.277)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.879 0.169 91.605)", + "chart-2": "oklch(0.769 0.188 70.08)", + "chart-3": "oklch(0.666 0.179 58.318)", + "chart-4": "oklch(0.555 0.163 48.998)", + "chart-5": "oklch(0.473 0.137 46.201)", + "sidebar-primary": "oklch(0.666 0.179 58.318)", + "sidebar-primary-foreground": "oklch(0.987 0.022 95.277)", + }, + dark: { + primary: "oklch(0.473 0.137 46.201)", + "primary-foreground": "oklch(0.987 0.022 95.277)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.879 0.169 91.605)", + "chart-2": "oklch(0.769 0.188 70.08)", + "chart-3": "oklch(0.666 0.179 58.318)", + "chart-4": "oklch(0.555 0.163 48.998)", + "chart-5": "oklch(0.473 0.137 46.201)", + "sidebar-primary": "oklch(0.769 0.188 70.08)", + "sidebar-primary-foreground": "oklch(0.279 0.077 45.635)", + }, + }, + }, + { + name: "blue", + title: "蓝色", + cssVars: { + light: { + primary: "oklch(0.488 0.243 264.376)", + "primary-foreground": "oklch(0.97 0.014 254.604)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.809 0.105 251.813)", + "chart-2": "oklch(0.623 0.214 259.815)", + "chart-3": "oklch(0.546 0.245 262.881)", + "chart-4": "oklch(0.488 0.243 264.376)", + "chart-5": "oklch(0.424 0.199 265.638)", + "sidebar-primary": "oklch(0.546 0.245 262.881)", + "sidebar-primary-foreground": "oklch(0.97 0.014 254.604)", + }, + dark: { + primary: "oklch(0.424 0.199 265.638)", + "primary-foreground": "oklch(0.97 0.014 254.604)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.809 0.105 251.813)", + "chart-2": "oklch(0.623 0.214 259.815)", + "chart-3": "oklch(0.546 0.245 262.881)", + "chart-4": "oklch(0.488 0.243 264.376)", + "chart-5": "oklch(0.424 0.199 265.638)", + "sidebar-primary": "oklch(0.623 0.214 259.815)", + "sidebar-primary-foreground": "oklch(0.97 0.014 254.604)", + }, + }, + }, + { + name: "cyan", + title: "青色", + cssVars: { + light: { + primary: "oklch(0.52 0.105 223.128)", + "primary-foreground": "oklch(0.984 0.019 200.873)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.865 0.127 207.078)", + "chart-2": "oklch(0.715 0.143 215.221)", + "chart-3": "oklch(0.609 0.126 221.723)", + "chart-4": "oklch(0.52 0.105 223.128)", + "chart-5": "oklch(0.45 0.085 224.283)", + "sidebar-primary": "oklch(0.609 0.126 221.723)", + "sidebar-primary-foreground": "oklch(0.984 0.019 200.873)", + }, + dark: { + primary: "oklch(0.45 0.085 224.283)", + "primary-foreground": "oklch(0.984 0.019 200.873)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.865 0.127 207.078)", + "chart-2": "oklch(0.715 0.143 215.221)", + "chart-3": "oklch(0.609 0.126 221.723)", + "chart-4": "oklch(0.52 0.105 223.128)", + "chart-5": "oklch(0.45 0.085 224.283)", + "sidebar-primary": "oklch(0.715 0.143 215.221)", + "sidebar-primary-foreground": "oklch(0.302 0.056 229.695)", + }, + }, + }, + { + name: "emerald", + title: "翠绿色", + cssVars: { + light: { + primary: "oklch(0.508 0.118 165.612)", + "primary-foreground": "oklch(0.979 0.021 166.113)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.845 0.143 164.978)", + "chart-2": "oklch(0.696 0.17 162.48)", + "chart-3": "oklch(0.596 0.145 163.225)", + "chart-4": "oklch(0.508 0.118 165.612)", + "chart-5": "oklch(0.432 0.095 166.913)", + "sidebar-primary": "oklch(0.596 0.145 163.225)", + "sidebar-primary-foreground": "oklch(0.979 0.021 166.113)", + }, + dark: { + primary: "oklch(0.432 0.095 166.913)", + "primary-foreground": "oklch(0.979 0.021 166.113)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.845 0.143 164.978)", + "chart-2": "oklch(0.696 0.17 162.48)", + "chart-3": "oklch(0.596 0.145 163.225)", + "chart-4": "oklch(0.508 0.118 165.612)", + "chart-5": "oklch(0.432 0.095 166.913)", + "sidebar-primary": "oklch(0.696 0.17 162.48)", + "sidebar-primary-foreground": "oklch(0.262 0.051 172.552)", + }, + }, + }, + { + name: "fuchsia", + title: "紫红色", + cssVars: { + light: { + primary: "oklch(0.518 0.253 323.949)", + "primary-foreground": "oklch(0.977 0.017 320.058)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.833 0.145 321.434)", + "chart-2": "oklch(0.667 0.295 322.15)", + "chart-3": "oklch(0.591 0.293 322.896)", + "chart-4": "oklch(0.518 0.253 323.949)", + "chart-5": "oklch(0.452 0.211 324.591)", + "sidebar-primary": "oklch(0.591 0.293 322.896)", + "sidebar-primary-foreground": "oklch(0.977 0.017 320.058)", + }, + dark: { + primary: "oklch(0.452 0.211 324.591)", + "primary-foreground": "oklch(0.977 0.017 320.058)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.833 0.145 321.434)", + "chart-2": "oklch(0.667 0.295 322.15)", + "chart-3": "oklch(0.591 0.293 322.896)", + "chart-4": "oklch(0.518 0.253 323.949)", + "chart-5": "oklch(0.452 0.211 324.591)", + "sidebar-primary": "oklch(0.667 0.295 322.15)", + "sidebar-primary-foreground": "oklch(0.977 0.017 320.058)", + }, + }, + }, + { + name: "green", + title: "绿色", + cssVars: { + light: { + primary: "oklch(0.527 0.154 150.069)", + "primary-foreground": "oklch(0.982 0.018 155.826)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.871 0.15 154.449)", + "chart-2": "oklch(0.723 0.219 149.579)", + "chart-3": "oklch(0.627 0.194 149.214)", + "chart-4": "oklch(0.527 0.154 150.069)", + "chart-5": "oklch(0.448 0.119 151.328)", + "sidebar-primary": "oklch(0.627 0.194 149.214)", + "sidebar-primary-foreground": "oklch(0.982 0.018 155.826)", + }, + dark: { + primary: "oklch(0.448 0.119 151.328)", + "primary-foreground": "oklch(0.982 0.018 155.826)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.871 0.15 154.449)", + "chart-2": "oklch(0.723 0.219 149.579)", + "chart-3": "oklch(0.627 0.194 149.214)", + "chart-4": "oklch(0.527 0.154 150.069)", + "chart-5": "oklch(0.448 0.119 151.328)", + "sidebar-primary": "oklch(0.723 0.219 149.579)", + "sidebar-primary-foreground": "oklch(0.982 0.018 155.826)", + }, + }, + }, + { + name: "indigo", + title: "靛蓝色", + cssVars: { + light: { + primary: "oklch(0.457 0.24 277.023)", + "primary-foreground": "oklch(0.962 0.018 272.314)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.785 0.115 274.713)", + "chart-2": "oklch(0.585 0.233 277.117)", + "chart-3": "oklch(0.511 0.262 276.966)", + "chart-4": "oklch(0.457 0.24 277.023)", + "chart-5": "oklch(0.398 0.195 277.366)", + "sidebar-primary": "oklch(0.511 0.262 276.966)", + "sidebar-primary-foreground": "oklch(0.962 0.018 272.314)", + }, + dark: { + primary: "oklch(0.398 0.195 277.366)", + "primary-foreground": "oklch(0.962 0.018 272.314)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.785 0.115 274.713)", + "chart-2": "oklch(0.585 0.233 277.117)", + "chart-3": "oklch(0.511 0.262 276.966)", + "chart-4": "oklch(0.457 0.24 277.023)", + "chart-5": "oklch(0.398 0.195 277.366)", + "sidebar-primary": "oklch(0.585 0.233 277.117)", + "sidebar-primary-foreground": "oklch(0.962 0.018 272.314)", + }, + }, + }, + { + name: "lime", + title: "青柠色", + cssVars: { + light: { + primary: "oklch(0.841 0.238 128.85)", + "primary-foreground": "oklch(0.405 0.101 131.063)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.897 0.196 126.665)", + "chart-2": "oklch(0.768 0.233 130.85)", + "chart-3": "oklch(0.648 0.2 131.684)", + "chart-4": "oklch(0.532 0.157 131.589)", + "chart-5": "oklch(0.453 0.124 130.933)", + "sidebar-primary": "oklch(0.648 0.2 131.684)", + "sidebar-primary-foreground": "oklch(0.986 0.031 120.757)", + }, + dark: { + primary: "oklch(0.768 0.233 130.85)", + "primary-foreground": "oklch(0.405 0.101 131.063)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.897 0.196 126.665)", + "chart-2": "oklch(0.768 0.233 130.85)", + "chart-3": "oklch(0.648 0.2 131.684)", + "chart-4": "oklch(0.532 0.157 131.589)", + "chart-5": "oklch(0.453 0.124 130.933)", + "sidebar-primary": "oklch(0.768 0.233 130.85)", + "sidebar-primary-foreground": "oklch(0.274 0.072 132.109)", + }, + }, + }, + { + name: "orange", + title: "橙色", + cssVars: { + light: { + primary: "oklch(0.553 0.195 38.402)", + "primary-foreground": "oklch(0.98 0.016 73.684)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.837 0.128 66.29)", + "chart-2": "oklch(0.705 0.213 47.604)", + "chart-3": "oklch(0.646 0.222 41.116)", + "chart-4": "oklch(0.553 0.195 38.402)", + "chart-5": "oklch(0.47 0.157 37.304)", + "sidebar-primary": "oklch(0.646 0.222 41.116)", + "sidebar-primary-foreground": "oklch(0.98 0.016 73.684)", + }, + dark: { + primary: "oklch(0.47 0.157 37.304)", + "primary-foreground": "oklch(0.98 0.016 73.684)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.837 0.128 66.29)", + "chart-2": "oklch(0.705 0.213 47.604)", + "chart-3": "oklch(0.646 0.222 41.116)", + "chart-4": "oklch(0.553 0.195 38.402)", + "chart-5": "oklch(0.47 0.157 37.304)", + "sidebar-primary": "oklch(0.705 0.213 47.604)", + "sidebar-primary-foreground": "oklch(0.98 0.016 73.684)", + }, + }, + }, + { + name: "pink", + title: "粉色", + cssVars: { + light: { + primary: "oklch(0.525 0.223 3.958)", + "primary-foreground": "oklch(0.971 0.014 343.198)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.823 0.12 346.018)", + "chart-2": "oklch(0.656 0.241 354.308)", + "chart-3": "oklch(0.592 0.249 0.584)", + "chart-4": "oklch(0.525 0.223 3.958)", + "chart-5": "oklch(0.459 0.187 3.815)", + "sidebar-primary": "oklch(0.592 0.249 0.584)", + "sidebar-primary-foreground": "oklch(0.971 0.014 343.198)", + }, + dark: { + primary: "oklch(0.459 0.187 3.815)", + "primary-foreground": "oklch(0.971 0.014 343.198)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.823 0.12 346.018)", + "chart-2": "oklch(0.656 0.241 354.308)", + "chart-3": "oklch(0.592 0.249 0.584)", + "chart-4": "oklch(0.525 0.223 3.958)", + "chart-5": "oklch(0.459 0.187 3.815)", + "sidebar-primary": "oklch(0.656 0.241 354.308)", + "sidebar-primary-foreground": "oklch(0.971 0.014 343.198)", + }, + }, + }, + { + name: "purple", + title: "紫色", + cssVars: { + light: { + primary: "oklch(0.496 0.265 301.924)", + "primary-foreground": "oklch(0.977 0.014 308.299)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.827 0.119 306.383)", + "chart-2": "oklch(0.627 0.265 303.9)", + "chart-3": "oklch(0.558 0.288 302.321)", + "chart-4": "oklch(0.496 0.265 301.924)", + "chart-5": "oklch(0.438 0.218 303.724)", + "sidebar-primary": "oklch(0.558 0.288 302.321)", + "sidebar-primary-foreground": "oklch(0.977 0.014 308.299)", + }, + dark: { + primary: "oklch(0.438 0.218 303.724)", + "primary-foreground": "oklch(0.977 0.014 308.299)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.827 0.119 306.383)", + "chart-2": "oklch(0.627 0.265 303.9)", + "chart-3": "oklch(0.558 0.288 302.321)", + "chart-4": "oklch(0.496 0.265 301.924)", + "chart-5": "oklch(0.438 0.218 303.724)", + "sidebar-primary": "oklch(0.627 0.265 303.9)", + "sidebar-primary-foreground": "oklch(0.977 0.014 308.299)", + }, + }, + }, + { + name: "red", + title: "红色", + cssVars: { + light: { + primary: "oklch(0.505 0.213 27.518)", + "primary-foreground": "oklch(0.971 0.013 17.38)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.808 0.114 19.571)", + "chart-2": "oklch(0.637 0.237 25.331)", + "chart-3": "oklch(0.577 0.245 27.325)", + "chart-4": "oklch(0.505 0.213 27.518)", + "chart-5": "oklch(0.444 0.177 26.899)", + "sidebar-primary": "oklch(0.577 0.245 27.325)", + "sidebar-primary-foreground": "oklch(0.971 0.013 17.38)", + }, + dark: { + primary: "oklch(0.444 0.177 26.899)", + "primary-foreground": "oklch(0.971 0.013 17.38)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.808 0.114 19.571)", + "chart-2": "oklch(0.637 0.237 25.331)", + "chart-3": "oklch(0.577 0.245 27.325)", + "chart-4": "oklch(0.505 0.213 27.518)", + "chart-5": "oklch(0.444 0.177 26.899)", + "sidebar-primary": "oklch(0.637 0.237 25.331)", + "sidebar-primary-foreground": "oklch(0.971 0.013 17.38)", + }, + }, + }, + { + name: "rose", + title: "玫红色", + cssVars: { + light: { + primary: "oklch(0.514 0.222 16.935)", + "primary-foreground": "oklch(0.969 0.015 12.422)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.81 0.117 11.638)", + "chart-2": "oklch(0.645 0.246 16.439)", + "chart-3": "oklch(0.586 0.253 17.585)", + "chart-4": "oklch(0.514 0.222 16.935)", + "chart-5": "oklch(0.455 0.188 13.697)", + "sidebar-primary": "oklch(0.586 0.253 17.585)", + "sidebar-primary-foreground": "oklch(0.969 0.015 12.422)", + }, + dark: { + primary: "oklch(0.455 0.188 13.697)", + "primary-foreground": "oklch(0.969 0.015 12.422)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.81 0.117 11.638)", + "chart-2": "oklch(0.645 0.246 16.439)", + "chart-3": "oklch(0.586 0.253 17.585)", + "chart-4": "oklch(0.514 0.222 16.935)", + "chart-5": "oklch(0.455 0.188 13.697)", + sidebar: "oklch(0.21 0.006 285.885)", + "sidebar-primary": "oklch(0.645 0.246 16.439)", + "sidebar-primary-foreground": "oklch(0.969 0.015 12.422)", + }, + }, + }, + { + name: "sky", + title: "天蓝色", + cssVars: { + light: { + primary: "oklch(0.5 0.134 242.749)", + "primary-foreground": "oklch(0.977 0.013 236.62)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.828 0.111 230.318)", + "chart-2": "oklch(0.685 0.169 237.323)", + "chart-3": "oklch(0.588 0.158 241.966)", + "chart-4": "oklch(0.5 0.134 242.749)", + "chart-5": "oklch(0.443 0.11 240.79)", + "sidebar-primary": "oklch(0.588 0.158 241.966)", + "sidebar-primary-foreground": "oklch(0.977 0.013 236.62)", + }, + dark: { + primary: "oklch(0.443 0.11 240.79)", + "primary-foreground": "oklch(0.977 0.013 236.62)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.828 0.111 230.318)", + "chart-2": "oklch(0.685 0.169 237.323)", + "chart-3": "oklch(0.588 0.158 241.966)", + "chart-4": "oklch(0.5 0.134 242.749)", + "chart-5": "oklch(0.443 0.11 240.79)", + "sidebar-primary": "oklch(0.685 0.169 237.323)", + "sidebar-primary-foreground": "oklch(0.293 0.066 243.157)", + }, + }, + }, + { + name: "teal", + title: "蓝绿色", + cssVars: { + light: { + primary: "oklch(0.511 0.096 186.391)", + "primary-foreground": "oklch(0.984 0.014 180.72)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.855 0.138 181.071)", + "chart-2": "oklch(0.704 0.14 182.503)", + "chart-3": "oklch(0.6 0.118 184.704)", + "chart-4": "oklch(0.511 0.096 186.391)", + "chart-5": "oklch(0.437 0.078 188.216)", + "sidebar-primary": "oklch(0.6 0.118 184.704)", + "sidebar-primary-foreground": "oklch(0.984 0.014 180.72)", + }, + dark: { + primary: "oklch(0.437 0.078 188.216)", + "primary-foreground": "oklch(0.984 0.014 180.72)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.855 0.138 181.071)", + "chart-2": "oklch(0.704 0.14 182.503)", + "chart-3": "oklch(0.6 0.118 184.704)", + "chart-4": "oklch(0.511 0.096 186.391)", + "chart-5": "oklch(0.437 0.078 188.216)", + "sidebar-primary": "oklch(0.704 0.14 182.503)", + "sidebar-primary-foreground": "oklch(0.277 0.046 192.524)", + }, + }, + }, + { + name: "violet", + title: "紫罗兰色", + cssVars: { + light: { + primary: "oklch(0.491 0.27 292.581)", + "primary-foreground": "oklch(0.969 0.016 293.756)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.811 0.111 293.571)", + "chart-2": "oklch(0.606 0.25 292.717)", + "chart-3": "oklch(0.541 0.281 293.009)", + "chart-4": "oklch(0.491 0.27 292.581)", + "chart-5": "oklch(0.432 0.232 292.759)", + "sidebar-primary": "oklch(0.541 0.281 293.009)", + "sidebar-primary-foreground": "oklch(0.969 0.016 293.756)", + }, + dark: { + primary: "oklch(0.432 0.232 292.759)", + "primary-foreground": "oklch(0.969 0.016 293.756)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.811 0.111 293.571)", + "chart-2": "oklch(0.606 0.25 292.717)", + "chart-3": "oklch(0.541 0.281 293.009)", + "chart-4": "oklch(0.491 0.27 292.581)", + "chart-5": "oklch(0.432 0.232 292.759)", + "sidebar-primary": "oklch(0.606 0.25 292.717)", + "sidebar-primary-foreground": "oklch(0.969 0.016 293.756)", + }, + }, + }, + { + name: "yellow", + title: "黄色", + cssVars: { + light: { + primary: "oklch(0.852 0.199 91.936)", + "primary-foreground": "oklch(0.421 0.095 57.708)", + secondary: "oklch(0.967 0.001 286.375)", + "secondary-foreground": "oklch(0.21 0.006 285.885)", + "chart-1": "oklch(0.905 0.182 98.111)", + "chart-2": "oklch(0.795 0.184 86.047)", + "chart-3": "oklch(0.681 0.162 75.834)", + "chart-4": "oklch(0.554 0.135 66.442)", + "chart-5": "oklch(0.476 0.114 61.907)", + "sidebar-primary": "oklch(0.681 0.162 75.834)", + "sidebar-primary-foreground": "oklch(0.987 0.026 102.212)", + }, + dark: { + primary: "oklch(0.795 0.184 86.047)", + "primary-foreground": "oklch(0.421 0.095 57.708)", + secondary: "oklch(0.274 0.006 286.033)", + "secondary-foreground": "oklch(0.985 0 0)", + "chart-1": "oklch(0.905 0.182 98.111)", + "chart-2": "oklch(0.795 0.184 86.047)", + "chart-3": "oklch(0.681 0.162 75.834)", + "chart-4": "oklch(0.554 0.135 66.442)", + "chart-5": "oklch(0.476 0.114 61.907)", + "sidebar-primary": "oklch(0.795 0.184 86.047)", + "sidebar-primary-foreground": "oklch(0.987 0.026 102.212)", + }, + }, + }, +] diff --git a/packages/blocks/src/blocks/appearance/ui-state.tsx b/packages/blocks/src/blocks/appearance/ui-state.tsx new file mode 100644 index 0000000..abef254 --- /dev/null +++ b/packages/blocks/src/blocks/appearance/ui-state.tsx @@ -0,0 +1,213 @@ +import React from "react" +import { + type UiState, + type UiStateKey, + type UiStateUpdate, + type UiStateValue, +} from "./state" +import { + buildThemeStyleSheet, + THEME_STYLE_ELEMENT_ID, + type ThemeScheme, +} from "./theme" + +const systemThemeQuery = "(prefers-color-scheme: dark)" +const themeStateKeys = new Set([ + "theme-mode", + "theme-light", + "theme-dark", +]) +let restoreThemeTransitionFrame: number | undefined + +function disableThemeTransitionsTemporarily() { + if (typeof document === "undefined") return + + const root = document.documentElement + root.classList.add("ui:theme-changing") + + if (restoreThemeTransitionFrame !== undefined) { + cancelAnimationFrame(restoreThemeTransitionFrame) + } + + restoreThemeTransitionFrame = requestAnimationFrame(() => { + restoreThemeTransitionFrame = requestAnimationFrame(() => { + root.classList.remove("ui:theme-changing") + restoreThemeTransitionFrame = undefined + }) + }) +} + +function applyUiState(state: UiState) { + if (typeof document === "undefined") return + + const root = document.documentElement + const resolvedTheme = + state["theme-mode"] === "system" ? getSystemTheme() : state["theme-mode"] + const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID) + + if (themeStyle) { + themeStyle.textContent = buildThemeStyleSheet( + state["theme-light"], + state["theme-dark"] + ) + } + + root.classList.toggle("ui:compacted", state["main-compacted"]) + root.classList.toggle("ui:collapsed", state["sidebar-collapsed"]) + root.classList.toggle("ui:dark", resolvedTheme === "dark") + root.classList.toggle("ui:light", resolvedTheme === "light") + root.style.colorScheme = resolvedTheme +} + +function subscribeToSystemTheme(listener: VoidFunction) { + const mediaQuery = window.matchMedia(systemThemeQuery) + mediaQuery.addEventListener("change", listener) + return () => mediaQuery.removeEventListener("change", listener) +} + +function getSystemTheme(): ThemeScheme { + return window.matchMedia(systemThemeQuery).matches ? "dark" : "light" +} + +function getServerSystemTheme(): ThemeScheme { + return "light" +} + +type UiStateStore = { + getSnapshot: () => UiState + getServerSnapshot: () => UiState + setValue: (key: K, value: UiState[K]) => void + subscribe: (listener: VoidFunction) => VoidFunction +} + +function createUiStateStore(initialState: UiState): UiStateStore { + let snapshot = initialState + const listeners = new Set() + + return { + getSnapshot: () => snapshot, + getServerSnapshot: () => initialState, + setValue: (key, value) => { + if (snapshot[key] === value) return + snapshot = { ...snapshot, [key]: value } + listeners.forEach((listener) => listener()) + }, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + } +} + +export type UiStateChangeHandler = ( + update: UiStateUpdate +) => Promise | void + +interface UiStateContextValue { + onStateChange?: UiStateChangeHandler + store: UiStateStore +} + +const UiStateContext = React.createContext(null) + +export interface UiStateProviderProps { + children: React.ReactNode + initialState: UiState + onStateChange?: UiStateChangeHandler +} + +export function UiStateProvider({ + children, + initialState, + onStateChange, +}: UiStateProviderProps) { + const storeRef = React.useRef(null) + if (!storeRef.current) { + storeRef.current = createUiStateStore(initialState) + } + + const state = React.useSyncExternalStore( + storeRef.current.subscribe, + storeRef.current.getSnapshot, + storeRef.current.getServerSnapshot + ) + + React.useLayoutEffect(() => { + applyUiState(state) + }, [state]) + + React.useEffect(() => { + if (state["theme-mode"] !== "system") return + + const mediaQuery = window.matchMedia(systemThemeQuery) + const applySystemTheme = () => { + disableThemeTransitionsTemporarily() + applyUiState(storeRef.current!.getSnapshot()) + } + + mediaQuery.addEventListener("change", applySystemTheme) + return () => mediaQuery.removeEventListener("change", applySystemTheme) + }, [state["theme-mode"]]) + + const contextValue = React.useMemo( + () => ({ + onStateChange, + store: storeRef.current!, + }), + [onStateChange] + ) + + return ( + + {children} + + ) +} + +export function useUiState( + key: K +): [UiStateValue, React.Dispatch>>] { + const context = React.useContext(UiStateContext) + if (!context) { + throw new Error("useUiState must be used within UiStateProvider") + } + const { onStateChange, store } = context + + const state = React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getServerSnapshot + ) + const value = state[key] as UiStateValue + + const setValue = React.useCallback< + React.Dispatch>> + >( + (action) => { + const currentValue = store.getSnapshot()[key] as UiStateValue + const nextValue = + typeof action === "function" ? action(currentValue) : action + + if (themeStateKeys.has(key)) { + disableThemeTransitionsTemporarily() + } + store.setValue(key, nextValue) + applyUiState(store.getSnapshot()) + void onStateChange?.({ key, value: nextValue } as UiStateUpdate) + }, + [key, onStateChange, store] + ) + + return [value, setValue] as const +} + +export function useResolvedTheme(): ThemeScheme { + const [themeMode] = useUiState("theme-mode") + const systemTheme = React.useSyncExternalStore( + subscribeToSystemTheme, + getSystemTheme, + getServerSystemTheme + ) + + return themeMode === "system" ? systemTheme : themeMode +} diff --git a/packages/blocks/src/blocks/chats/chat-popover-handle.ts b/packages/blocks/src/blocks/chats/chat-popover-handle.ts new file mode 100644 index 0000000..38b9183 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-popover-handle.ts @@ -0,0 +1,3 @@ +import { createPopoverHandle } from "@workspace/ui/components/popover" + +export const chatPopoverHandle = createPopoverHandle() diff --git a/packages/blocks/src/blocks/chats/chat-popover-trigger.tsx b/packages/blocks/src/blocks/chats/chat-popover-trigger.tsx new file mode 100644 index 0000000..f15c5ec --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-popover-trigger.tsx @@ -0,0 +1,13 @@ +import * as React from "react" +import { PopoverTrigger } from "@workspace/ui/components/popover" + +import { chatPopoverHandle } from "./chat-popover-handle" + +type ChatPopoverTriggerProps = Omit< + React.ComponentProps, + "handle" +> + +export function ChatPopoverTrigger(props: ChatPopoverTriggerProps) { + return +} diff --git a/packages/blocks/src/blocks/chats/chat-popover.tsx b/packages/blocks/src/blocks/chats/chat-popover.tsx new file mode 100644 index 0000000..0908fd4 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-popover.tsx @@ -0,0 +1,43 @@ +import { ItemGroup } from "@workspace/ui/components/item" +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, +} from "@workspace/ui/components/popover" + +import { useOnScroll } from "../../hooks/use-on-scroll" + +import { chatPopoverHandle } from "./chat-popover-handle" +import { ChatThreadItem } from "./chat-thread-item" +import type { ChatThread } from "./types" + +export interface ChatPopoverProps { + threads: readonly ChatThread[] + title?: string +} + +export function ChatPopover({ threads, title = "Chats" }: ChatPopoverProps) { + useOnScroll(() => { + chatPopoverHandle.close() + }) + + return ( + + + + + {title}({threads.length}) + + + + + {threads.map((thread) => ( + + ))} + + + + ) +} diff --git a/packages/blocks/src/blocks/chats/chat-thread-item.tsx b/packages/blocks/src/blocks/chats/chat-thread-item.tsx new file mode 100644 index 0000000..f46a071 --- /dev/null +++ b/packages/blocks/src/blocks/chats/chat-thread-item.tsx @@ -0,0 +1,45 @@ +import * as React from "react" +import { Avatar, AvatarImage } from "@workspace/ui/components/avatar" +import { + Item, + ItemContent, + ItemMedia, + ItemTitle, +} from "@workspace/ui/components/item" +import { useRippleRef } from "@workspace/ui/hooks/use-ripple" +import { cn } from "@workspace/ui/lib/utils" + +import type { ChatThread } from "./types" + +interface ChatThreadItemProps extends Omit< + React.ComponentProps, + "children" +> { + thread: ChatThread +} + +export function ChatThreadItem({ + thread, + ref, + className, + ...props +}: ChatThreadItemProps) { + const rippleRef = useRippleRef(ref) + + return ( + + + + + + + + {thread.name} + + + ) +} diff --git a/packages/blocks/src/blocks/chats/index.ts b/packages/blocks/src/blocks/chats/index.ts new file mode 100644 index 0000000..8f4a53a --- /dev/null +++ b/packages/blocks/src/blocks/chats/index.ts @@ -0,0 +1,4 @@ +export { ChatPopover } from "./chat-popover" +export type { ChatPopoverProps } from "./chat-popover" +export { ChatPopoverTrigger } from "./chat-popover-trigger" +export type { ChatThread } from "./types" diff --git a/packages/blocks/src/blocks/chats/types.ts b/packages/blocks/src/blocks/chats/types.ts new file mode 100644 index 0000000..b1d53c0 --- /dev/null +++ b/packages/blocks/src/blocks/chats/types.ts @@ -0,0 +1,5 @@ +export interface ChatThread { + avatarUrl: string + name: string + presence?: "offline" | "online" +} diff --git a/packages/blocks/src/blocks/layout/app-breadcrumb.tsx b/packages/blocks/src/blocks/layout/app-breadcrumb.tsx new file mode 100644 index 0000000..5fd2b3f --- /dev/null +++ b/packages/blocks/src/blocks/layout/app-breadcrumb.tsx @@ -0,0 +1,450 @@ +import { Link } from "@tanstack/react-router" +import * as React from "react" +import { + Breadcrumb, + BreadcrumbEllipsis, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@workspace/ui/components/breadcrumb" +import { Button } from "@workspace/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu" + +import { type NavigationInfo, useNavigationStack } from "../navigation" +import { Icon } from "../../components/icon" + +const MAX_VISIBLE_ENTRIES = 3 + +type BreadcrumbEntry = NavigationInfo + +type BreadcrumbDisplayItem = + | { + entry: BreadcrumbEntry + kind: "entry" + } + | { + entries: readonly BreadcrumbEntry[] + kind: "menu" + } + +function getDisplayItems( + entries: readonly BreadcrumbEntry[], + requestedVisibleCount: number +): BreadcrumbDisplayItem[] { + if (entries.length === 0) { + return [] + } + + const visibleCount = Math.max( + 1, + Math.min(MAX_VISIBLE_ENTRIES, requestedVisibleCount, entries.length) + ) + + if (entries.length <= visibleCount) { + return entries.map((entry) => ({ entry, kind: "entry" })) + } + + if (visibleCount === 1) { + return [ + { entries: entries.slice(0, -1), kind: "menu" }, + { entry: entries.at(-1)!, kind: "entry" }, + ] + } + + const trailingEntries = entries.slice(-(visibleCount - 1)) + const hiddenEntries = entries.slice(1, -(visibleCount - 1)) + + return [ + { entry: entries[0], kind: "entry" }, + ...(hiddenEntries.length > 0 + ? [{ entries: hiddenEntries, kind: "menu" as const }] + : []), + ...trailingEntries.map((entry) => ({ + entry, + kind: "entry" as const, + })), + ] +} + +function useVisibleEntryCount(entries: readonly BreadcrumbEntry[]) { + const breadcrumbRef = React.useRef(null) + const listRef = React.useRef(null) + const measurementRef = React.useRef(null) + const entriesKey = entries.map((entry) => entry.id).join("\u0000") + const maximumVisibleCount = Math.min(MAX_VISIBLE_ENTRIES, entries.length) + const [measurement, setMeasurement] = React.useState(() => ({ + entriesKey, + visibleCount: maximumVisibleCount, + })) + + React.useLayoutEffect(() => { + if (maximumVisibleCount === 0) { + setMeasurement({ entriesKey, visibleCount: 0 }) + return + } + + const breadcrumb = breadcrumbRef.current + const list = listRef.current + const measurement = measurementRef.current + + if (!breadcrumb || !list || !measurement) { + return + } + + const updateVisibleCount = () => { + const entryWidths = Array.from( + measurement.querySelectorAll( + "[data-breadcrumb-measure-entry]" + ) + ).map((element) => element.getBoundingClientRect().width) + const menuWidth = + measurement + .querySelector("[data-breadcrumb-measure-menu]") + ?.getBoundingClientRect().width ?? 0 + const separatorWidth = + measurement + .querySelector("[data-breadcrumb-measure-separator]") + ?.getBoundingClientRect().width ?? 0 + const listStyle = window.getComputedStyle(list) + const gap = Number.parseFloat(listStyle.columnGap || listStyle.gap) || 0 + const availableWidth = breadcrumb.getBoundingClientRect().width + + let nextVisibleCount = 1 + + for ( + let candidateCount = maximumVisibleCount; + candidateCount >= 1; + candidateCount -= 1 + ) { + const candidateItems = getDisplayItems(entries, candidateCount) + const itemWidth = candidateItems.reduce((total, item) => { + if (item.kind === "menu") { + return total + menuWidth + } + + const entryIndex = entries.indexOf(item.entry) + + return total + (entryWidths[entryIndex] ?? 0) + }, 0) + const separatorCount = Math.max(0, candidateItems.length - 1) + const naturalWidth = + itemWidth + separatorWidth * separatorCount + gap * separatorCount * 2 + + if (naturalWidth <= availableWidth) { + nextVisibleCount = candidateCount + break + } + } + + setMeasurement((current) => { + if ( + current.entriesKey === entriesKey && + current.visibleCount === nextVisibleCount + ) { + return current + } + + return { entriesKey, visibleCount: nextVisibleCount } + }) + } + + updateVisibleCount() + + const observer = new ResizeObserver(updateVisibleCount) + observer.observe(breadcrumb) + observer.observe(measurement) + + return () => observer.disconnect() + }, [entries, entriesKey, maximumVisibleCount]) + + const visibleCount = + measurement.entriesKey === entriesKey + ? Math.min(measurement.visibleCount, maximumVisibleCount) + : maximumVisibleCount + + return { + breadcrumbRef, + listRef, + measurementRef, + visibleCount, + } +} + +function BreadcrumbEntryContent({ entry }: { entry: BreadcrumbEntry }) { + return ( + <> + {entry.icon && } + {entry.label} + > + ) +} + +function BreadcrumbNavigationMenuItem({ + currentId, + entry, +}: { + currentId?: string + entry: BreadcrumbEntry +}) { + const isCurrent = entry.id === currentId + + if (entry.items?.length) { + return ( + + + + + + {entry.items.map((childEntry) => ( + + ))} + + + ) + } + + if (!entry.to) { + return ( + + + + ) + } + + return ( + + ) : ( + + ) + } + > + + + ) +} + +function BreadcrumbRouteLink({ + currentId, + entry, +}: { + currentId?: string + entry: BreadcrumbEntry +}) { + if (entry.items?.length) { + return ( + + + + + + + {entry.items.map((childEntry) => ( + + ))} + + + + ) + } + + if (!entry.to) { + return ( + + + + ) + } + + const link = + entry.to === "/" ? ( + + ) : ( + + ) + + return ( + + + + ) +} + +function BreadcrumbMenu({ entries }: { entries: readonly BreadcrumbEntry[] }) { + return ( + + + } + > + + + + + {entries.map((entry) => { + const content = ( + <> + {entry.icon && ( + + )} + {entry.label} + > + ) + + if (!entry.to) { + return ( + + {content} + + ) + } + + return ( + + ) : ( + + ) + } + > + {content} + + ) + })} + + + + ) +} + +export function AppBreadcrumb() { + const navigationStack = useNavigationStack() + const currentId = navigationStack.at(-1)?.id + const entries = React.useMemo( + () => navigationStack.filter((entry) => entry.to !== "/"), + [navigationStack] + ) + const { breadcrumbRef, listRef, measurementRef, visibleCount } = + useVisibleEntryCount(entries) + const displayItems = getDisplayItems(entries, visibleCount) + + if (entries.length === 0) { + return null + } + + return ( + + + {displayItems.map((item, index) => { + const isCurrent = index === displayItems.length - 1 + const key = + item.kind === "entry" + ? item.entry.id + : `menu-${item.entries.map((entry) => entry.id).join("-")}` + + return ( + + {index > 0 && } + + {item.kind === "menu" ? ( + + ) : isCurrent ? ( + + {item.entry.icon && ( + + )} + {item.entry.label} + + ) : ( + + )} + + + ) + })} + + + {entries.map((entry, index) => ( + + {entry.icon && ( + + )} + {entry.label} + + ))} + + + + + + + + + ) +} diff --git a/packages/blocks/src/blocks/layout/app-layout.tsx b/packages/blocks/src/blocks/layout/app-layout.tsx new file mode 100644 index 0000000..52d4efc --- /dev/null +++ b/packages/blocks/src/blocks/layout/app-layout.tsx @@ -0,0 +1,246 @@ +import * as React from "react" +import { Button } from "@workspace/ui/components/button" +import { Kbd } from "@workspace/ui/components/kbd" +import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint" +import { cn } from "@workspace/ui/lib/utils" +import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react" + +import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats" +import { Icon } from "../../components/icon" +import { + MobileNavigationSheet, + MobileNavigationSheetTrigger, + NavigationProvider, + type NavigationGroup, +} from "../navigation" +import { + NotificationCenterSheet, + NotificationCenterTrigger, + type UseNotificationsOptions, +} from "../notifications" +import { + BellIcon, + MessageMultiple01Icon, + Search01Icon, +} from "@hugeicons/core-free-icons" + +import { AppSidebar } from "./app-sidebar" +import { + headerActionIconClassName, + HeaderActionButton, +} from "./header-action-button" +import { useSidebarState } from "./sidebar-state" +import { UserMenu, UserMenuTrigger } from "./user-menu" +import { AppBreadcrumb } from "./app-breadcrumb" +import { Separator } from "@workspace/ui/components/separator" +import { AppQueryIndicator } from "./app-query-indicator" + +export interface AppLayoutProps { + chatThreads: readonly ChatThread[] + children: React.ReactNode + navigationGroups: readonly NavigationGroup[] + notifications: UseNotificationsOptions +} + +export function AppLayout({ + chatThreads, + children, + navigationGroups, + notifications, +}: AppLayoutProps) { + return ( + + + + + + + + + + + {children} + + + + ) +} + +function AppHeader() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + /> + + } + /> + + + + + + ) +} + +function AppHeaderBackground() { + return ( + + + + + + ) +} + +function AppMain({ children }: { children?: React.ReactNode }) { + return ( + + + {children} + + + ) +} + +function GlobalSearchTrigger() { + return ( + + + + + ⌘K + + + ) +} + +function SidebarCollapseButton() { + const { isCollapsed, setCollapsed } = useSidebarState() + const isTablet = useIsTablet() + + const ChevronIcon = isCollapsed ? ChevronRightIcon : ChevronLeftIcon + + return ( + setCollapsed(!isCollapsed)} + disabled={isTablet} + > + + + + ) +} diff --git a/packages/blocks/src/blocks/layout/app-query-indicator.tsx b/packages/blocks/src/blocks/layout/app-query-indicator.tsx new file mode 100644 index 0000000..12fab6d --- /dev/null +++ b/packages/blocks/src/blocks/layout/app-query-indicator.tsx @@ -0,0 +1,47 @@ +import { useRefresh } from "../../hooks/use-refresh" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { HeaderActionButton } from "./header-action-button" +import { ReloadIcon } from "@hugeicons/core-free-icons" +import { cn } from "@workspace/ui/lib/utils" + +export function AppQueryIndicator({ className }: { className?: string }) { + const { canRefresh, isRefreshing, refresh } = useRefresh() + + if (isRefreshing) { + return ( + + + + ) + } + + return ( + + + } + /> + + 点击可以刷新 + + 当前页面数据 + + + ) +} diff --git a/packages/blocks/src/blocks/layout/app-sidebar.tsx b/packages/blocks/src/blocks/layout/app-sidebar.tsx new file mode 100644 index 0000000..5470aab --- /dev/null +++ b/packages/blocks/src/blocks/layout/app-sidebar.tsx @@ -0,0 +1,35 @@ +import { useLocation } from "@tanstack/react-router" + +import { PrimaryNavigation } from "../navigation/primary-navigation" +import { + resolveNavigationRouteState, + type GetNavigationRouteState, +} from "../navigation/route-state" +import type { NavigationGroup } from "../navigation/types" +import { useIsSidebarCompact } from "./sidebar-state" + +export function AppSidebar({ groups }: { groups: readonly NavigationGroup[] }) { + const { pathname } = useLocation() + const isCompact = useIsSidebarCompact() + const getRouteState: GetNavigationRouteState = (to) => + resolveNavigationRouteState(pathname, to) + + return ( + + ) +} diff --git a/packages/blocks/src/blocks/layout/header-action-button.tsx b/packages/blocks/src/blocks/layout/header-action-button.tsx new file mode 100644 index 0000000..c34e18c --- /dev/null +++ b/packages/blocks/src/blocks/layout/header-action-button.tsx @@ -0,0 +1,35 @@ +import { Icon, type IconData } from "../../components/icon" +import { Button, type ButtonProps } from "@workspace/ui/components/button" +import { cn } from "@workspace/ui/lib/utils" + +export const headerActionIconClassName = + "transition-all transform origin-center group-hover/button:scale-105" + +type HeaderActionButtonProps = ButtonProps & { + icon?: IconData + showIndicator?: boolean +} + +export function HeaderActionButton({ + children, + className, + icon, + showIndicator, + ...props +}: HeaderActionButtonProps) { + return ( + + {icon ? ( + + ) : ( + children + )} + {showIndicator && ( + + )} + + ) +} diff --git a/packages/blocks/src/blocks/layout/index.ts b/packages/blocks/src/blocks/layout/index.ts new file mode 100644 index 0000000..94fb9b9 --- /dev/null +++ b/packages/blocks/src/blocks/layout/index.ts @@ -0,0 +1,14 @@ +export { AppBreadcrumb } from "./app-breadcrumb" +export { AppLayout, type AppLayoutProps } from "./app-layout" +export { AppQueryIndicator } from "./app-query-indicator" +export { AppSidebar } from "./app-sidebar" +export { + headerActionIconClassName, + HeaderActionButton, +} from "./header-action-button" +export { + SidebarStateProvider, + useIsSidebarCompact, + useSidebarState, +} from "./sidebar-state" +export { UserMenu, UserMenuTrigger } from "./user-menu" diff --git a/packages/blocks/src/blocks/layout/sidebar-state.tsx b/packages/blocks/src/blocks/layout/sidebar-state.tsx new file mode 100644 index 0000000..e0e3a13 --- /dev/null +++ b/packages/blocks/src/blocks/layout/sidebar-state.tsx @@ -0,0 +1,53 @@ +import * as React from "react" + +import { useUiState } from "../appearance/ui-state" +import { useBreakpoint } from "@workspace/ui/hooks/use-breakpoint" + +interface SidebarStateContextValue { + isCollapsed: boolean + setCollapsed: (collapsed: boolean) => void +} + +const SidebarStateContext = + React.createContext(null) + +export function SidebarStateProvider({ + children, +}: { + children: React.ReactNode +}) { + const [isCollapsed, setCollapsedPreference] = useUiState("sidebar-collapsed") + + const setCollapsed = React.useCallback( + (collapsed: boolean) => setCollapsedPreference(collapsed), + [setCollapsedPreference] + ) + + const contextValue = React.useMemo( + () => ({ isCollapsed, setCollapsed }), + [isCollapsed] + ) + + return ( + + {children} + + ) +} + +export function useSidebarState() { + const context = React.useContext(SidebarStateContext) + + if (!context) { + throw new Error("useSidebarState must be used within SidebarStateProvider.") + } + + return context +} + +export function useIsSidebarCompact() { + const breakpoint = useBreakpoint() + const { isCollapsed } = useSidebarState() + + return breakpoint === "tablet" || (breakpoint === "desktop" && isCollapsed) +} diff --git a/packages/blocks/src/blocks/layout/user-menu.tsx b/packages/blocks/src/blocks/layout/user-menu.tsx new file mode 100644 index 0000000..787ca4b --- /dev/null +++ b/packages/blocks/src/blocks/layout/user-menu.tsx @@ -0,0 +1,243 @@ +import { formatForDisplay } from "@tanstack/react-hotkeys" +import { Button, type ButtonProps } from "@workspace/ui/components/button" +import { + createDialogHandle, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, + type DialogHandle, +} from "@workspace/ui/components/dialog" +import { Kbd } from "@workspace/ui/components/kbd" +import { + createPopoverHandle, + Popover, + PopoverContent, + PopoverTrigger, + type PopoverHandle, +} from "@workspace/ui/components/popover" +import { + useIsMobile, + useOnBreakpoint, +} from "@workspace/ui/hooks/use-breakpoint" +import { cn } from "@workspace/ui/lib/utils" + +import { + getNavigationCommandProps, + navigationActions, + type NavigationAction, +} from "../../lib/command-actions" +import { + headerActionIconClassName, + HeaderActionButton, +} from "./header-action-button" +import { useOnScroll } from "../../hooks/use-on-scroll" +import { Icon, type IconData } from "../../components/icon" +import { + KeyboardIcon, + LogoutCircle02Icon, + ShieldKeyIcon, + SwatchIcon, +} from "@hugeicons/core-free-icons" + +interface UserMenuItem { + action?: NavigationAction + shortcut?: string + icon: IconData + label: string +} + +interface UserMenuTriggerProps { + className?: string +} + +const userMenuItems: readonly UserMenuItem[] = [ + { + action: navigationActions.appearance, + icon: SwatchIcon, + label: "界面外观", + shortcut: "Mod+,", + }, + { + icon: KeyboardIcon, + label: "按键映射", + }, + { + icon: ShieldKeyIcon, + label: "账号密码", + shortcut: "Mod+P", + }, +] + +let userMenuHandles: + | { + dialog: DialogHandle + popover: PopoverHandle + } + | undefined + +function getOrCreateUserMenuHandles() { + userMenuHandles ??= { + dialog: createDialogHandle(), + popover: createPopoverHandle(), + } + return userMenuHandles +} + +export function UserMenuTrigger({ className }: UserMenuTriggerProps) { + return useIsMobile() ? ( + + ) : ( + + ) +} + +export function UserMenu() { + useOnBreakpoint((breakpoint) => { + const { popover, dialog } = getOrCreateUserMenuHandles() + + if (breakpoint === "mobile") { + if (popover.isOpen) { + popover.close() + } + } else if (dialog.isOpen) { + dialog.close() + } + }) + + return useIsMobile() ? : +} + +function UserMenuPopoverTrigger({ className }: UserMenuTriggerProps) { + const { popover } = getOrCreateUserMenuHandles() + + return ( + } + /> + ) +} + +function UserMenuTriggerButton({ className, ...props }: ButtonProps) { + return ( + + + + ) +} + +function UserMenuContent({ onItemSelect }: { onItemSelect: VoidFunction }) { + return ( + + + + Jaydon Frankie + demo@minimals.cc + + + + {userMenuItems.map((item) => ( + + } + onClick={onItemSelect} + {...(item.action + ? getNavigationCommandProps(item.action) + : undefined)} + > + + {item.label} + {item.shortcut && } + + + ))} + + + + + + 退出登录 + + + + + ) +} + +function UserMenuPopover() { + const { popover } = getOrCreateUserMenuHandles() + + useOnScroll(() => { + popover.close() + }) + + return ( + + + popover.close()} /> + + + ) +} + +function UserMenuDialogTrigger({ className }: UserMenuTriggerProps) { + const { dialog } = getOrCreateUserMenuHandles() + return ( + } + /> + ) +} + +function UserMenuDialog() { + const { dialog } = getOrCreateUserMenuHandles() + return ( + + + + + + + dialog.close()} /> + + + ) +} + +function MenuShortcut({ shortcut }: { shortcut: string }) { + return ( + + {formatForDisplay(shortcut)} + + ) +} + +function MenuShortcutSequence({ shortcuts }: { shortcuts: readonly string[] }) { + return ( + + {shortcuts.map((shortcut) => formatForDisplay(shortcut)).join(" → ")} + + ) +} diff --git a/packages/blocks/src/blocks/navigation/flyout-navigation.tsx b/packages/blocks/src/blocks/navigation/flyout-navigation.tsx new file mode 100644 index 0000000..6acbf69 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/flyout-navigation.tsx @@ -0,0 +1,67 @@ +import { + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@workspace/ui/components/dropdown-menu" + +import { Icon } from "../../components/icon" + +import { resolveNavigationItemIcon } from "./item-icon" +import { NavigationBadge, NavigationLink } from "./navigation-button" +import type { GetNavigationRouteState } from "./route-state" +import type { NavigationItem } from "./types" + +interface SidebarFlyoutMenuItemsProps { + items: readonly NavigationItem[] + getRouteState: GetNavigationRouteState +} + +export function SidebarFlyoutMenuItems({ + items, + getRouteState, +}: SidebarFlyoutMenuItemsProps) { + return items.map((item) => { + const icon = resolveNavigationItemIcon(item) + const iconElement = icon ? ( + + ) : null + const routeState = getRouteState(item.to) + + if (item.items?.length) { + return ( + + + {iconElement} + {item.label} + + + + + + + ) + } + + return ( + : undefined} + > + {iconElement} + {item.label} + + + ) + }) +} diff --git a/packages/blocks/src/blocks/navigation/index.ts b/packages/blocks/src/blocks/navigation/index.ts new file mode 100644 index 0000000..5b94fbb --- /dev/null +++ b/packages/blocks/src/blocks/navigation/index.ts @@ -0,0 +1,19 @@ +export { + MobileNavigationSheet, + MobileNavigationSheetTrigger, +} from "./mobile-navigation" +export { resolveNavigationItemIcon } from "./item-icon" +export { PrimaryNavigation } from "./primary-navigation" +export { resolveNavigationRouteState } from "./route-state" +export { + NavigationProvider, + type NavigationProviderProps, + useNavigationInfo, + useNavigationStack, +} from "./use-navigation" +export type { + GetNavigationRouteState, + NavigationRouteState, +} from "./route-state" +export type { NavigationInfo, NavigationKey } from "./use-navigation" +export type { NavigationGroup, NavigationItem } from "./types" diff --git a/packages/blocks/src/blocks/navigation/item-icon.ts b/packages/blocks/src/blocks/navigation/item-icon.ts new file mode 100644 index 0000000..7782382 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/item-icon.ts @@ -0,0 +1,10 @@ +import type { IconData } from "../../components/icon" + +import type { NavigationItem } from "./types" + +export function resolveNavigationItemIcon( + item: NavigationItem, + visibleByDefault = false +): IconData | undefined { + return (item.showIcon ?? visibleByDefault) ? item.icon : undefined +} diff --git a/packages/blocks/src/blocks/navigation/mobile-navigation.tsx b/packages/blocks/src/blocks/navigation/mobile-navigation.tsx new file mode 100644 index 0000000..c8e6019 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/mobile-navigation.tsx @@ -0,0 +1,175 @@ +import { useLocation } from "@tanstack/react-router" +import type { ButtonProps } from "@workspace/ui/components/button" +import { Separator } from "@workspace/ui/components/separator" +import { + createSheetHandle, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components/sheet" +import { useOnBreakpoint } from "@workspace/ui/hooks/use-breakpoint" +import { cn, separate } from "@workspace/ui/lib/utils" +import { ChevronRightIcon } from "lucide-react" + +import { HeaderActionButton } from "../layout/header-action-button" + +import { + NavigationBadge, + NavigationButton, + NavigationLink, +} from "./navigation-button" +import { resolveNavigationItemIcon } from "./item-icon" +import { PrimaryNavigation } from "./primary-navigation" +import { + resolveNavigationRouteState, + type GetNavigationRouteState, +} from "./route-state" +import type { NavigationGroup } from "./types" +import { Icon } from "../../components/icon" +import { Menu02Icon } from "@hugeicons/core-free-icons" + +const mobileNavigationSheetHandle = createSheetHandle() + +export function MobileNavigationSheetTrigger({ + className, + ...props +}: ButtonProps) { + return ( + } + > + + + ) +} + +export interface MobileNavigationSheetProps { + groups: readonly NavigationGroup[] +} + +export function MobileNavigationSheet({ groups }: MobileNavigationSheetProps) { + useOnBreakpoint((breakpoint) => { + if (breakpoint !== "mobile" && mobileNavigationSheetHandle.isOpen) { + mobileNavigationSheetHandle.close() + } + }) + + const hasNestedItems = groups.some((group) => + group.items.some((item) => !!item.items?.length) + ) + + const { pathname } = useLocation() + const getRouteState: GetNavigationRouteState = (to) => + resolveNavigationRouteState(pathname, to) + + return ( + + + + Navigation + Navigate through the app. + + + + {hasNestedItems ? ( + + ) : ( + + )} + + + + + ) +} + +interface FlatNavigationGroupListProps { + className?: string + items: readonly NavigationGroup[] + getRouteState: GetNavigationRouteState +} + +function FlatNavigationGroupList({ + className, + items, + getRouteState, +}: FlatNavigationGroupListProps) { + return ( + + {items.map((group) => ( + + + {group.label} + + + {separate({ + items: group.items, + iterator: (item) => { + const routeState = getRouteState(item.to) + + return ( + : undefined + } + trailing={ + <> + + + + + > + } + /> + ) + }, + separator: (index) => ( + + ), + })} + + + ))} + + ) +} diff --git a/packages/blocks/src/blocks/navigation/navigation-button.tsx b/packages/blocks/src/blocks/navigation/navigation-button.tsx new file mode 100644 index 0000000..60eccb9 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/navigation-button.tsx @@ -0,0 +1,224 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { Link } from "@tanstack/react-router" +import * as React from "react" +import { Badge } from "@workspace/ui/components/badge" +import { useRippleRef } from "@workspace/ui/hooks/use-ripple" +import { cn } from "@workspace/ui/lib/utils" +import { ChevronRightIcon } from "lucide-react" + +import { Icon, type IconData } from "../../components/icon" + +export interface NavigationButtonProps extends Omit< + useRender.ComponentProps<"button", {}>, + "className" | "style" +> { + icon?: IconData + isActive: boolean + isCurrent: boolean + label: string + layout?: "inline" | "stacked" | "responsive" + className?: string | (() => string | undefined) | undefined + style?: + React.CSSProperties | (() => React.CSSProperties | undefined) | undefined + trailing?: React.ReactNode | (() => React.ReactNode) | undefined +} + +type NavigationLinkProps = Omit, "href"> & { + to: string +} + +export function NavigationLink({ to, ...props }: NavigationLinkProps) { + if (to === "/") { + return + } + + return +} + +export function NavigationButton({ + icon, + isActive, + isCurrent, + label, + layout = "inline", + trailing, + className, + style, + render, + ref, + ...props +}: NavigationButtonProps) { + const rippleRef = useRippleRef(ref) + + const trailingNode = typeof trailing === "function" ? trailing() : trailing + const hasTrailingContent = React.Children.count(trailingNode) > 0 + + const baseButtonProps: useRender.ElementProps<"button"> = { + className: cn( + "group/button relative items-center rounded-md [&>span]:inline-flex [&>span]:items-center", + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground data-route-active:bg-primary/10 data-route-active:text-primary dark:hover:bg-muted/50 dark:aria-expanded:bg-muted/50 dark:data-route-active:bg-muted/50", + layout === "stacked" + ? [ + "grid grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]", + "grid-rows-[auto_auto] content-center gap-x-0 gap-y-1", + ] + : layout === "responsive" + ? [ + "flex flex-row gap-3", + "tablet:grid tablet:grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]", + "tablet:grid-rows-[auto_auto] tablet:content-center tablet:gap-x-0 tablet:gap-y-1", + "not-mobile:collapsed:grid", + "not-mobile:collapsed:grid-cols-[minmax(0,1fr)_auto_auto_minmax(0,1fr)]", + "not-mobile:collapsed:grid-rows-[auto_auto]", + "not-mobile:collapsed:content-center", + "not-mobile:collapsed:gap-x-0 not-mobile:collapsed:gap-y-1", + ] + : "flex flex-row gap-3", + typeof className === "function" ? className() : className + ), + style: typeof style === "function" ? style() : style, + type: "button", + children: ( + + {icon && ( + + + + )} + + {label} + + {hasTrailingContent && ( + + {trailingNode} + + )} + + ), + "aria-label": label, + } + + return useRender({ + defaultTagName: "button", + ref: rippleRef, + render, + props: mergeProps<"button">(baseButtonProps, props, { + "aria-current": isCurrent ? "page" : undefined, + }), + state: { layout, "route-active": isActive }, + }) +} + +export function NavigationDisclosureIcon({ + staticWhenCompact = false, +}: { + staticWhenCompact?: boolean +}) { + return ( + + + + ) +} + +type NavigationBadgeProps = Omit< + React.ComponentProps, + "children" +> & { + itemId: string +} + +function getNavigationBadgeValue(_itemId: string) { + // TODO 暂时返回空的 + return "" +} + +export function NavigationBadge({ itemId, ...props }: NavigationBadgeProps) { + const badgeValue = getNavigationBadgeValue(itemId) + + if (!badgeValue) { + return null + } + + return {badgeValue} +} diff --git a/packages/blocks/src/blocks/navigation/nested-navigation.tsx b/packages/blocks/src/blocks/navigation/nested-navigation.tsx new file mode 100644 index 0000000..258c4d5 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/nested-navigation.tsx @@ -0,0 +1,109 @@ +import { Accordion } from "@base-ui/react/accordion" +import { cn } from "@workspace/ui/lib/utils" + +import { + NavigationBadge, + NavigationButton, + NavigationDisclosureIcon, + NavigationLink, +} from "./navigation-button" +import { resolveNavigationItemIcon } from "./item-icon" +import type { GetNavigationRouteState } from "./route-state" +import type { NavigationItem } from "./types" + +interface InlineNestedNavigationListProps { + className?: string + getRouteState: GetNavigationRouteState + items: readonly NavigationItem[] +} + +export function InlineNestedNavigationList({ + className, + items, + getRouteState, +}: InlineNestedNavigationListProps) { + const hasNestedItems = items.some((item) => !!item.items?.length) + + const listElement = ( + + + {items.map((item) => { + const hasChildItems = !!item.items?.length + const routeState = getRouteState(item.to) + let itemElement = ( + + ) : undefined + } + trailing={ + <> + + {hasChildItems && } + > + } + /> + ) + + if (hasChildItems) { + itemElement = ( + + + + + + + + + ) + } + + return ( + + {itemElement} + + ) + })} + + + ) + + if (!hasNestedItems) { + return listElement + } + + return ( + + {listElement} + + ) +} diff --git a/packages/blocks/src/blocks/navigation/primary-navigation.tsx b/packages/blocks/src/blocks/navigation/primary-navigation.tsx new file mode 100644 index 0000000..2f55b9c --- /dev/null +++ b/packages/blocks/src/blocks/navigation/primary-navigation.tsx @@ -0,0 +1,192 @@ +import { Accordion } from "@base-ui/react/accordion" +import * as React from "react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu" +import { cn } from "@workspace/ui/lib/utils" + +import { SidebarFlyoutMenuItems } from "./flyout-navigation" +import { resolveNavigationItemIcon } from "./item-icon" +import { + NavigationBadge, + NavigationButton, + NavigationDisclosureIcon, + NavigationLink, + type NavigationButtonProps, +} from "./navigation-button" +import { InlineNestedNavigationList } from "./nested-navigation" +import type { GetNavigationRouteState } from "./route-state" +import type { NavigationGroup, NavigationItem } from "./types" + +interface PrimaryNavigationItemProps { + item: NavigationItem + getRouteState: GetNavigationRouteState + isCompact: boolean + showIconByDefault: boolean +} + +function PrimaryNavigationItem({ + item, + getRouteState, + isCompact, + showIconByDefault, +}: PrimaryNavigationItemProps) { + const [isMenuOpen, setMenuOpen] = React.useState(false) + const routeState = getRouteState(item.to) + + React.useEffect(() => { + if (!isCompact) { + setMenuOpen(false) + } + }, [isCompact]) + + const navigationButtonProps: NavigationButtonProps = { + label: item.label, + layout: "responsive", + icon: resolveNavigationItemIcon(item, showIconByDefault), + ...routeState, + className: cn( + "group h-11 w-full ps-3 pe-2", + "tablet:p-0 not-mobile:collapsed:p-0", + "tablet:h-16 not-mobile:collapsed:h-16" + ), + } + + if (!item.items?.length) { + return ( + : undefined} + trailing={ + + } + /> + ) + } + + const navigationTrigger = ( + + + + > + } + /> + ) + + if (isCompact) { + return ( + { + const event = eventDetails.event + const isPointerEvent = + typeof PointerEvent !== "undefined" && event instanceof PointerEvent + const isMousePress = + eventDetails.reason === "trigger-press" && + (isPointerEvent + ? event.pointerType === "mouse" + : event instanceof MouseEvent) + + if (isMousePress) { + eventDetails.cancel() + return + } + + setMenuOpen(open) + }} + > + + + + + + + + ) + } + + return ( + + + + + + + + + ) +} + +interface PrimaryNavigationProps { + className?: string + items: readonly NavigationGroup[] + getRouteState: GetNavigationRouteState + isCompact?: boolean +} + +export function PrimaryNavigation({ + items, + className, + getRouteState, + isCompact = false, +}: PrimaryNavigationProps) { + return ( + + {items.map((group) => ( + + + {group.label} + + + {group.items.map((item) => ( + + ))} + + + ))} + + ) +} diff --git a/packages/blocks/src/blocks/navigation/route-state.ts b/packages/blocks/src/blocks/navigation/route-state.ts new file mode 100644 index 0000000..0d661a4 --- /dev/null +++ b/packages/blocks/src/blocks/navigation/route-state.ts @@ -0,0 +1,22 @@ +export interface NavigationRouteState { + isActive: boolean + isCurrent: boolean +} + +export type GetNavigationRouteState = ( + to: string | undefined +) => NavigationRouteState + +export function resolveNavigationRouteState( + pathname: string, + to: string | undefined +): NavigationRouteState { + if (!to) { + return { isActive: false, isCurrent: false } + } + + const isCurrent = pathname === to + const isActive = isCurrent || (to !== "/" && pathname.startsWith(`${to}/`)) + + return { isActive, isCurrent } +} diff --git a/packages/blocks/src/blocks/navigation/types.ts b/packages/blocks/src/blocks/navigation/types.ts new file mode 100644 index 0000000..c580ffe --- /dev/null +++ b/packages/blocks/src/blocks/navigation/types.ts @@ -0,0 +1,17 @@ +import type { IconData } from "../../components/icon" + +export interface NavigationItem { + id: string + to?: string + label: string + icon?: IconData + showIcon?: boolean + items?: readonly NavigationItem[] +} + +export interface NavigationGroup { + id: string + label: string + showIcon?: boolean + items: readonly NavigationItem[] +} diff --git a/packages/blocks/src/blocks/navigation/use-navigation.tsx b/packages/blocks/src/blocks/navigation/use-navigation.tsx new file mode 100644 index 0000000..c97ccff --- /dev/null +++ b/packages/blocks/src/blocks/navigation/use-navigation.tsx @@ -0,0 +1,146 @@ +import { useLocation } from "@tanstack/react-router" +import * as React from "react" + +import type { IconData } from "../../components/icon" +import { resolveNavigationItemIcon } from "./item-icon" +import type { NavigationGroup, NavigationItem } from "./types" + +export type NavigationKey = NavigationItem["id"] + +export interface NavigationInfo { + id: NavigationKey + icon?: IconData + items?: readonly NavigationInfo[] + label: string + to?: string +} + +interface NavigationRecord { + info: NavigationInfo + stack: readonly NavigationInfo[] +} + +interface NavigationIndex { + recordsByKey: ReadonlyMap + recordsByPathname: ReadonlyMap +} + +const EMPTY_NAVIGATION_STACK: readonly NavigationInfo[] = [] +const NavigationContext = React.createContext(null) + +function normalizePathname(pathname: string) { + if (pathname === "/") { + return pathname + } + + return pathname.replace(/\/+$/, "") +} + +function createNavigationIndex( + groups: readonly NavigationGroup[] +): NavigationIndex { + const recordsByKey = new Map() + const recordsByPathname = new Map() + + const createInfoItems = ( + items: readonly NavigationItem[], + showIconByDefault: boolean + ): readonly NavigationInfo[] => + items.map((item) => ({ + id: item.id, + icon: resolveNavigationItemIcon(item, showIconByDefault), + items: item.items?.length + ? createInfoItems(item.items, false) + : undefined, + label: item.label, + to: item.to, + })) + + const indexInfoItems = ( + items: readonly NavigationInfo[], + parentStack: readonly NavigationInfo[] + ) => { + for (const info of items) { + const stack = [...parentStack, info] + const record = { info, stack } satisfies NavigationRecord + + recordsByKey.set(info.id, record) + + if (info.to) { + recordsByPathname.set(normalizePathname(info.to), record) + } + + if (info.items?.length) { + indexInfoItems(info.items, stack) + } + } + } + + for (const group of groups) { + const items = createInfoItems(group.items, group.showIcon ?? true) + + indexInfoItems(items, EMPTY_NAVIGATION_STACK) + } + + return { recordsByKey, recordsByPathname } +} + +export interface NavigationProviderProps { + children: React.ReactNode + groups: readonly NavigationGroup[] +} + +export function NavigationProvider({ + children, + groups, +}: NavigationProviderProps) { + const index = React.useMemo(() => createNavigationIndex(groups), [groups]) + + return ( + + {children} + + ) +} + +function useNavigationRecord(key?: NavigationKey) { + const index = React.useContext(NavigationContext) + const pathname = useLocation({ select: (location) => location.pathname }) + + if (!index) { + throw new Error( + "useNavigationInfo and useNavigationStack must be used within NavigationProvider." + ) + } + + if (key !== undefined) { + return index.recordsByKey.get(key) + } + + return index.recordsByPathname.get(normalizePathname(pathname)) +} + +/** + * Returns one navigation node. + * + * Pass a navigation item `id` to select it explicitly. Without an `id`, the + * item matching the current route is returned. + */ +export function useNavigationInfo( + key?: NavigationKey +): NavigationInfo | undefined { + return useNavigationRecord(key)?.info +} + +/** + * Returns the navigation hierarchy from its top-level item to the selected + * node. + * + * Pass a navigation item `id` to select it explicitly. Without an `id`, the + * hierarchy matching the current route is returned. + */ +export function useNavigationStack( + key?: NavigationKey +): readonly NavigationInfo[] { + return useNavigationRecord(key)?.stack ?? EMPTY_NAVIGATION_STACK +} diff --git a/packages/blocks/src/blocks/notifications/index.ts b/packages/blocks/src/blocks/notifications/index.ts new file mode 100644 index 0000000..a92f01f --- /dev/null +++ b/packages/blocks/src/blocks/notifications/index.ts @@ -0,0 +1,31 @@ +export { + NotificationCenter, + type NotificationCenterProps, + type NotificationCenterStatus, +} from "./notification-center" +export { + NotificationItem, + type NotificationItemProps, +} from "./notification-item" +export { + NotificationCenterSheet, + type NotificationCenterSheetProps, + NotificationCenterTrigger, + type NotificationCenterTriggerProps, +} from "./notification-sheet" +export { + useNotifications, + type UseNotificationsOptions, + type UseNotificationsResult, +} from "./use-notifications" +export type { + Notification, + NotificationAction, + NotificationActionEvent, + NotificationActionHandler, + NotificationActionIntent, + NotificationAvatarMedia, + NotificationIconMedia, + NotificationMedia, + NotificationTone, +} from "./types" diff --git a/packages/blocks/src/blocks/notifications/notification-center.tsx b/packages/blocks/src/blocks/notifications/notification-center.tsx new file mode 100644 index 0000000..2a73eba --- /dev/null +++ b/packages/blocks/src/blocks/notifications/notification-center.tsx @@ -0,0 +1,145 @@ +import * as React from "react" +import { CheckCheckIcon } from "lucide-react" +import { Button } from "@workspace/ui/components/button" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { ItemGroup } from "@workspace/ui/components/item" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@workspace/ui/components/tooltip" +import { cn } from "@workspace/ui/lib/utils" + +import { NotificationItem } from "./notification-item" +import type { Notification, NotificationActionHandler } from "./types" + +export type NotificationCenterStatus = "error" | "pending" | "success" + +export interface NotificationCenterProps extends Omit< + React.ComponentProps<"section">, + "title" +> { + description?: React.ReactNode + notifications: readonly Notification[] + onAction?: NotificationActionHandler + onMarkAllAsRead: VoidFunction + onRefetch: VoidFunction + onViewAll?: VoidFunction + status: NotificationCenterStatus + title?: React.ReactNode + viewAllLabel?: React.ReactNode +} + +export function NotificationCenter({ + className, + description = "查看并处理通知", + notifications, + onAction, + onMarkAllAsRead, + onRefetch, + onViewAll, + status, + title = "Notifications", + viewAllLabel = "查看全部", + ...props +}: NotificationCenterProps) { + const unreadCount = notifications.filter( + (notification) => !notification.isRead + ).length + + return ( + + + + {title} + {unreadCount > 0 && ( + + ({unreadCount}) + + )} + + {description && {description}} + + + } + onClick={onMarkAllAsRead} + > + + + + 全部标记为已读 + + + + + {status === "pending" ? ( + + 正在加载通知… + + ) : status === "error" ? ( + + + 无法加载通知 + 请检查网络连接后重试。 + + 重新加载 + + + + ) : notifications.length === 0 ? ( + + + 暂无通知 + 新的动态会显示在这里。 + + + ) : ( + + {notifications.map((notification, index) => ( + + {index > 0 && ( + + )} + + + ))} + + )} + + + + ) +} diff --git a/packages/blocks/src/blocks/notifications/notification-item.tsx b/packages/blocks/src/blocks/notifications/notification-item.tsx new file mode 100644 index 0000000..d1e08a7 --- /dev/null +++ b/packages/blocks/src/blocks/notifications/notification-item.tsx @@ -0,0 +1,221 @@ +import * as React from "react" +import { Link } from "@tanstack/react-router" +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar" +import { Button } from "@workspace/ui/components/button" +import { + Item as BaseItem, + ItemContent, + ItemDescription, + ItemFooter, + ItemMedia, + ItemTitle, +} from "@workspace/ui/components/item" +import { cn } from "@workspace/ui/lib/utils" + +import { Icon } from "../../components/icon" + +import type { + Notification, + NotificationActionHandler, + NotificationActionIntent, + NotificationMedia, + NotificationTone, +} from "./types" + +const ACTION_VARIANTS: Record< + NotificationActionIntent, + "default" | "destructive" | "outline" +> = { + destructive: "destructive", + neutral: "outline", + primary: "default", +} + +const ICON_TONE_CLASSES: Record = { + destructive: "bg-destructive/10 text-destructive", + info: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + neutral: "bg-muted text-muted-foreground", + success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + warning: "bg-amber-500/10 text-amber-600 dark:text-amber-400", +} + +const RELATIVE_TIME_FORMAT = new Intl.RelativeTimeFormat("zh-CN", { + numeric: "auto", +}) + +const RELATIVE_TIME_UNITS: ReadonlyArray< + readonly [seconds: number, unit: Intl.RelativeTimeFormatUnit] +> = [ + [60 * 60 * 24 * 365, "year"], + [60 * 60 * 24 * 30, "month"], + [60 * 60 * 24, "day"], + [60 * 60, "hour"], + [60, "minute"], +] + +function getInitials(media: Extract) { + if (media.fallback) { + return media.fallback + } + + return (media.alt ?? "") + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() +} + +function formatRelativeTime(createdAt: string) { + const createdAtTime = new Date(createdAt).getTime() + + if (!Number.isFinite(createdAtTime)) { + return createdAt + } + + const differenceInSeconds = (createdAtTime - Date.now()) / 1000 + const absoluteDifference = Math.abs(differenceInSeconds) + + for (const [seconds, unit] of RELATIVE_TIME_UNITS) { + if (absoluteDifference >= seconds) { + return RELATIVE_TIME_FORMAT.format( + Math.round(differenceInSeconds / seconds), + unit + ) + } + } + + return "刚刚" +} + +function NotificationItemMedia({ media }: { media: NotificationMedia }) { + if (media.kind === "avatar") { + return ( + + + {getInitials(media)} + + ) + } + + return ( + + + + ) +} + +function NotificationRouteLink({ + children, + to, +}: { + children: React.ReactNode + to: string +}) { + if (to === "/") { + return {children} + } + + return ( + + {children} + + ) +} + +export interface NotificationItemProps extends Omit< + React.ComponentProps, + "children" +> { + notification: Notification + onAction?: NotificationActionHandler +} + +export function NotificationItem({ + className, + notification, + onAction, + ref, + ...props +}: NotificationItemProps) { + return ( + + {notification.media && ( + + + + )} + + + {notification.to ? ( + + {notification.title} + + ) : ( + notification.title + )} + + {notification.description && ( + + {notification.description} + + )} + + + {formatRelativeTime(notification.createdAt)} + + {notification.category && ( + <> + + {notification.category} + > + )} + + + {!notification.isRead && ( + + )} + {notification.actions && notification.actions.length > 0 && ( + + {notification.actions.map((action) => ( + onAction?.({ action, notification })} + > + {action.label} + + ))} + + )} + + ) +} diff --git a/packages/blocks/src/blocks/notifications/notification-sheet.tsx b/packages/blocks/src/blocks/notifications/notification-sheet.tsx new file mode 100644 index 0000000..2671dca --- /dev/null +++ b/packages/blocks/src/blocks/notifications/notification-sheet.tsx @@ -0,0 +1,82 @@ +import * as React from "react" +import { + createSheetHandle, + Sheet, + SheetContent, + SheetDescription, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components/sheet" + +import { NotificationCenter } from "./notification-center" +import type { Notification, NotificationActionHandler } from "./types" +import { + useNotifications, + type UseNotificationsOptions, +} from "./use-notifications" + +const notificationCenterSheetHandle = createSheetHandle() + +export type NotificationCenterTriggerProps = Omit< + React.ComponentProps, + "handle" +> + +export function NotificationCenterTrigger( + props: NotificationCenterTriggerProps +) { + return +} + +export interface NotificationCenterSheetProps extends UseNotificationsOptions { + onAction?: NotificationActionHandler + onMarkAllAsRead?: (notifications: readonly Notification[]) => void + onViewAll?: VoidFunction + title?: string +} + +export function NotificationCenterSheet({ + onAction, + onMarkAllAsRead, + onViewAll, + queryFn, + queryKey, + title = "Notifications", +}: NotificationCenterSheetProps) { + const notifications = useNotifications({ queryFn, queryKey }) + + const handleAction = React.useCallback( + (event) => { + notifications.executeAction(event) + onAction?.(event) + }, + [notifications, onAction] + ) + + const handleMarkAllAsRead = React.useCallback(() => { + const nextNotifications = notifications.markAllAsRead() + + onMarkAllAsRead?.(nextNotifications) + }, [notifications, onMarkAllAsRead]) + + return ( + + + {title} + 查看并处理通知 + + + + ) +} diff --git a/packages/blocks/src/blocks/notifications/types.ts b/packages/blocks/src/blocks/notifications/types.ts new file mode 100644 index 0000000..e1997b5 --- /dev/null +++ b/packages/blocks/src/blocks/notifications/types.ts @@ -0,0 +1,46 @@ +import type { IconData } from "../../components/icon" + +export type NotificationTone = + "destructive" | "info" | "neutral" | "success" | "warning" + +export interface NotificationAvatarMedia { + alt?: string + fallback?: string + kind: "avatar" + src: string +} + +export interface NotificationIconMedia { + icon: IconData + kind: "icon" + tone?: NotificationTone +} + +export type NotificationMedia = NotificationAvatarMedia | NotificationIconMedia + +export type NotificationActionIntent = "destructive" | "neutral" | "primary" + +export interface NotificationAction { + id: string + intent?: NotificationActionIntent + label: string +} + +export interface Notification { + actions?: readonly NotificationAction[] + category?: string + createdAt: string + description?: string + id: string + isRead: boolean + media?: NotificationMedia + title: string + to?: string +} + +export interface NotificationActionEvent { + action: NotificationAction + notification: Notification +} + +export type NotificationActionHandler = (event: NotificationActionEvent) => void diff --git a/packages/blocks/src/blocks/notifications/use-notifications.ts b/packages/blocks/src/blocks/notifications/use-notifications.ts new file mode 100644 index 0000000..2da3961 --- /dev/null +++ b/packages/blocks/src/blocks/notifications/use-notifications.ts @@ -0,0 +1,107 @@ +import * as React from "react" +import { + type QueryFunction, + type QueryKey, + useQuery, + useQueryClient, +} from "@tanstack/react-query" + +import type { + Notification, + NotificationActionEvent, + NotificationActionHandler, +} from "./types" + +const EMPTY_NOTIFICATIONS: readonly Notification[] = [] + +export interface UseNotificationsResult { + error: Error | null + executeAction: NotificationActionHandler + isFetching: boolean + markAllAsRead: () => readonly Notification[] + markAsRead: (id: string) => void + notifications: readonly Notification[] + refetch: VoidFunction + status: "error" | "pending" | "success" + unreadCount: number +} + +export interface UseNotificationsOptions { + queryFn: QueryFunction + queryKey: QueryKey +} + +export function useNotifications({ + queryFn, + queryKey, +}: UseNotificationsOptions): UseNotificationsResult { + const queryClient = useQueryClient() + const query = useQuery({ + queryFn, + queryKey, + }) + const queryRefetch = query.refetch + const notifications = query.data ?? EMPTY_NOTIFICATIONS + const unreadCount = notifications.filter( + (notification) => !notification.isRead + ).length + + const updateReadState = React.useCallback( + (predicate: (notification: Notification) => boolean) => { + queryClient.setQueryData( + queryKey, + (currentNotifications) => + currentNotifications?.map((notification) => + predicate(notification) + ? { ...notification, isRead: true } + : notification + ) ?? EMPTY_NOTIFICATIONS + ) + }, + [queryClient, queryKey] + ) + + const markAsRead = React.useCallback( + (id: string) => { + updateReadState((notification) => notification.id === id) + }, + [updateReadState] + ) + + const markAllAsRead = React.useCallback(() => { + const nextNotifications = notifications.map((notification) => ({ + ...notification, + isRead: true, + })) + + queryClient.setQueryData( + queryKey, + nextNotifications + ) + + return nextNotifications + }, [notifications, queryClient, queryKey]) + + const executeAction = React.useCallback( + ({ notification }: NotificationActionEvent) => { + markAsRead(notification.id) + }, + [markAsRead] + ) + + const refetch = React.useCallback(() => { + void queryRefetch() + }, [queryRefetch]) + + return { + error: query.error, + executeAction, + isFetching: query.isFetching, + markAllAsRead, + markAsRead, + notifications, + refetch, + status: query.status, + unreadCount, + } +} diff --git a/packages/blocks/src/components/icon.tsx b/packages/blocks/src/components/icon.tsx new file mode 100644 index 0000000..5628df8 --- /dev/null +++ b/packages/blocks/src/components/icon.tsx @@ -0,0 +1,30 @@ +import { type HugeiconsIconProps, HugeiconsIcon } from "@hugeicons/react" +import { cn } from "@workspace/ui/lib/utils" + +export type IconData = HugeiconsIconProps["icon"] + +export type IconProps = Omit & { + data: IconData + size?: "xs" | "sm" | "md" | "lg" | "xl" +} + +const sizeClasses = { + xs: "size-3", + sm: "size-4", + md: "size-5", + lg: "size-6", + xl: "size-7", +} + +export function Icon({ data, className, size = "md", ...props }: IconProps) { + return ( + + ) +} diff --git a/packages/blocks/src/hooks/use-on-scroll.ts b/packages/blocks/src/hooks/use-on-scroll.ts new file mode 100644 index 0000000..de1a649 --- /dev/null +++ b/packages/blocks/src/hooks/use-on-scroll.ts @@ -0,0 +1,70 @@ +import * as React from "react" +import { getOverflowAncestors, isOverflowElement } from "@floating-ui/utils/dom" +import { addEventListener } from "@base-ui/utils/addEventListener" +import { mergeCleanups } from "@base-ui/utils/mergeCleanups" +import { ownerWindow } from "@base-ui/utils/owner" + +type ScrollContainer = HTMLElement | React.RefObject +type ScrollTarget = Element | VisualViewport | Window + +function getScrollTargets(element: HTMLElement): ScrollTarget[] { + const targets: ScrollTarget[] = [] + + if (isOverflowElement(element)) { + targets.push(element) + } + + targets.push(...getOverflowAncestors(element, [], false)) + + if (targets.length === 0) { + targets.push(ownerWindow(element)) + } + + return Array.from(new Set(targets)) +} + +export function useOnScroll(listener: VoidFunction): void +export function useOnScroll( + container: ScrollContainer, + listener: VoidFunction +): void +export function useOnScroll( + containerOrListener: ScrollContainer | VoidFunction, + listener?: VoidFunction +) { + const container = + typeof containerOrListener === "function" ? null : containerOrListener + const scrollListener = + typeof containerOrListener === "function" ? containerOrListener : listener + + if (!scrollListener) { + throw new Error( + "useOnScroll requires a listener when a scroll container is provided." + ) + } + + const onScroll = React.useEffectEvent(scrollListener) + + React.useEffect(() => { + const targets = + container === null + ? [window] + : "current" in container + ? container.current && getScrollTargets(container.current) + : getScrollTargets(container) + + if (!targets) { + return + } + + const handleScroll = () => { + onScroll() + } + + return mergeCleanups( + ...targets.map((target) => + addEventListener(target, "scroll", handleScroll, { passive: true }) + ) + ) + }, [container]) +} diff --git a/packages/blocks/src/hooks/use-refresh.ts b/packages/blocks/src/hooks/use-refresh.ts new file mode 100644 index 0000000..6e516f2 --- /dev/null +++ b/packages/blocks/src/hooks/use-refresh.ts @@ -0,0 +1,33 @@ +import * as React from "react" +import { useIsFetching, useQueryClient } from "@tanstack/react-query" + +const ACTIVE_QUERY_FILTERS = { type: "active" } as const + +export function useRefresh() { + const queryClient = useQueryClient() + const queryCache = queryClient.getQueryCache() + const isRefreshing = useIsFetching(ACTIVE_QUERY_FILTERS) > 0 + const subscribe = React.useCallback( + (listener: VoidFunction) => queryCache.subscribe(listener), + [queryCache] + ) + const getSnapshot = React.useCallback( + () => queryCache.findAll(ACTIVE_QUERY_FILTERS).length > 0, + [queryCache] + ) + const canRefresh = React.useSyncExternalStore( + subscribe, + getSnapshot, + () => false + ) + + const refresh = React.useCallback(() => { + if (!canRefresh || isRefreshing) { + return + } + + void queryClient.refetchQueries(ACTIVE_QUERY_FILTERS) + }, [canRefresh, isRefreshing, queryClient]) + + return { canRefresh, isRefreshing, refresh } +} diff --git a/packages/blocks/src/lib/command-actions.test.tsx b/packages/blocks/src/lib/command-actions.test.tsx new file mode 100644 index 0000000..9dfcf5e --- /dev/null +++ b/packages/blocks/src/lib/command-actions.test.tsx @@ -0,0 +1,35 @@ +// @vitest-environment jsdom + +import { fireEvent, render } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { + getNavigationCommandProps, + NavigationActionTarget, + navigationActions, +} from "./command-actions" + +describe("navigation command", () => { + it("connects an action button to its command target", () => { + expect(getNavigationCommandProps(navigationActions.search)).toEqual({ + command: "--invoke", + commandfor: "navigation-action-search", + }) + }) + + it("invokes the matching command target", () => { + const onInvoke = vi.fn() + const { container } = render( + + ) + const target = container.querySelector("#navigation-action-search") + const event = new Event("command") + Object.defineProperty(event, "command", { value: "--invoke" }) + + fireEvent(target!, event) + + expect(onInvoke).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/blocks/src/lib/command-actions.tsx b/packages/blocks/src/lib/command-actions.tsx new file mode 100644 index 0000000..430d8cd --- /dev/null +++ b/packages/blocks/src/lib/command-actions.tsx @@ -0,0 +1,55 @@ +import React from "react" + +export const navigationActions = { + appearance: "appearance", + logout: "logout", + passwords: "passwords", + search: "search", + userMenu: "user-menu", +} as const + +export type NavigationAction = + (typeof navigationActions)[keyof typeof navigationActions] + +const NAVIGATION_INVOKE_COMMAND = "--invoke" +const NAVIGATION_ACTION_TARGET_PREFIX = "navigation-action-" + +export function getNavigationCommandProps(action: NavigationAction) { + return { + command: NAVIGATION_INVOKE_COMMAND, + commandfor: `${NAVIGATION_ACTION_TARGET_PREFIX}${action}`, + } +} + +export function NavigationActionTarget({ + action, + onInvoke, +}: { + action: NavigationAction + onInvoke: VoidFunction +}) { + const targetRef = React.useRef(null) + const invoke = React.useEffectEvent(onInvoke) + + React.useEffect(() => { + const target = targetRef.current + if (!target) return + + const handleCommand = (event: Event) => { + if ((event as CommandEvent).command === NAVIGATION_INVOKE_COMMAND) { + invoke() + } + } + + target.addEventListener("command", handleCommand) + return () => target.removeEventListener("command", handleCommand) + }, []) + + return ( + + ) +} diff --git a/packages/blocks/src/styles/globals.css b/packages/blocks/src/styles/globals.css new file mode 100644 index 0000000..f3275b1 --- /dev/null +++ b/packages/blocks/src/styles/globals.css @@ -0,0 +1 @@ +@source "../"; diff --git a/packages/blocks/tsconfig.json b/packages/blocks/tsconfig.json new file mode 100644 index 0000000..7333b2e --- /dev/null +++ b/packages/blocks/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "paths": { + "@workspace/blocks/*": ["./src/*"], + "@workspace/ui/*": ["../ui/src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}
{description}
+ 启用后,页面主内容将使用紧凑宽度 +
demo@minimals.cc
+ {notification.description} +