feat(ui): improve mobile responsive layouts

This commit is contained in:
CaIon
2026-04-30 19:53:02 +08:00
parent aa730395f1
commit d46df94f05
84 changed files with 1174 additions and 731 deletions
+8
View File
@@ -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
View File
@@ -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>
@@ -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 (
@@ -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
View File
@@ -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
View File
@@ -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
}
/**