chore(web): improve frontend dialog layout and sizing (#5346)
Merge pull request #5346 from QuantumNous/perf/ui-dialog
This commit is contained in:
+127
@@ -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>
|
||||
)
|
||||
}
|
||||
+17
-26
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
Vendored
+106
-117
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
+47
-52
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+176
-178
@@ -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}
|
||||
|
||||
+75
-82
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+178
-181
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+47
-50
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+216
-220
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+143
-149
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+191
-195
@@ -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 */}
|
||||
|
||||
+192
-199
@@ -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}
|
||||
|
||||
+346
-360
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+73
-77
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+108
-112
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+143
-160
@@ -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
|
||||
|
||||
+153
-168
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+124
-134
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+33
-38
@@ -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
-22
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+62
-65
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-130
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+255
-306
@@ -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
|
||||
|
||||
+27
-30
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+110
-122
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+212
-214
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+200
-212
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+102
-98
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+155
-163
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+166
-171
@@ -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'>
|
||||
|
||||
+42
-49
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+71
-81
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+45
-49
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+59
-64
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+46
-53
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+84
-86
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+60
-64
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+127
-127
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-38
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+170
-175
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+392
-398
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+146
-148
@@ -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}>
|
||||
|
||||
+121
-126
@@ -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}>
|
||||
|
||||
+36
-37
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+42
-5
@@ -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')}
|
||||
|
||||
+214
-216
@@ -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 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-102
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+123
-129
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-127
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+99
-104
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
+36
-42
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
+80
-85
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+141
-142
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+117
-123
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+28
-32
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+539
-547
File diff suppressed because it is too large
Load Diff
+35
-42
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+89
-96
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+113
-116
@@ -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
|
||||
|
||||
+61
-69
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+211
-216
@@ -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 */}
|
||||
|
||||
+31
-37
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
+9
-2
@@ -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.",
|
||||
|
||||
Vendored
+9
-2
@@ -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 d’analyse 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 d’un 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 d’IA 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 l’analyse des modèles",
|
||||
"Model Analytics Filters": "Filtres d’analyse 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 l’analyse 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.",
|
||||
|
||||
Vendored
+65
-58
@@ -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 blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com 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.": "有効にすると、新しく作成されたトークンは最初の自動グループで開始されます。",
|
||||
|
||||
Vendored
+9
-2
@@ -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.": "При включении вновь созданные токены начинаются в первой автогруппе.",
|
||||
|
||||
Vendored
+11
-4
@@ -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.",
|
||||
|
||||
Vendored
+26
-19
@@ -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 blocked-site.com": "example.com blocked-site.com",
|
||||
"example.com company.com": "example.com 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.": "启用后,新创建的令牌将从第一个自动分组开始。",
|
||||
|
||||
Reference in New Issue
Block a user