chore(web): improve frontend dialog layout and sizing (#5346)

Merge pull request #5346 from QuantumNous/perf/ui-dialog
This commit is contained in:
同語
2026-06-06 23:16:53 +08:00
committed by GitHub
80 changed files with 8263 additions and 8516 deletions
+127
View File
@@ -0,0 +1,127 @@
/*
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 * as React from 'react'
import { cn } from '@/lib/utils'
import {
Dialog as DialogRoot,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
type DialogProps = React.ComponentProps<typeof DialogRoot> & {
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
trigger?: React.ReactElement
footer?: React.ReactNode
contentHeight?: React.CSSProperties['height']
contentClassName?: string
headerClassName?: string
titleClassName?: string
descriptionClassName?: string
bodyClassName?: string
footerClassName?: string
initialFocus?: boolean
showCloseButton?: boolean
}
const dialogContentMotionClassName =
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 duration-100'
export function Dialog({
title,
description,
children,
trigger,
footer,
contentHeight = 'auto',
contentClassName,
headerClassName,
titleClassName,
descriptionClassName,
bodyClassName,
footerClassName,
initialFocus,
showCloseButton,
...dialogProps
}: DialogProps) {
return (
<DialogRoot {...dialogProps}>
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
)}
initialFocus={initialFocus}
showCloseButton={showCloseButton}
style={
{
'--dialog-content-height': contentHeight,
} as React.CSSProperties
}
>
<DialogHeader
className={cn('flex-shrink-0 text-start', headerClassName)}
>
<DialogTitle className={titleClassName}>{title}</DialogTitle>
{description ? (
<DialogDescription className={descriptionClassName}>
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div
className={cn(
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
)}
>
<div
className={cn(
'min-w-0 px-1 py-1',
'[&_form]:overflow-x-visible',
'[&_[data-slot=scroll-area-viewport]]:px-1 [&_[data-slot=scroll-area-viewport]]:py-1',
bodyClassName
)}
>
{children}
</div>
</div>
{footer ? (
<DialogFooter
className={cn(
'flex-shrink-0 gap-2 sm:-mx-6 sm:-mb-6 sm:justify-end sm:p-6',
footerClassName
)}
>
{footer}
</DialogFooter>
) : null}
</DialogContent>
</DialogRoot>
)
}
@@ -25,15 +25,8 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
@@ -427,28 +420,26 @@ export function PublicHeader(props: PublicHeaderProps) {
closeAuthPrompt()
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Sign in required')}</DialogTitle>
<DialogDescription>
{t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
</DialogDescription>
</DialogHeader>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
title={t('Sign in required')}
description={t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
contentClassName='sm:max-w-md'
contentHeight='auto'
footer={
<>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
</Dialog>
</>
)
@@ -20,16 +20,9 @@ import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog } from '@/components/dialog'
import type {
SecureVerificationState,
VerificationMethod,
@@ -91,122 +84,118 @@ export function SecureVerificationDialog({
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</>
}
description={description}
contentClassName='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 overflow-hidden border-none shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
headerClassName='border-b pb-4 text-left'
titleClassName='flex items-center gap-2 text-lg font-semibold'
descriptionClassName='text-left'
contentHeight='auto'
bodyClassName='px-1 py-1'
showCloseButton={!state.loading}
footerClassName='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'
footer={
<>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</>
}
>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) => onMethodChange(value as VerificationMethod)}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>{t('Authenticator code')}</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<div className='flex-1 overflow-y-auto px-6 py-5'>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
}}
/>
</TabsContent>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</TabsContent>
</Tabs>
)}
</Dialog>
)
}
@@ -32,14 +32,6 @@ import {
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -50,6 +42,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
@@ -414,43 +407,16 @@ export function UserAuthForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -474,8 +440,32 @@ export function UserAuthForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -26,14 +26,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -44,6 +36,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
@@ -387,43 +380,16 @@ export function SignUpForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -447,8 +413,32 @@ export function SignUpForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -22,14 +22,6 @@ import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -38,6 +30,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchDelete,
handleBatchDisable,
@@ -188,29 +181,21 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => {
@@ -221,22 +206,37 @@ export function DataTableBulkActions<TData>({
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</>
}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -161,53 +154,55 @@ export function BalanceQueryDialog({
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</Dialog>
)
}
@@ -33,14 +33,6 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -75,6 +67,7 @@ import {
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -529,179 +522,184 @@ export function ChannelTestDialog({
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Test Channel Connection')}
description={
<>
{t('Test connectivity for:')}
<strong>{currentRow.name}</strong>
</>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
</Dialog>
<FailureDetailsSheet
details={failureDetails}
@@ -24,15 +24,8 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
@@ -129,78 +122,18 @@ export function CodexOAuthDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Authorization')}
description={t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -215,8 +148,68 @@ export function CodexOAuthDialog({
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
</Dialog>
)
}
@@ -31,16 +31,9 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
@@ -414,177 +407,23 @@ export function CodexUsageDialog({
}, [response])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Account & Usage')}
description={
<>
{t('Channel:')}
<strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -592,8 +431,166 @@ export function CodexUsageDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -74,45 +67,20 @@ export function CopyChannelDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -122,10 +90,39 @@ export function CopyChannelDialog({
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
@@ -222,216 +215,23 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
@@ -439,8 +239,204 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -28,14 +28,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -44,6 +36,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
@@ -365,152 +358,153 @@ export function FetchModelsDialog({
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{activeChannel ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)}
</DialogDescription>
</DialogHeader>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -47,6 +40,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
@@ -228,215 +222,217 @@ export function MultiKeyManageDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={currentRow.name}
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<RefreshCw className='h-4 w-4' />
</Button>
<div className='flex items-center gap-2'>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='outline'
variant='default'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<RefreshCw className='h-4 w-4' />
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</DialogContent>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirmation Dialog */}
@@ -34,18 +34,11 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
@@ -375,203 +368,203 @@ export function OllamaModelsDialog({
if (!open) return null
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
: 0
}
/>
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</div>
)}
<AlertDialog
open={deleteOpen}
@@ -43,14 +43,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -63,6 +55,7 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// ---------------------------------------------------------------------------
// Types
@@ -1701,356 +1694,20 @@ export function ParamOverrideEditorDialog(
// ---------------------------------------------------------------------------
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Parameter Override')}
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -2061,8 +1718,337 @@ export function ParamOverrideEditorDialog(
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() => setSelectedOperationId(operation.id)}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) => handleDragOver(e, operation.id)}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(operation.mode || 'set')
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps {
open: boolean
@@ -84,73 +77,22 @@ export function StatusCodeRiskDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
@@ -161,8 +103,62 @@ export function StatusCodeRiskDialog({
>
{t('I confirm enabling high-risk retry')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</Dialog>
)
}
@@ -23,18 +23,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
@@ -190,115 +183,118 @@ export function TagBatchEditDialog({
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -21,17 +21,11 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
@@ -120,157 +114,15 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return (
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
@@ -284,8 +136,139 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog
@@ -21,15 +21,6 @@ import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -39,6 +30,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS,
@@ -74,165 +66,158 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</DialogTrigger>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle>
<DialogDescription>
{t(
'Choose the default charts, range, and time granularity for model analytics.'
)}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='grid gap-2'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
</DialogFooter>
</DialogContent>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</Button>
}
title={t('Model Analytics Defaults')}
description={t('Set default ranges and charts for model analytics.')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='grid gap-3'
footer={
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
}
>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</Dialog>
)
}
@@ -23,15 +23,6 @@ import { useAuthStore } from '@/stores/auth-store'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -44,6 +35,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
@@ -144,129 +136,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</DialogTrigger>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription>
{t(
'Set filters to customize your dashboard statistics and charts.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='flex-1 pr-3 sm:pr-4'>
<div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</Button>
}
title={t('Model Analytics Filters')}
description={t('Filter the model analytics view by time range and user.')}
contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'
contentHeight='min(48vh, 460px)'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
footer={
<>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
@@ -275,8 +160,113 @@ export function ModelsFilter(props: ModelsFilterProps) {
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<ScrollArea className='h-full pr-3 sm:pr-4'>
<div className='grid gap-2.5 py-2'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={selectedRange === range.days ? 'default' : 'outline'}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-2.5'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface AnnouncementDetailModalProps {
open: boolean
@@ -47,38 +41,39 @@ export function AnnouncementDetailModal({
}: AnnouncementDetailModalProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Announcement Details')}</DialogTitle>
{announcement?.publishDate && (
<DialogDescription>
{t('Published:')}{' '}
{formatDateTimeObject(new Date(announcement.publishDate))}
</DialogDescription>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Announcement Details')}
description={
announcement?.publishDate
? `${t('Published:')} ${formatDateTimeObject(new Date(announcement.publishDate))}`
: undefined
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[min(58vh,520px)] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -23,15 +23,9 @@ import { toast } from 'sonner'
import { getUserModels } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
const APP_CONFIGS = {
claude: {
@@ -151,76 +145,78 @@ export function CCSwitchDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Import to CC Switch')}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import to CC Switch')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
</Dialog>
)
}
@@ -24,20 +24,13 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchEnableModels,
handleBatchDisableModels,
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Models?')}</DialogTitle>
<DialogDescription>
{t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Models?')}
description={t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -17,14 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = {
open: boolean
@@ -41,21 +35,22 @@ export function DescriptionDialog({
}: DescriptionDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{modelName}</DialogTitle>
<DialogDescription>{t('Model Description')}</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</DialogContent>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -22,15 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -164,62 +158,16 @@ export function ExtendDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Extend deployment')}</DialogTitle>
</DialogHeader>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Extend deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -229,8 +177,57 @@ export function ExtendDeploymentDialog({
) : null}
{t('Extend')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -37,6 +30,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -115,133 +109,130 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Missing Models')}</DialogTitle>
<DialogDescription>
{t('Models that are being used but not configured in the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Missing Models')}
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')} {totalItems}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
</div>
</div>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{totalItems}
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
)}
</div>
)}
</Dialog>
)
}
@@ -25,7 +25,6 @@ import {
Plus,
RefreshCcw,
Trash2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,14 +39,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -64,6 +55,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
@@ -172,186 +164,233 @@ export function PrefillGroupManagementDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
style={{ maxWidth: 'min(100vw, 64rem)' }}
>
<div
className={cn(
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
isMobile ? 'rounded-none' : 'rounded-2xl'
)}
>
<div
className={cn(
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
)}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</>
}
description={t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
contentClassName={cn(
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
isMobile && 'max-w-none rounded-none'
)}
titleClassName='flex flex-wrap items-center gap-2 text-lg'
descriptionClassName='text-sm leading-relaxed'
contentHeight='auto'
bodyClassName={cn(
'space-y-3',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
)}
>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</DialogTitle>
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div className='flex flex-col gap-3'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed py-10'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</DialogDescription>
</DialogHeader>
<DialogClose
render={
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6'
/>
}
>
<span className='sr-only'>{t('Close dialog')}</span>
<X className='h-4 w-4' />
</DialogClose>
</div>
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-3'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-4'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>
·
</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
</div>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</div>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[680px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[240px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
@@ -368,7 +407,7 @@ export function PrefillGroupManagementDialog({
copyable={false}
/>
)}
</div>
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
@@ -376,131 +415,41 @@ export function PrefillGroupManagementDialog({
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(
({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>
{group.name}
</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems
.slice(0, 6)
.map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>
Edit group
</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>
Delete group
</span>
</Button>
</div>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</DialogContent>
)}
</div>
</Dialog>
<ConfirmDialog
@@ -22,14 +22,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -111,27 +105,16 @@ export function RenameDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Rename deployment')}</DialogTitle>
</DialogHeader>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Rename deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -141,8 +124,22 @@ export function RenameDeploymentDialog({
) : null}
{t('Rename')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -125,117 +118,16 @@ export function SyncWizardDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
<DialogDescription>
{t('Synchronize models and vendors from an upstream source')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Sync Upstream Models')}
description={t('Synchronize models and vendors from an upstream source')}
initialFocus={!isMobile}
contentHeight='auto'
bodyClassName='flex flex-col gap-6'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -246,10 +138,106 @@ export function SyncWizardDialog({
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? 'Syncing...' : 'Sync Now'}
{isSyncing ? t('Syncing...') : t('Sync Now')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</Dialog>
)
}
@@ -30,13 +30,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -47,6 +40,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -64,6 +58,8 @@ const schema = z.object({
type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input)
@@ -212,226 +208,228 @@ export function UpdateConfigDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input
placeholder='ollama/ollama:latest'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
isLoading ? null : (
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={UPDATE_CONFIG_FORM_ID}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</>
)
}
>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
id={UPDATE_CONFIG_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='command'
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
<Input placeholder='ollama/ollama:latest' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
)}
</DialogContent>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='command'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</div>
)}
</Dialog>
)
}
@@ -37,14 +37,6 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -67,6 +59,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -453,222 +446,217 @@ export function UpstreamConflictDialog({
}
onOpenChange(nextOpen)
}}
>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
initialFocus={!isMobile}
>
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
<DialogDescription>
title={t('Resolve Conflicts')}
description={t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
contentClassName='w-full sm:max-w-5xl'
contentHeight='min(72vh, 720px)'
bodyClassName='flex flex-col gap-4'
initialFocus={!isMobile}
footerClassName='sm:justify-between'
footer={
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</DialogDescription>
</DialogHeader>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
}
>
<div className='flex min-h-0 flex-1 flex-col gap-4'>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</div>
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter className='flex-shrink-0'>
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
)}
</div>
</DialogFooter>
</DialogContent>
)}
</div>
</Dialog>
)
}
@@ -24,14 +24,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types'
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null
}
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({
open,
onOpenChange,
@@ -118,98 +113,107 @@ export function VendorMutateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
</DialogTitle>
<DialogDescription>
{isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
description={
isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={VENDOR_MUTATE_FORM_ID}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : isEdit ? t('Update') : t('Create')}
</Button>
</>
}
>
<Form {...form}>
<form
id={VENDOR_MUTATE_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -27,14 +27,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({
@@ -116,160 +110,15 @@ export function ViewDetailsDialog({
}, [details])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Deployment details')}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -277,8 +126,151 @@ export function ViewDetailsDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(details?.total_gpus ?? details?.hardware_qty ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>{id}</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</Dialog>
)
}
@@ -21,12 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -36,6 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps {
@@ -142,180 +137,180 @@ export function ViewLogsDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</DialogTitle>
</DialogHeader>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</>
}
contentClassName='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>
{t('Container')}
</div>
<Select
items={[
...containers.flatMap((c) => {
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select
items={[
...containers.flatMap((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</DialogContent>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div>
</Dialog>
)
}
@@ -32,12 +32,6 @@ import { formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -45,6 +39,7 @@ import {
TooltipTrigger,
TooltipProvider,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { Turnstile } from '@/components/turnstile'
import { getCheckinStatus, performCheckin } from '../api'
import type { CheckinRecord } from '../types'
@@ -253,27 +248,26 @@ export function CheckinCalendarCard({
setTurnstileWidgetKey((v) => v + 1)
}
}}
title={t('Security Check')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Security Check')}</DialogTitle>
</DialogHeader>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
</DialogContent>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
</Dialog>
<div className='bg-card overflow-hidden rounded-2xl border'>
@@ -20,17 +20,10 @@ import { useEffect } from 'react'
import { RefreshCw, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { useAccessToken } from '../../hooks'
// ============================================================================
@@ -57,45 +50,18 @@ export function AccessTokenDialog({
}, [open, token, generate])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Access Token')}</DialogTitle>
<DialogDescription>
{t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Access Token')}
description={t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -116,8 +82,35 @@ export function AccessTokenDialog({
)}
{generating ? t('Generating...') : t('Regenerate')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
</Dialog>
)
}
@@ -21,15 +21,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { updateUserProfile } from '../../api'
@@ -114,82 +107,79 @@ export function ChangePasswordDialog({
}
}
const formId = 'change-password-form'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('Change Password')}</DialogTitle>
<DialogDescription>
{t('Update your password for account:')}{' '}
<strong>{username}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Change Password')}
description={
<>
{t('Update your password for account:')} <strong>{username}</strong>
</>
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' form={formId} disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</>
}
>
<form id={formId} onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) => handleChange('originalPassword', e.target.value)}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) =>
handleChange('originalPassword', e.target.value)
}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>
{t('Confirm New Password')}
</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) =>
handleChange('confirmPassword', e.target.value)
}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</DialogFooter>
</form>
</DialogContent>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>{t('Confirm New Password')}</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</form>
</Dialog>
)
}
@@ -25,16 +25,9 @@ import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { deleteUserAccount } from '../../api'
// ============================================================================
@@ -101,45 +94,24 @@ export function DeleteAccountDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</DialogTitle>
<DialogDescription>
{t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</>
}
description={t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -157,8 +129,32 @@ export function DeleteAccountDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Deleting...') : t('Delete Account')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { sendEmailVerification, bindEmail } from '../../api'
// ============================================================================
@@ -129,60 +122,22 @@ export function EmailBindDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Email')}</DialogTitle>
<DialogDescription>
{currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Bind Email')}
description={
currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -199,8 +154,48 @@ export function EmailBindDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Binding...') : t('Bind Email')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
</Dialog>
)
}
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Telegram Bind Dialog Component
@@ -45,56 +39,55 @@ export function TelegramBindDialog({
}: TelegramBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Telegram Account')}</DialogTitle>
<DialogDescription>
{t('Click the button below to bind your Telegram account')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind Telegram Account')}
description={t('Click the button below to bind your Telegram account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
'You will be redirected to Telegram to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
'You will be redirected to Telegram to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
"After clicking the button, you'll be asked to authorize the bot"
)}
</p>
</div>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
</div>
</div>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
"After clicking the button, you'll be asked to authorize the bot"
)}
</p>
</div>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
</div>
</div>
</div>
</DialogContent>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
</div>
</Dialog>
)
}
@@ -23,17 +23,10 @@ import { toast } from 'sonner'
import { regenerate2FABackupCodes } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Backup Codes Dialog Component
@@ -94,82 +87,26 @@ export function TwoFABackupDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</DialogTitle>
<DialogDescription>
{backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</>
}
description={
backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')
}
contentClassName='sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
{backupCodes.length === 0 ? (
<>
<Button
@@ -187,8 +124,69 @@ export function TwoFABackupDialog({
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { disable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Disable Dialog Component
@@ -98,60 +91,24 @@ export function TwoFADisableDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</DialogTitle>
<DialogDescription>
{t(
'This action will permanently remove 2FA protection from your account.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</>
}
description={t(
'This action will permanently remove 2FA protection from your account.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
@@ -167,8 +124,47 @@ export function TwoFADisableDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Disabling...') : t('Disable 2FA')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -24,17 +24,10 @@ import { toast } from 'sonner'
import { setup2FA, enable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import type { TwoFASetupData } from '../../types'
// ============================================================================
@@ -136,123 +129,23 @@ export function TwoFASetupDialog({
}, [open, setupData, initializing, handleSetup])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Setup Two-Factor Authentication')}</DialogTitle>
<DialogDescription>
{t('Step')} {step + 1} {t('of 3:')} {stepLabels[step]}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Setup Two-Factor Authentication')}
description={
<>
{t('Step')}
{step + 1}
{t('of 3:')}
{stepLabels[step]}
</>
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
{step > 0 && (
<Button
variant='outline'
@@ -278,8 +171,115 @@ export function TwoFASetupDialog({
{loading ? t('Enabling...') : t('Enable 2FA')}
</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
</Dialog>
)
}
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { QrCode } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
// ============================================================================
// WeChat Bind Dialog Component
@@ -43,40 +37,39 @@ export function WeChatBindDialog({
}: WeChatBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind WeChat Account')}</DialogTitle>
<DialogDescription>
{t('Scan the QR code with WeChat to bind your account')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind WeChat Account')}
description={t('Scan the QR code with WeChat to bind your account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
</p>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
</p>
</div>
</DialogContent>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
</p>
</div>
</Dialog>
)
}
@@ -25,12 +25,6 @@ import { formatQuota } from '@/lib/format'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -40,6 +34,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import {
paySubscriptionStripe,
@@ -259,189 +254,189 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</DialogTitle>
</DialogHeader>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</>
}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
</div>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
{t('Reset Period')}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
</div>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Reset Period')}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
</div>
</div>
{limitReached && (
{limitReached && (
<Alert variant='destructive'>
<AlertDescription>
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
<Alert variant='destructive'>
<AlertDescription>
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
{t('This plan does not allow balance redemption')}
</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('This plan does not allow balance redemption')}
</AlertDescription>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('Insufficient balance')}
</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) =>
v !== null && setSelectedEpayMethod(v)
}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
)}
</div>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
</DialogContent>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) => v !== null && setSelectedEpayMethod(v)}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
)}
</div>
)}
</div>
</Dialog>
)
}
@@ -21,14 +21,6 @@ import { type Resolver, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -50,6 +42,7 @@ import {
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import {
SettingsForm,
SettingsSwitchContent,
@@ -74,6 +67,8 @@ type ProviderFormDialogProps = {
provider?: CustomOAuthProvider | null
}
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
export function ProviderFormDialog(props: ProviderFormDialogProps) {
const { t } = useTranslation()
const isEditing = !!props.provider
@@ -174,98 +169,97 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
const isPending = createProvider.isPending || updateProvider.isPending
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>
{isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
</DialogTitle>
<DialogDescription>
{isEditing
? t('Update the configuration for this custom OAuth provider.')
: t(
'Configure a new custom OAuth provider for user authentication.'
)}
</DialogDescription>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
description={
isEditing
? t('Update the configuration for this custom OAuth provider.')
: t('Configure a new custom OAuth provider for user authentication.')
}
contentClassName='max-h-[85vh] overflow-y-auto sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</>
}
>
<Form {...form}>
<SettingsForm
id={PROVIDER_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='enabled'
name='name'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Input placeholder={t('e.g. My GitLab')} {...field} />
</FormControl>
</SettingsSwitchItem>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. My GitLab')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
</FormControl>
<FormDescription>
{t('Used in URLs and API routes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='icon'
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormLabel>{t('Slug')}</FormLabel>
<FormControl>
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
</FormControl>
<FormDescription>
{t('Optional icon identifier for the login button')}
{t('Used in URLs and API routes')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -273,341 +267,341 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
/>
</div>
<Separator />
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional icon identifier for the login button')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='auth_style'
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'How client credentials are sent to the token endpoint'
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='auth_style'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('How client credentials are sent to the token endpoint')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
</FormDescription>
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</DialogFooter>
</SettingsForm>
</Form>
</DialogContent>
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsForm>
</Form>
</Dialog>
)
}
@@ -36,14 +36,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -72,6 +64,7 @@ import {
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -105,6 +98,8 @@ const announcementSchema = z.object({
type AnnouncementFormValues = z.infer<typeof announcementSchema>
const ANNOUNCEMENT_FORM_ID = 'announcement-form'
const typeOptions = [
{
value: 'default',
@@ -460,154 +455,157 @@ export function AnnouncementsSection({
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingAnnouncement
? t('Edit Announcement')
: t('Add Announcement')}
</DialogTitle>
<DialogDescription>
{t('Create or update system announcements for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingAnnouncement ? t('Edit Announcement') : t('Add Announcement')
}
description={t(
'Create or update system announcements for the dashboard'
)}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
{t('Cancel')}
</Button>
<Button type='submit' form={ANNOUNCEMENT_FORM_ID}>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={ANNOUNCEMENT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -36,14 +36,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -70,6 +62,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -98,6 +91,8 @@ const createApiInfoSchema = (t: (key: string) => string) =>
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
const API_INFO_FORM_ID = 'api-info-form'
const colorOptions = [
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
@@ -408,133 +403,133 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
</DialogTitle>
<DialogDescription>
{t('Configure API documentation links for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
description={t('Configure API documentation links for the dashboard')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
{t('Cancel')}
</Button>
<Button type='submit' form={API_INFO_FORM_ID}>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={API_INFO_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createChatDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -49,6 +42,8 @@ const createChatDialogSchema = (t: (key: string) => string) =>
type ChatDialogFormValues = z.infer<ReturnType<typeof createChatDialogSchema>>
const CHAT_DIALOG_FORM_ID = 'chat-dialog-form'
export type ChatEntryData = {
name: string
url: string
@@ -97,74 +92,73 @@ export function ChatDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit chat preset') : t('Add chat preset')}
</DialogTitle>
<DialogDescription>
{t('Configure a predefined chat link for end users.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit chat preset') : t('Add chat preset')}
description={t('Configure a predefined chat link for end users.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={CHAT_DIALOG_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={CHAT_DIALOG_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -35,14 +35,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -62,6 +54,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -90,6 +83,8 @@ const faqSchema = z.object({
type FAQFormValues = z.infer<typeof faqSchema>
const FAQ_FORM_ID = 'faq-form'
export function FAQSection({ enabled, data }: FAQSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -348,79 +343,78 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingFaq ? t('Edit FAQ') : t('Add FAQ')}
</DialogTitle>
<DialogDescription>
{t('Create or update frequently asked questions for users')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
)}
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Maximum 1000 characters. Supports Markdown and HTML.'
{t('Cancel')}
</Button>
<Button type='submit' form={FAQ_FORM_ID}>
{editingFaq ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={FAQ_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingFaq ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 1000 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -35,14 +35,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -61,6 +53,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -97,6 +90,8 @@ const createUptimeKumaSchema = (t: (key: string) => string) =>
type UptimeKumaFormValues = z.infer<ReturnType<typeof createUptimeKumaSchema>>
const UPTIME_KUMA_FORM_ID = 'uptime-kuma-form'
export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -359,96 +354,100 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')}
</DialogTitle>
<DialogDescription>
{t('Configure monitoring status page groups for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')
}
description={t(
'Configure monitoring status page groups for the dashboard'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingGroup ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
{t('Cancel')}
</Button>
<Button type='submit' form={UPTIME_KUMA_FORM_ID}>
{editingGroup ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={UPTIME_KUMA_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -20,12 +20,7 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatTimestampToDate } from '@/lib/format'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getAffinityUsageCache } from './api'
function formatRate(hit: number, total: number): string {
@@ -135,38 +130,42 @@ export function CacheStatsDialog(props: Props) {
}, [stats, props.target, t])
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Channel Affinity: Upstream Cache Hit')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
)}
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='font-medium'>{row.value}</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Channel Affinity: Upstream Cache Hit')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
)}
</DialogContent>
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between gap-4 border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='text-right font-medium break-all'>
{row.value}
</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
)}
</Dialog>
)
}
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,7 +40,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
@@ -82,6 +82,43 @@ function RuleBadgeList(props: { items: string[] }) {
)
}
function ChannelAffinityConfirmDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
desc: ReactNode
handleConfirm: () => void
destructive?: boolean
}) {
const { t } = useTranslation()
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={props.title}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='flex items-start'
footer={
<>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button
variant={props.destructive ? 'destructive' : 'default'}
onClick={props.handleConfirm}
>
{t('Continue')}
</Button>
</>
}
>
<div className='text-muted-foreground text-sm'>{props.desc}</div>
</Dialog>
)
}
function serializeRules(rules: AffinityRule[]): string {
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
}
@@ -641,7 +678,7 @@ export function ChannelAffinitySection(props: Props) {
templateKey={ruleTemplateKey}
/>
<ConfirmDialog
<ChannelAffinityConfirmDialog
open={clearAllDialogOpen}
onOpenChange={setClearAllDialogOpen}
title={t('Confirm clearing all channel affinity cache')}
@@ -653,7 +690,7 @@ export function ChannelAffinitySection(props: Props) {
/>
{clearRuleName !== null && (
<ConfirmDialog
<ChannelAffinityConfirmDialog
open
onOpenChange={(v) => !v && setClearRuleName(null)}
title={t('Confirm clearing cache for this rule')}
@@ -663,7 +700,7 @@ export function ChannelAffinitySection(props: Props) {
/>
)}
<ConfirmDialog
<ChannelAffinityConfirmDialog
open={fillTemplateDialogOpen}
onOpenChange={setFillTemplateDialogOpen}
title={t('Fill Codex CLI / Claude CLI Templates')}
@@ -27,13 +27,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -46,6 +39,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types'
@@ -69,6 +63,8 @@ const CONTEXT_KEY_PRESETS = [
'specific_channel_id',
]
const RULE_FORM_ID = 'channel-affinity-rule-form'
interface RuleFormValues {
name: string
model_regex_text: string
@@ -230,228 +226,230 @@ export function RuleEditorDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-h-[85vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{isEdit ? t('Edit Rule') : t('Add Rule')}</DialogTitle>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={isEdit ? t('Edit Rule') : t('Add Rule')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='pr-2'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' form={RULE_FORM_ID}>
{t('Save')}
</Button>
</>
}
>
<form
id={RULE_FORM_ID}
onSubmit={form.handleSubmit(handleSave)}
className='min-w-0 space-y-4 overflow-x-clip'
>
<div className='grid gap-1.5'>
<Label>{t('Name')} *</Label>
<Input
placeholder='prefer-by-conversation-id'
{...form.register('name', { required: true })}
/>
</div>
<form onSubmit={form.handleSubmit(handleSave)} className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='grid gap-1.5'>
<Label>{t('Name')} *</Label>
<Input
placeholder='prefer-by-conversation-id'
{...form.register('name', { required: true })}
<Label>{t('Model Regex (one per line)')} *</Label>
<Textarea
rows={4}
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
{...form.register('model_regex_text', { required: true })}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Model Regex (one per line)')} *</Label>
<Textarea
rows={4}
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
{...form.register('model_regex_text', { required: true })}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Path Regex (one per line)')}</Label>
<Textarea
rows={4}
placeholder='/v1/chat/completions'
{...form.register('path_regex_text')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Path Regex (one per line)')}</Label>
<Textarea
rows={4}
placeholder='/v1/chat/completions'
{...form.register('path_regex_text')}
/>
</div>
</div>
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<Separator />
<Separator />
{/* Key Sources */}
<div>
<div className='mb-2 flex items-center justify-between'>
<Label>{t('Key Sources')}</Label>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setKeySources((prev) => [
...prev,
{ type: 'gjson', path: '' },
])
}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add')}
</Button>
</div>
<p className='text-muted-foreground mb-2 text-xs'>
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
</p>
<div className='space-y-2'>
{keySources.map((src, idx) => (
<div key={idx} className='flex items-center gap-2'>
<Select
items={[
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
value={src.type}
onValueChange={(v) => {
if (v === null) return
const next = [...keySources]
next[idx] = normalizeKeySource({
...src,
type: v as KeySource['type'],
})
setKeySources(next)
}}
>
<SelectTrigger className='w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{KEY_SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
className='flex-1'
placeholder={
src.type === 'gjson'
? 'metadata.conversation_id'
: 'user_id'
}
value={
src.type === 'gjson' ? src.path || '' : src.key || ''
}
onChange={(e) => {
const next = [...keySources]
if (src.type === 'gjson') {
next[idx] = { ...src, path: e.target.value }
} else {
next[idx] = { ...src, key: e.target.value }
}
setKeySources(next)
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() =>
setKeySources((prev) => prev.filter((_, i) => i !== idx))
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
</div>
<Separator />
{/* Advanced */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
className='w-full justify-start'
/>
}
>
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
</CollapsibleTrigger>
<CollapsibleContent className='space-y-3 pt-2'>
<div className='grid gap-1.5'>
<Label>{t('User-Agent include (one per line)')}</Label>
<Textarea
rows={3}
placeholder='curl&#10;PostmanRuntime'
{...form.register('user_agent_include_text')}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Value Regex')}</Label>
<Input
placeholder='^[-0-9A-Za-z._:]{1,128}$'
{...form.register('value_regex')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('TTL (seconds, 0 = default)')}</Label>
<Input
type='number'
min={0}
{...form.register('ttl_seconds')}
/>
</div>
</div>
<div className='grid gap-1.5'>
<Label>{t('Parameter Override Template (JSON)')}</Label>
<Textarea
rows={5}
placeholder='{"operations": [...]}'
{...form.register('param_override_template_json')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
<SettingsSwitchField
checked={form.watch('include_using_group')}
onCheckedChange={(v) =>
form.setValue('include_using_group', v)
}
label={t('Include Group')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) =>
form.setValue('include_model_name', v)
}
label={t('Include Model')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
</div>
</CollapsibleContent>
</Collapsible>
<DialogFooter>
{/* Key Sources */}
<div>
<div className='mb-2 flex items-center justify-between'>
<Label>{t('Key Sources')}</Label>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
size='sm'
onClick={() =>
setKeySources((prev) => [...prev, { type: 'gjson', path: '' }])
}
>
{t('Cancel')}
<Plus className='mr-1 h-3 w-3' />
{t('Add')}
</Button>
<Button type='submit'>{t('Save')}</Button>
</DialogFooter>
</form>
</DialogContent>
</div>
<p className='text-muted-foreground mb-2 text-xs'>
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
</p>
<div className='space-y-2'>
{keySources.map((src, idx) => (
<div
key={idx}
className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center'
>
<Select
items={[
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
value={src.type}
onValueChange={(v) => {
if (v === null) return
const next = [...keySources]
next[idx] = normalizeKeySource({
...src,
type: v as KeySource['type'],
})
setKeySources(next)
}}
>
<SelectTrigger className='w-full sm:w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{KEY_SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
className='min-w-0 flex-1'
placeholder={
src.type === 'gjson'
? 'metadata.conversation_id'
: 'user_id'
}
value={src.type === 'gjson' ? src.path || '' : src.key || ''}
onChange={(e) => {
const next = [...keySources]
if (src.type === 'gjson') {
next[idx] = { ...src, path: e.target.value }
} else {
next[idx] = { ...src, key: e.target.value }
}
setKeySources(next)
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() =>
setKeySources((prev) => prev.filter((_, i) => i !== idx))
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
</div>
<Separator />
{/* Advanced */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
className='w-full justify-start'
/>
}
>
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
</CollapsibleTrigger>
<CollapsibleContent className='space-y-3 pt-2'>
<div className='grid gap-1.5'>
<Label>{t('User-Agent include (one per line)')}</Label>
<Textarea
rows={3}
placeholder='curl&#10;PostmanRuntime'
{...form.register('user_agent_include_text')}
/>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='grid gap-1.5'>
<Label>{t('Value Regex')}</Label>
<Input
placeholder='^[-0-9A-Za-z._:]{1,128}$'
{...form.register('value_regex')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('TTL (seconds, 0 = default)')}</Label>
<Input
type='number'
min={0}
{...form.register('ttl_seconds')}
/>
</div>
</div>
<div className='grid gap-1.5'>
<Label>{t('Parameter Override Template (JSON)')}</Label>
<Textarea
rows={5}
placeholder='{"operations": [...]}'
{...form.register('param_override_template_json')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
<SettingsSwitchField
checked={form.watch('include_using_group')}
onCheckedChange={(v) => form.setValue('include_using_group', v)}
label={t('Include Group')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) => form.setValue('include_model_name', v)}
label={t('Include Model')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createAmountDiscountDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -57,6 +50,8 @@ type AmountDiscountDialogFormValues = z.infer<
ReturnType<typeof createAmountDiscountDialogSchema>
>
const AMOUNT_DISCOUNT_FORM_ID = 'amount-discount-form'
export type AmountDiscountData = {
amount: number
discountRate: number
@@ -115,102 +110,103 @@ export function AmountDiscountDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit discount tier') : t('Add discount tier')}
</DialogTitle>
<DialogDescription>
{t('Set a discount rate for a specific recharge amount threshold.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit discount tier') : t('Add discount tier')}
description={t(
'Set a discount rate for a specific recharge amount threshold.'
)}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
<FormControl>
<Input
type='number'
step='1'
min='1'
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Amount cannot be changed when editing.')
: t(
'Minimum recharge amount to qualify for this discount.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={AMOUNT_DISCOUNT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={AMOUNT_DISCOUNT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
<FormControl>
<Input
type='number'
step='1'
min='1'
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Amount cannot be changed when editing.')
: t(
'Minimum recharge amount to qualify for this discount.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='discountRate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Discount Rate')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0.01'
max='1'
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
= {discountPercentage}
{t('% off')}
</span>
)}
)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='discountRate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Discount Rate')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0.01'
max='1'
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
= {discountPercentage}
{t('% off')}
</span>
)}
)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -48,6 +40,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import type { CreemProduct } from '@/features/wallet/types'
import { safeNumberFieldProps } from '../utils/numeric-field'
@@ -61,6 +54,8 @@ const creemProductDialogSchema = z.object({
type CreemProductDialogFormValues = z.infer<typeof creemProductDialogSchema>
const CREEM_PRODUCT_FORM_ID = 'creem-product-form'
// Re-export for backwards compatibility
export type CreemProductData = CreemProduct
@@ -119,150 +114,149 @@ export function CreemProductDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit product') : t('Add product')}
</DialogTitle>
<DialogDescription>
{t('Configure a Creem product for user recharge options.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit product') : t('Add product')}
description={t('Configure a Creem product for user recharge options.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' form={CREEM_PRODUCT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={CREEM_PRODUCT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Basic Package')} {...field} />
</FormControl>
<FormDescription>
{t('Display name shown to users.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='productId'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., prod_xxx')}
disabled={isEditMode}
{...field}
/>
</FormControl>
<FormDescription>
{t('Creem product ID from your Creem dashboard.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
name='currency'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Basic Package')} {...field} />
</FormControl>
<FormDescription>
{t('Display name shown to users.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='productId'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., prod_xxx')}
disabled={isEditMode}
{...field}
/>
</FormControl>
<FormDescription>
{t('Creem product ID from your Creem dashboard.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='currency'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Currency')}</FormLabel>
<Select
items={[
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select currency')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='USD'>USD ($)</SelectItem>
<SelectItem value='EUR'>EUR ()</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Price')}</FormLabel>
<FormLabel>{t('Currency')}</FormLabel>
<Select
items={[
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<Input
type='number'
step='0.01'
min={0.01}
placeholder='10.00'
{...safeNumberFieldProps(field)}
/>
<SelectTrigger>
<SelectValue placeholder={t('Select currency')} />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='USD'>USD ($)</SelectItem>
<SelectItem value='EUR'>EUR ()</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota'
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota')}</FormLabel>
<FormLabel>{t('Price')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder={t('e.g., 500000')}
step='0.01'
min={0.01}
placeholder='10.00'
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Amount of quota to credit to user account.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='quota'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder={t('e.g., 500000')}
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Amount of quota to credit to user account.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -23,14 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -41,6 +33,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -54,6 +47,8 @@ type PaymentMethodDialogFormValues = z.infer<
ReturnType<typeof createPaymentMethodDialogSchema>
>
const PAYMENT_METHOD_FORM_ID = 'payment-method-form'
export type PaymentMethodData = {
name: string
type: string
@@ -169,134 +164,133 @@ export function PaymentMethodDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit payment method') : t('Add payment method')}
</DialogTitle>
<DialogDescription>
{t('Configure a payment method for user recharge options.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit payment method') : t('Add payment method')}
description={t('Configure a payment method for user recharge options.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
</FormControl>
<FormDescription>
{t('Display name for this payment method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={PAYMENT_METHOD_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={PAYMENT_METHOD_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
</FormControl>
<FormDescription>
{t('Display name for this payment method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<FormControl>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<FormControl>
<Combobox
options={PAYMENT_TYPES}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter payment type')}
searchPlaceholder={t('Search payment types...')}
allowCustomValue
/>
</FormControl>
<FormDescription>
{t('Select from presets or type custom identifier.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Color')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Combobox
options={PAYMENT_TYPES}
options={COLOR_PRESETS}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter payment type')}
searchPlaceholder={t('Search payment types...')}
placeholder={t('Select or enter color value')}
searchPlaceholder={t('Search colors...')}
allowCustomValue
className='flex-1'
/>
</FormControl>
<FormDescription>
{t('Select from presets or type custom identifier.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Color')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Combobox
options={COLOR_PRESETS}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter color value')}
searchPlaceholder={t('Search colors...')}
allowCustomValue
className='flex-1'
{colorPreview && (
<div
className='size-9 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
title={colorPreview}
/>
{colorPreview && (
<div
className='size-9 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
title={colorPreview}
/>
)}
</div>
</FormControl>
<FormDescription>
{t('Select preset or enter custom CSS color value.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</FormControl>
<FormDescription>
{t('Select preset or enter custom CSS color value.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='min_topup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
placeholder={t('e.g., 50')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional minimum recharge amount for this method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='min_topup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
placeholder={t('e.g., 50')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional minimum recharge amount for this method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
@@ -41,6 +34,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
export interface WaffoSettingsValues {
@@ -411,101 +405,16 @@ export function WaffoSettingsSection({
</div>
</div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingIdx === -1
? t('Add payment method')
: t('Edit payment method')}
</DialogTitle>
</DialogHeader>
<div className='space-y-3'>
<div className='grid gap-1.5'>
<Label>{t('Display name')} *</Label>
<Input
value={methodForm.name}
onChange={(e) =>
setMethodForm((p) => ({ ...p, name: e.target.value }))
}
/>
</div>
<div className='grid gap-2'>
<Label>{t('Icon')}</Label>
<div className='flex items-center gap-3'>
{methodForm.icon ? (
<img
src={methodForm.icon}
alt={methodForm.name || t('Icon')}
className='h-10 w-10 rounded border object-contain p-1'
/>
) : (
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
{t('Icon')}
</div>
)}
<input
ref={iconFileInputRef}
type='file'
accept='image/png,image/jpeg,image/svg+xml,image/webp'
className='hidden'
onChange={handleIconFileChange}
/>
<Button
type='button'
variant='outline'
onClick={() => iconFileInputRef.current?.click()}
>
{t('Upload')}
</Button>
{methodForm.icon ? (
<Button
type='button'
variant='outline'
onClick={() =>
setMethodForm((previous) => ({
...previous,
icon: '',
}))
}
>
{t('Clear')}
</Button>
) : null}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
)}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method type')}</Label>
<Input
value={methodForm.payMethodType}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodType: e.target.value,
}))
}
placeholder='CREDITCARD,DEBITCARD'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method name')}</Label>
<Input
value={methodForm.payMethodName}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodName: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={methodDialogOpen}
onOpenChange={setMethodDialogOpen}
title={
editingIdx === -1 ? t('Add payment method') : t('Edit payment method')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -516,8 +425,94 @@ export function WaffoSettingsSection({
<Button type='button' onClick={saveMethod}>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3'>
<div className='grid gap-1.5'>
<Label>{t('Display name')} *</Label>
<Input
value={methodForm.name}
onChange={(e) =>
setMethodForm((p) => ({ ...p, name: e.target.value }))
}
/>
</div>
<div className='grid gap-2'>
<Label>{t('Icon')}</Label>
<div className='flex items-center gap-3'>
{methodForm.icon ? (
<img
src={methodForm.icon}
alt={methodForm.name || t('Icon')}
className='h-10 w-10 rounded border object-contain p-1'
/>
) : (
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
{t('Icon')}
</div>
)}
<input
ref={iconFileInputRef}
type='file'
accept='image/png,image/jpeg,image/svg+xml,image/webp'
className='hidden'
onChange={handleIconFileChange}
/>
<Button
type='button'
variant='outline'
onClick={() => iconFileInputRef.current?.click()}
>
{t('Upload')}
</Button>
{methodForm.icon ? (
<Button
type='button'
variant='outline'
onClick={() =>
setMethodForm((previous) => ({
...previous,
icon: '',
}))
}
>
{t('Clear')}
</Button>
) : null}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
)}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method type')}</Label>
<Input
value={methodForm.payMethodType}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodType: e.target.value,
}))
}
placeholder='CREDITCARD,DEBITCARD'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method name')}</Label>
<Input
value={methodForm.payMethodName}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodName: e.target.value,
}))
}
/>
</div>
</div>
</Dialog>
</>
)
@@ -22,15 +22,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatTimestamp, formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { Dialog } from '@/components/dialog'
import { SettingsSection } from '../components/settings-section'
type ReleaseInfo = {
@@ -140,38 +133,29 @@ export function UpdateCheckerSection({
</div>
</SettingsSection>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='max-h-[80vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{release?.tag_name
? t('New version available: {{version}}', {
version: release.tag_name,
})
: t('Release details')}
</DialogTitle>
{release?.published_at && (
<DialogDescription>
{t('Published')}{' '}
{formatTimestampToDate(
new Date(release.published_at).getTime(),
'milliseconds'
)}
</DialogDescription>
)}
</DialogHeader>
<div className='space-y-4'>
{release?.body ? (
<Markdown>{release.body}</Markdown>
) : (
<p className='text-muted-foreground text-sm'>
{t('No release notes provided.')}
</p>
)}
</div>
<DialogFooter>
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={
release?.tag_name
? t('New version available: {{version}}', {
version: release.tag_name,
})
: t('Release details')
}
description={
release?.published_at
? `${t('Published')} ${formatTimestampToDate(
new Date(release.published_at).getTime(),
'milliseconds'
)}`
: undefined
}
contentClassName='max-h-[80vh] overflow-y-auto'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='secondary'
@@ -185,8 +169,18 @@ export function UpdateCheckerSection({
{t('Open release')}
</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{release?.body ? (
<Markdown>{release.body}</Markdown>
) : (
<p className='text-muted-foreground text-sm'>
{t('No release notes provided.')}
</p>
)}
</div>
</Dialog>
</>
)
@@ -30,14 +30,6 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -56,6 +48,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import type { UpstreamChannel } from '../types'
import {
@@ -330,87 +323,89 @@ export function ChannelSelectorDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'>
<DialogHeader>
<DialogTitle>{t('Select Sync Channels')}</DialogTitle>
<DialogDescription>
{t('Choose channels to sync upstream ratio configurations from')}
</DialogDescription>
</DialogHeader>
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by name or URL...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='ps-8'
/>
</div>
</div>
<div className='flex-1 overflow-auto rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Select Sync Channels')}
description={t(
'Choose channels to sync upstream ratio configurations from'
)}
contentClassName='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm}>{t('Confirm Selection')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by name or URL...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='ps-8'
/>
</div>
</div>
<div className='flex-1 overflow-auto rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
</Dialog>
)
}
@@ -33,14 +33,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -51,6 +43,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { safeJsonParse } from '../utils/json-parser'
type GroupRatioVisualEditorProps = {
@@ -677,25 +670,15 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
/>
{/* Auto Group Dialog */}
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add auto group')}</DialogTitle>
<DialogDescription>
{t('Add a group identifier to the auto assignment list.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group identifier')}</Label>
<Input
value={autoGroupInput}
onChange={(e) => setAutoGroupInput(e.target.value)}
placeholder={t('default')}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={autoGroupDialogOpen}
onOpenChange={setAutoGroupDialogOpen}
title={t('Add auto group')}
description={t('Add a group identifier to the auto assignment list.')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => setAutoGroupDialogOpen(false)}
@@ -703,30 +686,33 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Cancel')}
</Button>
<Button onClick={handleAutoGroupSave}>{t('Add')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group identifier')}</Label>
<Input
value={autoGroupInput}
onChange={(e) => setAutoGroupInput(e.target.value)}
placeholder={t('default')}
/>
</div>
</div>
</Dialog>
{/* User Group Dialog */}
<Dialog open={userGroupDialogOpen} onOpenChange={setUserGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add user group')}</DialogTitle>
<DialogDescription>
{t('Create a new user group to configure ratio overrides for.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('User group name')}</Label>
<Input
value={userGroupInput}
onChange={(e) => setUserGroupInput(e.target.value)}
placeholder={t('vip')}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={userGroupDialogOpen}
onOpenChange={setUserGroupDialogOpen}
title={t('Add user group')}
description={t(
'Create a new user group to configure ratio overrides for.'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => setUserGroupDialogOpen(false)}
@@ -734,8 +720,19 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Cancel')}
</Button>
<Button onClick={handleUserGroupSave}>{t('Add')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('User group name')}</Label>
<Input
value={userGroupInput}
onChange={(e) => setUserGroupInput(e.target.value)}
placeholder={t('vip')}
/>
</div>
</div>
</Dialog>
{/* Group Override Dialog */}
@@ -1016,51 +1013,52 @@ function SimpleGroupDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editData
? t('Edit {{title}}', { title })
: t('Add {{title}}', { title })}
</DialogTitle>
<DialogDescription>
{t('Configure the ratio for this group.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('default')}
disabled={!!editData}
/>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={value}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setValue(val)
}
}}
placeholder='1.0'
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
editData
? t('Edit {{title}}', { title })
: t('Add {{title}}', { title })
}
description={t('Configure the ratio for this group.')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('default')}
disabled={!!editData}
/>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={value}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setValue(val)
}
}}
placeholder='1.0'
/>
</div>
</div>
</Dialog>
)
}
@@ -1107,65 +1105,66 @@ function GroupOverrideDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editData ? t('Edit ratio override') : t('Add ratio override')}
</DialogTitle>
<DialogDescription>
{userGroup
? t(
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
{ userGroup }
)
: t(
'Configure a custom ratio for when users use a specific token group.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Target group')}</Label>
<Input
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
placeholder={t('edit_this')}
disabled={!!editData}
/>
<p className='text-muted-foreground text-xs'>
{t('The token group that will have a custom ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setRatio(val)
}
}}
placeholder='0.9'
/>
<p className='text-muted-foreground text-xs'>
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
userGroup: userGroup || t('this user group'),
targetGroup: targetGroup || t('this token group'),
})}
</p>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={editData ? t('Edit ratio override') : t('Add ratio override')}
description={
userGroup
? t(
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
{ userGroup }
)
: t(
'Configure a custom ratio for when users use a specific token group.'
)
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Target group')}</Label>
<Input
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
placeholder={t('edit_this')}
disabled={!!editData}
/>
<p className='text-muted-foreground text-xs'>
{t('The token group that will have a custom ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setRatio(val)
}
}}
placeholder='0.9'
/>
<p className='text-muted-foreground text-xs'>
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
userGroup: userGroup || t('this user group'),
targetGroup: targetGroup || t('this token group'),
})}
</p>
</div>
</div>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const rateLimitDialogSchema = z.object({
groupName: z.string().min(1, 'Group name is required'),
@@ -55,6 +48,8 @@ const rateLimitDialogSchema = z.object({
type RateLimitDialogFormValues = z.infer<typeof rateLimitDialogSchema>
const RATE_LIMIT_FORM_ID = 'rate-limit-form'
export type RateLimitEntryData = {
groupName: string
maxRequests: number
@@ -105,126 +100,125 @@ export function RateLimitDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode
? t('Edit group rate limit')
: t('Add group rate limit')}
</DialogTitle>
<DialogDescription>
{t('Configure rate limiting rules for a specific user group.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
isEditMode ? t('Edit group rate limit') : t('Add group rate limit')
}
description={t(
'Configure rate limiting rules for a specific user group.'
)}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='groupName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group Name')}</FormLabel>
<FormControl>
{t('Cancel')}
</Button>
<Button type='submit' form={RATE_LIMIT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={RATE_LIMIT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='groupName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., default, vip, premium')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Group name cannot be changed when editing.')
: t('Unique identifier for this group.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxRequests'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Requests (including failures)')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
placeholder={t('e.g., default, vip, premium')}
type='number'
min={0}
max={2147483647}
step={1}
{...field}
disabled={isEditMode}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Group name cannot be changed when editing.')
: t('Unique identifier for this group.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Total requests allowed per period. 0 = unlimited.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxRequests'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Max Requests (including failures)')}
</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={0}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Total requests allowed per period. 0 = unlimited.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxSuccess'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Successful Requests')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={1}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Only successful requests count toward this limit.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='maxSuccess'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Successful Requests')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={1}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Only successful requests count toward this limit.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -21,13 +21,8 @@ import { ExternalLink, Copy, Music } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
export interface AudioClip {
@@ -152,32 +147,33 @@ export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
const clips = Array.isArray(props.clips) ? props.clips : []
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</DialogTitle>
</DialogHeader>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard
key={clip.clip_id || clip.id || idx}
clip={clip}
/>
))}
</div>
</ScrollArea>
)}
</DialogContent>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</>
}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
))}
</div>
</ScrollArea>
)}
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
@@ -20,15 +20,9 @@ import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface FailReasonDialogProps {
failReason: string
@@ -45,43 +39,42 @@ export function FailReasonDialog({
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Fail Reason Details')}</DialogTitle>
<DialogDescription>
{t('View the complete error message and details')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(failReason)}
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Fail Reason Details')}
description={t('View the complete error message and details')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(failReason)}
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
</div>
</ScrollArea>
</DialogContent>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
interface ImageDialogProps {
imageUrl: string
@@ -65,56 +59,55 @@ export function ImageDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Image Preview')}</DialogTitle>
<DialogDescription>
{taskId
? `${t('Task ID:')} ${taskId}`
: t('View the generated image')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Image Preview')}
description={
taskId ? `${t('Task ID:')} ${taskId}` : t('View the generated image')
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -20,15 +20,9 @@ import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface PromptDialogProps {
prompt: string
@@ -47,69 +41,68 @@ export function PromptDialog({
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Prompt Details')}</DialogTitle>
<DialogDescription>
{t('View the complete prompt and its English translation')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Prompt Details')}
description={t('View the complete prompt and its English translation')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(prompt)}
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
</p>
</div>
</div>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(prompt)}
onClick={() => copyToClipboard(promptEn)}
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
{promptEn}
</p>
</div>
</div>
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(promptEn)}
title={t('Copy to clipboard')}
>
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{promptEn}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -21,14 +21,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatQuota, formatCompactNumber } from '@/lib/format'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { getUserInfo } from '../../api'
import type { UserInfo } from '../../types'
@@ -88,104 +82,103 @@ export function UserInfoDialog({
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('User Information')}</DialogTitle>
<DialogDescription>
{t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('User Information')}
description={t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
)}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</div>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</DialogContent>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</Dialog>
)
}
@@ -33,13 +33,6 @@ import { SiGithub, SiDiscord } from 'react-icons/si'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
@@ -49,6 +42,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getUser,
@@ -318,121 +312,124 @@ export function UserBindingDialog(props: Props) {
return (
<>
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Link2 className='h-5 w-5' />
{t('Account Binding Management')}
</DialogTitle>
<DialogDescription className='sr-only'>
{t('Manage account bindings for this user')}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Link2 className='h-5 w-5' />
{t('Account Binding Management')}
</>
}
description={t('Manage account bindings for this user')}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
descriptionClassName='sr-only'
contentHeight='auto'
bodyClassName='space-y-4'
>
{loading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
{user && (
<p className='text-muted-foreground text-sm'>
{user.username} (ID: {user.id})
</p>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
onClick={() => setShowBoundOnly((v) => !v)}
/>
}
>
{showBoundOnly ? (
<Eye className='h-3.5 w-3.5' />
) : (
<EyeOff className='h-3.5 w-3.5' />
)}
{showBoundOnly ? t('Show All') : t('Bound Only')}
</TooltipTrigger>
<TooltipContent>
{showBoundOnly
? t('Show all providers including unbound')
: t('Show only bound providers')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
{user && (
<p className='text-muted-foreground text-sm'>
{user.username} (ID: {user.id})
</p>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Separator />
<ScrollArea className='max-h-[50vh]'>
{displayedBindings.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{showBoundOnly
? t('This user has no bindings')
: t('No providers available')}
</p>
) : (
<div className='grid grid-cols-1 gap-2 pr-3 lg:grid-cols-2'>
{displayedBindings.map((binding) => (
<div
key={binding.key}
className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${
!binding.isBound ? 'opacity-50' : ''
}`}
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='text-muted-foreground shrink-0'>
{binding.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>
{binding.label}
</span>
{!binding.isEnabled && (
<StatusBadge
variant='neutral'
label={t('Disabled')}
copyable={false}
size='sm'
/>
)}
</div>
<p className='text-muted-foreground max-w-[140px] truncate text-xs'>
{binding.isBound ? binding.value : t('Not bound')}
</p>
</div>
</div>
{binding.isBound && (
<Button
variant='ghost'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
onClick={() => setShowBoundOnly((v) => !v)}
/>
}
>
{showBoundOnly ? (
<Eye className='h-3.5 w-3.5' />
) : (
<EyeOff className='h-3.5 w-3.5' />
className='text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='h-3.5 w-3.5' />
</Button>
)}
{showBoundOnly ? t('Show All') : t('Bound Only')}
</TooltipTrigger>
<TooltipContent>
{showBoundOnly
? t('Show all providers including unbound')
: t('Show only bound providers')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</ScrollArea>
<Separator />
<ScrollArea className='max-h-[50vh]'>
{displayedBindings.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{showBoundOnly
? t('This user has no bindings')
: t('No providers available')}
</p>
) : (
<div className='grid grid-cols-1 gap-2 pr-3 lg:grid-cols-2'>
{displayedBindings.map((binding) => (
<div
key={binding.key}
className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${
!binding.isBound ? 'opacity-50' : ''
}`}
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='text-muted-foreground shrink-0'>
{binding.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>
{binding.label}
</span>
{!binding.isEnabled && (
<StatusBadge
variant='neutral'
label={t('Disabled')}
copyable={false}
size='sm'
/>
)}
</div>
<p className='text-muted-foreground max-w-[140px] truncate text-xs'>
{binding.isBound ? binding.value : t('Not bound')}
</p>
</div>
</div>
{binding.isBound && (
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='h-3.5 w-3.5' />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<p className='text-muted-foreground text-xs'>
{t('Bound')}: {boundCount} / {allBindings.length}
</p>
</div>
)}
</DialogContent>
<p className='text-muted-foreground text-xs'>
{t('Bound')}: {boundCount} / {allBindings.length}
</p>
</div>
)}
</Dialog>
<ConfirmDialog
@@ -23,16 +23,9 @@ import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { adjustUserQuota } from '../api'
import type { QuotaAdjustMode } from '../types'
@@ -115,73 +108,72 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
: t('Enter amount in {{currency}}', { currency: currencyLabel })
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Adjust Quota')}</DialogTitle>
<DialogDescription>
{t('Select an operation mode and enter the amount')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{getPreviewText()}
</div>
<div className='space-y-2'>
<Label>{t('Mode')}</Label>
<div className='flex gap-1'>
{(['add', 'subtract', 'override'] as const).map((m) => (
<Button
key={m}
type='button'
variant='outline'
size='sm'
className={cn(
mode === m &&
'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
)}
onClick={() => {
setMode(m)
setAmount('')
}}
>
{m === 'add'
? t('Add')
: m === 'subtract'
? t('Subtract')
: t('Override')}
</Button>
))}
</div>
</div>
<div className='space-y-2'>
<Label>
{t('Amount')} ({currencyLabel})
</Label>
<Input
type='number'
step={tokensOnly ? 1 : 0.000001}
min={mode === 'override' ? undefined : 0}
placeholder={placeholder}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm()
}}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Adjust Quota')}
description={t('Select an operation mode and enter the amount')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm} disabled={loading}>
{loading ? t('Processing...') : t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>{getPreviewText()}</div>
<div className='space-y-2'>
<Label>{t('Mode')}</Label>
<div className='flex gap-1'>
{(['add', 'subtract', 'override'] as const).map((m) => (
<Button
key={m}
type='button'
variant='outline'
size='sm'
className={cn(
mode === m &&
'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
)}
onClick={() => {
setMode(m)
setAmount('')
}}
>
{m === 'add'
? t('Add')
: m === 'subtract'
? t('Subtract')
: t('Override')}
</Button>
))}
</div>
</div>
<div className='space-y-2'>
<Label>
{t('Amount')} ({currencyLabel})
</Label>
<Input
type='number'
step={tokensOnly ? 1 : 0.000001}
min={mode === 'override' ? undefined : 0}
placeholder={placeholder}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm()
}}
/>
</div>
</div>
</Dialog>
)
}
@@ -33,13 +33,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -52,6 +45,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { useBillingHistory } from '../../hooks/use-billing-history'
import {
@@ -101,222 +95,223 @@ export function BillingHistoryDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>{t('Billing History')}</DialogTitle>
<DialogDescription>
{t('View your topup transaction records and payment history')}
</DialogDescription>
</DialogHeader>
<div className='min-h-0 flex-1 space-y-3 sm:space-y-4'>
{/* Search and Filter Bar */}
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by order number...')}
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
className='h-9 pl-10'
/>
</div>
<Select
items={[
{ value: '10', label: t('10 / page') },
{ value: '20', label: t('20 / page') },
{ value: '50', label: t('50 / page') },
{ value: '100', label: t('100 / page') },
]}
value={pageSize.toString()}
onValueChange={(value) =>
value !== null && handlePageSizeChange(parseInt(value))
}
>
<SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='10'>{t('10 / page')}</SelectItem>
<SelectItem value='20'>{t('20 / page')}</SelectItem>
<SelectItem value='50'>{t('50 / page')}</SelectItem>
<SelectItem value='100'>{t('100 / page')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Billing History')}
description={t(
'View your topup transaction records and payment history'
)}
contentClassName='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'
contentHeight='auto'
bodyClassName='space-y-3'
>
<div className='min-h-0 space-y-3'>
{/* Search and Filter Bar */}
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by order number...')}
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
className='h-9 pl-10'
/>
</div>
<Select
items={[
{ value: '10', label: t('10 / page') },
{ value: '20', label: t('20 / page') },
{ value: '50', label: t('50 / page') },
{ value: '100', label: t('100 / page') },
]}
value={pageSize.toString()}
onValueChange={(value) =>
value !== null && handlePageSizeChange(parseInt(value))
}
>
<SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='10'>{t('10 / page')}</SelectItem>
<SelectItem value='20'>{t('20 / page')}</SelectItem>
<SelectItem value='50'>{t('50 / page')}</SelectItem>
<SelectItem value='100'>{t('100 / page')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Records List */}
<ScrollArea className='h-[calc(100dvh-15rem)] pr-3 sm:h-[500px] sm:pr-4'>
{loading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='rounded-lg border p-3 sm:p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' />
<Skeleton className='h-3 w-32' />
</div>
<Skeleton className='h-5 w-16' />
</div>
<div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
{/* Records List */}
<ScrollArea className='max-h-[min(54vh,520px)] pr-3 sm:pr-4'>
{loading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='rounded-lg border p-3 sm:p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' />
<Skeleton className='h-3 w-32' />
</div>
<Skeleton className='h-5 w-16' />
</div>
<div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
</div>
))}
</div>
) : records.length === 0 ? (
<div className='text-muted-foreground flex h-[320px] flex-col items-center justify-center text-center sm:h-[400px]'>
<p className='text-sm font-medium'>
{t('No billing records found')}
</p>
<p className='mt-1 text-xs'>
{keyword
? t('Try adjusting your search')
: t('Your transaction history will appear here')}
</p>
</div>
) : (
<div className='space-y-3'>
{records.map((record) => {
const statusConfig = getStatusConfig(record.status)
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground truncate font-mono text-sm'>
{record.trade_no}
</code>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0'
onClick={() => copyToClipboard(record.trade_no)}
>
{copiedText === record.trade_no ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
{isAdmin && record.user_id != null && (
<StatusBadge
label={`${t('User ID')}: ${record.user_id}`}
variant='neutral'
size='sm'
copyText={String(record.user_id)}
/>
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatTimestamp(record.create_time)}
</div>
</div>
<StatusBadge
label={statusConfig.label}
variant={statusConfig.variant}
showDot
copyable={false}
/>
</div>
{/* Details Grid */}
<div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment Method')}
</Label>
<div className='text-sm font-medium'>
{getPaymentMethodName(record.payment_method, t)}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Amount')}
</Label>
<div className='text-sm font-semibold'>
{formatCurrencyFromUSD(record.amount, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment')}
</Label>
<div className='text-sm font-semibold text-red-600'>
{formatNumber(record.money)}
</div>
</div>
</div>
{/* Admin Actions */}
{isAdmin && record.status === 'pending' && (
<div className='mt-4 flex justify-end'>
<Button
size='sm'
variant='outline'
onClick={() => setConfirmTradeNo(record.trade_no)}
disabled={completing}
>
{t('Complete Order')}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</ScrollArea>
{/* Pagination */}
{!loading && records.length > 0 && (
<div className='flex flex-col items-center gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground text-xs sm:text-sm'>
{t('Showing')} {(page - 1) * pageSize + 1}-
{Math.min(page * pageSize, total)} {t('of')} {total}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className='h-8 w-8 p-0'
>
<ChevronLeft className='h-4 w-4' />
</Button>
<div className='text-muted-foreground flex items-center gap-1 text-sm'>
<span className='font-medium'>{page}</span>
<span>/</span>
<span>{totalPages}</span>
</div>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className='h-8 w-8 p-0'
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
))}
</div>
) : records.length === 0 ? (
<div className='text-muted-foreground flex min-h-40 flex-col items-center justify-center py-10 text-center'>
<p className='text-sm font-medium'>
{t('No billing records found')}
</p>
<p className='mt-1 text-xs'>
{keyword
? t('Try adjusting your search')
: t('Your transaction history will appear here')}
</p>
</div>
) : (
<div className='space-y-3'>
{records.map((record) => {
const statusConfig = getStatusConfig(record.status)
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground truncate font-mono text-sm'>
{record.trade_no}
</code>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0'
onClick={() => copyToClipboard(record.trade_no)}
>
{copiedText === record.trade_no ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
{isAdmin && record.user_id != null && (
<StatusBadge
label={`${t('User ID')}: ${record.user_id}`}
variant='neutral'
size='sm'
copyText={String(record.user_id)}
/>
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatTimestamp(record.create_time)}
</div>
</div>
<StatusBadge
label={statusConfig.label}
variant={statusConfig.variant}
showDot
copyable={false}
/>
</div>
{/* Details Grid */}
<div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment Method')}
</Label>
<div className='text-sm font-medium'>
{getPaymentMethodName(record.payment_method, t)}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Amount')}
</Label>
<div className='text-sm font-semibold'>
{formatCurrencyFromUSD(record.amount, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment')}
</Label>
<div className='text-sm font-semibold text-red-600'>
{formatNumber(record.money)}
</div>
</div>
</div>
{/* Admin Actions */}
{isAdmin && record.status === 'pending' && (
<div className='mt-4 flex justify-end'>
<Button
size='sm'
variant='outline'
onClick={() => setConfirmTradeNo(record.trade_no)}
disabled={completing}
>
{t('Complete Order')}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</DialogContent>
</ScrollArea>
{/* Pagination */}
{!loading && records.length > 0 && (
<div className='flex flex-col items-center gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground text-xs sm:text-sm'>
{t('Showing')} {(page - 1) * pageSize + 1}-
{Math.min(page * pageSize, total)} {t('of')} {total}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className='h-8 w-8 p-0'
>
<ChevronLeft className='h-4 w-4' />
</Button>
<div className='text-muted-foreground flex items-center gap-1 text-sm'>
<span className='font-medium'>{page}</span>
<span>/</span>
<span>{totalPages}</span>
</div>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className='h-8 w-8 p-0'
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirm Complete Order Dialog */}
@@ -20,14 +20,7 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { formatCreemPrice } from '../../lib/format'
import type { CreemProduct } from '../../types'
@@ -51,33 +44,17 @@ export function CreemConfirmDialog({
if (!product) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
<DialogDescription>
{t('Review your purchase details before proceeding.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Price')}</span>
<span className='font-medium text-indigo-600'>
{formatCreemPrice(product.price, product.currency)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium'>{formatNumber(product.quota)}</span>
</div>
</div>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Confirm Creem Purchase')}
description={t('Review your purchase details before proceeding.')}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -89,8 +66,25 @@ export function CreemConfirmDialog({
{processing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Confirm Payment')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Price')}</span>
<span className='font-medium text-indigo-600'>
{formatCreemPrice(product.price, product.currency)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium'>{formatNumber(product.quota)}</span>
</div>
</div>
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { QUOTA_PER_DOLLAR } from '../../constants'
interface TransferDialogProps {
@@ -66,51 +59,18 @@ export function TransferDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-xl font-semibold'>
{t('Transfer Rewards')}
</DialogTitle>
<DialogDescription>
{t('Move affiliate rewards to your main balance')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')}
</Label>
<div className='text-2xl font-semibold'>
{formatQuota(availableQuota)}
</div>
</div>
<div className='space-y-3'>
<Label
htmlFor='transfer-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
>
{t('Transfer Amount')}
</Label>
<Input
id='transfer-amount'
type='number'
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={QUOTA_PER_DOLLAR}
max={availableQuota}
step={QUOTA_PER_DOLLAR}
className='font-mono text-lg'
/>
<p className='text-muted-foreground text-xs'>
{t('Minimum:')} {formatQuota(QUOTA_PER_DOLLAR)}
</p>
</div>
</div>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Transfer Rewards')}
description={t('Move affiliate rewards to your main balance')}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'
titleClassName='text-xl font-semibold'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -122,8 +82,41 @@ export function TransferDialog({
{transferring && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Transfer')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')}
</Label>
<div className='text-2xl font-semibold'>
{formatQuota(availableQuota)}
</div>
</div>
<div className='space-y-3'>
<Label
htmlFor='transfer-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
>
{t('Transfer Amount')}
</Label>
<Input
id='transfer-amount'
type='number'
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={QUOTA_PER_DOLLAR}
max={availableQuota}
step={QUOTA_PER_DOLLAR}
className='font-mono text-lg'
/>
<p className='text-muted-foreground text-xs'>
{t('Minimum:')} {formatQuota(QUOTA_PER_DOLLAR)}
</p>
</div>
</div>
</Dialog>
)
}
+9 -2
View File
@@ -645,7 +645,6 @@
"Channel Affinity": "Channel Affinity",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.",
"Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
"Keep affinity when channel is disabled": "Keep affinity when channel is disabled",
"Channel copied successfully": "Channel copied successfully",
"Channel created successfully": "Channel created successfully",
"Channel deleted successfully": "Channel deleted successfully",
@@ -965,6 +964,7 @@
"Copy to clipboard": "Copy to clipboard",
"Copy token": "Copy token",
"Copy URL": "Copy URL",
"Copying...": "Copying...",
"Copywriting, ad creative, SEO": "Copywriting, ad creative, SEO",
"Core concepts": "Core concepts",
"Core Configuration": "Core Configuration",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Filter models by provider, group, type, endpoint, and tags.",
"Filter models by type, endpoint, vendor, group and tags": "Filter models by type, endpoint, vendor, group and tags",
"Filter models...": "Filter models...",
"Filter the model analytics view by time range and user.": "Filter the model analytics view by time range and user.",
"Filter...": "Filter...",
"Filters": "Filters",
"Filters active": "Filters active",
@@ -1997,7 +1998,6 @@
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "If default auto group is enabled, newly created tokens start with auto instead of an empty group.",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.",
"If this keeps happening, please report it on GitHub Issues.": "If this keeps happening, please report it on GitHub Issues.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.",
"Ignored upstream models": "Ignored upstream models",
@@ -2114,6 +2114,7 @@
"Just now": "Just now",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "Keep affinity when channel is disabled",
"Keep enabled if you need to proxy requests for different upstream accounts.": "Keep enabled if you need to proxy requests for different upstream accounts.",
"Keep enough balance before production traffic": "Keep enough balance before production traffic",
"Keep original value": "Keep original value",
@@ -2332,6 +2333,8 @@
"Model": "Model",
"Model Access": "Model Access",
"Model Analytics": "Model Analytics",
"Model Analytics Defaults": "Model Analytics Defaults",
"Model Analytics Filters": "Model Analytics Filters",
"model billing support": "model billing support",
"Model Call Analytics": "Model Call Analytics",
"Model context usage": "Model context usage",
@@ -3616,6 +3619,7 @@
"Set a tag for": "Set a tag for",
"Set API key access restrictions": "Set API key access restrictions",
"Set API key basic information": "Set API key basic information",
"Set default ranges and charts for model analytics.": "Set default ranges and charts for model analytics.",
"Set Field": "Set Field",
"Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.",
"Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "Sync Endpoints",
"Sync Fields": "Sync Fields",
"Sync from the public upstream metadata repository.": "Sync from the public upstream metadata repository.",
"Sync Now": "Sync Now",
"Sync this model with official upstream": "Sync this model with official upstream",
"Sync Upstream": "Sync Upstream",
"Sync Upstream Models": "Sync Upstream Models",
"Synchronize models and vendors from an upstream source": "Synchronize models and vendors from an upstream source",
"Syncing prices, please wait...": "Syncing prices, please wait...",
"Syncing...": "Syncing...",
"System": "System",
"System Administration": "System Administration",
"System Announcements": "System Announcements",
@@ -4460,6 +4466,7 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "When enabled, if channels in the current group fail, it will try channels in the next group in order.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "When enabled, Midjourney callbacks are accepted (reveals server IP).",
"When enabled, newly created tokens start in the first auto group.": "When enabled, newly created tokens start in the first auto group.",
+9 -2
View File
@@ -645,7 +645,6 @@
"Channel Affinity": "Affinité de canal",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.",
"Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont",
"Keep affinity when channel is disabled": "Conserver l'affinité lorsque le canal est désactivé",
"Channel copied successfully": "Canal copié avec succès",
"Channel created successfully": "Canal créé avec succès",
"Channel deleted successfully": "Canal supprimé avec succès",
@@ -965,6 +964,7 @@
"Copy to clipboard": "Copier dans le presse-papiers",
"Copy token": "Copier le Jeton",
"Copy URL": "Copier l'URL",
"Copying...": "Copie...",
"Copywriting, ad creative, SEO": "Rédaction publicitaire, créatif, SEO",
"Core concepts": "Concepts clés",
"Core Configuration": "Configuration principale",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Filtrer les modèles par fournisseur, groupe, type, endpoint et tags.",
"Filter models by type, endpoint, vendor, group and tags": "Filtrer les modèles par type, point d'accès, fournisseur, groupe et tags",
"Filter models...": "Filtrer les modèles...",
"Filter the model analytics view by time range and user.": "Filtrez la vue danalyse des modèles par période et utilisateur.",
"Filter...": "Filtrer...",
"Filters": "Filtres",
"Filters active": "Filtres actifs",
@@ -1997,7 +1998,6 @@
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Si vous vous connectez à des projets de relais One API ou New API en amont, utilisez le type OpenAI à la place sauf si vous savez ce que vous faites",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Si le groupe auto par défaut est activé, les nouveaux jetons commencent avec auto au lieu dun groupe vide.",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Si le canal affinitaire échoue et qu'une nouvelle tentative réussit sur un autre canal, mettre à jour l'affinité vers le canal ayant réussi.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal affinitaire est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Laissez-la désactivée pour supprimer l'entrée et sélectionner un autre canal.",
"If this keeps happening, please report it on GitHub Issues.": "Si cela continue, veuillez le signaler sur GitHub Issues.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Si vous fournissez des services dIA générative au public en Chine continentale, vous remplirez les obligations légales applicables, notamment le dépôt, l’évaluation de sécurité, la sécurité du contenu, le traitement des plaintes, l’étiquetage du contenu généré, la conservation des journaux et la protection des informations personnelles.",
"Ignored upstream models": "Modèles amont ignorés",
@@ -2114,6 +2114,7 @@
"Just now": "À l'instant",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "Conserver l'affinité lorsque le canal est désactivé",
"Keep enabled if you need to proxy requests for different upstream accounts.": "Gardez activé si vous devez proxifier les requêtes pour différents comptes en amont.",
"Keep enough balance before production traffic": "Gardez un solde suffisant avant le trafic de production",
"Keep original value": "Conserver la valeur originale",
@@ -2332,6 +2333,8 @@
"Model": "Modèle",
"Model Access": "Accès au modèle",
"Model Analytics": "Analyse des modèles",
"Model Analytics Defaults": "Paramètres par défaut de lanalyse des modèles",
"Model Analytics Filters": "Filtres danalyse des modèles",
"model billing support": "prise en charge de la facturation des modèles",
"Model Call Analytics": "Analyse des appels de modèles",
"Model context usage": "Utilisation du contexte du modèle",
@@ -3616,6 +3619,7 @@
"Set a tag for": "Définir un tag pour",
"Set API key access restrictions": "Définir les restrictions d'accès de la clé API",
"Set API key basic information": "Définir les informations de base de la clé API",
"Set default ranges and charts for model analytics.": "Définissez les plages et graphiques par défaut pour lanalyse des modèles.",
"Set Field": "Définir le champ",
"Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.",
"Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "Points de synchronisation",
"Sync Fields": "Synchroniser les champs",
"Sync from the public upstream metadata repository.": "Synchroniser depuis le dépôt de métadonnées public en amont.",
"Sync Now": "Synchroniser maintenant",
"Sync this model with official upstream": "Synchroniser ce modèle avec la source amont officielle",
"Sync Upstream": "Synchroniser l'amont",
"Sync Upstream Models": "Synchroniser les modèles amont",
"Synchronize models and vendors from an upstream source": "Synchroniser les modèles et les fournisseurs à partir d'une source amont",
"Syncing prices, please wait...": "Synchronisation des prix, veuillez patienter...",
"Syncing...": "Synchronisation...",
"System": "Système",
"System Administration": "Administration du système",
"System Announcements": "Annonces système",
@@ -4460,6 +4466,7 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Quand un jeton utilise le groupe auto, le système essaie les groupes de haut en bas jusqu’à trouver un groupe disponible.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "Si les conditions sont remplies, le prix final est multiplié par X. Plusieurs correspondances se multiplient ; les valeurs < 1 agissent comme des remises.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Lorsqu'elle est activée, si les canaux du groupe actuel échouent, le système essaiera les canaux du groupe suivant dans l'ordre.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal affinitaire est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Laissez-la désactivée pour supprimer l'entrée et sélectionner un autre canal.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "Lorsqu'activé, les corps de requête volumineux sont temporairement stockés sur disque, réduisant considérablement l'utilisation mémoire. SSD recommandé.",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "Lorsque activé, les callbacks Midjourney sont acceptés (révèle l'IP du serveur).",
"When enabled, newly created tokens start in the first auto group.": "Lorsqu'elle est activée, les jetons nouvellement créés commencent dans le premier groupe automatique.",
+65 -58
View File
@@ -16,8 +16,8 @@
"({{total}} total, {{omit}} omitted)": "(合計 {{total}} 件、{{omit}} 件を省略)",
"(Leave empty to dissolve tag)": "(タグを解除するには空欄のままにしてください)",
"(Optional: redirect model names)": "(オプション: モデル名をリダイレクト)",
"(Override all channels' groups)": "(全チャネルのグループを上書き)",
"(Override all channels' models)": "(全チャネルのモデルを上書き)",
"(Override all channels' groups)": "(全チャネルのグループを上書き)",
"(Override all channels' models)": "(全チャネルのモデルを上書き)",
"[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]": "[{\"ChatGPT\":\"https://chat.openai.com\"},{\"Lobe Chat\":\"https://chat-preview.lobehub.com/?settings={...}\"}]",
"[{\"name\":\"支付宝\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]": "[{\"name\":\"Alipay\",\"type\":\"alipay\",\"color\":\"#1677FF\"}]",
"{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
@@ -140,7 +140,7 @@
"Add {{title}}": "{{title}}を追加",
"Add a group identifier to the auto assignment list.": "自動割り当てリストにグループ識別子を追加します。",
"Add a new API key by providing necessary info.": "必要な情報を提供して新しいAPIキーを追加。",
"Add a new channel by providing the necessary information.": "必要な情報を提供して新しいチャネルを追加。",
"Add a new channel by providing the necessary information.": "必要な情報を提供して新しいチャネルを追加。",
"Add a new model to the system by providing the necessary information.": "必要な情報を提供してシステムに新しいモデルを追加してください。",
"Add a new user by providing necessary info.": "必要な情報を提供して新しいユーザーを追加します。",
"Add a new vendor to the system": "システムに新しいベンダーを追加",
@@ -365,7 +365,7 @@
"Append": "末尾に追加",
"Append mode: New keys will be added to the end of the existing key list": "追記モード: 新しいキーは既存のキー一覧の末尾に追加されます",
"Append Template": "テンプレートを追加",
"Append to channel": "チャネルに追加",
"Append to channel": "チャネルに追加",
"Append to End": "末尾に追加",
"Append to existing keys": "既存のキーに追加",
"Append value to array / string / object end": "配列/文字列/オブジェクトの末尾に値を追加",
@@ -452,7 +452,7 @@
"Auto-discovers endpoints from the provider": "プロバイダーからエンドポイントを自動検出します",
"Auto-fill when one field exists and another is missing": "一方のフィールドがあり他方が欠けている場合に自動補完",
"Auto-retry status codes": "自動リトライするステータスコード",
"Automatically disable channel on repeated failures": "繰り返しの失敗でチャネルを自動的に無効にする",
"Automatically disable channel on repeated failures": "繰り返しの失敗でチャネルを自動的に無効にする",
"Automatically disable channels exceeding this response time": "この応答時間を超えるチャネルを自動的に無効にする",
"Automatically disable channels when tests fail": "テストが失敗したときにチャネルを自動的に無効にする",
"Automatically probe all channels in the background": "バックグラウンドですべてのチャネルを自動的にプローブする",
@@ -510,7 +510,7 @@
"Base Price": "基本価格",
"Base rate limit windows for this account.": "このアカウント向けの基本レート制限ウィンドウ。",
"Base URL": "ベースURL",
"Base URL is required for this channel type": "このチャネルタイプには Base URL が必要です",
"Base URL is required for this channel type": "このチャネルタイプには Base URL が必要です",
"Base URL of your Uptime Kuma instance": "Uptime KumaインスタンスのベースURL",
"Basic Authentication": "基本認証",
"Basic Configuration": "基本設定",
@@ -645,26 +645,25 @@
"Channel Affinity": "チャネルアフィニティ",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。",
"Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
"Keep affinity when channel is disabled": "チャネル無効時にアフィニティを保持",
"Channel copied successfully": "チャネルが正常にコピーされました",
"Channel created successfully": "チャネルが正常に作成されました",
"Channel deleted successfully": "チャネルが正常に削除されました",
"Channel disabled successfully": "チャネルが正常に効化されました",
"Channel enabled successfully": "チャンネルが正常に有効化されました",
"Channel copied successfully": "チャネルが正常にコピーされました",
"Channel created successfully": "チャネルが正常に作成されました",
"Channel deleted successfully": "チャネルが正常に削除されました",
"Channel disabled successfully": "チャネルが正常に無効化されました",
"Channel enabled successfully": "チャネルが正常に効化されました",
"Channel Extra Settings": "チャネル詳細設定",
"Channel ID": "チャネルID",
"Channel key": "チャネルキー",
"Channel key unlocked": "チャネルキーが解除されました",
"Channel key unlocked": "チャネルキーが解除されました",
"Channel models": "チャネルモデル",
"Channel name is required": "チャネル名が必要です",
"Channel test completed": "チャネルテストが完了しました",
"Channel type is required": "チャネルタイプが必要です",
"Channel updated successfully": "チャネルが正常に更新されました",
"Channel-specific settings (JSON format)": "チャネル固有の設定 (JSON 形式)",
"Channel:": "チャネル:",
"Channel name is required": "チャネル名が必要です",
"Channel test completed": "チャネルテストが完了しました",
"Channel type is required": "チャネルタイプが必要です",
"Channel updated successfully": "チャネルが正常に更新されました",
"Channel-specific settings (JSON format)": "チャネル固有の設定 (JSON 形式)",
"Channel:": "チャネル:",
"channel(s)? This action cannot be undone.": "チャネルを削除しますか?この操作は元に戻せません。",
"Channels": "チャネル",
"Channels deleted successfully": "チャネルが正常に削除されました",
"Channels deleted successfully": "チャネルが正常に削除されました",
"Character chat, storytelling, persona": "キャラクター会話・ストーリーテリング・ペルソナ",
"Chart Preferences": "チャートの環境設定",
"Chart Settings": "チャート設定",
@@ -771,8 +770,8 @@
"Codex": "Codex",
"Codex Account & Usage": "Codex アカウントと使用量",
"Codex Authorization": "Codex認証",
"Codex channels do not support batch creation": "Codex チャネルは一括作成をサポートしていません",
"Codex channels use an OAuth JSON credential as the key.": "CodexチャネルはOAuth JSON認証情報をキーとして使用します。",
"Codex channels do not support batch creation": "Codex チャネルは一括作成をサポートしていません",
"Codex channels use an OAuth JSON credential as the key.": "CodexチャネルはOAuth JSON認証情報をキーとして使用します。",
"Codex CLI Header Passthrough": "Codex CLI ヘッダーパススルー",
"Codex credential must be a JSON object with access_token and account_id": "Codex 認証情報は access_token と account_id を含む JSON オブジェクトである必要があります",
"Cohere": "Cohere",
@@ -965,6 +964,7 @@
"Copy to clipboard": "クリップボードにコピー",
"Copy token": "トークンをコピー",
"Copy URL": "URLをコピー",
"Copying...": "コピー中...",
"Copywriting, ad creative, SEO": "コピーライティング・広告クリエイティブ・SEO",
"Core concepts": "基本概念",
"Core Configuration": "コア設定",
@@ -993,7 +993,7 @@
"Create deployment": "デプロイを作成",
"Create Model": "モデルを作成",
"Create multiple API keys at once (random suffix will be added to names)": "一度に複数のAPIキーを一括作成(名前にランダムな接尾辞が追加されます)",
"Create multiple channels from multiple keys": "複数のキーから複数のチャネルを作成",
"Create multiple channels from multiple keys": "複数のキーから複数のチャネルを作成",
"Create multiple redemption codes at once (1-100)": "複数の引き換えコードを一度に作成します (1-100)",
"Create new subscription plan": "新しいサブスクリプションプランを作成",
"Create or update frequently asked questions for users": "ユーザー向けのよくある質問を作成または更新します",
@@ -1047,7 +1047,7 @@
"Custom": "カスタム",
"Custom (seconds)": "カスタム(秒)",
"Custom Amount": "カスタム金額",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "カスタムAPIベースURL。公式チャネルの場合、New APIには組み込みのアドレスがあります。これは、サードパーティのプロキシサイトまたは特別なエンドポイントに対してのみ入力してください。/v1 や末尾のスラッシュを追加しないでください。",
"Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.": "カスタムAPIベースURL。公式チャネルの場合、New APIには組み込みのアドレスがあります。これは、サードパーティのプロキシサイトまたは特別なエンドポイントに対してのみ入力してください。/v1 や末尾のスラッシュを追加しないでください。",
"Custom API base URL. Leave empty to use provider default.": "カスタム API ベース URL。プロバイダのデフォルトを使用する場合は空にしてください。",
"Custom Currency": "カスタム通貨",
"Custom Currency Symbol": "カスタム通貨記号",
@@ -1097,14 +1097,14 @@
"Default (New Frontend)": "デフォルト(新フロントエンド)",
"Default / range": "デフォルト / 範囲",
"Default API Version *": "デフォルトのAPIバージョン *",
"Default API version for this channel": "このチャネルのデフォルトのAPIバージョン",
"Default API version for this channel": "このチャネルのデフォルトのAPIバージョン",
"Default Collapse Sidebar": "デフォルトのサイドバー折りたたみ",
"Default consumption chart": "デフォルトの消費チャート",
"Default Max Tokens": "デフォルトの最大トークン",
"Default model call chart": "デフォルトのモデル呼び出しチャート",
"Default range": "デフォルト範囲",
"Default Responses API version, if empty, will use the API version above": "デフォルトの応答APIバージョン。空の場合、上記のAPIバージョンが使用されます",
"Default system prompt for this channel": "このチャネルのデフォルトのシステムプロンプト",
"Default system prompt for this channel": "このチャネルのデフォルトのシステムプロンプト",
"Default time granularity": "デフォルトの時間粒度",
"Default to auto groups": "デフォルトで自動グループ化",
"Default TTL (seconds)": "デフォルト TTL(秒)",
@@ -1119,7 +1119,7 @@
"Delete a runtime request header": "ランタイムリクエストヘッダーを削除",
"Delete Account": "アカウント削除",
"Delete All Disabled": "すべての無効なものを削除",
"Delete All Disabled Channels?": "すべての無効なチャネルを削除しますか?",
"Delete All Disabled Channels?": "すべての無効なチャネルを削除しますか?",
"Delete Auto-Disabled": "自動無効化されたものを削除",
"Delete Channel": "チャネルを削除",
"Delete Channels?": "チャネルを削除しますか?",
@@ -1324,7 +1324,7 @@
"Edit Announcement": "お知らせを編集",
"Edit API Shortcut": "API ショートカットを編集",
"Edit billing ratios and user-selectable groups in one table.": "課金倍率とユーザーが選択できるグループを1つの表で編集します。",
"Edit Channel": "チャネルを編集",
"Edit Channel": "チャネルを編集",
"Edit chat preset": "チャットプリセットを編集",
"Edit discount tier": "割引ティアを編集",
"Edit FAQ": "FAQ を編集",
@@ -1513,8 +1513,8 @@
"Exact": "完全一致",
"Exact Match": "完全一致",
"Example": "サンプル",
"Example (all channels):": "例(全チャネル):",
"Example (specific channels):": "例(特定チャネル):",
"Example (all channels):": "例(全チャネル):",
"Example (specific channels):": "例(特定チャネル):",
"Example:": "例:",
"example.com&#10;blocked-site.com": "example.com &#10;blocked-site.com",
"example.com&#10;company.com": "example.com &#10;company.com",
@@ -1577,7 +1577,7 @@
"Failed to copy model names": "モデル名のコピーに失敗しました",
"Failed to copy to clipboard": "クリップボードにコピーできませんでした",
"Failed to create API key": "APIキーの作成に失敗しました",
"Failed to create channel": "チャネルの作成に失敗しました",
"Failed to create channel": "チャネルの作成に失敗しました",
"Failed to create deployment": "デプロイの作成に失敗しました",
"Failed to create provider": "プロバイダーの作成に失敗しました",
"Failed to create redemption code": "引き換えコードの作成に失敗しました",
@@ -1586,7 +1586,7 @@
"Failed to delete account": "アカウントの削除に失敗しました",
"Failed to delete API key": "APIキーの削除に失敗しました",
"Failed to delete API keys": "APIキーの削除に失敗しました",
"Failed to delete channel": "チャネルの削除に失敗しました",
"Failed to delete channel": "チャネルの削除に失敗しました",
"Failed to delete disabled channels": "無効化されたチャネルの削除に失敗しました",
"Failed to delete invalid redemption codes": "無効な引き換えコードの削除に失敗しました",
"Failed to delete model": "モデルの削除に失敗しました",
@@ -1602,9 +1602,9 @@
"Failed to discover OIDC endpoints": "OIDCエンドポイントの検出に失敗しました",
"Failed to enable {{count}} model(s)": "{{count}} 個のモデルの有効化に失敗しました",
"Failed to enable 2FA": "2FA の有効化に失敗しました",
"Failed to enable channels": "チャネルの有効化に失敗しました",
"Failed to enable channels": "チャネルの有効化に失敗しました",
"Failed to enable model": "モデルの有効化に失敗しました",
"Failed to enable tag channels": "タグチャネルの有効化に失敗しました",
"Failed to enable tag channels": "タグチャネルの有効化に失敗しました",
"Failed to fetch channel key": "チャネルキーの取得に失敗しました",
"Failed to fetch checkin status": "チェックインステータスの取得に失敗しました",
"Failed to fetch deployment details": "デプロイメント詳細の取得に失敗しました",
@@ -1709,8 +1709,8 @@
"Files to Retain": "保持ファイル数",
"Fill All Models": "すべてのモデルを埋める",
"Fill Codex CLI / Claude CLI Templates": "Codex CLI / Claude CLI テンプレートを入力",
"Fill example (all channels)": "例を入力(全チャネル)",
"Fill example (specific channels)": "例を入力(特定チャネル)",
"Fill example (all channels)": "例を入力(全チャネル)",
"Fill example (specific channels)": "例を入力(特定チャネル)",
"Fill in both Merchant ID and API Private Key before creating.": "作成前に Merchant ID と API 秘密鍵の両方を入力してください。",
"Fill in the credentials above to begin.": "開始するには上記の認証情報を入力してください。",
"Fill in the following info to create a new subscription plan": "以下の情報を入力して新しいサブスクリプションプランを作成",
@@ -1722,7 +1722,7 @@
"Filled {{count}} related model(s)": "{{count}} 個の関連モデルを補完しました",
"Filter": "フィルター",
"Filter by API key...": "APIキーでフィルター...",
"Filter by channel ID": "チャネルIDでフィルター",
"Filter by channel ID": "チャネルIDでフィルター",
"Filter by group": "グループでフィルター",
"Filter by Midjourney task ID": "MidjourneyタスクIDでフィルター",
"Filter by model name...": "モデル名でフィルター...",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "プロバイダー、グループ、タイプ、エンドポイント、タグでモデルを絞り込みます。",
"Filter models by type, endpoint, vendor, group and tags": "タイプ、エンドポイント、ベンダー、グループ、タグでモデルをフィルタリング",
"Filter models...": "モデルをフィルタリング...",
"Filter the model analytics view by time range and user.": "時間範囲とユーザーでモデル分析ビューを絞り込みます。",
"Filter...": "フィルター…",
"Filters": "フィルター",
"Filters active": "フィルター有効",
@@ -1782,7 +1783,7 @@
"Force a syntactically valid JSON response": "構文的に有効な JSON 応答を強制",
"Force AUTH LOGIN": "AUTH LOGINを強制",
"Force Format": "強制フォーマット",
"Force format response to OpenAI standard (OpenAI channel only)": "応答をOpenAI標準に強制フォーマット (OpenAIチャネルのみ)",
"Force format response to OpenAI standard (OpenAI channel only)": "応答をOpenAI標準に強制フォーマット (OpenAIチャネルのみ)",
"Force JSON object or schema-conforming output": "JSON オブジェクトまたはスキーマ準拠の出力を強制します",
"Force SMTP authentication using AUTH LOGIN method": "AUTH LOGIN方式を使用してSMTP認証を強制する",
"Forest Whisper": "フォレストウィスパー",
@@ -1823,7 +1824,7 @@
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "アダプターが無効になっていても、Geminiは思考モードを自動検出します。価格設定と予算編成をより細かく制御する必要がある場合にのみ、これを有効にしてください。",
"General": "一般",
"General Settings": "一般設定",
"Generate a Codex OAuth credential and paste it into the channel key field.": "Codex OAuth認証情報を生成し、チャネルキー欄に貼り付けてください。",
"Generate a Codex OAuth credential and paste it into the channel key field.": "Codex OAuth認証情報を生成し、チャネルキー欄に貼り付けてください。",
"Generate and manage your API access token": "API アクセス トークンを生成および管理",
"Generate credential": "認証情報を生成",
"Generate Lyrics": "歌詞を生成",
@@ -1993,11 +1994,10 @@
"Icon identifier (e.g. github, gitlab)": "アイコン識別子 (例: github, gitlab)",
"ID": "ID",
"If an upstream error contains any of these keywords (case insensitive), the channel will be disabled automatically.": "アップストリームエラーにこれらのキーワードのいずれかが含まれている場合 (大文字と小文字を区別しない)、チャネルは自動的に無効になります。",
"If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.": "認証が成功すると、生成されたJSONがキー欄に挿入されます。保存するにはチャネルを保存してください。",
"If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.": "認証が成功すると、生成されたJSONがキー欄に挿入されます。保存するにはチャネルを保存してください。",
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "上流の One API または New API リレープロジェクトに接続する場合、知っている場合を除き OpenAI タイプを使用してください",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "デフォルト auto グループを有効にすると、新規トークンは空グループではなく auto で開始します。",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "アフィニティチャネルが失敗し、別のチャネルでリトライが成功した場合、アフィニティを成功したチャネルに更新します。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効のままにすると、エントリを削除して別のチャネルを選択します。",
"If this keeps happening, please report it on GitHub Issues.": "この問題が続く場合は、GitHub Issues で報告してください。",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "中国本土で一般向けに生成 AI サービスを提供する場合、届出、セキュリティ評価、コンテンツ安全、苦情対応、生成コンテンツのラベル表示、ログ保存、個人情報保護などの法的義務を履行します。",
"Ignored upstream models": "無視する上流モデル",
@@ -2114,6 +2114,7 @@
"Just now": "たった今",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "チャネル無効時にアフィニティを保持",
"Keep enabled if you need to proxy requests for different upstream accounts.": "異なる上流アカウントのリクエストをプロキシする必要がある場合は有効にしたままにしてください。",
"Keep enough balance before production traffic": "本番トラフィック前に十分な残高を確保",
"Keep original value": "元の値を保持",
@@ -2202,7 +2203,7 @@
"Load template...": "テンプレートをロード...",
"Loader": "ローダー",
"Loading": "読み込み中",
"Loading channel details": "チャネル詳細を読み込み中",
"Loading channel details": "チャネル詳細を読み込み中",
"Loading configuration": "設定を読み込んでいます",
"Loading content settings...": "コンテンツ設定をロード中...",
"Loading current models...": "現在のモデルをロード中...",
@@ -2332,6 +2333,8 @@
"Model": "モデル",
"Model Access": "モデルアクセス",
"Model Analytics": "モデル分析",
"Model Analytics Defaults": "モデル分析のデフォルト設定",
"Model Analytics Filters": "モデル分析フィルター",
"model billing support": "モデル課金対応",
"Model Call Analytics": "モデル呼び出し分析",
"Model context usage": "モデルのコンテキスト使用量",
@@ -2464,8 +2467,8 @@
"Name must be between {{min}} and {{max}} characters": "名前は{{min}}文字以上{{max}}文字以下である必要があります",
"Name Rule": "名前ルール",
"Name Suffix": "名前サフィックス",
"Name the channel and choose the upstream provider.": "チャネル名を設定し、上流プロバイダーを選択します。",
"Name the channel, choose the provider, configure API access, and set credentials.": "チャネル名を設定し、プロバイダーを選択し、API アクセスと認証情報を設定します。",
"Name the channel and choose the upstream provider.": "チャネル名を設定し、上流プロバイダーを選択します。",
"Name the channel, choose the provider, configure API access, and set credentials.": "チャネル名を設定し、プロバイダーを選択し、API アクセスと認証情報を設定します。",
"Name, provider type, and availability.": "名前、プロバイダー種別、利用可否。",
"name@example.com": "name@example.com",
"Native format": "ネイティブ形式",
@@ -2522,7 +2525,7 @@
"No changes made": "変更はありません",
"No changes to save": "保存する変更がありません",
"No channel selected": "チャネルが選択されていません",
"No channel type found.": "チャネルタイプが見つかりません。",
"No channel type found.": "チャネルタイプが見つかりません。",
"No channels available. Create your first channel to get started.": "利用可能なチャネルがありません。最初のチャネルを作成して開始してください。",
"No channels found": "チャネルが見つかりません",
"No Channels Found": "チャネルが見つかりません",
@@ -2610,7 +2613,7 @@
"No records found. Try adjusting your filters.": "記録が見つかりません。フィルターを調整してみてください。",
"No redemption codes available. Create your first redemption code to get started.": "利用可能な引き換えコードがありません。最初の引き換えコードを作成して開始してください。",
"No Redemption Codes Found": "引き換えコードが見つかりません",
"No related models available for this channel type": "このチャネルタイプに関連するモデルが利用できません",
"No related models available for this channel type": "このチャネルタイプに関連するモデルが利用できません",
"No release notes provided.": "リリースノートは提供されていません。",
"No Reset": "リセットなし",
"No restriction": "制限なし",
@@ -2756,13 +2759,13 @@
"Optional JSON policy to restrict access based on user info fields": "ユーザー情報フィールドに基づいてアクセスを制限するためのオプションのJSONポリシー",
"Optional minimum recharge amount for this method.": "この方法のオプションの最小チャージ額。",
"Optional multiplier per user group used when calculating recharge pricing. Provide a JSON object such as": "チャージ料金を計算する際に使用されるユーザーグループごとのオプションの乗数。次のようなJSONオブジェクトを提供してください",
"Optional notes about this channel": "このチャネルに関するオプションのノート",
"Optional notes about this channel": "このチャネルに関するオプションのノート",
"Optional notes about when to use this group": "このグループを使用する時期に関するオプションのメモ",
"Optional ratio used when upstream cache hits occur.": "アップストリームキャッシュヒットが発生したときに使用されるオプションの比率。",
"Optional rule description": "任意のルール説明",
"Optional settings for advanced container configuration.": "高度なコンテナ設定のためのオプション設定。",
"Optional supplementary information (max 100 characters)": "オプションの補足情報 (最大100文字)",
"Optional tag for grouping channels": "チャネルをグループ化するためのオプションのタグ",
"Optional tag for grouping channels": "チャネルをグループ化するためのオプションのタグ",
"Opus Model": "Opus モデル",
"Or continue with": "または、以下で続行",
"Or enter this key manually:": "または、このキーを手動で入力してください:",
@@ -2973,7 +2976,7 @@
"Please select a payment method": "お支払い方法を選択してください",
"Please select a primary model": "プライマリモデルを選択してください",
"Please select a subscription plan": "サブスクリプションプランを選択してください",
"Please select at least one channel": "少なくとも1つのチャネルを選択してください",
"Please select at least one channel": "少なくとも1つのチャネルを選択してください",
"Please select at least one model": "少なくとも1つのモデルを選択してください",
"Please select items to delete": "削除する項目を選択してください",
"Please Select user groups that can access this channel.": "このチャネルにアクセスできるユーザーグループを選択してください。",
@@ -3482,7 +3485,7 @@
"Search": "検索",
"Search by name or URL...": "名前またはURLで検索...",
"Search by order number...": "注文番号で検索...",
"Search channel type...": "チャネルタイプを検索...",
"Search channel type...": "チャネルタイプを検索...",
"Search chat presets...": "チャットプリセットを検索...",
"Search colors...": "色を検索...",
"Search conflicting models or fields": "競合するモデルまたはフィールドを検索",
@@ -3560,7 +3563,7 @@
"Select Model": "モデルを選択",
"Select model {{model}}": "モデル {{model}} を選択",
"Select models (empty for allow all)": "モデルを選択 (すべて許可する場合は空)",
"Select models and apply to channel models list.": "モデルを選択し、チャネルモデルリストに適用します。",
"Select models and apply to channel models list.": "モデルを選択し、チャネルモデルリストに適用します。",
"Select models or add custom ones": "モデルを選択するか、カスタムモデルを追加",
"Select models to process. Unselected \"add\" models will be ignored.": "処理するモデルを選択してください。未選択の「追加」モデルは無視されます。",
"Select models to run batch tests.": "バッチテストを実行するモデルを選択してください。",
@@ -3577,7 +3580,7 @@
"Select start time": "開始時間を選択",
"Select subscription plan": "サブスクリプションプランを選択",
"Select Sync Channels": "同期チャネルを選択",
"Select sync channels to compare prices": "価格比較のために同期チャネルを選択してください",
"Select sync channels to compare prices": "価格比較のために同期チャネルを選択してください",
"Select sync channels to compare ratios": "比率を比較するために同期チャネルを選択",
"Select Sync Source": "同期元を選択",
"Select the API endpoint region": "APIエンドポイントのリージョンを選択",
@@ -3589,7 +3592,7 @@
"Selectable groups": "選択可能なグループ",
"selected": "選択済み",
"Selected {{count}}": "{{count}} 件選択済み",
"selected channel(s). Leave empty to remove tag.": "選択されたチャネル。タグを削除するには空のままにしてください。",
"selected channel(s). Leave empty to remove tag.": "選択されたチャネル。タグを削除するには空のままにしてください。",
"Selected conflicts were overwritten successfully.": "選択した競合が正常に上書きされました。",
"Selected when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。",
"Self-Use Mode": "セルフユースモード",
@@ -3616,6 +3619,7 @@
"Set a tag for": "のタグを設定",
"Set API key access restrictions": "API キーのアクセス制限を設定",
"Set API key basic information": "API キーの基本情報を設定",
"Set default ranges and charts for model analytics.": "モデル分析の既定の範囲とチャートを設定します。",
"Set Field": "フィールドを設定",
"Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。",
"Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "同期エンドポイント",
"Sync Fields": "フィールド同期",
"Sync from the public upstream metadata repository.": "公開上流メタデータリポジトリから同期します。",
"Sync Now": "今すぐ同期",
"Sync this model with official upstream": "このモデルを公式アップストリームと同期",
"Sync Upstream": "アップストリームを同期",
"Sync Upstream Models": "アップストリームモデルを同期",
"Synchronize models and vendors from an upstream source": "アップストリームソースからモデルとベンダーを同期",
"Syncing prices, please wait...": "価格を同期中、しばらくお待ちください...",
"Syncing...": "同期中...",
"System": "システム",
"System Administration": "システム管理",
"System Announcements": "システムのお知らせ",
@@ -3942,9 +3948,9 @@
"This action cannot be undone.": "この操作は元に戻せません。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "この操作は元に戻せません。これにより、あなたのアカウントは完全に削除され、すべてのデータがサーバーから削除されます。",
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
"This channel is not an Ollama channel.": "このチャネルはOllamaチャネルではありません。",
"This channel type does not support fetching models": "このチャネルタイプはモデルの取得をサポートしていません",
"This channel type requires additional configuration": "このチャネルタイプには追加設定が必要です",
"This channel is not an Ollama channel.": "このチャネルはOllamaチャネルではありません。",
"This channel type does not support fetching models": "このチャネルタイプはモデルの取得をサポートしていません",
"This channel type requires additional configuration": "このチャネルタイプには追加設定が必要です",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "この確認により、支払い、引換コード、サブスクリプションプラン、招待報酬の機能が解除されます。各項目をよく読んでください。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "これはモデルリクエストのレート制限を制御します。Web/API ルートのスロットリングは環境変数で設定され、引き続き 429 を返す場合があります。",
"This data may be unreliable, use with caution": "このデータは信頼できない可能性があります。注意して使用してください",
@@ -4293,7 +4299,7 @@
"User Group": "ユーザーグループ",
"User group name": "ユーザーグループ名",
"User Group: {{ratio}}x": "ユーザーグループ:{{ratio}}x",
"User groups that can access channels with this tag": "このタグを持つチャネルにアクセスできるユーザーグループ",
"User groups that can access channels with this tag": "このタグを持つチャネルにアクセスできるユーザーグループ",
"User groups that can access this channel. ": "このチャネルにアクセスできるユーザグループ。",
"User ID": "ユーザー ID",
"User ID Field": "ユーザーIDフィールド",
@@ -4459,7 +4465,8 @@
"What would you like to know?": "何を知りたいですか?",
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "トークンが auto グループを使用すると、システムは上から順に利用可能なグループを探します。",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件に一致したとき、最終価格に X を掛けます。複数一致は掛け合わさり、1 未満は割引として効きます。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "有効にすると、現在のグループのチャネルが失敗した場合、次のグループのチャネルを順番に試します。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "有効にすると、現在のグループのチャネルが失敗した場合、次のグループのチャネルを順番に試します。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効のままにすると、エントリを削除して別のチャネルを選択します。",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "有効にすると、大きなリクエストボディはメモリではなくディスクに一時保存され、メモリ使用量が大幅に削減されます。SSD環境での使用を推奨します。",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "有効にすると、Midjourney のコールバックを受け入れます (サーバーの IP を公開します)。",
"When enabled, newly created tokens start in the first auto group.": "有効にすると、新しく作成されたトークンは最初の自動グループで開始されます。",
+9 -2
View File
@@ -645,7 +645,6 @@
"Channel Affinity": "Привязка к каналу",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.",
"Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
"Keep affinity when channel is disabled": "Сохранять привязку при отключении канала",
"Channel copied successfully": "Канал успешно скопирован",
"Channel created successfully": "Канал успешно создан",
"Channel deleted successfully": "Канал успешно удалён",
@@ -965,6 +964,7 @@
"Copy to clipboard": "Копировать в буфер обмена",
"Copy token": "Копировать токен",
"Copy URL": "Скопировать URL",
"Copying...": "Копирование...",
"Copywriting, ad creative, SEO": "Копирайтинг, рекламные креативы, SEO",
"Core concepts": "Основные понятия",
"Core Configuration": "Основная конфигурация",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Фильтруйте модели по поставщику, группе, типу, endpoint и тегам.",
"Filter models by type, endpoint, vendor, group and tags": "Фильтровать модели по типу, точке доступа, поставщику, группе и тегам",
"Filter models...": "Фильтровать модели...",
"Filter the model analytics view by time range and user.": "Фильтруйте представление аналитики моделей по периоду и пользователю.",
"Filter...": "Фильтр...",
"Filters": "Фильтры",
"Filters active": "Фильтры активны",
@@ -1997,7 +1998,6 @@
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "При подключении к upstream One API или проектам-ретрансляторам New API используйте тип OpenAI, если только вы точно знаете, что делаете",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Если группа auto включена по умолчанию, новые токены создаются с auto вместо пустой группы.",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Если привязанный канал не работает и повторная попытка удалась через другой канал, привязка обновляется на успешный канал.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Если включено, запись привязки сохраняется, даже когда привязанный канал отключён или больше не подходит для текущей группы/модели. Оставьте выключенным, чтобы удалять запись и выбирать другой канал.",
"If this keeps happening, please report it on GitHub Issues.": "Если проблема повторяется, сообщите о ней в GitHub Issues.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Если вы предоставляете услуги генеративного ИИ населению материкового Китая, вы будете выполнять юридические обязанности, включая регистрацию, оценку безопасности, безопасность контента, обработку жалоб, маркировку сгенерированного контента, хранение журналов и защиту персональных данных.",
"Ignored upstream models": "Игнорируемые upstream-модели",
@@ -2114,6 +2114,7 @@
"Just now": "Только что",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "Сохранять привязку при отключении канала",
"Keep enabled if you need to proxy requests for different upstream accounts.": "Оставьте включённым, если нужно проксировать запросы для разных upstream-аккаунтов.",
"Keep enough balance before production traffic": "Поддерживайте достаточный баланс перед рабочим трафиком",
"Keep original value": "Сохранить исходное значение",
@@ -2332,6 +2333,8 @@
"Model": "Модель",
"Model Access": "Доступ к моделям",
"Model Analytics": "Аналитика моделей",
"Model Analytics Defaults": "Настройки аналитики моделей по умолчанию",
"Model Analytics Filters": "Фильтры аналитики моделей",
"model billing support": "поддержка биллинга моделей",
"Model Call Analytics": "Аналитика вызовов моделей",
"Model context usage": "Использование контекста модели",
@@ -3616,6 +3619,7 @@
"Set a tag for": "Установить тег для",
"Set API key access restrictions": "Настройте ограничения доступа API-ключа",
"Set API key basic information": "Настройте основные сведения API-ключа",
"Set default ranges and charts for model analytics.": "Настройте диапазоны и графики по умолчанию для аналитики моделей.",
"Set Field": "Установить поле",
"Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.",
"Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "Точки синхронизации",
"Sync Fields": "Синхронизировать поля",
"Sync from the public upstream metadata repository.": "Синхронизировать из публичного репозитория метаданных верхнего уровня.",
"Sync Now": "Синхронизировать сейчас",
"Sync this model with official upstream": "Синхронизировать эту модель с официальным upstream",
"Sync Upstream": "Синхронизировать Upstream",
"Sync Upstream Models": "Синхронизировать модели Upstream",
"Synchronize models and vendors from an upstream source": "Синхронизировать модели и поставщиков из upstream источника",
"Syncing prices, please wait...": "Синхронизация цен, подождите...",
"Syncing...": "Синхронизация...",
"System": "Система",
"System Administration": "Администрирование системы",
"System Announcements": "Системные объявления",
@@ -4460,6 +4466,7 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Когда токен использует группу auto, система перебирает группы сверху вниз, пока не найдёт доступную.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "При совпадении условий итоговая цена умножается на X. Несколько совпадений умножаются вместе; значения < 1 действуют как скидки.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Если включено, при сбое каналов в текущей группе система попробует каналы следующей группы по порядку.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Если включено, запись привязки сохраняется, даже когда привязанный канал отключён или больше не подходит для текущей группы/модели. Оставьте выключенным, чтобы удалять запись и выбирать другой канал.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "При включении большие тела запросов временно сохраняются на диске, что значительно снижает использование памяти. Рекомендуется SSD.",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "При включении принимаются обратные вызовы Midjourney (раскрывает IP сервера).",
"When enabled, newly created tokens start in the first auto group.": "При включении вновь созданные токены начинаются в первой автогруппе.",
+11 -4
View File
@@ -645,7 +645,6 @@
"Channel Affinity": "Ưu tiên kênh",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.",
"Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream",
"Keep affinity when channel is disabled": "Giữ ưu tiên khi kênh bị tắt",
"Channel copied successfully": "Sao chép kênh thành công",
"Channel created successfully": "Tạo kênh thành công",
"Channel deleted successfully": "Xóa kênh thành công",
@@ -655,7 +654,7 @@
"Channel ID": "Mã kênh",
"Channel key": "Khóa kênh",
"Channel key unlocked": "Khóa kênh đã được mở khóa",
"Channel models": "Channel model",
"Channel models": "Mô hình kênh",
"Channel name is required": "Tên kênh là bắt buộc",
"Channel test completed": "Kiểm tra kênh hoàn tất",
"Channel type is required": "Loại kênh là bắt buộc",
@@ -965,6 +964,7 @@
"Copy to clipboard": "Sao chép vào bảng tạm",
"Copy token": "Sao chép mã thông báo",
"Copy URL": "Sao chép URL",
"Copying...": "Đang sao chép...",
"Copywriting, ad creative, SEO": "Viết copy, sáng tạo quảng cáo, SEO",
"Core concepts": "Khái niệm chính",
"Core Configuration": "Cấu hình chính",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "Lọc mô hình theo nhà cung cấp, nhóm, loại, endpoint và thẻ.",
"Filter models by type, endpoint, vendor, group and tags": "Lọc mô hình theo loại, endpoint, nhà cung cấp, nhóm và thẻ",
"Filter models...": "Lọc mô hình...",
"Filter the model analytics view by time range and user.": "Lọc chế độ xem phân tích mô hình theo khoảng thời gian và người dùng.",
"Filter...": "Lọc...",
"Filters": "Bộ lọc",
"Filters active": "Bộ lọc đang bật",
@@ -1997,7 +1998,6 @@
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "Nếu kết nối với dự án relay One API hoặc New API upstream, hãy sử dụng loại OpenAI thay thế trừ khi bạn biết mình đang làm gì",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "Nếu bật nhóm auto mặc định, token mới sẽ bắt đầu với auto thay vì nhóm trống.",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "Nếu kênh ưu tiên thất bại và thử lại thành công trên kênh khác, cập nhật ưu tiên sang kênh thành công.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Khi bật, giữ mục ưu tiên ngay cả khi kênh ưu tiên bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Để tắt để xóa mục đó và chọn kênh khác.",
"If this keeps happening, please report it on GitHub Issues.": "Nếu sự cố tiếp tục xảy ra, vui lòng báo cáo trên GitHub Issues.",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "Nếu bạn cung cấp dịch vụ AI tạo sinh cho công chúng tại Trung Quốc đại lục, bạn sẽ thực hiện các nghĩa vụ pháp lý bao gồm đăng ký, đánh giá an toàn, an toàn nội dung, xử lý khiếu nại, gắn nhãn nội dung được tạo, lưu giữ nhật ký và bảo vệ thông tin cá nhân.",
"Ignored upstream models": "Mô hình upstream bị bỏ qua",
@@ -2114,6 +2114,7 @@
"Just now": "Vừa nãy",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "Giữ ưu tiên khi kênh bị tắt",
"Keep enabled if you need to proxy requests for different upstream accounts.": "Giữ bật nếu bạn cần proxy yêu cầu cho các tài khoản upstream khác nhau.",
"Keep enough balance before production traffic": "Giữ đủ số dư trước khi chạy lưu lượng production",
"Keep original value": "Giữ giá trị gốc",
@@ -2332,6 +2333,8 @@
"Model": "Mô hình",
"Model Access": "Truy cập mô hình",
"Model Analytics": "Phân tích mô hình",
"Model Analytics Defaults": "Mặc định phân tích mô hình",
"Model Analytics Filters": "Bộ lọc phân tích mô hình",
"model billing support": "hỗ trợ tính phí mô hình",
"Model Call Analytics": "Phân tích lượt gọi mô hình",
"Model context usage": "Sử dụng ngữ cảnh mô hình",
@@ -3616,6 +3619,7 @@
"Set a tag for": "Gắn thẻ cho",
"Set API key access restrictions": "Thiết lập hạn chế truy cập cho khóa API",
"Set API key basic information": "Thiết lập thông tin cơ bản cho khóa API",
"Set default ranges and charts for model analytics.": "Đặt khoảng thời gian và biểu đồ mặc định cho phân tích mô hình.",
"Set Field": "Đặt trường",
"Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.",
"Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "Điểm đồng bộ",
"Sync Fields": "Đồng bộ trường",
"Sync from the public upstream metadata repository.": "Đồng bộ từ kho lưu trữ siêu dữ liệu upstream công khai.",
"Sync Now": "Đồng bộ ngay",
"Sync this model with official upstream": "Synchronize this model with the official source.",
"Sync Upstream": "Đồng bộ nguồn",
"Sync Upstream Models": "Đồng bộ các mô hình nguồn",
"Synchronize models and vendors from an upstream source": "Đồng bộ hóa các mô hình và nhà cung cấp từ một nguồn thượng nguồn",
"Syncing prices, please wait...": "Đang đồng bộ giá, vui lòng đợi...",
"Syncing...": "Đang đồng bộ...",
"System": "Hệ thống",
"System Administration": "Quản trị hệ thống",
"System Announcements": "Thông báo hệ thống",
@@ -3883,7 +3889,7 @@
"Test": "Kiểm tra",
"Test {{count}} selected": "Kiểm tra {{count}} mục đã chọn",
"Test All Channels": "Kiểm tra tất cả các kênh",
"Test Channel Connection": "Check channel connection",
"Test Channel Connection": "Kiểm tra kết nối kênh",
"Test Connection": "Kiểm tra kết nối",
"Test connectivity for:": "Kiểm tra kết nối cho:",
"Test failed": "Kiểm tra thất bại",
@@ -4460,6 +4466,7 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "Khi token dùng nhóm auto, hệ thống thử các nhóm từ trên xuống dưới cho đến khi tìm được nhóm khả dụng.",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "Khi thỏa điều kiện, giá cuối nhân với X. Nhiều điều kiện khớp nhân lại với nhau; giá trị < 1 hoạt động như giảm giá.",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "Khi được bật, nếu các kênh trong nhóm hiện tại thất bại, hệ thống sẽ thử các kênh của nhóm tiếp theo theo thứ tự.",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "Khi bật, giữ mục ưu tiên ngay cả khi kênh ưu tiên bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Để tắt để xóa mục đó và chọn kênh khác.",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "Khi bật, nội dung yêu cầu lớn sẽ được lưu tạm trên đĩa thay vì bộ nhớ, giảm đáng kể việc sử dụng bộ nhớ. Khuyến nghị dùng SSD.",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "Khi được bật, các callback của Midjourney được chấp nhận (lộ IP máy chủ).",
"When enabled, newly created tokens start in the first auto group.": "Khi được bật, các token mới được tạo sẽ bắt đầu trong nhóm tự động đầu tiên.",
+26 -19
View File
@@ -140,7 +140,7 @@
"Add {{title}}": "添加{{title}}",
"Add a group identifier to the auto assignment list.": "将分组标识符添加到自动分配列表。",
"Add a new API key by providing necessary info.": "通过提供必要信息添加新的 API 密钥。",
"Add a new channel by providing the necessary information.": "通过提供必要信息添加新的道。",
"Add a new channel by providing the necessary information.": "通过提供必要信息添加新的道。",
"Add a new model to the system by providing the necessary information.": "通过提供必要信息向系统添加新模型。",
"Add a new user by providing necessary info.": "通过提供必要信息来添加新用户。",
"Add a new vendor to the system": "向系统添加新供应商",
@@ -452,7 +452,7 @@
"Auto-discovers endpoints from the provider": "自动从提供商发现端点",
"Auto-fill when one field exists and another is missing": "在一个字段有值、另一个缺失时自动补齐",
"Auto-retry status codes": "自动重试状态码",
"Automatically disable channel on repeated failures": "重复失败时自动禁用道",
"Automatically disable channel on repeated failures": "重复失败时自动禁用道",
"Automatically disable channels exceeding this response time": "自动禁用超出此响应时间的渠道",
"Automatically disable channels when tests fail": "当测试失败时自动禁用渠道",
"Automatically probe all channels in the background": "在后台自动探测所有渠道",
@@ -645,7 +645,6 @@
"Channel Affinity": "渠道亲和性",
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。",
"Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
"Keep affinity when channel is disabled": "渠道禁用后保留亲和",
"Channel copied successfully": "渠道复制成功",
"Channel created successfully": "渠道创建成功",
"Channel deleted successfully": "渠道删除成功",
@@ -661,7 +660,7 @@
"Channel type is required": "渠道类型是必填的",
"Channel updated successfully": "渠道更新成功",
"Channel-specific settings (JSON format)": "渠道特定设置(JSON 格式)",
"Channel:": "道:",
"Channel:": "道:",
"channel(s)? This action cannot be undone.": "渠道?此操作无法撤销。",
"Channels": "渠道",
"Channels deleted successfully": "渠道删除成功",
@@ -772,7 +771,7 @@
"Codex Account & Usage": "Codex 账户和用量",
"Codex Authorization": "Codex 授权",
"Codex channels do not support batch creation": "Codex 渠道不支持批量创建",
"Codex channels use an OAuth JSON credential as the key.": "Codex 道使用 OAuth JSON 凭据作为密钥。",
"Codex channels use an OAuth JSON credential as the key.": "Codex 道使用 OAuth JSON 凭据作为密钥。",
"Codex CLI Header Passthrough": "Codex CLI 请求头透传",
"Codex credential must be a JSON object with access_token and account_id": "Codex 凭据必须是包含 access_token 和 account_id 的 JSON 对象",
"Cohere": "Cohere",
@@ -965,6 +964,7 @@
"Copy to clipboard": "复制到剪贴板",
"Copy token": "复制令牌",
"Copy URL": "复制 URL",
"Copying...": "正在复制...",
"Copywriting, ad creative, SEO": "文案、广告创意、SEO",
"Core concepts": "核心概念",
"Core Configuration": "核心配置",
@@ -1513,8 +1513,8 @@
"Exact": "精确",
"Exact Match": "完全匹配",
"Example": "示例",
"Example (all channels):": "示例(全部道):",
"Example (specific channels):": "示例(指定道):",
"Example (all channels):": "示例(全部道):",
"Example (specific channels):": "示例(指定道):",
"Example:": "示例:",
"example.com&#10;blocked-site.com": "example.com&#10;blocked-site.com",
"example.com&#10;company.com": "example.com&#10;company.com",
@@ -1665,12 +1665,12 @@
"Failed to sync prices": "同步价格失败",
"Failed to sync ratios": "同步比率失败",
"Failed to test all channels": "无法测试所有渠道",
"Failed to test channel": "测试道失败",
"Failed to test channel": "测试道失败",
"Failed to update all balances": "无法更新所有余额",
"Failed to update API key": "更新 API 密钥失败",
"Failed to update API key status": "更新 API 密钥状态失败",
"Failed to update balance": "无法更新余额",
"Failed to update channel": "更新道失败",
"Failed to update channel": "更新道失败",
"Failed to update models": "更新模型失败",
"Failed to update profile": "无法更新个人资料",
"Failed to update provider": "更新提供商失败",
@@ -1709,8 +1709,8 @@
"Files to Retain": "保留文件数",
"Fill All Models": "填充所有模型",
"Fill Codex CLI / Claude CLI Templates": "填充 Codex CLI / Claude CLI 模板",
"Fill example (all channels)": "填充示例(全部道)",
"Fill example (specific channels)": "填充示例(指定道)",
"Fill example (all channels)": "填充示例(全部道)",
"Fill example (specific channels)": "填充示例(指定道)",
"Fill in both Merchant ID and API Private Key before creating.": "创建前请填写 Merchant ID 和 API 私钥。",
"Fill in the credentials above to begin.": "请先填写上方凭证。",
"Fill in the following info to create a new subscription plan": "填写以下信息创建新的订阅套餐",
@@ -1722,7 +1722,7 @@
"Filled {{count}} related model(s)": "已填充 {{count}} 个关联模型",
"Filter": "筛选",
"Filter by API key...": "按 API 密钥筛选...",
"Filter by channel ID": "按道 ID 筛选",
"Filter by channel ID": "按道 ID 筛选",
"Filter by group": "按分组筛选",
"Filter by Midjourney task ID": "按 Midjourney 任务 ID 筛选",
"Filter by model name...": "按模型名称筛选...",
@@ -1741,6 +1741,7 @@
"Filter models by provider, group, type, endpoint, and tags.": "按供应商、分组、类型、端点和标签筛选模型。",
"Filter models by type, endpoint, vendor, group and tags": "按类型、端点、供应商、分组和标签筛选模型",
"Filter models...": "筛选模型...",
"Filter the model analytics view by time range and user.": "按时间范围和用户筛选模型分析视图。",
"Filter...": "筛选...",
"Filters": "筛选器",
"Filters active": "筛选已启用",
@@ -1823,7 +1824,7 @@
"Gemini will continue to auto-detect thinking mode even with the adapter disabled. Enable this only when you need finer control over pricing and budgeting.": "即使禁用适配器,Gemini 也会继续自动检测思维模式。仅当您需要对定价和预算进行更精细的控制时才启用此选项。",
"General": "常规",
"General Settings": "通用设置",
"Generate a Codex OAuth credential and paste it into the channel key field.": "生成 Codex OAuth 凭据并粘贴到道密钥字段。",
"Generate a Codex OAuth credential and paste it into the channel key field.": "生成 Codex OAuth 凭据并粘贴到道密钥字段。",
"Generate and manage your API access token": "生成和管理您的 API 访问令牌",
"Generate credential": "生成凭据",
"Generate Lyrics": "生成歌词",
@@ -1993,11 +1994,10 @@
"Icon identifier (e.g. github, gitlab)": "图标标识符(例如 github、gitlab",
"ID": "ID",
"If an upstream error contains any of these keywords (case insensitive), the channel will be disabled automatically.": "如果上游错误包含以下任何关键字(不区分大小写),渠道将自动禁用。",
"If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.": "授权成功后,生成的 JSON 将插入密钥字段。您仍需保存道以持久化。",
"If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.": "授权成功后,生成的 JSON 将插入密钥字段。您仍需保存道以持久化。",
"If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing": "如果连接上游 One API 或 New API 中继项目,除非您知道自己在做什么,否则请使用 OpenAI 类型",
"If default auto group is enabled, newly created tokens start with auto instead of an empty group.": "如果启用默认 auto 分组,新建令牌会默认使用 auto,而不是空分组。",
"If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"If this keeps happening, please report it on GitHub Issues.": "如果问题持续出现,请到 GitHub Issues 反馈。",
"If you provide generative AI services to the public in mainland China, you will fulfill legal obligations including filing, security assessment, content safety, complaint handling, generated content labeling, log retention, and personal information protection.": "如果你在中国大陆向公众提供生成式人工智能服务,你将履行备案、安全评估、内容安全、投诉处理、生成内容标识、日志留存和个人信息保护等法律义务。",
"Ignored upstream models": "已忽略上游模型",
@@ -2114,6 +2114,7 @@
"Just now": "刚刚",
"JustSong": "JustSong",
"K": "K",
"Keep affinity when channel is disabled": "渠道禁用后保留亲和",
"Keep enabled if you need to proxy requests for different upstream accounts.": "如果需要为不同上游账户代理请求,请保持启用。",
"Keep enough balance before production traffic": "生产流量前保持充足余额",
"Keep original value": "保留原值",
@@ -2332,6 +2333,8 @@
"Model": "模型",
"Model Access": "模型访问",
"Model Analytics": "模型数据分析",
"Model Analytics Defaults": "模型分析默认设置",
"Model Analytics Filters": "模型分析筛选",
"model billing support": "模型计费支持",
"Model Call Analytics": "模型调用分析",
"Model context usage": "模型上下文用量",
@@ -2375,7 +2378,7 @@
"Model Square": "模型广场",
"Model Tags": "模型标签",
"Model to use for testing": "用于测试的模型",
"Model to use when testing channel connectivity": "测试道连接时使用的模型",
"Model to use when testing channel connectivity": "测试道连接时使用的模型",
"Model Version *": "模型版本 *",
"model(s) selected out of": "已选模型(共)",
"model(s)? This action cannot be undone.": "模型?此操作无法撤销。",
@@ -2429,7 +2432,7 @@
"ms": "毫秒",
"Multi-key channel: Keys will be": "多密钥渠道:密钥将",
"Multi-Key Management": "多密钥管理",
"Multi-Key Mode (multiple keys, one channel)": "多密钥模式(多个密钥,一个道)",
"Multi-Key Mode (multiple keys, one channel)": "多密钥模式(多个密钥,一个道)",
"Multi-Key Strategy": "多密钥策略",
"Multi-key: Polling rotation": "多密钥:轮询",
"Multi-key: Random rotation": "多密钥:随机",
@@ -3616,6 +3619,7 @@
"Set a tag for": "设置标签为",
"Set API key access restrictions": "设置令牌的访问限制",
"Set API key basic information": "设置令牌的基本信息",
"Set default ranges and charts for model analytics.": "设置模型分析的默认时间范围和图表。",
"Set Field": "设置字段",
"Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。",
"Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。",
@@ -3817,11 +3821,13 @@
"Sync Endpoints": "同步端点",
"Sync Fields": "字段同步",
"Sync from the public upstream metadata repository.": "从公共上游元数据仓库同步。",
"Sync Now": "立即同步",
"Sync this model with official upstream": "将此模型与官方上游同步",
"Sync Upstream": "同步上游",
"Sync Upstream Models": "同步上游模型",
"Synchronize models and vendors from an upstream source": "从上游源同步模型和供应商",
"Syncing prices, please wait...": "正在同步价格,请稍候...",
"Syncing...": "同步中...",
"System": "系统",
"System Administration": "系统管理",
"System Announcements": "系统公告",
@@ -3893,7 +3899,7 @@
"Test Model": "测试模型",
"Test models and prompts from the browser": "在浏览器中测试模型和提示词",
"Test selected models": "测试所选模型",
"Testing all enabled channels started. Please refresh to see results.": "测试所有启用的道已开始。请刷新以查看结果。",
"Testing all enabled channels started. Please refresh to see results.": "测试所有启用的道已开始。请刷新以查看结果。",
"Testing...": "测试中...",
"Text": "文本",
"Text description of the desired image": "想要生成图像的文字描述",
@@ -3943,7 +3949,7 @@
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作无法撤消。这将永久删除您的账户并从我们的服务器中移除您的所有数据。",
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
"This channel is not an Ollama channel.": "该渠道不是 Ollama 渠道。",
"This channel type does not support fetching models": "此道类型不支持获取模型",
"This channel type does not support fetching models": "此道类型不支持获取模型",
"This channel type requires additional configuration": "此渠道类型需要填写额外配置",
"This confirmation unlocks payment, redemption code, subscription plan, and invitation reward features. Please read the statements carefully.": "此确认会解锁支付、兑换码、订阅套餐和邀请奖励功能。请仔细阅读相关声明。",
"This controls model request rate limiting. Web/API route throttling is configured by environment variables and may still return 429.": "此处仅控制模型请求速率限制。Web/API 路由限流由环境变量配置,仍可能返回 429。",
@@ -4460,6 +4466,7 @@
"When a token uses the auto group, the system tries groups from top to bottom until it finds an available group.": "当令牌使用 auto 分组时,系统会按从上到下的顺序尝试,直到找到可用分组。",
"When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.": "条件满足时,最终价格乘以 X;多条命中的倍率会相乘;小于 1 的值为折扣。",
"When enabled, if channels in the current group fail, it will try channels in the next group in order.": "开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道。",
"When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. Leave it off to delete the entry and select another channel.": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.": "启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用。建议在 SSD 环境下使用。",
"When enabled, Midjourney callbacks are accepted (reveals server IP).": "启用时,接受 Midjourney 回调 (会泄露服务器 IP)。",
"When enabled, newly created tokens start in the first auto group.": "启用后,新创建的令牌将从第一个自动分组开始。",