mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): add appearance setting (follow system / dark / light) (#253)
* feat(web): add appearance setting (follow system / dark / light) Add user-facing appearance preference to the settings page, allowing users to choose between Follow System, Dark, and Light themes. The preference is persisted to localStorage and takes priority over automatic detection in the existing theme pipeline. * fix(web): update theme on cross-tab appearance change The storage event handler only updated React state without calling updateScheme(), leaving data-theme and useTheme subscribers stale. * fix(web): move cross-tab appearance sync to global initializeTheme The storage event listener was inside useAppearance(), which is only mounted on the settings page. Other pages never received cross-tab theme updates. Move the listener into initializeTheme() so all pages respond to appearance changes from other tabs.
This commit is contained in:
@@ -1,9 +1,65 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { getTelegramWebApp } from './useTelegram'
|
||||
|
||||
type ColorScheme = 'light' | 'dark'
|
||||
|
||||
export type AppearancePreference = 'system' | 'dark' | 'light'
|
||||
|
||||
const APPEARANCE_KEY = 'hapi-appearance'
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
}
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
if (!isBrowser()) return null
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemoveItem(key: string): void {
|
||||
if (!isBrowser()) return
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function parseAppearance(raw: string | null): AppearancePreference {
|
||||
if (raw === 'dark' || raw === 'light') return raw
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function getStoredAppearance(): AppearancePreference {
|
||||
return parseAppearance(safeGetItem(APPEARANCE_KEY))
|
||||
}
|
||||
|
||||
export function getAppearanceOptions(): ReadonlyArray<{ value: AppearancePreference; labelKey: string }> {
|
||||
return [
|
||||
{ value: 'system', labelKey: 'settings.display.appearance.system' },
|
||||
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
|
||||
{ value: 'light', labelKey: 'settings.display.appearance.light' },
|
||||
]
|
||||
}
|
||||
|
||||
function getColorScheme(): ColorScheme {
|
||||
const pref = getStoredAppearance()
|
||||
if (pref === 'dark' || pref === 'light') return pref
|
||||
|
||||
// 'system': use Telegram → system preference → light
|
||||
const tg = getTelegramWebApp()
|
||||
if (tg?.colorScheme) {
|
||||
return tg.colorScheme === 'dark' ? 'dark' : 'light'
|
||||
@@ -68,6 +124,36 @@ export function useTheme(): { colorScheme: ColorScheme; isDark: boolean } {
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppearance(): { appearance: AppearancePreference; setAppearance: (pref: AppearancePreference) => void } {
|
||||
const [appearance, setAppearanceState] = useState<AppearancePreference>(getStoredAppearance)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBrowser()) return
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== APPEARANCE_KEY) return
|
||||
setAppearanceState(parseAppearance(event.newValue))
|
||||
}
|
||||
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const setAppearance = useCallback((pref: AppearancePreference) => {
|
||||
setAppearanceState(pref)
|
||||
|
||||
if (pref === 'system') {
|
||||
safeRemoveItem(APPEARANCE_KEY)
|
||||
} else {
|
||||
safeSetItem(APPEARANCE_KEY, pref)
|
||||
}
|
||||
|
||||
updateScheme()
|
||||
}, [])
|
||||
|
||||
return { appearance, setAppearance }
|
||||
}
|
||||
|
||||
// Call this once at app startup to ensure theme is applied and listeners attached
|
||||
export function initializeTheme(): void {
|
||||
currentScheme = getColorScheme()
|
||||
@@ -85,5 +171,12 @@ export function initializeTheme(): void {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', updateScheme)
|
||||
}
|
||||
|
||||
// Cross-tab appearance sync: update theme when another tab changes localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event: StorageEvent) => {
|
||||
if (event.key === APPEARANCE_KEY) updateScheme()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,10 @@ export default {
|
||||
'settings.language.title': 'Language',
|
||||
'settings.language.label': 'Language',
|
||||
'settings.display.title': 'Display',
|
||||
'settings.display.appearance': 'Appearance',
|
||||
'settings.display.appearance.system': 'Follow System',
|
||||
'settings.display.appearance.dark': 'Dark',
|
||||
'settings.display.appearance.light': 'Light',
|
||||
'settings.display.fontSize': 'Font Size',
|
||||
'settings.voice.title': 'Voice Assistant',
|
||||
'settings.voice.language': 'Voice Language',
|
||||
|
||||
@@ -251,6 +251,10 @@ export default {
|
||||
'settings.language.title': '语言',
|
||||
'settings.language.label': '语言',
|
||||
'settings.display.title': '显示',
|
||||
'settings.display.appearance': '外观',
|
||||
'settings.display.appearance.system': '跟随系统',
|
||||
'settings.display.appearance.dark': '深色',
|
||||
'settings.display.appearance.light': '浅色',
|
||||
'settings.display.fontSize': '字体大小',
|
||||
'settings.voice.title': '语音助手',
|
||||
'settings.voice.language': '语音语言',
|
||||
|
||||
@@ -22,6 +22,16 @@ vi.mock('@/hooks/useFontScale', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks/useTheme', () => ({
|
||||
useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }),
|
||||
getAppearanceOptions: () => [
|
||||
{ value: 'system', labelKey: 'settings.display.appearance.system' },
|
||||
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
|
||||
{ value: 'light', labelKey: 'settings.display.appearance.light' },
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock languages
|
||||
vi.mock('@/lib/languages', () => ({
|
||||
getElevenLabsSupportedLanguages: () => [
|
||||
@@ -98,4 +108,17 @@ describe('SettingsPage', () => {
|
||||
expect(calledKeys).toContain('settings.about.appVersion')
|
||||
expect(calledKeys).toContain('settings.about.protocolVersion')
|
||||
})
|
||||
|
||||
it('renders the Appearance setting', () => {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('uses correct i18n keys for Appearance setting', () => {
|
||||
const spyT = renderWithSpyT(<SettingsPage />)
|
||||
const calledKeys = spyT.mock.calls.map((call) => call[0])
|
||||
expect(calledKeys).toContain('settings.display.appearance')
|
||||
expect(calledKeys).toContain('settings.display.appearance.system')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation'
|
||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
|
||||
import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale'
|
||||
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
|
||||
import { PROTOCOL_VERSION } from '@hapi/protocol'
|
||||
|
||||
const locales: { value: Locale; nativeLabel: string }[] = [
|
||||
@@ -73,12 +74,15 @@ export default function SettingsPage() {
|
||||
const { t, locale, setLocale } = useTranslation()
|
||||
const goBack = useAppGoBack()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
|
||||
const [isFontOpen, setIsFontOpen] = useState(false)
|
||||
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const appearanceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const fontContainerRef = useRef<HTMLDivElement>(null)
|
||||
const voiceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { fontScale, setFontScale } = useFontScale()
|
||||
const { appearance, setAppearance } = useAppearance()
|
||||
|
||||
// Voice language state - read from localStorage
|
||||
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
|
||||
@@ -86,7 +90,9 @@ export default function SettingsPage() {
|
||||
})
|
||||
|
||||
const fontScaleOptions = getFontScaleOptions()
|
||||
const appearanceOptions = getAppearanceOptions()
|
||||
const currentLocale = locales.find((loc) => loc.value === locale)
|
||||
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
|
||||
const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%'
|
||||
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
|
||||
|
||||
@@ -95,6 +101,11 @@ export default function SettingsPage() {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleAppearanceChange = (pref: AppearancePreference) => {
|
||||
setAppearance(pref)
|
||||
setIsAppearanceOpen(false)
|
||||
}
|
||||
|
||||
const handleFontScaleChange = (newScale: FontScale) => {
|
||||
setFontScale(newScale)
|
||||
setIsFontOpen(false)
|
||||
@@ -112,12 +123,15 @@ export default function SettingsPage() {
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isOpen && !isFontOpen && !isVoiceOpen) return
|
||||
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) {
|
||||
setIsAppearanceOpen(false)
|
||||
}
|
||||
if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) {
|
||||
setIsFontOpen(false)
|
||||
}
|
||||
@@ -128,15 +142,16 @@ export default function SettingsPage() {
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, isFontOpen, isVoiceOpen])
|
||||
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen && !isFontOpen && !isVoiceOpen) return
|
||||
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setIsAppearanceOpen(false)
|
||||
setIsFontOpen(false)
|
||||
setIsVoiceOpen(false)
|
||||
}
|
||||
@@ -144,7 +159,7 @@ export default function SettingsPage() {
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, isFontOpen, isVoiceOpen])
|
||||
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -223,6 +238,54 @@ export default function SettingsPage() {
|
||||
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
|
||||
{t('settings.display.title')}
|
||||
</div>
|
||||
<div ref={appearanceContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAppearanceOpen(!isAppearanceOpen)}
|
||||
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
|
||||
aria-expanded={isAppearanceOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className="text-[var(--app-fg)]">{t('settings.display.appearance')}</span>
|
||||
<span className="flex items-center gap-1 text-[var(--app-hint)]">
|
||||
<span>{t(currentAppearanceLabel)}</span>
|
||||
<ChevronDownIcon className={`transition-transform ${isAppearanceOpen ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isAppearanceOpen && (
|
||||
<div
|
||||
className="absolute right-3 top-full mt-1 min-w-[160px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
|
||||
role="listbox"
|
||||
aria-label={t('settings.display.appearance')}
|
||||
>
|
||||
{appearanceOptions.map((opt) => {
|
||||
const isSelected = appearance === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleAppearanceChange(opt.value)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
|
||||
isSelected
|
||||
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
|
||||
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
|
||||
}`}
|
||||
>
|
||||
<span>{t(opt.labelKey)}</span>
|
||||
{isSelected && (
|
||||
<span className="ml-2 text-[var(--app-link)]">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={fontContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user