feat(docs): add bilingual package documentation site
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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"
|
||||
|
||||
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 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(window.location.pathname, "zh-Hans")}
|
||||
label="简体中文"
|
||||
locale="zh-Hans"
|
||||
selected={locale === "zh-Hans"}
|
||||
onClick={() => selectLocale("zh-Hans")}
|
||||
/>
|
||||
<LocaleLink
|
||||
disabled={autoDetectLocale}
|
||||
href={getLocalizedHref(window.location.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)
|
||||
}
|
||||
Reference in New Issue
Block a user