* chore(dev): add local setup state reset target - add a reset-setup make target to clear setup records, root users, and related options. - support both docker dev PostgreSQL and local SQLite development databases. - restart the docker dev backend so setup status is recalculated after reset. * fix(chat): prevent preset menu text overflow - add truncation layout for chat preset names to keep long labels inside the sidebar menu. - prevent loading and external-link icons from shrinking in constrained menu rows. * fix(i18n): translate dashboard granularity options - call t() for granularity option labels in dashboard system settings. - keep localized text consistent between the select trigger and dropdown items. * chore(dev): add backend dev service rebuild target - add a dev-api-rebuild make target to rebuild and start the docker backend service. - reuse DEV_COMPOSE_FILE and DEV_BACKEND_SERVICE variables to avoid repeated compose config literals. * fix(i18n): align interface language option labels - add shared interface language options to keep display names consistent. - reuse the shared options in the header switcher and profile preferences. - normalize language codes so zh-CN and zh_CN resolve to Simplified Chinese. * fix(i18n): add missing frontend translation keys - route channel key prompts, form validation messages, and channel fallback text through i18n. - add missing translations across six locales for channels, rankings, billing, and logs. - update i18n sync reports so literal t() keys are present in the base locale.
151 lines
4.9 KiB
TypeScript
Vendored
151 lines
4.9 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
INTERFACE_LANGUAGE_OPTIONS,
|
|
normalizeInterfaceLanguage,
|
|
} from '@/i18n/languages'
|
|
import { Languages, Loader2 } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { useAuthStore } from '@/stores/auth-store'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { TitledCard } from '@/components/ui/titled-card'
|
|
import { updateUserLanguage } from '../api'
|
|
import { parseUserSettings } from '../lib'
|
|
import type { UserProfile } from '../types'
|
|
|
|
type LanguagePreferencesCardProps = {
|
|
profile: UserProfile | null
|
|
onProfileUpdate: () => void
|
|
}
|
|
|
|
export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
|
|
const { t, i18n } = useTranslation()
|
|
const { auth } = useAuthStore()
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const savedLanguage = useMemo(() => {
|
|
const settings = parseUserSettings(props.profile?.setting)
|
|
return normalizeInterfaceLanguage(settings.language || i18n.language)
|
|
}, [props.profile?.setting, i18n.language])
|
|
|
|
const [currentLanguage, setCurrentLanguage] = useState(savedLanguage)
|
|
|
|
useEffect(() => {
|
|
setCurrentLanguage(savedLanguage)
|
|
}, [savedLanguage])
|
|
|
|
const handleLanguageChange = async (language: string | null) => {
|
|
if (!language) return
|
|
const nextLanguage = normalizeInterfaceLanguage(language)
|
|
if (nextLanguage === currentLanguage) return
|
|
|
|
const previousLanguage = currentLanguage
|
|
setCurrentLanguage(nextLanguage)
|
|
setSaving(true)
|
|
await i18n.changeLanguage(nextLanguage)
|
|
|
|
try {
|
|
const response = await updateUserLanguage(nextLanguage)
|
|
if (!response.success) {
|
|
throw new Error(response.message || t('Failed to update settings'))
|
|
}
|
|
|
|
if (auth.user) {
|
|
const existingSetting =
|
|
typeof auth.user.setting === 'string'
|
|
? parseUserSettings(auth.user.setting)
|
|
: (auth.user.setting ?? {})
|
|
auth.setUser({
|
|
...auth.user,
|
|
setting: JSON.stringify({
|
|
...existingSetting,
|
|
language: nextLanguage,
|
|
}),
|
|
})
|
|
}
|
|
|
|
props.onProfileUpdate()
|
|
toast.success(t('Language preference saved'))
|
|
} catch (_error) {
|
|
setCurrentLanguage(previousLanguage)
|
|
await i18n.changeLanguage(previousLanguage)
|
|
toast.error(t('Failed to update settings'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<TitledCard
|
|
title={t('Language Preferences')}
|
|
description={t('Set the language used across the interface')}
|
|
icon={<Languages className='h-4 w-4' />}
|
|
>
|
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
|
|
<div className='space-y-1'>
|
|
<div className='text-sm font-medium'>{t('Interface Language')}</div>
|
|
<p className='text-muted-foreground line-clamp-2 text-xs sm:text-sm'>
|
|
{t(
|
|
'Language preferences sync across your signed-in devices and affect API error messages.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className='flex items-center gap-2 sm:min-w-48'>
|
|
<Select
|
|
items={[
|
|
...INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
|
|
value: language.code,
|
|
label: language.label,
|
|
})),
|
|
]}
|
|
value={currentLanguage}
|
|
onValueChange={handleLanguageChange}
|
|
disabled={saving}
|
|
>
|
|
<SelectTrigger className='w-full sm:w-48'>
|
|
<SelectValue placeholder={t('Select language')} />
|
|
</SelectTrigger>
|
|
<SelectContent alignItemWithTrigger={false}>
|
|
<SelectGroup>
|
|
{INTERFACE_LANGUAGE_OPTIONS.map((language) => (
|
|
<SelectItem key={language.code} value={language.code}>
|
|
{language.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
{saving && (
|
|
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TitledCard>
|
|
)
|
|
}
|