feat(appearance): add composable theme package
This commit is contained in:
@@ -83,6 +83,27 @@
|
||||
"wrangler": "^4.135.0",
|
||||
},
|
||||
},
|
||||
"packages/appearance": {
|
||||
"name": "@workspace/appearance",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@icones/react": "^0.0.1",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/blocks": {
|
||||
"name": "@workspace/blocks",
|
||||
"version": "0.0.0",
|
||||
@@ -999,6 +1020,8 @@
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.40", "", {}, "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg=="],
|
||||
|
||||
"@workspace/appearance": ["@workspace/appearance@workspace:packages/appearance"],
|
||||
|
||||
"@workspace/blocks": ["@workspace/blocks@workspace:packages/blocks"],
|
||||
|
||||
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@workspace/appearance",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icones/react": "^0.0.1",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/preferences": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"react": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx",
|
||||
"./effects": "./src/effects.ts",
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./locales": "./src/locales/catalogs.ts",
|
||||
"./locales/*": "./src/locales/*.ts",
|
||||
"./preferences": "./src/preferences.ts",
|
||||
"./theme": "./src/theme.ts",
|
||||
"./use-resolved-theme": "./src/use-resolved-theme.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { messages as englishMessages } from "../locales/en-US"
|
||||
import { CompactLayoutField } from "./compact-layout-field"
|
||||
import { accentColorSwatches, ThemeColorPicker } from "./theme-color-picker"
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderLocalized(node: React.ReactNode) {
|
||||
return render(
|
||||
<I18nProvider locale="en-US" catalogs={{ "en-US": englishMessages }}>
|
||||
{node}
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearance atoms", () => {
|
||||
it("reports compact layout changes without owning state", () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<CompactLayoutField checked={false} onCheckedChange={onCheckedChange} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("switch"))
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it("reports the selected theme color", () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
renderLocalized(
|
||||
<ThemeColorPicker
|
||||
onValueChange={onValueChange}
|
||||
swatches={accentColorSwatches}
|
||||
title="Primary color"
|
||||
value="blue"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rose" }))
|
||||
expect(onValueChange).toHaveBeenCalledWith("rose")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
import {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
appearanceDialogHandle,
|
||||
type AppearanceDialogHandle,
|
||||
} from "./appearance-dialog"
|
||||
import { AppearanceHotkeys } from "./appearance-hotkeys"
|
||||
|
||||
export interface AppearanceControllerProps {
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceController({
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceControllerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
const toggleDialog = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handle.close()
|
||||
} else {
|
||||
handle.open(null)
|
||||
}
|
||||
}, [handle, isOpen])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeMode(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setThemeMode])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<AppearanceDialog handle={handle} onOpenChange={handleOpenChange}>
|
||||
<AppearanceHotkeys
|
||||
onToggleDialog={toggleDialog}
|
||||
onToggleTheme={toggleTheme}
|
||||
/>
|
||||
<AppearanceDialogContent />
|
||||
</AppearanceDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ComponentProps, ReactNode } from "react"
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import {
|
||||
createDialogHandle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
type DialogHandle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import { AppearanceSettings } from "./appearance-settings"
|
||||
|
||||
export type AppearanceDialogHandle = DialogHandle<void>
|
||||
|
||||
export function createAppearanceDialogHandle(): AppearanceDialogHandle {
|
||||
return createDialogHandle<void>()
|
||||
}
|
||||
|
||||
export const appearanceDialogHandle = createAppearanceDialogHandle()
|
||||
|
||||
export interface AppearanceDialogProps {
|
||||
children?: ReactNode
|
||||
handle?: AppearanceDialogHandle
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AppearanceDialog({
|
||||
children,
|
||||
handle = appearanceDialogHandle,
|
||||
onOpenChange,
|
||||
}: AppearanceDialogProps) {
|
||||
return (
|
||||
<Dialog handle={handle} onOpenChange={onOpenChange}>
|
||||
{children ?? <AppearanceDialogContent />}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceDialogHeader() {
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<LocalizedText message={appearanceMessages.appearance} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<LocalizedText message={appearanceMessages.themeAndLayoutPreferences} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogContentProps extends Omit<
|
||||
ComponentProps<typeof DialogContent>,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function AppearanceDialogContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AppearanceDialogContentProps) {
|
||||
return (
|
||||
<DialogContent className={cn("sm:max-w-max", className)} {...props}>
|
||||
<AppearanceDialogHeader />
|
||||
<div className="-mx-6 max-h-[75vh] overflow-y-auto px-6">
|
||||
{children ?? <AppearanceSettings />}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppearanceDialogTriggerProps extends Omit<
|
||||
ComponentProps<typeof DialogTrigger>,
|
||||
"handle"
|
||||
> {
|
||||
handle?: AppearanceDialogHandle
|
||||
}
|
||||
|
||||
export function AppearanceDialogTrigger({
|
||||
handle = appearanceDialogHandle,
|
||||
...props
|
||||
}: AppearanceDialogTriggerProps) {
|
||||
return <DialogTrigger handle={handle} {...props} />
|
||||
}
|
||||
|
||||
export const AppearanceTrigger = AppearanceDialogTrigger
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
|
||||
export interface AppearanceHotkeysProps {
|
||||
onToggleDialog: VoidFunction
|
||||
onToggleTheme: VoidFunction
|
||||
}
|
||||
|
||||
export function AppearanceHotkeys({
|
||||
onToggleDialog,
|
||||
onToggleTheme,
|
||||
}: AppearanceHotkeysProps) {
|
||||
const appearanceTitle = useMessage(appearanceMessages.appearance)
|
||||
const appearanceDescription = useMessage(
|
||||
appearanceMessages.appearanceCommandDescription
|
||||
)
|
||||
const toggleThemeTitle = useMessage(appearanceMessages.toggleTheme)
|
||||
const toggleThemeDescription = useMessage(
|
||||
appearanceMessages.toggleThemeDescription
|
||||
)
|
||||
|
||||
useHotkey("Mod+,", onToggleDialog, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: appearanceTitle,
|
||||
description: appearanceDescription,
|
||||
},
|
||||
})
|
||||
|
||||
useHotkey("Mod+D", onToggleTheme, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: toggleThemeTitle,
|
||||
description: toggleThemeDescription,
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
import { CompactLayoutField } from "./compact-layout-field"
|
||||
import { ThemeConfigCard } from "./theme-config-card"
|
||||
import { ThemeModeField } from "./theme-mode-field"
|
||||
|
||||
export interface AppearanceSettingsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettings({ className }: AppearanceSettingsProps) {
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
const [compacted, setCompacted] = usePreference("main-compacted")
|
||||
const [lightTheme, setLightTheme] = usePreference("theme-light")
|
||||
const [darkTheme, setDarkTheme] = usePreference("theme-dark")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-10", className)}>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<ThemeModeField value={themeMode} onValueChange={setThemeMode} />
|
||||
<CompactLayoutField
|
||||
checked={compacted}
|
||||
onCheckedChange={setCompacted}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
<ThemeConfigCard
|
||||
active={resolvedTheme === "light"}
|
||||
compacted={compacted}
|
||||
config={lightTheme}
|
||||
onConfigChange={setLightTheme}
|
||||
scheme="light"
|
||||
/>
|
||||
<ThemeConfigCard
|
||||
active={resolvedTheme === "dark"}
|
||||
compacted={compacted}
|
||||
config={darkTheme}
|
||||
onConfigChange={setDarkTheme}
|
||||
scheme="dark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import { Switch } from "@workspace/ui/components/switch"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
|
||||
export interface CompactLayoutFieldProps {
|
||||
checked: boolean
|
||||
className?: string
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function CompactLayoutField({
|
||||
checked,
|
||||
className,
|
||||
onCheckedChange,
|
||||
}: CompactLayoutFieldProps) {
|
||||
return (
|
||||
<div className={cn("lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">
|
||||
<LocalizedText message={appearanceMessages.compactLayout} />
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<LocalizedText message={appearanceMessages.compactDescription} />
|
||||
</p>
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={checked}
|
||||
onCheckedChange={(nextChecked) => onCheckedChange(nextChecked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { themeColorMessages } from "../messages"
|
||||
import {
|
||||
accentColors,
|
||||
baseColors,
|
||||
getTheme,
|
||||
type AccentColor,
|
||||
type BaseColor,
|
||||
} from "../theme"
|
||||
|
||||
export type ThemeColor = BaseColor | AccentColor
|
||||
|
||||
export interface ThemeColorSwatch<T extends ThemeColor = ThemeColor> {
|
||||
dark: ThemeColorSwatchScheme
|
||||
light: ThemeColorSwatchScheme
|
||||
name: T
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchScheme {
|
||||
background: string
|
||||
foreground: string
|
||||
}
|
||||
|
||||
export function createThemeColorSwatches<T extends ThemeColor>(
|
||||
colors: readonly T[]
|
||||
): ThemeColorSwatch<T>[] {
|
||||
return colors.map((name) => {
|
||||
const theme = getTheme(name)!
|
||||
|
||||
return {
|
||||
name,
|
||||
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 = createThemeColorSwatches(baseColors)
|
||||
export const accentColorSwatches = createThemeColorSwatches(accentColors)
|
||||
|
||||
export interface ThemeColorPickerProps<T extends ThemeColor> {
|
||||
className?: string
|
||||
onValueChange: (color: T) => void
|
||||
swatches: readonly ThemeColorSwatch<T>[]
|
||||
title: ReactNode
|
||||
value: T
|
||||
}
|
||||
|
||||
export function ThemeColorPicker<T extends ThemeColor>({
|
||||
className,
|
||||
onValueChange,
|
||||
swatches,
|
||||
title,
|
||||
value,
|
||||
}: ThemeColorPickerProps<T>) {
|
||||
return (
|
||||
<fieldset className={cn("relative rounded-lg border p-4", className)}>
|
||||
<legend className="absolute top-0 left-4 z-2 block -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground">
|
||||
{title}
|
||||
</legend>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{swatches.map((swatch) => (
|
||||
<ThemeColorSwatchButton
|
||||
key={swatch.name}
|
||||
swatch={swatch}
|
||||
pressed={value === swatch.name}
|
||||
onClick={() => onValueChange(swatch.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ThemeColorSwatchButtonProps {
|
||||
onClick: VoidFunction
|
||||
pressed: boolean
|
||||
swatch: ThemeColorSwatch
|
||||
}
|
||||
|
||||
export function ThemeColorSwatchButton({
|
||||
onClick,
|
||||
pressed,
|
||||
swatch,
|
||||
}: ThemeColorSwatchButtonProps) {
|
||||
const swatchTitle = useMessage(themeColorMessages[swatch.name])
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={swatchTitle}
|
||||
aria-pressed={pressed}
|
||||
className={cn(
|
||||
"h-8 min-w-8 appearance-none truncate overflow-hidden rounded-4xl border-2 px-2 text-xs whitespace-nowrap ring-popover ring-inset",
|
||||
"border-(--light-bg) dark:border-(--dark-bg)",
|
||||
"bg-(--light-bg) dark:bg-(--dark-bg)",
|
||||
"text-(--light-fg) dark:text-(--dark-fg)",
|
||||
"ring-0 aria-pressed:ring-2"
|
||||
)}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{swatchTitle}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import { Badge } from "@workspace/ui/components/badge"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeColorConfig, ThemeScheme } from "../theme"
|
||||
import {
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
ThemeColorPicker,
|
||||
} from "./theme-color-picker"
|
||||
import { ThemePreview } from "./theme-preview"
|
||||
|
||||
export interface ThemeConfigCardProps {
|
||||
active?: boolean
|
||||
className?: string
|
||||
compacted?: boolean
|
||||
config: ThemeColorConfig
|
||||
onConfigChange: (config: ThemeColorConfig) => void
|
||||
scheme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeConfigCard({
|
||||
active = false,
|
||||
className,
|
||||
compacted = false,
|
||||
config,
|
||||
onConfigChange,
|
||||
scheme,
|
||||
}: ThemeConfigCardProps) {
|
||||
const titleMessage =
|
||||
scheme === "light"
|
||||
? appearanceMessages.lightTheme
|
||||
: appearanceMessages.darkTheme
|
||||
const descriptionMessage =
|
||||
scheme === "light"
|
||||
? appearanceMessages.lightThemeDescription
|
||||
: appearanceMessages.darkThemeDescription
|
||||
|
||||
return (
|
||||
<section className={cn("w-md rounded-xl border", className)}>
|
||||
<header className="flex gap-4 border-b p-3">
|
||||
{scheme === "light" ? (
|
||||
<SunIcon className="size-5" />
|
||||
) : (
|
||||
<MoonIcon className="size-5" />
|
||||
)}
|
||||
<h2>
|
||||
<LocalizedText message={titleMessage} />
|
||||
</h2>
|
||||
{active && (
|
||||
<Badge className="ml-auto">
|
||||
<LocalizedText message={appearanceMessages.active} />
|
||||
</Badge>
|
||||
)}
|
||||
</header>
|
||||
<div className="space-y-4 p-4">
|
||||
<p>
|
||||
<LocalizedText message={descriptionMessage} />
|
||||
</p>
|
||||
<ThemePreview compacted={compacted} config={config} scheme={scheme} />
|
||||
<ThemeColorPicker
|
||||
swatches={baseColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.baseColor} />}
|
||||
value={config.baseColor}
|
||||
onValueChange={(baseColor) =>
|
||||
onConfigChange({ ...config, baseColor })
|
||||
}
|
||||
/>
|
||||
<ThemeColorPicker
|
||||
swatches={accentColorSwatches}
|
||||
title={<LocalizedText message={appearanceMessages.primaryColor} />}
|
||||
value={config.accentColor}
|
||||
onValueChange={(accentColor) =>
|
||||
onConfigChange({ ...config, accentColor })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeMode } from "../preferences"
|
||||
|
||||
export interface ThemeModeFieldProps {
|
||||
className?: string
|
||||
onValueChange: (value: ThemeMode) => void
|
||||
value: ThemeMode
|
||||
}
|
||||
|
||||
export function ThemeModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
if (mode === "light") return <SunIcon />
|
||||
if (mode === "dark") return <MoonIcon />
|
||||
return <MonitorIcon />
|
||||
}
|
||||
|
||||
export function ThemeModeField({
|
||||
className,
|
||||
onValueChange,
|
||||
value,
|
||||
}: ThemeModeFieldProps) {
|
||||
const lightLabel = useMessage(appearanceMessages.lightMode)
|
||||
const darkLabel = useMessage(appearanceMessages.darkMode)
|
||||
const systemLabel = useMessage(appearanceMessages.followSystem)
|
||||
const lightDescription = useMessage(appearanceMessages.lightModeDescription)
|
||||
const darkDescription = useMessage(appearanceMessages.darkModeDescription)
|
||||
const systemDescription = useMessage(
|
||||
appearanceMessages.followSystemDescription
|
||||
)
|
||||
const title = useMessage(appearanceMessages.themeMode)
|
||||
const modeItems: ReadonlyArray<{ label: string; value: ThemeMode }> = [
|
||||
{ label: lightLabel, value: "light" },
|
||||
{ label: darkLabel, value: "dark" },
|
||||
{ label: systemLabel, value: "system" },
|
||||
]
|
||||
const description = {
|
||||
light: lightDescription,
|
||||
dark: darkDescription,
|
||||
system: systemDescription,
|
||||
}[value]
|
||||
|
||||
return (
|
||||
<div className={cn("lg:flex-1", className)}>
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<Select
|
||||
items={modeItems}
|
||||
value={value}
|
||||
onValueChange={(mode) => {
|
||||
if (mode) onValueChange(mode)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-2 w-40">
|
||||
<SelectValue>
|
||||
{(selectedValue: ThemeMode | null) => (
|
||||
<>
|
||||
<ThemeModeIcon mode={selectedValue} />
|
||||
{modeItems.find((item) => item.value === selectedValue)?.label}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
{modeItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<ThemeModeIcon mode={item.value} />
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { CSSProperties } from "react"
|
||||
import {
|
||||
getTheme,
|
||||
getThemeCssVariables,
|
||||
type ThemeColorConfig,
|
||||
type ThemeScheme,
|
||||
} from "../theme"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
export interface ThemePreviewProps {
|
||||
className?: string
|
||||
compacted?: boolean
|
||||
config: ThemeColorConfig
|
||||
scheme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemePreview({
|
||||
scheme,
|
||||
config,
|
||||
className,
|
||||
compacted = false,
|
||||
}: ThemePreviewProps) {
|
||||
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 (
|
||||
<div className={cn(className, `ui:${scheme}`)} style={style}>
|
||||
<div className="isolate h-52 max-w-103.5 rounded-lg bg-sidebar p-3 ring ring-border">
|
||||
<div className="flex gap-2.5 [&>div]:bg-foreground/10">
|
||||
<div className="size-5 rounded-sm" />
|
||||
<div className="ml-auto size-5 rounded-sm" />
|
||||
<div className="size-5 rounded-sm" />
|
||||
<div className="size-5 rounded-sm" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex w-16 flex-col gap-1 py-5 [&>div]:bg-foreground/10">
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="h-2.5 w-full rounded-r-full bg-primary!" />
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="h-2.5 w-full rounded-r-full" />
|
||||
<div className="mt-auto h-2.5 w-full rounded-r-full" />
|
||||
</div>
|
||||
<div className="flex-1 py-5">
|
||||
<div className={cn("space-y-2.5", { "px-7.5": compacted })}>
|
||||
<div className="flex h-2.5 items-center gap-2 [&>div]:bg-foreground/10">
|
||||
<div className="h-2.5 w-3 rounded-sm" />
|
||||
<div className="ml-auto size-2.5 rounded-sm" />
|
||||
<div className="h-2.5 w-3.75 rounded-sm" />
|
||||
<div className="h-2.5 w-5 rounded-sm bg-primary!" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 [&>div]:rounded-sm [&>div]:bg-foreground/10">
|
||||
<div className="flex h-18 items-end gap-2 p-2 compacted:gap-1">
|
||||
<div className="h-6 w-2 rounded-t-sm bg-(--preview-base)/55" />
|
||||
<div className="h-4 w-2 rounded-t-sm bg-(--preview-base)/55" />
|
||||
<div className="h-11 w-2 rounded-t-sm bg-(--preview-base)/55" />
|
||||
<div className="h-8 w-2 rounded-t-sm bg-(--preview-base)/55" />
|
||||
<div className="h-9 w-2 rounded-t-sm bg-(--preview-base)/55" />
|
||||
</div>
|
||||
<div className="flex h-18 flex-col items-stretch justify-between gap-1 p-2">
|
||||
<div className="flex items-center gap-1 opacity-55">
|
||||
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="ml-auto h-1 w-6 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55 compacted:w-4" />
|
||||
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-5 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-2 rounded-xs bg-(--preview-base)/55 compacted:w-1" />
|
||||
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-5" />
|
||||
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="size-1 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="h-1 w-3 rounded-xs bg-(--preview-base)/55" />
|
||||
<div className="size-1 rounded-xs bg-(--preview-base)/55 compacted:w-3" />
|
||||
<div className="h-1 w-6 rounded-xs bg-(--preview-base)/55 compacted:hidden" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-2 w-6 rounded-sm bg-primary" />
|
||||
<div className="h-2 w-6 rounded-sm bg-primary/75" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-18" />
|
||||
<div className="h-10 flex-1" />
|
||||
<div className="h-10 flex-1" />
|
||||
<div className="h-10 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ComponentProps } from "react"
|
||||
import { MoonIcon, SunIcon } from "@workspace/ui/components/icon"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { appearanceMessages } from "../messages"
|
||||
import type { ThemeScheme } from "../theme"
|
||||
import { useResolvedTheme } from "../use-resolved-theme"
|
||||
|
||||
export interface ThemeToggleProps extends Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"children" | "onClick"
|
||||
> {
|
||||
onThemeChange: (theme: ThemeScheme) => void
|
||||
theme: ThemeScheme
|
||||
}
|
||||
|
||||
export function ThemeToggle({
|
||||
className,
|
||||
onThemeChange,
|
||||
theme,
|
||||
...props
|
||||
}: ThemeToggleProps) {
|
||||
const switchToDarkLabel = useMessage(appearanceMessages.switchToDark)
|
||||
const switchToLightLabel = useMessage(appearanceMessages.switchToLight)
|
||||
const nextTheme = theme === "dark" ? "light" : "dark"
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label={nextTheme === "dark" ? switchToDarkLabel : switchToLightLabel}
|
||||
className={cn(
|
||||
"rounded-lg [&_svg]:opacity-45 [&_svg]:transition-opacity hover:[&_svg]:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onClick={() => onThemeChange(nextTheme)}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ThemeToggleButtonProps = Omit<
|
||||
ThemeToggleProps,
|
||||
"onThemeChange" | "theme"
|
||||
>
|
||||
|
||||
export function ThemeToggleButton(props: ThemeToggleButtonProps) {
|
||||
const [, setThemeMode] = usePreference("theme-mode")
|
||||
const resolvedTheme = useResolvedTheme()
|
||||
|
||||
return (
|
||||
<ThemeToggle
|
||||
{...props}
|
||||
theme={resolvedTheme}
|
||||
onThemeChange={setThemeMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||
import { PreferencesProvider, usePreference } from "@workspace/preferences"
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest"
|
||||
|
||||
import { appearancePreferenceEffect } from "./effects"
|
||||
import { defaultAppearancePreferences } from "./preferences"
|
||||
import { THEME_STYLE_ELEMENT_ID } from "./theme"
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
)
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 1)
|
||||
)
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
const style = document.createElement("style")
|
||||
style.id = THEME_STYLE_ELEMENT_ID
|
||||
document.head.append(style)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.remove()
|
||||
document.documentElement.className = ""
|
||||
document.documentElement.style.colorScheme = ""
|
||||
})
|
||||
|
||||
function PreferenceProbe() {
|
||||
const [themeMode, setThemeMode] = usePreference("theme-mode")
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => setThemeMode("dark")}>
|
||||
{themeMode}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe("appearancePreferenceEffect", () => {
|
||||
it("integrates with the shared PreferencesProvider", () => {
|
||||
const onPreferenceChange = vi.fn()
|
||||
|
||||
render(
|
||||
<PreferencesProvider
|
||||
effects={[appearancePreferenceEffect]}
|
||||
initialPreferences={defaultAppearancePreferences}
|
||||
onPreferenceChange={onPreferenceChange}
|
||||
>
|
||||
<PreferenceProbe />
|
||||
</PreferencesProvider>
|
||||
)
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:light")).toBe(true)
|
||||
expect(
|
||||
document.getElementById(THEME_STYLE_ELEMENT_ID)?.textContent
|
||||
).toContain("--background:")
|
||||
|
||||
fireEvent.click(screen.getByRole("button"))
|
||||
|
||||
expect(document.documentElement.classList.contains("ui:dark")).toBe(true)
|
||||
expect(onPreferenceChange).toHaveBeenCalledWith({
|
||||
key: "theme-mode",
|
||||
value: "dark",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
type PreferenceEffect,
|
||||
type PreferenceEffectCallback,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import type { AppearancePreferences } from "./preferences"
|
||||
import {
|
||||
buildThemeStyleSheet,
|
||||
THEME_STYLE_ELEMENT_ID,
|
||||
type ThemeScheme,
|
||||
} from "./theme"
|
||||
|
||||
const systemThemeQuery = "(prefers-color-scheme: dark)"
|
||||
const themePreferenceKeys = [
|
||||
"theme-mode",
|
||||
"theme-light",
|
||||
"theme-dark",
|
||||
] as const satisfies readonly (keyof AppearancePreferences)[]
|
||||
|
||||
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 getSystemTheme(): ThemeScheme {
|
||||
return window.matchMedia(systemThemeQuery).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
export function applyAppearancePreferences(preferences: AppearancePreferences) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const resolvedTheme =
|
||||
preferences["theme-mode"] === "system"
|
||||
? getSystemTheme()
|
||||
: preferences["theme-mode"]
|
||||
const themeStyle = document.getElementById(THEME_STYLE_ELEMENT_ID)
|
||||
|
||||
if (themeStyle) {
|
||||
themeStyle.textContent = buildThemeStyleSheet(
|
||||
preferences["theme-light"],
|
||||
preferences["theme-dark"]
|
||||
)
|
||||
}
|
||||
|
||||
root.classList.toggle("ui:compacted", preferences["main-compacted"])
|
||||
root.classList.toggle("ui:collapsed", preferences["sidebar-collapsed"])
|
||||
root.classList.toggle("ui:dark", resolvedTheme === "dark")
|
||||
root.classList.toggle("ui:light", resolvedTheme === "light")
|
||||
root.style.colorScheme = resolvedTheme
|
||||
}
|
||||
|
||||
/** Applies appearance preferences before the browser paints the updated UI. */
|
||||
export const applyAppearancePreferenceEffect: PreferenceEffectCallback = ({
|
||||
previousPreferences,
|
||||
preferences,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
const previousAppearancePreferences =
|
||||
previousPreferences as AppearancePreferences
|
||||
|
||||
if (
|
||||
themePreferenceKeys.some(
|
||||
(key) =>
|
||||
!Object.is(
|
||||
previousAppearancePreferences[key],
|
||||
appearancePreferences[key]
|
||||
)
|
||||
)
|
||||
) {
|
||||
disableThemeTransitionsTemporarily()
|
||||
}
|
||||
|
||||
applyAppearancePreferences(appearancePreferences)
|
||||
}
|
||||
|
||||
/** Keeps system-mode themes synchronized with the operating system setting. */
|
||||
export const subscribeSystemThemePreferenceEffect: PreferenceEffectCallback = ({
|
||||
preferences,
|
||||
store,
|
||||
}) => {
|
||||
const appearancePreferences = preferences as AppearancePreferences
|
||||
if (appearancePreferences["theme-mode"] !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia(systemThemeQuery)
|
||||
const applySystemTheme = () => {
|
||||
disableThemeTransitionsTemporarily()
|
||||
applyAppearancePreferences(store.getSnapshot() as AppearancePreferences)
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener("change", applySystemTheme)
|
||||
return () => mediaQuery.removeEventListener("change", applySystemTheme)
|
||||
}
|
||||
|
||||
/** Register this effect with the application's shared `PreferencesProvider`. */
|
||||
export const appearancePreferenceEffect: PreferenceEffect = {
|
||||
layoutEffect: applyAppearancePreferenceEffect,
|
||||
effect: subscribeSystemThemePreferenceEffect,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export {
|
||||
AppearanceController,
|
||||
type AppearanceControllerProps,
|
||||
} from "./components/appearance-controller"
|
||||
export {
|
||||
AppearanceDialog,
|
||||
AppearanceDialogContent,
|
||||
AppearanceDialogHeader,
|
||||
AppearanceDialogTrigger,
|
||||
AppearanceTrigger,
|
||||
appearanceDialogHandle,
|
||||
createAppearanceDialogHandle,
|
||||
type AppearanceDialogContentProps,
|
||||
type AppearanceDialogHandle,
|
||||
type AppearanceDialogProps,
|
||||
type AppearanceDialogTriggerProps,
|
||||
} from "./components/appearance-dialog"
|
||||
export {
|
||||
AppearanceHotkeys,
|
||||
type AppearanceHotkeysProps,
|
||||
} from "./components/appearance-hotkeys"
|
||||
export {
|
||||
AppearanceSettings,
|
||||
type AppearanceSettingsProps,
|
||||
} from "./components/appearance-settings"
|
||||
export {
|
||||
CompactLayoutField,
|
||||
type CompactLayoutFieldProps,
|
||||
} from "./components/compact-layout-field"
|
||||
export {
|
||||
ThemeColorPicker,
|
||||
ThemeColorSwatchButton,
|
||||
accentColorSwatches,
|
||||
baseColorSwatches,
|
||||
createThemeColorSwatches,
|
||||
type ThemeColor,
|
||||
type ThemeColorPickerProps,
|
||||
type ThemeColorSwatch,
|
||||
type ThemeColorSwatchButtonProps,
|
||||
type ThemeColorSwatchScheme,
|
||||
} from "./components/theme-color-picker"
|
||||
export {
|
||||
ThemeConfigCard,
|
||||
type ThemeConfigCardProps,
|
||||
} from "./components/theme-config-card"
|
||||
export {
|
||||
ThemeModeField,
|
||||
ThemeModeIcon,
|
||||
type ThemeModeFieldProps,
|
||||
} from "./components/theme-mode-field"
|
||||
export {
|
||||
ThemePreview,
|
||||
type ThemePreviewProps,
|
||||
} from "./components/theme-preview"
|
||||
export {
|
||||
ThemeToggle,
|
||||
ThemeToggleButton,
|
||||
type ThemeToggleButtonProps,
|
||||
type ThemeToggleProps,
|
||||
} from "./components/theme-toggle-button"
|
||||
export {
|
||||
applyAppearancePreferenceEffect,
|
||||
applyAppearancePreferences,
|
||||
appearancePreferenceEffect,
|
||||
subscribeSystemThemePreferenceEffect,
|
||||
} from "./effects"
|
||||
export {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
defaultThemeColorConfig,
|
||||
isAppearancePreferenceUpdate,
|
||||
isThemeColorConfig,
|
||||
type AppearancePreferenceKey,
|
||||
type AppearancePreferences,
|
||||
type AppearancePreferenceUpdate,
|
||||
type AppearancePreferenceValue,
|
||||
type ThemeMode,
|
||||
} from "./preferences"
|
||||
export * from "./theme"
|
||||
export { useResolvedTheme } from "./use-resolved-theme"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages"
|
||||
import { appearanceCatalogLocales } from "./catalogs"
|
||||
import { messages as enUS } from "./en-US"
|
||||
import { messages as zhHans } from "./zh-Hans"
|
||||
|
||||
describe("appearance locale catalogs", () => {
|
||||
it("ships every message in every built-in locale", () => {
|
||||
const ids = [
|
||||
...Object.values(appearanceMessages),
|
||||
...Object.values(themeColorMessages),
|
||||
].map((descriptor) => descriptor.id)
|
||||
const catalogs = { "en-US": enUS, "zh-Hans": zhHans }
|
||||
|
||||
expect(appearanceCatalogLocales).toEqual(["en-US", "zh-Hans"])
|
||||
for (const locale of appearanceCatalogLocales) {
|
||||
expect(Object.keys(catalogs[locale])).toHaveLength(ids.length)
|
||||
expect(ids.every((id) => id in catalogs[locale])).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { appearanceMessages, themeColorMessages } from "../messages"
|
||||
|
||||
export const appearanceCatalogLocales = ["en-US", "zh-Hans"] as const
|
||||
|
||||
interface MessageDescriptorMap {
|
||||
readonly [key: string]: {
|
||||
readonly id: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessageId<T extends MessageDescriptorMap> = T[keyof T]["id"]
|
||||
|
||||
export type AppearanceCatalogLocale = (typeof appearanceCatalogLocales)[number]
|
||||
export type AppearanceMessageCatalog = Readonly<
|
||||
Record<
|
||||
MessageId<typeof appearanceMessages> | MessageId<typeof themeColorMessages>,
|
||||
string
|
||||
>
|
||||
>
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
import { appearanceMessages, themeColorMessages } from "../messages.ts"
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en-US"
|
||||
export const languageTag = "en-US"
|
||||
|
||||
function createSourceCatalog(
|
||||
...groups: readonly Record<string, MessageDescriptor>[]
|
||||
): AppearanceMessageCatalog {
|
||||
return Object.fromEntries(
|
||||
groups.flatMap((group) =>
|
||||
Object.values(group).map((descriptor) => [
|
||||
descriptor.id,
|
||||
descriptor.message,
|
||||
])
|
||||
)
|
||||
) as AppearanceMessageCatalog
|
||||
}
|
||||
|
||||
export const messages = createSourceCatalog(
|
||||
appearanceMessages,
|
||||
themeColorMessages
|
||||
) satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AppearanceMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
|
||||
export const messages = {
|
||||
"appearance.active": "使用中",
|
||||
"appearance.baseColor": "基础颜色",
|
||||
"appearance.color.amber": "琥珀色",
|
||||
"appearance.color.blue": "蓝色",
|
||||
"appearance.color.cyan": "青色",
|
||||
"appearance.color.emerald": "翠绿色",
|
||||
"appearance.color.fuchsia": "紫红色",
|
||||
"appearance.color.green": "绿色",
|
||||
"appearance.color.indigo": "靛蓝色",
|
||||
"appearance.color.lime": "青柠色",
|
||||
"appearance.color.mauve": "紫灰",
|
||||
"appearance.color.mist": "雾灰",
|
||||
"appearance.color.neutral": "中性灰",
|
||||
"appearance.color.olive": "橄榄灰",
|
||||
"appearance.color.orange": "橙色",
|
||||
"appearance.color.pink": "粉色",
|
||||
"appearance.color.purple": "紫色",
|
||||
"appearance.color.red": "红色",
|
||||
"appearance.color.rose": "玫红色",
|
||||
"appearance.color.sky": "天蓝色",
|
||||
"appearance.color.stone": "石灰",
|
||||
"appearance.color.taupe": "灰褐",
|
||||
"appearance.color.teal": "蓝绿色",
|
||||
"appearance.color.violet": "紫罗兰色",
|
||||
"appearance.color.yellow": "黄色",
|
||||
"appearance.color.zinc": "锌灰",
|
||||
"appearance.command.description": "打开主题与布局偏好",
|
||||
"appearance.compact.description": "启用后,页面主内容将使用紧凑宽度",
|
||||
"appearance.compact.title": "紧凑布局",
|
||||
"appearance.description": "主题与布局偏好",
|
||||
"appearance.mode.dark": "深色",
|
||||
"appearance.mode.dark.description": "界面将始终使用深色主题",
|
||||
"appearance.mode.light": "浅色",
|
||||
"appearance.mode.light.description": "界面将始终使用浅色主题",
|
||||
"appearance.mode.system": "系统",
|
||||
"appearance.mode.system.description": "界面主题将跟随系统外观设置",
|
||||
"appearance.mode.title": "主题模式",
|
||||
"appearance.primaryColor": "主要颜色",
|
||||
"appearance.theme.dark": "深色主题",
|
||||
"appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题",
|
||||
"appearance.theme.light": "浅色主题",
|
||||
"appearance.theme.light.description": "当系统设置为浅色模式时,将使用此主题",
|
||||
"appearance.title": "界面外观",
|
||||
"appearance.toggleTheme": "切换主题",
|
||||
"appearance.toggleTheme.dark": "切换为深色模式",
|
||||
"appearance.toggleTheme.description": "在浅色与深色主题之间切换",
|
||||
"appearance.toggleTheme.light": "切换为浅色模式",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const appearanceMessages = {
|
||||
active: /* i18n */ {
|
||||
id: "appearance.active",
|
||||
message: "Active",
|
||||
},
|
||||
appearance: /* i18n */ {
|
||||
id: "appearance.title",
|
||||
message: "Appearance",
|
||||
},
|
||||
appearanceCommandDescription: /* i18n */ {
|
||||
id: "appearance.command.description",
|
||||
message: "Open theme and layout preferences",
|
||||
},
|
||||
baseColor: /* i18n */ {
|
||||
id: "appearance.baseColor",
|
||||
message: "Base color",
|
||||
},
|
||||
compactDescription: /* i18n */ {
|
||||
id: "appearance.compact.description",
|
||||
message: "Use a compact width for the main page content",
|
||||
},
|
||||
compactLayout: /* i18n */ {
|
||||
id: "appearance.compact.title",
|
||||
message: "Compact layout",
|
||||
},
|
||||
darkMode: /* i18n */ {
|
||||
id: "appearance.mode.dark",
|
||||
message: "Dark",
|
||||
},
|
||||
darkModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.dark.description",
|
||||
message: "The interface will always use the dark theme",
|
||||
},
|
||||
darkTheme: /* i18n */ {
|
||||
id: "appearance.theme.dark",
|
||||
message: "Dark theme",
|
||||
},
|
||||
darkThemeDescription: /* i18n */ {
|
||||
id: "appearance.theme.dark.description",
|
||||
message: "This theme is used when the system is in dark mode",
|
||||
},
|
||||
followSystem: /* i18n */ {
|
||||
id: "appearance.mode.system",
|
||||
message: "System",
|
||||
},
|
||||
followSystemDescription: /* i18n */ {
|
||||
id: "appearance.mode.system.description",
|
||||
message: "The interface theme will follow the system appearance",
|
||||
},
|
||||
lightMode: /* i18n */ {
|
||||
id: "appearance.mode.light",
|
||||
message: "Light",
|
||||
},
|
||||
lightModeDescription: /* i18n */ {
|
||||
id: "appearance.mode.light.description",
|
||||
message: "The interface will always use the light theme",
|
||||
},
|
||||
lightTheme: /* i18n */ {
|
||||
id: "appearance.theme.light",
|
||||
message: "Light theme",
|
||||
},
|
||||
lightThemeDescription: /* i18n */ {
|
||||
id: "appearance.theme.light.description",
|
||||
message: "This theme is used when the system is in light mode",
|
||||
},
|
||||
primaryColor: /* i18n */ {
|
||||
id: "appearance.primaryColor",
|
||||
message: "Primary color",
|
||||
},
|
||||
themeAndLayoutPreferences: /* i18n */ {
|
||||
id: "appearance.description",
|
||||
message: "Theme and layout preferences",
|
||||
},
|
||||
themeMode: /* i18n */ {
|
||||
id: "appearance.mode.title",
|
||||
message: "Theme mode",
|
||||
},
|
||||
toggleTheme: /* i18n */ {
|
||||
id: "appearance.toggleTheme",
|
||||
message: "Toggle theme",
|
||||
},
|
||||
toggleThemeDescription: /* i18n */ {
|
||||
id: "appearance.toggleTheme.description",
|
||||
message: "Switch between light and dark themes",
|
||||
},
|
||||
switchToDark: /* i18n */ {
|
||||
id: "appearance.toggleTheme.dark",
|
||||
message: "Switch to dark mode",
|
||||
},
|
||||
switchToLight: /* i18n */ {
|
||||
id: "appearance.toggleTheme.light",
|
||||
message: "Switch to light mode",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
|
||||
export const themeColorMessages = {
|
||||
amber: /* i18n */ {
|
||||
id: "appearance.color.amber",
|
||||
message: "Amber",
|
||||
},
|
||||
blue: /* i18n */ {
|
||||
id: "appearance.color.blue",
|
||||
message: "Blue",
|
||||
},
|
||||
cyan: /* i18n */ {
|
||||
id: "appearance.color.cyan",
|
||||
message: "Cyan",
|
||||
},
|
||||
emerald: /* i18n */ {
|
||||
id: "appearance.color.emerald",
|
||||
message: "Emerald",
|
||||
},
|
||||
fuchsia: /* i18n */ {
|
||||
id: "appearance.color.fuchsia",
|
||||
message: "Fuchsia",
|
||||
},
|
||||
green: /* i18n */ {
|
||||
id: "appearance.color.green",
|
||||
message: "Green",
|
||||
},
|
||||
indigo: /* i18n */ {
|
||||
id: "appearance.color.indigo",
|
||||
message: "Indigo",
|
||||
},
|
||||
lime: /* i18n */ {
|
||||
id: "appearance.color.lime",
|
||||
message: "Lime",
|
||||
},
|
||||
mauve: /* i18n */ {
|
||||
id: "appearance.color.mauve",
|
||||
message: "Mauve",
|
||||
},
|
||||
mist: /* i18n */ {
|
||||
id: "appearance.color.mist",
|
||||
message: "Mist",
|
||||
},
|
||||
neutral: /* i18n */ {
|
||||
id: "appearance.color.neutral",
|
||||
message: "Neutral",
|
||||
},
|
||||
olive: /* i18n */ {
|
||||
id: "appearance.color.olive",
|
||||
message: "Olive",
|
||||
},
|
||||
orange: /* i18n */ {
|
||||
id: "appearance.color.orange",
|
||||
message: "Orange",
|
||||
},
|
||||
pink: /* i18n */ {
|
||||
id: "appearance.color.pink",
|
||||
message: "Pink",
|
||||
},
|
||||
purple: /* i18n */ {
|
||||
id: "appearance.color.purple",
|
||||
message: "Purple",
|
||||
},
|
||||
red: /* i18n */ {
|
||||
id: "appearance.color.red",
|
||||
message: "Red",
|
||||
},
|
||||
rose: /* i18n */ {
|
||||
id: "appearance.color.rose",
|
||||
message: "Rose",
|
||||
},
|
||||
sky: /* i18n */ {
|
||||
id: "appearance.color.sky",
|
||||
message: "Sky",
|
||||
},
|
||||
stone: /* i18n */ {
|
||||
id: "appearance.color.stone",
|
||||
message: "Stone",
|
||||
},
|
||||
taupe: /* i18n */ {
|
||||
id: "appearance.color.taupe",
|
||||
message: "Taupe",
|
||||
},
|
||||
teal: /* i18n */ {
|
||||
id: "appearance.color.teal",
|
||||
message: "Teal",
|
||||
},
|
||||
violet: /* i18n */ {
|
||||
id: "appearance.color.violet",
|
||||
message: "Violet",
|
||||
},
|
||||
yellow: /* i18n */ {
|
||||
id: "appearance.color.yellow",
|
||||
message: "Yellow",
|
||||
},
|
||||
zinc: /* i18n */ {
|
||||
id: "appearance.color.zinc",
|
||||
message: "Zinc",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
appearancePreferenceDefinitions,
|
||||
defaultAppearancePreferences,
|
||||
isAppearancePreferenceUpdate,
|
||||
} from "./preferences"
|
||||
|
||||
describe("appearance preferences", () => {
|
||||
it("creates stable defaults", () => {
|
||||
expect(defaultAppearancePreferences).toEqual({
|
||||
"main-compacted": false,
|
||||
"sidebar-collapsed": false,
|
||||
"theme-dark": { accentColor: "blue", baseColor: "neutral" },
|
||||
"theme-light": { accentColor: "blue", baseColor: "neutral" },
|
||||
"theme-mode": "system",
|
||||
})
|
||||
})
|
||||
|
||||
it("serializes and validates preference updates", () => {
|
||||
const compacted = appearancePreferenceDefinitions["main-compacted"]
|
||||
const theme = appearancePreferenceDefinitions["theme-light"]
|
||||
|
||||
expect(compacted.parse(compacted.serialize(true))).toBe(true)
|
||||
expect(
|
||||
theme.parse(
|
||||
theme.serialize({
|
||||
accentColor: "violet",
|
||||
baseColor: "zinc",
|
||||
})
|
||||
)
|
||||
).toEqual({ accentColor: "violet", baseColor: "zinc" })
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "dark" })
|
||||
).toBe(true)
|
||||
expect(
|
||||
isAppearancePreferenceUpdate({ key: "theme-mode", value: "unknown" })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createDefaultPreferences,
|
||||
createPreferenceUpdateGuard,
|
||||
type PreferenceDefinitions,
|
||||
type PreferenceUpdateFor,
|
||||
} from "@workspace/preferences"
|
||||
|
||||
import { accentColors, baseColors, type ThemeColorConfig } from "./theme"
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system"
|
||||
|
||||
export interface AppearancePreferences {
|
||||
"main-compacted": boolean
|
||||
"sidebar-collapsed": boolean
|
||||
"theme-mode": ThemeMode
|
||||
"theme-light": ThemeColorConfig
|
||||
"theme-dark": ThemeColorConfig
|
||||
}
|
||||
|
||||
declare module "@workspace/preferences" {
|
||||
interface PreferencesCustom extends AppearancePreferences {}
|
||||
}
|
||||
|
||||
export type AppearancePreferenceKey = keyof AppearancePreferences
|
||||
export type AppearancePreferenceValue<Key extends AppearancePreferenceKey> =
|
||||
AppearancePreferences[Key]
|
||||
export type AppearancePreferenceUpdate =
|
||||
PreferenceUpdateFor<AppearancePreferences>
|
||||
|
||||
export const defaultThemeColorConfig: ThemeColorConfig = {
|
||||
accentColor: "blue",
|
||||
baseColor: "neutral",
|
||||
}
|
||||
|
||||
export function isThemeColorConfig(value: unknown): value is ThemeColorConfig {
|
||||
if (!value || typeof value !== "object") return false
|
||||
|
||||
const config = value as {
|
||||
accentColor?: unknown
|
||||
baseColor?: unknown
|
||||
}
|
||||
|
||||
return (
|
||||
typeof config.baseColor === "string" &&
|
||||
baseColors.some((color) => color === config.baseColor) &&
|
||||
typeof config.accentColor === "string" &&
|
||||
accentColors.some((color) => color === config.accentColor)
|
||||
)
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === "boolean"
|
||||
}
|
||||
|
||||
function isThemeMode(value: unknown): value is ThemeMode {
|
||||
return value === "light" || value === "dark" || value === "system"
|
||||
}
|
||||
|
||||
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 appearancePreferenceDefinitions: PreferenceDefinitions<AppearancePreferences> =
|
||||
{
|
||||
"main-compacted": {
|
||||
cookie: "ui-main-compacted",
|
||||
defaultValue: false,
|
||||
is: isBoolean,
|
||||
parse: (value) => value === "1",
|
||||
serialize: (value) => (value ? "1" : "0"),
|
||||
},
|
||||
"sidebar-collapsed": {
|
||||
cookie: "ui-sidebar-collapsed",
|
||||
defaultValue: false,
|
||||
is: isBoolean,
|
||||
parse: (value) => value === "true" || value === "1",
|
||||
serialize: (value) => (value ? "1" : "0"),
|
||||
},
|
||||
"theme-mode": {
|
||||
cookie: "ui-theme",
|
||||
defaultValue: "system",
|
||||
is: isThemeMode,
|
||||
parse: (value) => (isThemeMode(value) ? value : "system"),
|
||||
serialize: (value) => value,
|
||||
},
|
||||
"theme-light": {
|
||||
cookie: "ui-theme-light",
|
||||
defaultValue: { ...defaultThemeColorConfig },
|
||||
is: isThemeColorConfig,
|
||||
parse: parseThemeColorConfig,
|
||||
serialize: JSON.stringify,
|
||||
},
|
||||
"theme-dark": {
|
||||
cookie: "ui-theme-dark",
|
||||
defaultValue: { ...defaultThemeColorConfig },
|
||||
is: isThemeColorConfig,
|
||||
parse: parseThemeColorConfig,
|
||||
serialize: JSON.stringify,
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultAppearancePreferences = createDefaultPreferences(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
|
||||
export const isAppearancePreferenceUpdate = createPreferenceUpdateGuard(
|
||||
appearancePreferenceDefinitions
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
@source "../";
|
||||
@@ -0,0 +1,45 @@
|
||||
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(new Set(themes.map((theme) => theme.name)).size).toBe(themes.length)
|
||||
})
|
||||
|
||||
it("combines base and accent palettes", () => {
|
||||
const variables = getThemeCssVariables("light", {
|
||||
accentColor: "rose",
|
||||
baseColor: "stone",
|
||||
})
|
||||
|
||||
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(
|
||||
{ accentColor: "blue", baseColor: "neutral" },
|
||||
{ accentColor: "violet", baseColor: "zinc" }
|
||||
)
|
||||
|
||||
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:")
|
||||
})
|
||||
})
|
||||
@@ -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, string>): 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("")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import { usePreference } from "@workspace/preferences"
|
||||
|
||||
import type { ThemeMode } from "./preferences"
|
||||
import type { ThemeScheme } from "./theme"
|
||||
|
||||
const systemThemeQuery = "(prefers-color-scheme: dark)"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
export function useResolvedTheme(): ThemeScheme {
|
||||
const themeMode: ThemeMode = usePreference("theme-mode")[0]
|
||||
const systemTheme = React.useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemTheme,
|
||||
getServerSystemTheme
|
||||
)
|
||||
|
||||
return themeMode === "system" ? systemTheme : themeMode
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/appearance": ["./src/index.ts"],
|
||||
"@workspace/appearance/*": ["./src/*"],
|
||||
"@workspace/i18n": ["../i18n/src/index.ts"],
|
||||
"@workspace/preferences": ["../preferences/src/index.tsx"],
|
||||
"@workspace/ui/*": ["../ui/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user