feat(ui): improve mobile responsive layouts
This commit is contained in:
+8
@@ -41,6 +41,14 @@ export async function updateUserSettings(
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update interface language preference
|
||||
*/
|
||||
export async function updateUserLanguage(language: string): Promise<ApiResponse> {
|
||||
const res = await api.put('/api/user/self', { language })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user account
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Languages, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
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'
|
||||
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'zh', label: '简体中文' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'vi', label: 'Tiếng Việt' },
|
||||
] as const
|
||||
|
||||
function normalizeLanguage(value?: string | null): string {
|
||||
if (!value) return 'en'
|
||||
const normalized = value.trim().replace(/_/g, '-').toLowerCase()
|
||||
if (normalized.startsWith('zh')) return 'zh'
|
||||
return LANGUAGE_OPTIONS.some((lang) => lang.value === normalized)
|
||||
? normalized
|
||||
: 'en'
|
||||
}
|
||||
|
||||
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 normalizeLanguage(settings.language || i18n.language)
|
||||
}, [props.profile?.setting, i18n.language])
|
||||
|
||||
const [currentLanguage, setCurrentLanguage] = useState(savedLanguage)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentLanguage(savedLanguage)
|
||||
}, [savedLanguage])
|
||||
|
||||
const handleLanguageChange = async (language: string) => {
|
||||
const nextLanguage = normalizeLanguage(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
|
||||
value={currentLanguage}
|
||||
onValueChange={handleLanguageChange}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className='w-full sm:w-48'>
|
||||
<SelectValue placeholder={t('Select language')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{saving && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TitledCard>
|
||||
)
|
||||
}
|
||||
+87
-75
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { KeyRound, ShieldAlert, Loader2 } from 'lucide-react'
|
||||
import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
@@ -169,14 +169,13 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
|
||||
if (pageLoading || loading) {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='p-3 sm:p-5'>
|
||||
<Skeleton className='h-6 w-48' />
|
||||
<Skeleton className='mt-2 h-4 w-64' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<Skeleton className='h-12 w-full' />
|
||||
<Skeleton className='h-12 w-full' />
|
||||
<CardContent className='p-3 sm:p-5'>
|
||||
<Skeleton className='h-20 w-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -191,19 +190,20 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xl tracking-tight'>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='p-3 sm:p-5'>
|
||||
<CardTitle className='text-lg tracking-tight sm:text-xl'>
|
||||
{t('Passkey Login')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
{t('Use Passkey to sign in without entering your password.')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-stretch 2xl:flex-row 2xl:items-center'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<CardContent className='p-3 sm:p-5'>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='bg-muted rounded-md p-2'>
|
||||
<KeyRound className='h-5 w-5' />
|
||||
</div>
|
||||
@@ -241,74 +241,86 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
|
||||
{t('Last used:')} {formattedLastUsed}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!enabled && (
|
||||
<Button
|
||||
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
|
||||
onClick={handleRegister}
|
||||
disabled={!supported || registering}
|
||||
>
|
||||
{registering && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{t('Enable Passkey')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!enabled ? (
|
||||
<Button
|
||||
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
|
||||
onClick={handleRegister}
|
||||
disabled={!supported || registering}
|
||||
>
|
||||
{registering && (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
)}
|
||||
{t('Register Passkey')}
|
||||
</Button>
|
||||
) : (
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
|
||||
disabled={removing}
|
||||
>
|
||||
{t('Remove Passkey')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Remove Passkey?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removing}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
{enabled && (
|
||||
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
className='flex-1'
|
||||
disabled={removing}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleRemove()
|
||||
}}
|
||||
>
|
||||
{t('Remove')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{removing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<AlertTriangle className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Remove Passkey')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('Remove Passkey?')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removing}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={removing}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
handleRemove()
|
||||
}}
|
||||
>
|
||||
{t('Remove')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showUnsupportedNotice && (
|
||||
<div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
|
||||
<ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
|
||||
<div>
|
||||
<p className='text-foreground font-medium'>
|
||||
{t('Passkey not supported on this device')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showUnsupportedNotice && (
|
||||
<div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
|
||||
<ShieldAlert className='mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500' />
|
||||
<div>
|
||||
<p className='text-foreground font-medium'>
|
||||
{t('Passkey not supported on this device')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
+16
-16
@@ -82,17 +82,17 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
|
||||
|
||||
return (
|
||||
<div className='bg-card overflow-hidden rounded-lg border'>
|
||||
<div className='p-4 sm:p-5'>
|
||||
<div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
|
||||
<Avatar className='ring-background h-16 w-16 rounded-2xl text-lg ring-4'>
|
||||
<AvatarFallback className='bg-primary/10 text-primary rounded-2xl'>
|
||||
<div className='p-3 sm:p-5'>
|
||||
<div className='flex items-center gap-3 text-left sm:gap-4'>
|
||||
<Avatar className='ring-background h-12 w-12 rounded-xl text-sm ring-2 sm:h-16 sm:w-16 sm:rounded-2xl sm:text-lg sm:ring-4'>
|
||||
<AvatarFallback className='bg-primary/10 text-primary rounded-xl sm:rounded-2xl'>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className='min-w-0 flex-1 space-y-3'>
|
||||
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
<div className='min-w-0 flex-1 space-y-1.5 sm:space-y-3'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<h1 className='truncate text-xl font-semibold tracking-tight sm:text-2xl'>
|
||||
{displayName}
|
||||
</h1>
|
||||
<StatusBadge
|
||||
@@ -102,18 +102,18 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='text-muted-foreground flex flex-col gap-1 text-sm sm:flex-row sm:flex-wrap sm:justify-start sm:gap-4'>
|
||||
<span>@{profile.username}</span>
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:gap-x-4 sm:text-sm'>
|
||||
<span className='truncate'>@{profile.username}</span>
|
||||
{profile.email && (
|
||||
<>
|
||||
<span className='hidden sm:inline'>•</span>
|
||||
<span>{profile.email}</span>
|
||||
<span>•</span>
|
||||
<span className='truncate'>{profile.email}</span>
|
||||
</>
|
||||
)}
|
||||
{profile.group && (
|
||||
<>
|
||||
<span className='hidden sm:inline'>•</span>
|
||||
<span>{profile.group}</span>
|
||||
<span>•</span>
|
||||
<span className='truncate'>{profile.group}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -121,9 +121,9 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className='border-t'>
|
||||
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
|
||||
<div className='divide-border/60 grid grid-cols-3 divide-x'>
|
||||
{stats.map((item) => (
|
||||
<div key={item.label} className='px-4 py-3.5 sm:px-5 sm:py-4'>
|
||||
<div key={item.label} className='min-w-0 px-3 py-3 sm:px-5 sm:py-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
@@ -131,7 +131,7 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-foreground mt-2 font-mono text-2xl font-bold tracking-tight break-all tabular-nums'>
|
||||
<div className='text-foreground mt-1.5 truncate font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
|
||||
{item.value}
|
||||
</div>
|
||||
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
|
||||
|
||||
@@ -4,11 +4,10 @@ import { useDialogs } from '@/hooks/use-dialog'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TitledCard } from '@/components/ui/titled-card'
|
||||
import type { UserProfile } from '../types'
|
||||
import { AccessTokenDialog } from './dialogs/access-token-dialog'
|
||||
import { ChangePasswordDialog } from './dialogs/change-password-dialog'
|
||||
@@ -34,12 +33,12 @@ export function ProfileSecurityCard({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='border-b'>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
|
||||
<Skeleton className='h-6 w-32' />
|
||||
<Skeleton className='mt-2 h-4 w-48' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 pt-6'>
|
||||
<CardContent className='space-y-3 p-3 sm:p-5'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 w-full' />
|
||||
))}
|
||||
@@ -76,31 +75,18 @@ export function ProfileSecurityCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='border-b'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Shield className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='text-xl tracking-tight'>
|
||||
{t('Security')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Manage your security settings and account access')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='pt-6'>
|
||||
<div className='grid grid-cols-1 gap-3 md:grid-cols-3'>
|
||||
<TitledCard
|
||||
title={t('Security')}
|
||||
description={t('Manage your security settings and account access')}
|
||||
icon={<Shield className='h-4 w-4' />}
|
||||
>
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
|
||||
{securityActions.map((item) => (
|
||||
<button
|
||||
key={item.title}
|
||||
type='button'
|
||||
onClick={item.action}
|
||||
className={`hover:bg-muted/50 flex flex-col items-center gap-2 rounded-lg border p-4 text-center transition-colors ${
|
||||
className={`hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 text-left transition-colors md:flex-col md:gap-2 md:p-4 md:text-center ${
|
||||
item.variant === 'destructive'
|
||||
? 'border-destructive/30 hover:border-destructive/50 hover:bg-destructive/5'
|
||||
: ''
|
||||
@@ -115,15 +101,16 @@ export function ProfileSecurityCard({
|
||||
>
|
||||
<item.icon className='h-5 w-5' />
|
||||
</div>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{item.description}
|
||||
</p>
|
||||
<div className='min-w-0 md:contents'>
|
||||
<p className='text-sm font-medium'>{item.title}</p>
|
||||
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TitledCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
<ChangePasswordDialog
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { TitledCard } from '@/components/ui/titled-card'
|
||||
import type { UserProfile } from '../types'
|
||||
import { AccountBindingsTab } from './tabs/account-bindings-tab'
|
||||
import { NotificationTab } from './tabs/notification-tab'
|
||||
@@ -34,12 +33,12 @@ export function ProfileSettingsCard({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='border-b'>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
|
||||
<Skeleton className='h-6 w-32' />
|
||||
<Skeleton className='mt-2 h-4 w-48' />
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4 pt-6'>
|
||||
<CardContent className='space-y-4 p-3 sm:p-5'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-20 w-full' />
|
||||
@@ -50,29 +49,16 @@ export function ProfileSettingsCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='border-b'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Settings className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='text-xl tracking-tight'>
|
||||
{t('Settings')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('Configure your account preferences and integrations')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='pt-6'>
|
||||
<TitledCard
|
||||
title={t('Settings')}
|
||||
description={t('Configure your account preferences and integrations')}
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className='grid h-auto w-full grid-cols-2 gap-1 rounded-xl p-1'>
|
||||
<TabsTrigger
|
||||
value='bindings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2.5'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Link2 className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
|
||||
@@ -80,7 +66,7 @@ export function ProfileSettingsCard({
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2.5'
|
||||
className='h-auto gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<Settings className='h-4 w-4' />
|
||||
<span className='hidden sm:inline'>
|
||||
@@ -90,15 +76,14 @@ export function ProfileSettingsCard({
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='bindings' className='mt-6'>
|
||||
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
|
||||
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='settings' className='mt-6'>
|
||||
<TabsContent value='settings' className='mt-4 sm:mt-6'>
|
||||
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TitledCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,23 +182,23 @@ export function SidebarModulesCard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader className='border-b'>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg'>
|
||||
<div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
|
||||
<LayoutDashboard className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='text-xl tracking-tight'>
|
||||
<CardTitle className='text-lg tracking-tight sm:text-xl'>
|
||||
{t('Sidebar Personal Settings')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
{t('Customize sidebar display content')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-5 pt-6'>
|
||||
<CardContent className='space-y-4 p-3 sm:space-y-5 sm:p-5'>
|
||||
{sectionDefs.map((section) => {
|
||||
const sectionEnabled = config[section.key]?.enabled !== false
|
||||
return (
|
||||
|
||||
+11
-11
@@ -245,14 +245,14 @@ export function AccountBindingsTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
|
||||
{bindings.map((binding) => (
|
||||
<div
|
||||
key={binding.id}
|
||||
className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
|
||||
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
<div className='bg-muted shrink-0 rounded-md p-2'>
|
||||
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
|
||||
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
|
||||
<binding.icon className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
@@ -274,7 +274,7 @@ export function AccountBindingsTab({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
|
||||
className='h-7 shrink-0 px-2.5 text-xs'
|
||||
onClick={binding.onBind}
|
||||
disabled={binding.isBound && binding.id !== 'email'}
|
||||
>
|
||||
@@ -295,7 +295,7 @@ export function AccountBindingsTab({
|
||||
<p className='text-muted-foreground mb-3 text-sm font-medium'>
|
||||
{t('Custom OAuth')}
|
||||
</p>
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
|
||||
{customProviders.map((provider) => {
|
||||
const binding = customBindings.find(
|
||||
(b) => b.provider_id === provider.id
|
||||
@@ -304,10 +304,10 @@ export function AccountBindingsTab({
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className='flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'
|
||||
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
|
||||
>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
<div className='bg-muted shrink-0 rounded-md p-2'>
|
||||
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
|
||||
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
|
||||
<Link2 className='h-4 w-4' />
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
@@ -332,7 +332,7 @@ export function AccountBindingsTab({
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
|
||||
className='text-destructive hover:text-destructive h-7 shrink-0 px-2.5 text-xs'
|
||||
onClick={() => setUnbindTarget(binding)}
|
||||
>
|
||||
<Unlink className='mr-1 h-3 w-3' />
|
||||
@@ -342,7 +342,7 @@ export function AccountBindingsTab({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 shrink-0 self-start px-2.5 text-xs sm:self-auto'
|
||||
className='h-7 shrink-0 px-2.5 text-xs'
|
||||
onClick={() => handleBindCustomOAuth(provider)}
|
||||
>
|
||||
{t('Bind')}
|
||||
|
||||
@@ -102,16 +102,16 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* Notification Type */}
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-2.5'>
|
||||
<Label>{t('Notification Method')}</Label>
|
||||
<RadioGroup
|
||||
value={settings.notify_type}
|
||||
onValueChange={(value) =>
|
||||
updateField('notify_type', value as NotifyType)
|
||||
}
|
||||
className='grid grid-cols-2 gap-3 sm:grid-cols-4'
|
||||
className='grid grid-cols-4 gap-1.5 sm:gap-3'
|
||||
>
|
||||
{NOTIFICATION_METHODS.map((method) => {
|
||||
const Icon = NOTIFICATION_ICONS[method.value]
|
||||
@@ -120,7 +120,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
<Label
|
||||
key={method.value}
|
||||
htmlFor={method.value}
|
||||
className={`flex cursor-pointer flex-col items-center gap-2 rounded-lg border-2 p-3 transition-colors ${
|
||||
className={`flex min-h-16 cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border p-2 text-center transition-colors sm:min-h-20 sm:gap-2 sm:border-2 sm:p-3 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-muted hover:border-muted-foreground/25 hover:bg-muted/50'
|
||||
@@ -131,8 +131,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
id={method.value}
|
||||
className='sr-only'
|
||||
/>
|
||||
<Icon className='h-5 w-5' />
|
||||
<span className='text-sm font-medium'>{t(method.label)}</span>
|
||||
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
|
||||
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
|
||||
{t(method.label)}
|
||||
</span>
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
@@ -140,11 +142,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Warning Threshold */}
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='threshold'>{t('Quota Warning Threshold')}</Label>
|
||||
<Input
|
||||
id='threshold'
|
||||
type='number'
|
||||
className='h-9'
|
||||
value={settings.quota_warning_threshold}
|
||||
onChange={(e) =>
|
||||
updateField('quota_warning_threshold', Number(e.target.value))
|
||||
@@ -158,11 +161,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
|
||||
{/* Email Settings */}
|
||||
{settings.notify_type === 'email' && (
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='notifyEmail'>{t('Notification Email')}</Label>
|
||||
<Input
|
||||
id='notifyEmail'
|
||||
type='email'
|
||||
className='h-9'
|
||||
value={settings.notification_email}
|
||||
onChange={(e) => updateField('notification_email', e.target.value)}
|
||||
placeholder={t('Leave empty to use account email')}
|
||||
@@ -173,17 +177,18 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
{/* Webhook Settings */}
|
||||
{settings.notify_type === 'webhook' && (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='webhookUrl'>{t('Webhook URL')}</Label>
|
||||
<Input
|
||||
id='webhookUrl'
|
||||
type='url'
|
||||
className='h-9'
|
||||
value={settings.webhook_url}
|
||||
onChange={(e) => updateField('webhook_url', e.target.value)}
|
||||
placeholder={t('https://example.com/webhook')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='webhookSecret'>{t('Webhook Secret')}</Label>
|
||||
<PasswordInput
|
||||
id='webhookSecret'
|
||||
@@ -197,11 +202,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
|
||||
{/* Bark Settings */}
|
||||
{settings.notify_type === 'bark' && (
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='barkUrl'>{t('Bark Push URL')}</Label>
|
||||
<Input
|
||||
id='barkUrl'
|
||||
type='url'
|
||||
className='h-9'
|
||||
value={settings.bark_url}
|
||||
onChange={(e) => updateField('bark_url', e.target.value)}
|
||||
placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')}
|
||||
@@ -215,11 +221,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
{/* Gotify Settings */}
|
||||
{settings.notify_type === 'gotify' && (
|
||||
<>
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='gotifyUrl'>{t('Gotify Server URL')}</Label>
|
||||
<Input
|
||||
id='gotifyUrl'
|
||||
type='url'
|
||||
className='h-9'
|
||||
value={settings.gotify_url}
|
||||
onChange={(e) => updateField('gotify_url', e.target.value)}
|
||||
placeholder={t('https://gotify.example.com')}
|
||||
@@ -228,7 +235,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
{t('Enter the full URL of your Gotify server')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='gotifyToken'>{t('Gotify Application Token')}</Label>
|
||||
<PasswordInput
|
||||
id='gotifyToken'
|
||||
@@ -240,11 +247,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
{t('Token obtained from your Gotify application')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='gotifyPriority'>{t('Message Priority')}</Label>
|
||||
<Input
|
||||
id='gotifyPriority'
|
||||
type='number'
|
||||
className='h-9'
|
||||
min='0'
|
||||
max='10'
|
||||
value={settings.gotify_priority}
|
||||
@@ -259,8 +267,8 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='bg-muted/50 rounded-lg border p-4'>
|
||||
<h5 className='mb-2 text-sm font-medium'>
|
||||
<div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
|
||||
<h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
|
||||
{t('Setup Instructions')}
|
||||
</h5>
|
||||
<ol className='text-muted-foreground space-y-1 text-xs'>
|
||||
@@ -287,7 +295,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
<div className='border-t' />
|
||||
|
||||
{/* Preferences Section */}
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<h4 className='text-sm font-medium'>{t('Preferences')}</h4>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
@@ -297,12 +305,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
|
||||
{/* Receive Upstream Model Update Notifications (admin only) */}
|
||||
{isAdmin && (
|
||||
<div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label htmlFor='upstreamModelUpdateNotify'>
|
||||
{t('Receive Upstream Model Update Notifications')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-muted-foreground line-clamp-3 text-xs sm:line-clamp-none sm:text-sm'>
|
||||
{t(
|
||||
'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
|
||||
)}
|
||||
@@ -320,12 +328,12 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
)}
|
||||
|
||||
{/* Accept Unset Model Price */}
|
||||
<div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label htmlFor='acceptUnsetPrice'>
|
||||
{t('Accept Unpriced Models')}
|
||||
</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{t('Allow using models without price configuration')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -340,10 +348,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Record IP Log */}
|
||||
<div className='flex flex-col gap-3 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<p className='text-muted-foreground text-xs sm:text-sm'>
|
||||
{t('Log IP address for usage and error logs')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -33,12 +33,12 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
|
||||
|
||||
if (pageLoading || loading) {
|
||||
return (
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='p-3 sm:p-5'>
|
||||
<Skeleton className='h-6 w-48' />
|
||||
<Skeleton className='mt-2 h-4 w-64' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className='p-3 sm:p-5'>
|
||||
<Skeleton className='h-20 w-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -47,17 +47,17 @@ export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='overflow-hidden'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-xl tracking-tight'>
|
||||
<Card className='gap-0 overflow-hidden py-0'>
|
||||
<CardHeader className='p-3 sm:p-5'>
|
||||
<CardTitle className='text-lg tracking-tight sm:text-xl'>
|
||||
{t('Two-Factor Authentication')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<CardDescription className='text-xs sm:text-sm'>
|
||||
{t('Add an extra layer of security to your account')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<CardContent className='p-3 sm:p-5'>
|
||||
<div className='space-y-6'>
|
||||
{/* Status Section */}
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
|
||||
|
||||
+10
-5
@@ -6,6 +6,7 @@ import {
|
||||
CardStaggerItem,
|
||||
} from '@/components/page-transition'
|
||||
import { CheckinCalendarCard } from './components/checkin-calendar-card'
|
||||
import { LanguagePreferencesCard } from './components/language-preferences-card'
|
||||
import { PasskeyCard } from './components/passkey-card'
|
||||
import { ProfileHeader } from './components/profile-header'
|
||||
import { ProfileSecurityCard } from './components/profile-security-card'
|
||||
@@ -30,24 +31,28 @@ export function Profile() {
|
||||
<>
|
||||
<AppHeader />
|
||||
<Main>
|
||||
<div className='min-h-0 flex-1 overflow-auto px-4 py-4 sm:py-6'>
|
||||
<CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-5 sm:gap-6'>
|
||||
<div className='min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4 sm:py-6'>
|
||||
<CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
|
||||
<CardStaggerItem>
|
||||
<ProfileHeader profile={profile} loading={loading} />
|
||||
</CardStaggerItem>
|
||||
|
||||
<CardStaggerItem>
|
||||
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
|
||||
<div className='space-y-5 sm:space-y-6'>
|
||||
<div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
<ProfileSettingsCard
|
||||
profile={profile}
|
||||
loading={loading}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<LanguagePreferencesCard
|
||||
profile={profile}
|
||||
onProfileUpdate={refreshProfile}
|
||||
/>
|
||||
<ProfileSecurityCard profile={profile} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className='space-y-5 sm:space-y-6 xl:sticky xl:top-6'>
|
||||
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
|
||||
{checkinEnabled && (
|
||||
<CheckinCalendarCard
|
||||
checkinEnabled={checkinEnabled}
|
||||
|
||||
+2
@@ -98,6 +98,8 @@ export interface UserSettings {
|
||||
record_ip_log?: boolean
|
||||
/** Receive upstream model update notifications (admin only) */
|
||||
upstream_model_update_notify_enabled?: boolean
|
||||
/** Preferred interface/API response language */
|
||||
language?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user