feat: add settings page with language dropdown

This commit is contained in:
weishu
2026-01-19 21:33:22 +08:00
parent 55dbfa397b
commit 7a5946731d
5 changed files with 235 additions and 2 deletions
+6
View File
@@ -13,6 +13,12 @@ export function useAppGoBack(): () => void {
return return
} }
// Settings page always goes back to sessions
if (pathname === '/settings') {
navigate({ to: '/sessions' })
return
}
// For single file view, go back to files list // For single file view, go back to files list
if (pathname.match(/^\/sessions\/[^/]+\/file$/)) { if (pathname.match(/^\/sessions\/[^/]+\/file$/)) {
const filesPath = pathname.replace(/\/file$/, '/files') const filesPath = pathname.replace(/\/file$/, '/files')
+5
View File
@@ -225,6 +225,11 @@ export default {
'language.english': 'English', 'language.english': 'English',
'language.chinese': '简体中文', 'language.chinese': '简体中文',
// Settings page
'settings.title': 'Settings',
'settings.language.title': 'Language',
'settings.language.label': 'Language',
// Misc // Misc
'misc.noMachines': 'No machines available', 'misc.noMachines': 'No machines available',
'misc.machine': 'Machine', 'misc.machine': 'Machine',
+5
View File
@@ -227,6 +227,11 @@ export default {
'language.english': 'English', 'language.english': 'English',
'language.chinese': '简体中文', 'language.chinese': '简体中文',
// Settings page
'settings.title': '设置',
'settings.language.title': '语言',
'settings.language.label': '语言',
// Misc // Misc
'misc.noMachines': '无可用机器', 'misc.noMachines': '无可用机器',
'misc.machine': '机器', 'misc.machine': '机器',
+36 -2
View File
@@ -13,7 +13,6 @@ import { SessionChat } from '@/components/SessionChat'
import { SessionList } from '@/components/SessionList' import { SessionList } from '@/components/SessionList'
import { NewSession } from '@/components/NewSession' import { NewSession } from '@/components/NewSession'
import { LoadingState } from '@/components/LoadingState' import { LoadingState } from '@/components/LoadingState'
import { LanguageSwitcher } from '@/components/LanguageSwitcher'
import { useAppContext } from '@/lib/app-context' import { useAppContext } from '@/lib/app-context'
import { useAppGoBack } from '@/hooks/useAppGoBack' import { useAppGoBack } from '@/hooks/useAppGoBack'
import { isTelegramApp } from '@/hooks/useTelegram' import { isTelegramApp } from '@/hooks/useTelegram'
@@ -28,6 +27,7 @@ import { useTranslation } from '@/lib/use-translation'
import FilesPage from '@/routes/sessions/files' import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file' import FilePage from '@/routes/sessions/file'
import TerminalPage from '@/routes/sessions/terminal' import TerminalPage from '@/routes/sessions/terminal'
import SettingsPage from '@/routes/settings'
function BackIcon(props: { className?: string }) { function BackIcon(props: { className?: string }) {
return ( return (
@@ -68,6 +68,26 @@ function PlusIcon(props: { className?: string }) {
) )
} }
function SettingsIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
function SessionsPage() { function SessionsPage() {
const { api } = useAppContext() const { api } = useAppContext()
const navigate = useNavigate() const navigate = useNavigate()
@@ -88,7 +108,14 @@ function SessionsPage() {
{t('sessions.count', { n: sessions.length, m: projectCount })} {t('sessions.count', { n: sessions.length, m: projectCount })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageSwitcher /> <button
type="button"
onClick={() => navigate({ to: '/settings' })}
className="p-1.5 rounded-full text-[var(--app-hint)] hover:text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)] transition-colors"
title={t('settings.title')}
>
<SettingsIcon className="h-5 w-5" />
</button>
<button <button
type="button" type="button"
onClick={() => navigate({ to: '/sessions/new' })} onClick={() => navigate({ to: '/sessions/new' })}
@@ -310,6 +337,12 @@ const newSessionRoute = createRoute({
component: NewSessionPage, component: NewSessionPage,
}) })
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/settings',
component: SettingsPage,
})
export const routeTree = rootRoute.addChildren([ export const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
sessionsRoute, sessionsRoute,
@@ -318,6 +351,7 @@ export const routeTree = rootRoute.addChildren([
sessionFilesRoute, sessionFilesRoute,
sessionFileRoute, sessionFileRoute,
newSessionRoute, newSessionRoute,
settingsRoute,
]) ])
type RouterHistory = Parameters<typeof createRouter>[0]['history'] type RouterHistory = Parameters<typeof createRouter>[0]['history']
+183
View File
@@ -0,0 +1,183 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation, type Locale } from '@/lib/use-translation'
import { useAppGoBack } from '@/hooks/useAppGoBack'
const locales: { value: Locale; nativeLabel: string }[] = [
{ value: 'en', nativeLabel: 'English' },
{ value: 'zh-CN', nativeLabel: '简体中文' },
]
function BackIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="15 18 9 12 15 6" />
</svg>
)
}
function CheckIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function ChevronDownIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
>
<polyline points="6 9 12 15 18 9" />
</svg>
)
}
export default function SettingsPage() {
const { t, locale, setLocale } = useTranslation()
const goBack = useAppGoBack()
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentLocale = locales.find((loc) => loc.value === locale)
const handleLocaleChange = (newLocale: Locale) => {
setLocale(newLocale)
setIsOpen(false)
}
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
// Close on escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
return (
<div className="flex h-full flex-col">
<div className="bg-[var(--app-bg)] pt-[env(safe-area-inset-top)]">
<div className="mx-auto w-full max-w-content flex items-center gap-2 p-3 border-b border-[var(--app-border)]">
<button
type="button"
onClick={goBack}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<BackIcon />
</button>
<div className="flex-1 font-semibold">{t('settings.title')}</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-content">
{/* Language section */}
<div className="border-b border-[var(--app-divider)]">
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
{t('settings.language.title')}
</div>
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.language.label')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{currentLocale?.nativeLabel}</span>
<ChevronDownIcon className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isOpen && (
<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.language.title')}
>
{locales.map((loc) => {
const isSelected = locale === loc.value
return (
<button
key={loc.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleLocaleChange(loc.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-sm 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>{loc.nativeLabel}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}