Files
simple-react-app-kit/docs/src/pages/settings.tsx
T
2026-09-21 16:12:12 +08:00

370 lines
13 KiB
TypeScript

import { Icon } from "@icones/react"
import { usePreference } from "@workspace/preferences"
import {
Container,
SiteFooter,
SiteHeader,
} 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 (
<>
<SiteHeader
subleading={
<h1 className="text-xl font-semibold">{t(messages.settings)}</h1>
}
className="h-16 border-b bg-white/70 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-950/80"
actions={<SearchTrigger />}
/>
<main>
<Container className="pt-8 sm:pt-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>
<SiteFooter />
</>
)
}
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)
}