368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { Icon } from "@icones/react"
|
|
import { usePreference } from "@workspace/preferences"
|
|
|
|
import { BackButton, Container, SlashLine } from "../components/layout"
|
|
import { LocaleFlag } from "../components/locale-flag"
|
|
import { SearchTrigger } from "../components/search-trigger"
|
|
import { Swatch } from "../components/swatch"
|
|
import { getLocalizedHref } from "../content/registry"
|
|
import type { DocLocale } from "../content/types"
|
|
import {
|
|
accentColors,
|
|
backgroundShades,
|
|
getPaletteColor,
|
|
themeModes,
|
|
useAppearance,
|
|
type AccentColor,
|
|
type BackgroundShade,
|
|
type ForegroundShade,
|
|
type ThemeMode,
|
|
} from "../lib/appearance"
|
|
import { useLocale, useTranslate, type Translation } from "../lib/locale"
|
|
import { navigateToLocale } from "../lib/locale-navigation"
|
|
import { detectLocale } from "../lib/preferences"
|
|
import { usePathname } from "../lib/router"
|
|
|
|
const foregroundOptions: ReadonlyArray<{
|
|
label: Translation
|
|
value: ForegroundShade
|
|
}> = [
|
|
{ value: "slate", label: { zh: "冷色", us: "Cool" } },
|
|
{ value: "neutral", label: { zh: "中性", us: "Neutral" } },
|
|
{ value: "stone", label: { zh: "暖色", us: "Warm" } },
|
|
]
|
|
|
|
const messages = {
|
|
accent: { zh: "强调色", us: "Accent color" },
|
|
appearance: { zh: "外观", us: "Appearance" },
|
|
autoDetect: { zh: "自动检测", us: "Automatic detection" },
|
|
autoDetectDescription: {
|
|
zh: "跟随浏览器或操作系统语言设置。",
|
|
us: "Follow the browser or operating system language.",
|
|
},
|
|
background: { zh: "背景色调", us: "Background shade" },
|
|
enable: { zh: "启用", us: "Enable" },
|
|
foreground: { zh: "前景色调", us: "Foreground shade" },
|
|
language: { zh: "语言", us: "Language" },
|
|
settings: { zh: "设置", us: "Settings" },
|
|
theme: { zh: "主题", us: "Theme" },
|
|
themeModes: {
|
|
light: { zh: "浅色", us: "Light" },
|
|
dark: { zh: "深色", us: "Dark" },
|
|
system: { zh: "跟随系统", us: "System" },
|
|
},
|
|
} as const
|
|
|
|
export default function SettingsPage() {
|
|
const locale = useLocale()
|
|
const pathname = usePathname()
|
|
const t = useTranslate()
|
|
const appearance = useAppearance()
|
|
const [localeMode, setLocaleMode] = usePreference("locale-mode")
|
|
const [, setPreferredLocale] = usePreference("locale")
|
|
const autoDetectLocale = localeMode === "auto"
|
|
|
|
function selectLocale(nextLocale: DocLocale) {
|
|
setPreferredLocale(nextLocale)
|
|
setLocaleMode("manual")
|
|
navigateToLocale(nextLocale)
|
|
}
|
|
|
|
function setAutomaticLocale(enabled: boolean) {
|
|
if (enabled) {
|
|
setLocaleMode("auto")
|
|
navigateToLocale(detectLocale())
|
|
} else {
|
|
setPreferredLocale(locale)
|
|
setLocaleMode("manual")
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<header className="border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80">
|
|
<Container className="flex h-16 items-center gap-4">
|
|
<BackButton />
|
|
<SlashLine />
|
|
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
|
|
<div className="ml-auto">
|
|
<SearchTrigger />
|
|
</div>
|
|
</Container>
|
|
</header>
|
|
|
|
<main>
|
|
<Container className="py-8 sm:py-12">
|
|
<section aria-labelledby="appearance-heading">
|
|
<h2 id="appearance-heading" className="mb-4 text-xl font-semibold">
|
|
{t(messages.appearance)}
|
|
</h2>
|
|
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
|
<fieldset>
|
|
<legend className="mb-3">{t(messages.theme)}</legend>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label={t(messages.theme)}
|
|
className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-56 dark:border-slate-700"
|
|
onKeyDown={(event) => {
|
|
if (
|
|
event.key !== "ArrowDown" &&
|
|
event.key !== "ArrowUp" &&
|
|
event.key !== "ArrowRight" &&
|
|
event.key !== "ArrowLeft" &&
|
|
event.key !== "Home" &&
|
|
event.key !== "End"
|
|
) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const options = Array.from(
|
|
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
|
'[role="radio"]'
|
|
)
|
|
)
|
|
const currentIndex = options.indexOf(
|
|
document.activeElement as HTMLButtonElement
|
|
)
|
|
const moveForward =
|
|
event.key === "ArrowDown" || event.key === "ArrowRight"
|
|
let nextIndex = currentIndex
|
|
|
|
if (event.key === "Home") nextIndex = 0
|
|
else if (event.key === "End") nextIndex = options.length - 1
|
|
else if (moveForward) {
|
|
nextIndex = (currentIndex + 1) % options.length
|
|
} else {
|
|
nextIndex =
|
|
(currentIndex - 1 + options.length) % options.length
|
|
}
|
|
|
|
options[nextIndex]?.focus()
|
|
options[nextIndex]?.click()
|
|
}}
|
|
>
|
|
{themeModes.map((mode) => {
|
|
const selected = appearance.themeMode === mode
|
|
|
|
return (
|
|
<button
|
|
key={mode}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
tabIndex={selected ? 0 : -1}
|
|
className="flex h-11 w-full cursor-pointer items-center gap-3 rounded-lg px-3 text-left transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-checked:bg-indigo-100 aria-checked:text-indigo-700 dark:hover:bg-slate-800 dark:aria-checked:bg-indigo-950 dark:aria-checked:text-indigo-300"
|
|
onClick={() =>
|
|
appearance.updateAppearance({ themeMode: mode })
|
|
}
|
|
>
|
|
<ThemeIcon mode={mode} />
|
|
<span>{t(messages.themeModes[mode])}</span>
|
|
{selected && (
|
|
<Icon
|
|
name="tabler:check"
|
|
className="ml-auto size-4"
|
|
/>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<ColorField
|
|
label={t(messages.accent)}
|
|
value={appearance.accentColor}
|
|
options={accentColors}
|
|
getColors={(accentColor) => ({
|
|
light: getPaletteColor(accentColor, 600),
|
|
dark: getPaletteColor(accentColor, 400),
|
|
})}
|
|
onChange={(accentColor) =>
|
|
appearance.updateAppearance({ accentColor })
|
|
}
|
|
/>
|
|
|
|
<ColorField
|
|
label={t(messages.background)}
|
|
value={appearance.backgroundShade}
|
|
options={backgroundShades}
|
|
getColors={(backgroundShade) => ({
|
|
light: getPaletteColor(backgroundShade, 50),
|
|
dark: getPaletteColor(backgroundShade, 950),
|
|
})}
|
|
onChange={(backgroundShade) =>
|
|
appearance.updateAppearance({ backgroundShade })
|
|
}
|
|
/>
|
|
|
|
<fieldset>
|
|
<legend className="mb-3">{t(messages.foreground)}</legend>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{foregroundOptions.map((option) => (
|
|
<Swatch
|
|
key={option.value}
|
|
light={getPaletteColor(option.value, 950)}
|
|
dark={getPaletteColor(option.value, 50)}
|
|
aria-label={t(option.label)}
|
|
title={t(option.label)}
|
|
selected={appearance.foregroundShade === option.value}
|
|
onClick={() =>
|
|
appearance.updateAppearance({
|
|
foregroundShade: option.value,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
</section>
|
|
|
|
<section
|
|
className="mt-10 sm:mt-12"
|
|
aria-labelledby="language-heading"
|
|
>
|
|
<h2 id="language-heading" className="mb-4 text-xl font-semibold">
|
|
{t(messages.language)}
|
|
</h2>
|
|
<div className="space-y-8 rounded-xl border bg-white/50 p-5 sm:p-8 dark:border-slate-800 dark:bg-slate-900/30">
|
|
<fieldset>
|
|
<legend className="mb-3">{t(messages.language)}</legend>
|
|
<div className="w-full space-y-1 rounded-xl border p-1 sm:w-fit sm:min-w-48 dark:border-slate-700">
|
|
<LocaleLink
|
|
disabled={autoDetectLocale}
|
|
href={getLocalizedHref(pathname, "zh-Hans")}
|
|
label="简体中文"
|
|
locale="zh-Hans"
|
|
selected={locale === "zh-Hans"}
|
|
onClick={() => selectLocale("zh-Hans")}
|
|
/>
|
|
<LocaleLink
|
|
disabled={autoDetectLocale}
|
|
href={getLocalizedHref(pathname, "en-US")}
|
|
label="English"
|
|
locale="en-US"
|
|
selected={locale === "en-US"}
|
|
onClick={() => selectLocale("en-US")}
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div>{t(messages.autoDetect)}</div>
|
|
<label className="inline-flex w-fit cursor-pointer items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
className="size-4 accent-indigo-600"
|
|
checked={autoDetectLocale}
|
|
onChange={(event) =>
|
|
setAutomaticLocale(event.currentTarget.checked)
|
|
}
|
|
/>
|
|
<span>{t(messages.enable)}</span>
|
|
</label>
|
|
<p className="text-sm text-(--docs-muted-light) dark:text-(--docs-muted-dark)">
|
|
{t(messages.autoDetectDescription)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</Container>
|
|
</main>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ColorField<T extends AccentColor | BackgroundShade>({
|
|
getColors,
|
|
label,
|
|
onChange,
|
|
options,
|
|
value,
|
|
}: {
|
|
getColors: (value: T) => { dark: string; light: string }
|
|
label: string
|
|
onChange: (value: T) => void
|
|
options: readonly T[]
|
|
value: T
|
|
}) {
|
|
return (
|
|
<fieldset>
|
|
<legend className="mb-3">{label}</legend>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{options.map((option) => {
|
|
const colors = getColors(option)
|
|
const optionLabel = capitalize(option)
|
|
|
|
return (
|
|
<Swatch
|
|
key={option}
|
|
light={colors.light}
|
|
dark={colors.dark}
|
|
aria-label={optionLabel}
|
|
title={optionLabel}
|
|
selected={option === value}
|
|
onClick={() => onChange(option)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
)
|
|
}
|
|
|
|
function ThemeIcon({ mode }: { mode: ThemeMode }) {
|
|
if (mode === "light") {
|
|
return <Icon name="tabler:sun-high" className="size-5" />
|
|
}
|
|
if (mode === "dark") {
|
|
return <Icon name="tabler:moon" className="size-5" />
|
|
}
|
|
return <Icon name="tabler:device-desktop" className="size-5" />
|
|
}
|
|
|
|
function LocaleLink({
|
|
disabled,
|
|
href,
|
|
label,
|
|
locale,
|
|
onClick,
|
|
selected,
|
|
}: {
|
|
disabled: boolean
|
|
href: string
|
|
label: string
|
|
locale: DocLocale
|
|
onClick: () => void
|
|
selected: boolean
|
|
}) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
hrefLang={locale}
|
|
lang={locale}
|
|
aria-disabled={disabled}
|
|
aria-current={selected ? "page" : undefined}
|
|
tabIndex={disabled ? -1 : undefined}
|
|
className="flex h-11 items-center gap-2 rounded-lg px-3 transition-colors hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-indigo-500 aria-disabled:cursor-not-allowed aria-disabled:opacity-45 aria-disabled:hover:bg-transparent aria-[current=page]:bg-indigo-100 aria-[current=page]:text-indigo-700 dark:hover:bg-slate-800 dark:aria-disabled:hover:bg-transparent dark:aria-[current=page]:bg-indigo-950 dark:aria-[current=page]:text-indigo-300"
|
|
onClick={(event) => {
|
|
if (disabled) {
|
|
event.preventDefault()
|
|
return
|
|
}
|
|
onClick()
|
|
}}
|
|
>
|
|
<LocaleFlag locale={locale} className="size-5" />
|
|
<span>{label}</span>
|
|
{selected && <Icon name="tabler:check" className="ml-auto size-4" />}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
function capitalize(value: string) {
|
|
return value.charAt(0).toUpperCase() + value.slice(1)
|
|
}
|