perf(web): improve dialog sizing and footer layout

- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
This commit is contained in:
QuentinHsu
2026-06-06 21:49:33 +08:00
parent 7a5348caa3
commit 2eaa943d9f
80 changed files with 8137 additions and 8517 deletions
@@ -20,16 +20,9 @@ import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog } from '@/components/dialog'
import type {
SecureVerificationState,
VerificationMethod,
@@ -91,122 +84,118 @@ export function SecureVerificationDialog({
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</>
}
description={description}
contentClassName='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 overflow-hidden border-none shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
headerClassName='border-b pb-4 text-left'
titleClassName='flex items-center gap-2 text-lg font-semibold'
descriptionClassName='text-left'
contentHeight='auto'
bodyClassName='px-1 py-1'
showCloseButton={!state.loading}
footerClassName='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'
footer={
<>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</>
}
>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) => onMethodChange(value as VerificationMethod)}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>{t('Authenticator code')}</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<div className='flex-1 overflow-y-auto px-6 py-5'>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
}}
/>
</TabsContent>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</TabsContent>
</Tabs>
)}
</Dialog>
)
}
@@ -32,14 +32,6 @@ import {
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -50,6 +42,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
@@ -414,43 +407,16 @@ export function UserAuthForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -474,8 +440,32 @@ export function UserAuthForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -26,14 +26,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -44,6 +36,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
@@ -387,43 +380,16 @@ export function SignUpForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -447,8 +413,32 @@ export function SignUpForm({
) : null}
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</Dialog>
)}
</Form>
@@ -22,14 +22,6 @@ import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -38,6 +30,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchDelete,
handleBatchDisable,
@@ -188,29 +181,21 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => {
@@ -221,22 +206,37 @@ export function DataTableBulkActions<TData>({
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</>
}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -161,53 +154,55 @@ export function BalanceQueryDialog({
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</Dialog>
)
}
@@ -33,14 +33,6 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -75,6 +67,7 @@ import {
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -529,179 +522,184 @@ export function ChannelTestDialog({
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Test Channel Connection')}
description={
<>
{t('Test connectivity for:')}
<strong>{currentRow.name}</strong>
</>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
</Dialog>
<FailureDetailsSheet
details={failureDetails}
@@ -24,15 +24,8 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
@@ -129,78 +122,18 @@ export function CodexOAuthDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Authorization')}
description={t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -215,8 +148,68 @@ export function CodexOAuthDialog({
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
</Dialog>
)
}
@@ -31,16 +31,9 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
@@ -414,177 +407,23 @@ export function CodexUsageDialog({
}, [response])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Account & Usage')}
description={
<>
{t('Channel:')}
<strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -592,8 +431,166 @@ export function CodexUsageDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -74,45 +67,20 @@ export function CopyChannelDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -122,10 +90,39 @@ export function CopyChannelDialog({
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
@@ -222,216 +215,23 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
@@ -439,8 +239,204 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -28,14 +28,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -44,6 +36,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
@@ -365,152 +358,153 @@ export function FetchModelsDialog({
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{activeChannel ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)}
</DialogDescription>
</DialogHeader>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -47,6 +40,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
@@ -228,215 +222,217 @@ export function MultiKeyManageDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={currentRow.name}
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<RefreshCw className='h-4 w-4' />
</Button>
<div className='flex items-center gap-2'>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='outline'
variant='default'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<RefreshCw className='h-4 w-4' />
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</DialogContent>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirmation Dialog */}
@@ -34,18 +34,11 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
@@ -375,203 +368,203 @@ export function OllamaModelsDialog({
if (!open) return null
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
: 0
}
/>
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</div>
)}
<AlertDialog
open={deleteOpen}
@@ -43,14 +43,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -63,6 +55,7 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// ---------------------------------------------------------------------------
// Types
@@ -1701,356 +1694,20 @@ export function ParamOverrideEditorDialog(
// ---------------------------------------------------------------------------
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Parameter Override')}
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -2061,8 +1718,337 @@ export function ParamOverrideEditorDialog(
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() => setSelectedOperationId(operation.id)}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) => handleDragOver(e, operation.id)}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(operation.mode || 'set')
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps {
open: boolean
@@ -84,73 +77,22 @@ export function StatusCodeRiskDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
@@ -161,8 +103,62 @@ export function StatusCodeRiskDialog({
>
{t('I confirm enabling high-risk retry')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</Dialog>
)
}
@@ -23,18 +23,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
@@ -190,115 +183,118 @@ export function TagBatchEditDialog({
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -21,17 +21,11 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
@@ -120,157 +114,15 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return (
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
@@ -284,8 +136,139 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog
@@ -21,15 +21,6 @@ import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -39,6 +30,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS,
@@ -74,165 +66,158 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</DialogTrigger>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle>
<DialogDescription>
{t(
'Choose the default charts, range, and time granularity for model analytics.'
)}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='grid gap-2'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
</DialogFooter>
</DialogContent>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</Button>
}
title={t('Model Analytics Defaults')}
description={t('Set default ranges and charts for model analytics.')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='grid gap-3'
footer={
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
}
>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</Dialog>
)
}
@@ -23,15 +23,6 @@ import { useAuthStore } from '@/stores/auth-store'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -44,6 +35,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
@@ -144,129 +136,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</DialogTrigger>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription>
{t(
'Set filters to customize your dashboard statistics and charts.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='flex-1 pr-3 sm:pr-4'>
<div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</Button>
}
title={t('Model Analytics Filters')}
description={t('Filter the model analytics view by time range and user.')}
contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'
contentHeight='min(48vh, 460px)'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
footer={
<>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
@@ -275,8 +160,113 @@ export function ModelsFilter(props: ModelsFilterProps) {
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<ScrollArea className='h-full pr-3 sm:pr-4'>
<div className='grid gap-2.5 py-2'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={selectedRange === range.days ? 'default' : 'outline'}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-2.5'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface AnnouncementDetailModalProps {
open: boolean
@@ -47,38 +41,39 @@ export function AnnouncementDetailModal({
}: AnnouncementDetailModalProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Announcement Details')}</DialogTitle>
{announcement?.publishDate && (
<DialogDescription>
{t('Published:')}{' '}
{formatDateTimeObject(new Date(announcement.publishDate))}
</DialogDescription>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Announcement Details')}
description={
announcement?.publishDate
? `${t('Published:')} ${formatDateTimeObject(new Date(announcement.publishDate))}`
: undefined
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[min(58vh,520px)] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -23,15 +23,9 @@ import { toast } from 'sonner'
import { getUserModels } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
const APP_CONFIGS = {
claude: {
@@ -151,76 +145,78 @@ export function CCSwitchDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Import to CC Switch')}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import to CC Switch')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
</Dialog>
)
}
@@ -24,20 +24,13 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchEnableModels,
handleBatchDisableModels,
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Models?')}</DialogTitle>
<DialogDescription>
{t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Models?')}
description={t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -17,14 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = {
open: boolean
@@ -41,21 +35,22 @@ export function DescriptionDialog({
}: DescriptionDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{modelName}</DialogTitle>
<DialogDescription>{t('Model Description')}</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</DialogContent>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -22,15 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -164,62 +158,16 @@ export function ExtendDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Extend deployment')}</DialogTitle>
</DialogHeader>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Extend deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -229,8 +177,57 @@ export function ExtendDeploymentDialog({
) : null}
{t('Extend')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -37,6 +30,7 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -115,133 +109,130 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Missing Models')}</DialogTitle>
<DialogDescription>
{t('Models that are being used but not configured in the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Missing Models')}
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')} {totalItems}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
</div>
</div>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{totalItems}
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
)}
</div>
)}
</Dialog>
)
}
@@ -25,7 +25,6 @@ import {
Plus,
RefreshCcw,
Trash2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,14 +39,6 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -64,6 +55,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
@@ -172,186 +164,233 @@ export function PrefillGroupManagementDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
style={{ maxWidth: 'min(100vw, 64rem)' }}
>
<div
className={cn(
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
isMobile ? 'rounded-none' : 'rounded-2xl'
)}
>
<div
className={cn(
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
)}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</>
}
description={t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
contentClassName={cn(
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
isMobile && 'max-w-none rounded-none'
)}
titleClassName='flex flex-wrap items-center gap-2 text-lg'
descriptionClassName='text-sm leading-relaxed'
contentHeight='auto'
bodyClassName={cn(
'space-y-3',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
)}
>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</DialogTitle>
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div className='flex flex-col gap-3'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed py-10'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</DialogDescription>
</DialogHeader>
<DialogClose
render={
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6'
/>
}
>
<span className='sr-only'>{t('Close dialog')}</span>
<X className='h-4 w-4' />
</DialogClose>
</div>
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-3'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-4'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>
·
</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
</div>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</div>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[680px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[240px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
@@ -368,7 +407,7 @@ export function PrefillGroupManagementDialog({
copyable={false}
/>
)}
</div>
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
@@ -376,131 +415,41 @@ export function PrefillGroupManagementDialog({
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(
({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>
{group.name}
</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems
.slice(0, 6)
.map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>
Edit group
</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>
Delete group
</span>
</Button>
</div>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</DialogContent>
)}
</div>
</Dialog>
<ConfirmDialog
@@ -22,14 +22,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -111,27 +105,16 @@ export function RenameDeploymentDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Rename deployment')}</DialogTitle>
</DialogHeader>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Rename deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -141,8 +124,22 @@ export function RenameDeploymentDialog({
) : null}
{t('Rename')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -125,117 +118,16 @@ export function SyncWizardDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
<DialogDescription>
{t('Synchronize models and vendors from an upstream source')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Sync Upstream Models')}
description={t('Synchronize models and vendors from an upstream source')}
initialFocus={!isMobile}
contentHeight='auto'
bodyClassName='flex flex-col gap-6'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -246,10 +138,106 @@ export function SyncWizardDialog({
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? 'Syncing...' : 'Sync Now'}
{isSyncing ? t('Syncing...') : t('Sync Now')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</Dialog>
)
}
@@ -30,13 +30,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -47,6 +40,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -64,6 +58,8 @@ const schema = z.object({
type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input)
@@ -212,226 +208,228 @@ export function UpdateConfigDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input
placeholder='ollama/ollama:latest'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
isLoading ? null : (
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={UPDATE_CONFIG_FORM_ID}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</>
)
}
>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
id={UPDATE_CONFIG_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='command'
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
<Input placeholder='ollama/ollama:latest' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
)}
</DialogContent>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='command'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</div>
)}
</Dialog>
)
}
@@ -37,14 +37,6 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -67,6 +59,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -453,222 +446,217 @@ export function UpstreamConflictDialog({
}
onOpenChange(nextOpen)
}}
>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
initialFocus={!isMobile}
>
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
<DialogDescription>
title={t('Resolve Conflicts')}
description={t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
contentClassName='w-full sm:max-w-5xl'
contentHeight='min(72vh, 720px)'
bodyClassName='flex flex-col gap-4'
initialFocus={!isMobile}
footerClassName='sm:justify-between'
footer={
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</DialogDescription>
</DialogHeader>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
}
>
<div className='flex min-h-0 flex-1 flex-col gap-4'>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</div>
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter className='flex-shrink-0'>
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
)}
</div>
</DialogFooter>
</DialogContent>
)}
</div>
</Dialog>
)
}
@@ -24,14 +24,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types'
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null
}
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({
open,
onOpenChange,
@@ -118,98 +113,107 @@ export function VendorMutateDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
</DialogTitle>
<DialogDescription>
{isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
description={
isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={VENDOR_MUTATE_FORM_ID}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : isEdit ? t('Update') : t('Create')}
</Button>
</>
}
>
<Form {...form}>
<form
id={VENDOR_MUTATE_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -27,14 +27,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({
@@ -116,160 +110,15 @@ export function ViewDetailsDialog({
}, [details])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Deployment details')}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -277,8 +126,151 @@ export function ViewDetailsDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(details?.total_gpus ?? details?.hardware_qty ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>{id}</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</Dialog>
)
}
@@ -21,12 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -36,6 +30,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps {
@@ -142,180 +137,180 @@ export function ViewLogsDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</DialogTitle>
</DialogHeader>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</>
}
contentClassName='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>
{t('Container')}
</div>
<Select
items={[
...containers.flatMap((c) => {
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select
items={[
...containers.flatMap((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</DialogContent>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div>
</Dialog>
)
}
@@ -32,12 +32,6 @@ import { formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -45,6 +39,7 @@ import {
TooltipTrigger,
TooltipProvider,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { Turnstile } from '@/components/turnstile'
import { getCheckinStatus, performCheckin } from '../api'
import type { CheckinRecord } from '../types'
@@ -253,27 +248,26 @@ export function CheckinCalendarCard({
setTurnstileWidgetKey((v) => v + 1)
}
}}
title={t('Security Check')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Security Check')}</DialogTitle>
</DialogHeader>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
</DialogContent>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
</Dialog>
<div className='bg-card overflow-hidden rounded-2xl border'>
@@ -20,17 +20,10 @@ import { useEffect } from 'react'
import { RefreshCw, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { useAccessToken } from '../../hooks'
// ============================================================================
@@ -57,45 +50,18 @@ export function AccessTokenDialog({
}, [open, token, generate])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Access Token')}</DialogTitle>
<DialogDescription>
{t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Access Token')}
description={t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -116,8 +82,35 @@ export function AccessTokenDialog({
)}
{generating ? t('Generating...') : t('Regenerate')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
</Dialog>
)
}
@@ -21,15 +21,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { updateUserProfile } from '../../api'
@@ -114,82 +107,79 @@ export function ChangePasswordDialog({
}
}
const formId = 'change-password-form'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('Change Password')}</DialogTitle>
<DialogDescription>
{t('Update your password for account:')}{' '}
<strong>{username}</strong>
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Change Password')}
description={
<>
{t('Update your password for account:')} <strong>{username}</strong>
</>
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' form={formId} disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</>
}
>
<form id={formId} onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) => handleChange('originalPassword', e.target.value)}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) =>
handleChange('originalPassword', e.target.value)
}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>
{t('Confirm New Password')}
</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) =>
handleChange('confirmPassword', e.target.value)
}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</DialogFooter>
</form>
</DialogContent>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>{t('Confirm New Password')}</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</form>
</Dialog>
)
}
@@ -25,16 +25,9 @@ import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { deleteUserAccount } from '../../api'
// ============================================================================
@@ -101,45 +94,24 @@ export function DeleteAccountDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</DialogTitle>
<DialogDescription>
{t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</>
}
description={t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -157,8 +129,32 @@ export function DeleteAccountDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Deleting...') : t('Delete Account')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { sendEmailVerification, bindEmail } from '../../api'
// ============================================================================
@@ -129,60 +122,22 @@ export function EmailBindDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Email')}</DialogTitle>
<DialogDescription>
{currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Bind Email')}
description={
currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -199,8 +154,48 @@ export function EmailBindDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Binding...') : t('Bind Email')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
</Dialog>
)
}
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Telegram Bind Dialog Component
@@ -45,56 +39,55 @@ export function TelegramBindDialog({
}: TelegramBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Telegram Account')}</DialogTitle>
<DialogDescription>
{t('Click the button below to bind your Telegram account')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind Telegram Account')}
description={t('Click the button below to bind your Telegram account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
'You will be redirected to Telegram to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
'You will be redirected to Telegram to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
"After clicking the button, you'll be asked to authorize the bot"
)}
</p>
</div>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
</div>
</div>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
"After clicking the button, you'll be asked to authorize the bot"
)}
</p>
</div>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
</div>
</div>
</div>
</DialogContent>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
</div>
</Dialog>
)
}
@@ -23,17 +23,10 @@ import { toast } from 'sonner'
import { regenerate2FABackupCodes } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Backup Codes Dialog Component
@@ -94,82 +87,26 @@ export function TwoFABackupDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</DialogTitle>
<DialogDescription>
{backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</>
}
description={
backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')
}
contentClassName='sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
{backupCodes.length === 0 ? (
<>
<Button
@@ -187,8 +124,69 @@ export function TwoFABackupDialog({
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
</Dialog>
)
}
@@ -24,16 +24,9 @@ import { disable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Disable Dialog Component
@@ -98,60 +91,24 @@ export function TwoFADisableDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</DialogTitle>
<DialogDescription>
{t(
'This action will permanently remove 2FA protection from your account.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</>
}
description={t(
'This action will permanently remove 2FA protection from your account.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
@@ -167,8 +124,47 @@ export function TwoFADisableDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Disabling...') : t('Disable 2FA')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -24,17 +24,10 @@ import { toast } from 'sonner'
import { setup2FA, enable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import type { TwoFASetupData } from '../../types'
// ============================================================================
@@ -136,123 +129,23 @@ export function TwoFASetupDialog({
}, [open, setupData, initializing, handleSetup])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Setup Two-Factor Authentication')}</DialogTitle>
<DialogDescription>
{t('Step')} {step + 1} {t('of 3:')} {stepLabels[step]}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Setup Two-Factor Authentication')}
description={
<>
{t('Step')}
{step + 1}
{t('of 3:')}
{stepLabels[step]}
</>
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
{step > 0 && (
<Button
variant='outline'
@@ -278,8 +171,115 @@ export function TwoFASetupDialog({
{loading ? t('Enabling...') : t('Enable 2FA')}
</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
</Dialog>
)
}
@@ -19,13 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { QrCode } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
// ============================================================================
// WeChat Bind Dialog Component
@@ -43,40 +37,39 @@ export function WeChatBindDialog({
}: WeChatBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind WeChat Account')}</DialogTitle>
<DialogDescription>
{t('Scan the QR code with WeChat to bind your account')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind WeChat Account')}
description={t('Scan the QR code with WeChat to bind your account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
</p>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
</p>
</div>
</DialogContent>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
</p>
</div>
</Dialog>
)
}
@@ -25,12 +25,6 @@ import { formatQuota } from '@/lib/format'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -40,6 +34,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import {
paySubscriptionStripe,
@@ -259,189 +254,189 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</DialogTitle>
</DialogHeader>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</>
}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
</div>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
{t('Reset Period')}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
</div>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Reset Period')}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
</div>
</div>
{limitReached && (
{limitReached && (
<Alert variant='destructive'>
<AlertDescription>
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
<Alert variant='destructive'>
<AlertDescription>
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
{t('This plan does not allow balance redemption')}
</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('This plan does not allow balance redemption')}
</AlertDescription>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('Insufficient balance')}
</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) =>
v !== null && setSelectedEpayMethod(v)
}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
)}
</div>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
</DialogContent>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) => v !== null && setSelectedEpayMethod(v)}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
)}
</div>
)}
</div>
</Dialog>
)
}
@@ -21,14 +21,6 @@ import { type Resolver, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -50,6 +42,7 @@ import {
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import {
SettingsForm,
SettingsSwitchContent,
@@ -74,6 +67,8 @@ type ProviderFormDialogProps = {
provider?: CustomOAuthProvider | null
}
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
export function ProviderFormDialog(props: ProviderFormDialogProps) {
const { t } = useTranslation()
const isEditing = !!props.provider
@@ -174,98 +169,97 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
const isPending = createProvider.isPending || updateProvider.isPending
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>
{isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
</DialogTitle>
<DialogDescription>
{isEditing
? t('Update the configuration for this custom OAuth provider.')
: t(
'Configure a new custom OAuth provider for user authentication.'
)}
</DialogDescription>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
description={
isEditing
? t('Update the configuration for this custom OAuth provider.')
: t('Configure a new custom OAuth provider for user authentication.')
}
contentClassName='max-h-[85vh] overflow-y-auto sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</>
}
>
<Form {...form}>
<SettingsForm
id={PROVIDER_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='enabled'
name='name'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
<Input placeholder={t('e.g. My GitLab')} {...field} />
</FormControl>
</SettingsSwitchItem>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. My GitLab')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
</FormControl>
<FormDescription>
{t('Used in URLs and API routes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='icon'
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormLabel>{t('Slug')}</FormLabel>
<FormControl>
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
</FormControl>
<FormDescription>
{t('Optional icon identifier for the login button')}
{t('Used in URLs and API routes')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -273,341 +267,341 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
/>
</div>
<Separator />
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional icon identifier for the login button')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Separator />
<FormField
control={form.control}
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='auth_style'
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'How client credentials are sent to the token endpoint'
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='auth_style'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('How client credentials are sent to the token endpoint')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
</FormDescription>
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</DialogFooter>
</SettingsForm>
</Form>
</DialogContent>
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsForm>
</Form>
</Dialog>
)
}
@@ -36,14 +36,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -72,6 +64,7 @@ import {
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -105,6 +98,8 @@ const announcementSchema = z.object({
type AnnouncementFormValues = z.infer<typeof announcementSchema>
const ANNOUNCEMENT_FORM_ID = 'announcement-form'
const typeOptions = [
{
value: 'default',
@@ -460,154 +455,157 @@ export function AnnouncementsSection({
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingAnnouncement
? t('Edit Announcement')
: t('Add Announcement')}
</DialogTitle>
<DialogDescription>
{t('Create or update system announcements for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingAnnouncement ? t('Edit Announcement') : t('Add Announcement')
}
description={t(
'Create or update system announcements for the dashboard'
)}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
{t('Cancel')}
</Button>
<Button type='submit' form={ANNOUNCEMENT_FORM_ID}>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={ANNOUNCEMENT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -36,14 +36,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -70,6 +62,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -98,6 +91,8 @@ const createApiInfoSchema = (t: (key: string) => string) =>
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
const API_INFO_FORM_ID = 'api-info-form'
const colorOptions = [
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
@@ -408,133 +403,133 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
</DialogTitle>
<DialogDescription>
{t('Configure API documentation links for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
description={t('Configure API documentation links for the dashboard')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
{t('Cancel')}
</Button>
<Button type='submit' form={API_INFO_FORM_ID}>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={API_INFO_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createChatDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -49,6 +42,8 @@ const createChatDialogSchema = (t: (key: string) => string) =>
type ChatDialogFormValues = z.infer<ReturnType<typeof createChatDialogSchema>>
const CHAT_DIALOG_FORM_ID = 'chat-dialog-form'
export type ChatEntryData = {
name: string
url: string
@@ -97,74 +92,73 @@ export function ChatDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit chat preset') : t('Add chat preset')}
</DialogTitle>
<DialogDescription>
{t('Configure a predefined chat link for end users.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit chat preset') : t('Add chat preset')}
description={t('Configure a predefined chat link for end users.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={CHAT_DIALOG_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={CHAT_DIALOG_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -35,14 +35,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -62,6 +54,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -90,6 +83,8 @@ const faqSchema = z.object({
type FAQFormValues = z.infer<typeof faqSchema>
const FAQ_FORM_ID = 'faq-form'
export function FAQSection({ enabled, data }: FAQSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -348,79 +343,78 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingFaq ? t('Edit FAQ') : t('Add FAQ')}
</DialogTitle>
<DialogDescription>
{t('Create or update frequently asked questions for users')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
)}
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Maximum 1000 characters. Supports Markdown and HTML.'
{t('Cancel')}
</Button>
<Button type='submit' form={FAQ_FORM_ID}>
{editingFaq ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={FAQ_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingFaq ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 1000 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -35,14 +35,6 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -61,6 +53,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -97,6 +90,8 @@ const createUptimeKumaSchema = (t: (key: string) => string) =>
type UptimeKumaFormValues = z.infer<ReturnType<typeof createUptimeKumaSchema>>
const UPTIME_KUMA_FORM_ID = 'uptime-kuma-form'
export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -359,96 +354,100 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
</div>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')}
</DialogTitle>
<DialogDescription>
{t('Configure monitoring status page groups for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')
}
description={t(
'Configure monitoring status page groups for the dashboard'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingGroup ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
{t('Cancel')}
</Button>
<Button type='submit' form={UPTIME_KUMA_FORM_ID}>
{editingGroup ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={UPTIME_KUMA_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -20,12 +20,7 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatTimestampToDate } from '@/lib/format'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getAffinityUsageCache } from './api'
function formatRate(hit: number, total: number): string {
@@ -135,38 +130,42 @@ export function CacheStatsDialog(props: Props) {
}, [stats, props.target, t])
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Channel Affinity: Upstream Cache Hit')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
)}
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='font-medium'>{row.value}</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Channel Affinity: Upstream Cache Hit')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
)}
</DialogContent>
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between gap-4 border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='text-right font-medium break-all'>
{row.value}
</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
)}
</Dialog>
)
}
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -40,7 +40,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
@@ -82,6 +82,43 @@ function RuleBadgeList(props: { items: string[] }) {
)
}
function ChannelAffinityConfirmDialog(props: {
open: boolean
onOpenChange: (open: boolean) => void
title: ReactNode
desc: ReactNode
handleConfirm: () => void
destructive?: boolean
}) {
const { t } = useTranslation()
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={props.title}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='flex items-start'
footer={
<>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button
variant={props.destructive ? 'destructive' : 'default'}
onClick={props.handleConfirm}
>
{t('Continue')}
</Button>
</>
}
>
<div className='text-muted-foreground text-sm'>{props.desc}</div>
</Dialog>
)
}
function serializeRules(rules: AffinityRule[]): string {
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
}
@@ -641,7 +678,7 @@ export function ChannelAffinitySection(props: Props) {
templateKey={ruleTemplateKey}
/>
<ConfirmDialog
<ChannelAffinityConfirmDialog
open={clearAllDialogOpen}
onOpenChange={setClearAllDialogOpen}
title={t('Confirm clearing all channel affinity cache')}
@@ -653,7 +690,7 @@ export function ChannelAffinitySection(props: Props) {
/>
{clearRuleName !== null && (
<ConfirmDialog
<ChannelAffinityConfirmDialog
open
onOpenChange={(v) => !v && setClearRuleName(null)}
title={t('Confirm clearing cache for this rule')}
@@ -663,7 +700,7 @@ export function ChannelAffinitySection(props: Props) {
/>
)}
<ConfirmDialog
<ChannelAffinityConfirmDialog
open={fillTemplateDialogOpen}
onOpenChange={setFillTemplateDialogOpen}
title={t('Fill Codex CLI / Claude CLI Templates')}
@@ -27,13 +27,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -46,6 +39,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types'
@@ -69,6 +63,8 @@ const CONTEXT_KEY_PRESETS = [
'specific_channel_id',
]
const RULE_FORM_ID = 'channel-affinity-rule-form'
interface RuleFormValues {
name: string
model_regex_text: string
@@ -230,228 +226,230 @@ export function RuleEditorDialog(props: Props) {
}
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-h-[85vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{isEdit ? t('Edit Rule') : t('Add Rule')}</DialogTitle>
</DialogHeader>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={isEdit ? t('Edit Rule') : t('Add Rule')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='pr-2'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' form={RULE_FORM_ID}>
{t('Save')}
</Button>
</>
}
>
<form
id={RULE_FORM_ID}
onSubmit={form.handleSubmit(handleSave)}
className='min-w-0 space-y-4 overflow-x-clip'
>
<div className='grid gap-1.5'>
<Label>{t('Name')} *</Label>
<Input
placeholder='prefer-by-conversation-id'
{...form.register('name', { required: true })}
/>
</div>
<form onSubmit={form.handleSubmit(handleSave)} className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='grid gap-1.5'>
<Label>{t('Name')} *</Label>
<Input
placeholder='prefer-by-conversation-id'
{...form.register('name', { required: true })}
<Label>{t('Model Regex (one per line)')} *</Label>
<Textarea
rows={4}
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
{...form.register('model_regex_text', { required: true })}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Model Regex (one per line)')} *</Label>
<Textarea
rows={4}
placeholder={'^gpt-4o.*$\n^claude-3.*$'}
{...form.register('model_regex_text', { required: true })}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Path Regex (one per line)')}</Label>
<Textarea
rows={4}
placeholder='/v1/chat/completions'
{...form.register('path_regex_text')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Path Regex (one per line)')}</Label>
<Textarea
rows={4}
placeholder='/v1/chat/completions'
{...form.register('path_regex_text')}
/>
</div>
</div>
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<SettingsSwitchField
checked={form.watch('skip_retry_on_failure')}
onCheckedChange={(v) => form.setValue('skip_retry_on_failure', v)}
label={t('Skip retry on failure')}
/>
<Separator />
<Separator />
{/* Key Sources */}
<div>
<div className='mb-2 flex items-center justify-between'>
<Label>{t('Key Sources')}</Label>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setKeySources((prev) => [
...prev,
{ type: 'gjson', path: '' },
])
}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add')}
</Button>
</div>
<p className='text-muted-foreground mb-2 text-xs'>
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
</p>
<div className='space-y-2'>
{keySources.map((src, idx) => (
<div key={idx} className='flex items-center gap-2'>
<Select
items={[
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
value={src.type}
onValueChange={(v) => {
if (v === null) return
const next = [...keySources]
next[idx] = normalizeKeySource({
...src,
type: v as KeySource['type'],
})
setKeySources(next)
}}
>
<SelectTrigger className='w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{KEY_SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
className='flex-1'
placeholder={
src.type === 'gjson'
? 'metadata.conversation_id'
: 'user_id'
}
value={
src.type === 'gjson' ? src.path || '' : src.key || ''
}
onChange={(e) => {
const next = [...keySources]
if (src.type === 'gjson') {
next[idx] = { ...src, path: e.target.value }
} else {
next[idx] = { ...src, key: e.target.value }
}
setKeySources(next)
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() =>
setKeySources((prev) => prev.filter((_, i) => i !== idx))
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
</div>
<Separator />
{/* Advanced */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
className='w-full justify-start'
/>
}
>
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
</CollapsibleTrigger>
<CollapsibleContent className='space-y-3 pt-2'>
<div className='grid gap-1.5'>
<Label>{t('User-Agent include (one per line)')}</Label>
<Textarea
rows={3}
placeholder='curl&#10;PostmanRuntime'
{...form.register('user_agent_include_text')}
/>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='grid gap-1.5'>
<Label>{t('Value Regex')}</Label>
<Input
placeholder='^[-0-9A-Za-z._:]{1,128}$'
{...form.register('value_regex')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('TTL (seconds, 0 = default)')}</Label>
<Input
type='number'
min={0}
{...form.register('ttl_seconds')}
/>
</div>
</div>
<div className='grid gap-1.5'>
<Label>{t('Parameter Override Template (JSON)')}</Label>
<Textarea
rows={5}
placeholder='{"operations": [...]}'
{...form.register('param_override_template_json')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
<SettingsSwitchField
checked={form.watch('include_using_group')}
onCheckedChange={(v) =>
form.setValue('include_using_group', v)
}
label={t('Include Group')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) =>
form.setValue('include_model_name', v)
}
label={t('Include Model')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
</div>
</CollapsibleContent>
</Collapsible>
<DialogFooter>
{/* Key Sources */}
<div>
<div className='mb-2 flex items-center justify-between'>
<Label>{t('Key Sources')}</Label>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
size='sm'
onClick={() =>
setKeySources((prev) => [...prev, { type: 'gjson', path: '' }])
}
>
{t('Cancel')}
<Plus className='mr-1 h-3 w-3' />
{t('Add')}
</Button>
<Button type='submit'>{t('Save')}</Button>
</DialogFooter>
</form>
</DialogContent>
</div>
<p className='text-muted-foreground mb-2 text-xs'>
{t('Common Keys')}: {CONTEXT_KEY_PRESETS.join(', ')}
</p>
<div className='space-y-2'>
{keySources.map((src, idx) => (
<div
key={idx}
className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center'
>
<Select
items={[
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
value={src.type}
onValueChange={(v) => {
if (v === null) return
const next = [...keySources]
next[idx] = normalizeKeySource({
...src,
type: v as KeySource['type'],
})
setKeySources(next)
}}
>
<SelectTrigger className='w-full sm:w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{KEY_SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Input
className='min-w-0 flex-1'
placeholder={
src.type === 'gjson'
? 'metadata.conversation_id'
: 'user_id'
}
value={src.type === 'gjson' ? src.path || '' : src.key || ''}
onChange={(e) => {
const next = [...keySources]
if (src.type === 'gjson') {
next[idx] = { ...src, path: e.target.value }
} else {
next[idx] = { ...src, key: e.target.value }
}
setKeySources(next)
}}
/>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() =>
setKeySources((prev) => prev.filter((_, i) => i !== idx))
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
))}
</div>
</div>
<Separator />
{/* Advanced */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='ghost'
className='w-full justify-start'
/>
}
>
{advancedOpen ? '▼' : '▶'} {t('Advanced Settings')}
</CollapsibleTrigger>
<CollapsibleContent className='space-y-3 pt-2'>
<div className='grid gap-1.5'>
<Label>{t('User-Agent include (one per line)')}</Label>
<Textarea
rows={3}
placeholder='curl&#10;PostmanRuntime'
{...form.register('user_agent_include_text')}
/>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='grid gap-1.5'>
<Label>{t('Value Regex')}</Label>
<Input
placeholder='^[-0-9A-Za-z._:]{1,128}$'
{...form.register('value_regex')}
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('TTL (seconds, 0 = default)')}</Label>
<Input
type='number'
min={0}
{...form.register('ttl_seconds')}
/>
</div>
</div>
<div className='grid gap-1.5'>
<Label>{t('Parameter Override Template (JSON)')}</Label>
<Textarea
rows={5}
placeholder='{"operations": [...]}'
{...form.register('param_override_template_json')}
className='font-mono text-xs'
/>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
<SettingsSwitchField
checked={form.watch('include_using_group')}
onCheckedChange={(v) => form.setValue('include_using_group', v)}
label={t('Include Group')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) => form.setValue('include_model_name', v)}
label={t('Include Model')}
className='border-b-0 py-0'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createAmountDiscountDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -57,6 +50,8 @@ type AmountDiscountDialogFormValues = z.infer<
ReturnType<typeof createAmountDiscountDialogSchema>
>
const AMOUNT_DISCOUNT_FORM_ID = 'amount-discount-form'
export type AmountDiscountData = {
amount: number
discountRate: number
@@ -115,102 +110,103 @@ export function AmountDiscountDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit discount tier') : t('Add discount tier')}
</DialogTitle>
<DialogDescription>
{t('Set a discount rate for a specific recharge amount threshold.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit discount tier') : t('Add discount tier')}
description={t(
'Set a discount rate for a specific recharge amount threshold.'
)}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
<FormControl>
<Input
type='number'
step='1'
min='1'
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Amount cannot be changed when editing.')
: t(
'Minimum recharge amount to qualify for this discount.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={AMOUNT_DISCOUNT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={AMOUNT_DISCOUNT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Recharge Amount (USD)')}</FormLabel>
<FormControl>
<Input
type='number'
step='1'
min='1'
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Amount cannot be changed when editing.')
: t(
'Minimum recharge amount to qualify for this discount.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='discountRate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Discount Rate')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0.01'
max='1'
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
= {discountPercentage}
{t('% off')}
</span>
)}
)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='discountRate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Discount Rate')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
min='0.01'
max='1'
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
= {discountPercentage}
{t('% off')}
</span>
)}
)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -48,6 +40,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import type { CreemProduct } from '@/features/wallet/types'
import { safeNumberFieldProps } from '../utils/numeric-field'
@@ -61,6 +54,8 @@ const creemProductDialogSchema = z.object({
type CreemProductDialogFormValues = z.infer<typeof creemProductDialogSchema>
const CREEM_PRODUCT_FORM_ID = 'creem-product-form'
// Re-export for backwards compatibility
export type CreemProductData = CreemProduct
@@ -119,150 +114,149 @@ export function CreemProductDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit product') : t('Add product')}
</DialogTitle>
<DialogDescription>
{t('Configure a Creem product for user recharge options.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit product') : t('Add product')}
description={t('Configure a Creem product for user recharge options.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' form={CREEM_PRODUCT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={CREEM_PRODUCT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Basic Package')} {...field} />
</FormControl>
<FormDescription>
{t('Display name shown to users.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='productId'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., prod_xxx')}
disabled={isEditMode}
{...field}
/>
</FormControl>
<FormDescription>
{t('Creem product ID from your Creem dashboard.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
name='currency'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Basic Package')} {...field} />
</FormControl>
<FormDescription>
{t('Display name shown to users.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='productId'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Product ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., prod_xxx')}
disabled={isEditMode}
{...field}
/>
</FormControl>
<FormDescription>
{t('Creem product ID from your Creem dashboard.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='currency'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Currency')}</FormLabel>
<Select
items={[
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select currency')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='USD'>USD ($)</SelectItem>
<SelectItem value='EUR'>EUR ()</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Price')}</FormLabel>
<FormLabel>{t('Currency')}</FormLabel>
<Select
items={[
{ value: 'USD', label: 'USD ($)' },
{ value: 'EUR', label: 'EUR (€)' },
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<Input
type='number'
step='0.01'
min={0.01}
placeholder='10.00'
{...safeNumberFieldProps(field)}
/>
<SelectTrigger>
<SelectValue placeholder={t('Select currency')} />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='USD'>USD ($)</SelectItem>
<SelectItem value='EUR'>EUR ()</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota'
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota')}</FormLabel>
<FormLabel>{t('Price')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder={t('e.g., 500000')}
step='0.01'
min={0.01}
placeholder='10.00'
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Amount of quota to credit to user account.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='quota'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
placeholder={t('e.g., 500000')}
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Amount of quota to credit to user account.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -23,14 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -41,6 +33,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -54,6 +47,8 @@ type PaymentMethodDialogFormValues = z.infer<
ReturnType<typeof createPaymentMethodDialogSchema>
>
const PAYMENT_METHOD_FORM_ID = 'payment-method-form'
export type PaymentMethodData = {
name: string
type: string
@@ -169,134 +164,133 @@ export function PaymentMethodDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit payment method') : t('Add payment method')}
</DialogTitle>
<DialogDescription>
{t('Configure a payment method for user recharge options.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit payment method') : t('Add payment method')}
description={t('Configure a payment method for user recharge options.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
</FormControl>
<FormDescription>
{t('Display name for this payment method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{t('Cancel')}
</Button>
<Button type='submit' form={PAYMENT_METHOD_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={PAYMENT_METHOD_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., Alipay, WeChat')} {...field} />
</FormControl>
<FormDescription>
{t('Display name for this payment method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<FormControl>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<FormControl>
<Combobox
options={PAYMENT_TYPES}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter payment type')}
searchPlaceholder={t('Search payment types...')}
allowCustomValue
/>
</FormControl>
<FormDescription>
{t('Select from presets or type custom identifier.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Color')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Combobox
options={PAYMENT_TYPES}
options={COLOR_PRESETS}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter payment type')}
searchPlaceholder={t('Search payment types...')}
placeholder={t('Select or enter color value')}
searchPlaceholder={t('Search colors...')}
allowCustomValue
className='flex-1'
/>
</FormControl>
<FormDescription>
{t('Select from presets or type custom identifier.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Color')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Combobox
options={COLOR_PRESETS}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select or enter color value')}
searchPlaceholder={t('Search colors...')}
allowCustomValue
className='flex-1'
{colorPreview && (
<div
className='size-9 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
title={colorPreview}
/>
{colorPreview && (
<div
className='size-9 shrink-0 rounded border'
style={{ backgroundColor: colorPreview }}
title={colorPreview}
/>
)}
</div>
</FormControl>
<FormDescription>
{t('Select preset or enter custom CSS color value.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</FormControl>
<FormDescription>
{t('Select preset or enter custom CSS color value.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='min_topup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
placeholder={t('e.g., 50')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional minimum recharge amount for this method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='min_topup'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Minimum top-up (optional)')}</FormLabel>
<FormControl>
<Input
type='number'
step='0.01'
placeholder={t('e.g., 50')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional minimum recharge amount for this method.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
@@ -41,6 +34,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
export interface WaffoSettingsValues {
@@ -411,101 +405,16 @@ export function WaffoSettingsSection({
</div>
</div>
<Dialog open={methodDialogOpen} onOpenChange={setMethodDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingIdx === -1
? t('Add payment method')
: t('Edit payment method')}
</DialogTitle>
</DialogHeader>
<div className='space-y-3'>
<div className='grid gap-1.5'>
<Label>{t('Display name')} *</Label>
<Input
value={methodForm.name}
onChange={(e) =>
setMethodForm((p) => ({ ...p, name: e.target.value }))
}
/>
</div>
<div className='grid gap-2'>
<Label>{t('Icon')}</Label>
<div className='flex items-center gap-3'>
{methodForm.icon ? (
<img
src={methodForm.icon}
alt={methodForm.name || t('Icon')}
className='h-10 w-10 rounded border object-contain p-1'
/>
) : (
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
{t('Icon')}
</div>
)}
<input
ref={iconFileInputRef}
type='file'
accept='image/png,image/jpeg,image/svg+xml,image/webp'
className='hidden'
onChange={handleIconFileChange}
/>
<Button
type='button'
variant='outline'
onClick={() => iconFileInputRef.current?.click()}
>
{t('Upload')}
</Button>
{methodForm.icon ? (
<Button
type='button'
variant='outline'
onClick={() =>
setMethodForm((previous) => ({
...previous,
icon: '',
}))
}
>
{t('Clear')}
</Button>
) : null}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
)}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method type')}</Label>
<Input
value={methodForm.payMethodType}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodType: e.target.value,
}))
}
placeholder='CREDITCARD,DEBITCARD'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method name')}</Label>
<Input
value={methodForm.payMethodName}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodName: e.target.value,
}))
}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={methodDialogOpen}
onOpenChange={setMethodDialogOpen}
title={
editingIdx === -1 ? t('Add payment method') : t('Edit payment method')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -516,8 +425,94 @@ export function WaffoSettingsSection({
<Button type='button' onClick={saveMethod}>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3'>
<div className='grid gap-1.5'>
<Label>{t('Display name')} *</Label>
<Input
value={methodForm.name}
onChange={(e) =>
setMethodForm((p) => ({ ...p, name: e.target.value }))
}
/>
</div>
<div className='grid gap-2'>
<Label>{t('Icon')}</Label>
<div className='flex items-center gap-3'>
{methodForm.icon ? (
<img
src={methodForm.icon}
alt={methodForm.name || t('Icon')}
className='h-10 w-10 rounded border object-contain p-1'
/>
) : (
<div className='bg-muted text-muted-foreground flex h-10 w-10 items-center justify-center rounded border text-xs'>
{t('Icon')}
</div>
)}
<input
ref={iconFileInputRef}
type='file'
accept='image/png,image/jpeg,image/svg+xml,image/webp'
className='hidden'
onChange={handleIconFileChange}
/>
<Button
type='button'
variant='outline'
onClick={() => iconFileInputRef.current?.click()}
>
{t('Upload')}
</Button>
{methodForm.icon ? (
<Button
type='button'
variant='outline'
onClick={() =>
setMethodForm((previous) => ({
...previous,
icon: '',
}))
}
>
{t('Clear')}
</Button>
) : null}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Supports PNG, JPG, SVG, or WebP. Recommended size: 128×128 or smaller.'
)}
</p>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method type')}</Label>
<Input
value={methodForm.payMethodType}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodType: e.target.value,
}))
}
placeholder='CREDITCARD,DEBITCARD'
/>
</div>
<div className='grid gap-1.5'>
<Label>{t('Payment method name')}</Label>
<Input
value={methodForm.payMethodName}
onChange={(e) =>
setMethodForm((p) => ({
...p,
payMethodName: e.target.value,
}))
}
/>
</div>
</div>
</Dialog>
</>
)
@@ -22,15 +22,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatTimestamp, formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { Dialog } from '@/components/dialog'
import { SettingsSection } from '../components/settings-section'
type ReleaseInfo = {
@@ -140,38 +133,29 @@ export function UpdateCheckerSection({
</div>
</SettingsSection>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='max-h-[80vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{release?.tag_name
? t('New version available: {{version}}', {
version: release.tag_name,
})
: t('Release details')}
</DialogTitle>
{release?.published_at && (
<DialogDescription>
{t('Published')}{' '}
{formatTimestampToDate(
new Date(release.published_at).getTime(),
'milliseconds'
)}
</DialogDescription>
)}
</DialogHeader>
<div className='space-y-4'>
{release?.body ? (
<Markdown>{release.body}</Markdown>
) : (
<p className='text-muted-foreground text-sm'>
{t('No release notes provided.')}
</p>
)}
</div>
<DialogFooter>
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={
release?.tag_name
? t('New version available: {{version}}', {
version: release.tag_name,
})
: t('Release details')
}
description={
release?.published_at
? `${t('Published')} ${formatTimestampToDate(
new Date(release.published_at).getTime(),
'milliseconds'
)}`
: undefined
}
contentClassName='max-h-[80vh] overflow-y-auto'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='secondary'
@@ -185,8 +169,18 @@ export function UpdateCheckerSection({
{t('Open release')}
</Button>
)}
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{release?.body ? (
<Markdown>{release.body}</Markdown>
) : (
<p className='text-muted-foreground text-sm'>
{t('No release notes provided.')}
</p>
)}
</div>
</Dialog>
</>
)
@@ -30,14 +30,6 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Select,
@@ -56,6 +48,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import type { UpstreamChannel } from '../types'
import {
@@ -330,87 +323,89 @@ export function ChannelSelectorDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'>
<DialogHeader>
<DialogTitle>{t('Select Sync Channels')}</DialogTitle>
<DialogDescription>
{t('Choose channels to sync upstream ratio configurations from')}
</DialogDescription>
</DialogHeader>
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by name or URL...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='ps-8'
/>
</div>
</div>
<div className='flex-1 overflow-auto rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Select Sync Channels')}
description={t(
'Choose channels to sync upstream ratio configurations from'
)}
contentClassName='flex max-h-[90vh] max-w-[calc(100%-2rem)] flex-col sm:max-w-[90vw] xl:max-w-[1400px]'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm}>{t('Confirm Selection')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='flex flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by name or URL...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='ps-8'
/>
</div>
</div>
<div className='flex-1 overflow-auto rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
{t('No channels found')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} />
</div>
</Dialog>
)
}
@@ -33,14 +33,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -51,6 +43,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { safeJsonParse } from '../utils/json-parser'
type GroupRatioVisualEditorProps = {
@@ -677,25 +670,15 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
/>
{/* Auto Group Dialog */}
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add auto group')}</DialogTitle>
<DialogDescription>
{t('Add a group identifier to the auto assignment list.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group identifier')}</Label>
<Input
value={autoGroupInput}
onChange={(e) => setAutoGroupInput(e.target.value)}
placeholder={t('default')}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={autoGroupDialogOpen}
onOpenChange={setAutoGroupDialogOpen}
title={t('Add auto group')}
description={t('Add a group identifier to the auto assignment list.')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => setAutoGroupDialogOpen(false)}
@@ -703,30 +686,33 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Cancel')}
</Button>
<Button onClick={handleAutoGroupSave}>{t('Add')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group identifier')}</Label>
<Input
value={autoGroupInput}
onChange={(e) => setAutoGroupInput(e.target.value)}
placeholder={t('default')}
/>
</div>
</div>
</Dialog>
{/* User Group Dialog */}
<Dialog open={userGroupDialogOpen} onOpenChange={setUserGroupDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Add user group')}</DialogTitle>
<DialogDescription>
{t('Create a new user group to configure ratio overrides for.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('User group name')}</Label>
<Input
value={userGroupInput}
onChange={(e) => setUserGroupInput(e.target.value)}
placeholder={t('vip')}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={userGroupDialogOpen}
onOpenChange={setUserGroupDialogOpen}
title={t('Add user group')}
description={t(
'Create a new user group to configure ratio overrides for.'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => setUserGroupDialogOpen(false)}
@@ -734,8 +720,19 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
{t('Cancel')}
</Button>
<Button onClick={handleUserGroupSave}>{t('Add')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('User group name')}</Label>
<Input
value={userGroupInput}
onChange={(e) => setUserGroupInput(e.target.value)}
placeholder={t('vip')}
/>
</div>
</div>
</Dialog>
{/* Group Override Dialog */}
@@ -1016,51 +1013,52 @@ function SimpleGroupDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editData
? t('Edit {{title}}', { title })
: t('Add {{title}}', { title })}
</DialogTitle>
<DialogDescription>
{t('Configure the ratio for this group.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('default')}
disabled={!!editData}
/>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={value}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setValue(val)
}
}}
placeholder='1.0'
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
editData
? t('Edit {{title}}', { title })
: t('Add {{title}}', { title })
}
description={t('Configure the ratio for this group.')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Group name')}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('default')}
disabled={!!editData}
/>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={value}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setValue(val)
}
}}
placeholder='1.0'
/>
</div>
</div>
</Dialog>
)
}
@@ -1107,65 +1105,66 @@ function GroupOverrideDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editData ? t('Edit ratio override') : t('Add ratio override')}
</DialogTitle>
<DialogDescription>
{userGroup
? t(
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
{ userGroup }
)
: t(
'Configure a custom ratio for when users use a specific token group.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Target group')}</Label>
<Input
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
placeholder={t('edit_this')}
disabled={!!editData}
/>
<p className='text-muted-foreground text-xs'>
{t('The token group that will have a custom ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setRatio(val)
}
}}
placeholder='0.9'
/>
<p className='text-muted-foreground text-xs'>
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
userGroup: userGroup || t('this user group'),
targetGroup: targetGroup || t('this token group'),
})}
</p>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={editData ? t('Edit ratio override') : t('Add ratio override')}
description={
userGroup
? t(
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
{ userGroup }
)
: t(
'Configure a custom ratio for when users use a specific token group.'
)
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSave}>
{editData ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label>{t('Target group')}</Label>
<Input
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
placeholder={t('edit_this')}
disabled={!!editData}
/>
<p className='text-muted-foreground text-xs'>
{t('The token group that will have a custom ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Ratio')}</Label>
<Input
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
setRatio(val)
}
}}
placeholder='0.9'
/>
<p className='text-muted-foreground text-xs'>
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
userGroup: userGroup || t('this user group'),
targetGroup: targetGroup || t('this token group'),
})}
</p>
</div>
</div>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,6 +32,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const rateLimitDialogSchema = z.object({
groupName: z.string().min(1, 'Group name is required'),
@@ -55,6 +48,8 @@ const rateLimitDialogSchema = z.object({
type RateLimitDialogFormValues = z.infer<typeof rateLimitDialogSchema>
const RATE_LIMIT_FORM_ID = 'rate-limit-form'
export type RateLimitEntryData = {
groupName: string
maxRequests: number
@@ -105,126 +100,125 @@ export function RateLimitDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode
? t('Edit group rate limit')
: t('Add group rate limit')}
</DialogTitle>
<DialogDescription>
{t('Configure rate limiting rules for a specific user group.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
isEditMode ? t('Edit group rate limit') : t('Add group rate limit')
}
description={t(
'Configure rate limiting rules for a specific user group.'
)}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
<FormField
control={form.control}
name='groupName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group Name')}</FormLabel>
<FormControl>
{t('Cancel')}
</Button>
<Button type='submit' form={RATE_LIMIT_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={RATE_LIMIT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='groupName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., default, vip, premium')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Group name cannot be changed when editing.')
: t('Unique identifier for this group.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxRequests'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Requests (including failures)')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
placeholder={t('e.g., default, vip, premium')}
type='number'
min={0}
max={2147483647}
step={1}
{...field}
disabled={isEditMode}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{isEditMode
? t('Group name cannot be changed when editing.')
: t('Unique identifier for this group.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Total requests allowed per period. 0 = unlimited.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxRequests'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Max Requests (including failures)')}
</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={0}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Total requests allowed per period. 0 = unlimited.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='maxSuccess'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Successful Requests')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={1}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Only successful requests count toward this limit.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
<FormField
control={form.control}
name='maxSuccess'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Successful Requests')}</FormLabel>
<FormControl>
<div className='flex items-center gap-2'>
<Input
type='number'
min={1}
max={2147483647}
step={1}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
<span className='text-muted-foreground text-sm'>
{t('times')}
</span>
</div>
</FormControl>
<FormDescription>
{t('Only successful requests count toward this limit.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</Dialog>
)
}
@@ -21,13 +21,8 @@ import { ExternalLink, Copy, Music } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
export interface AudioClip {
@@ -152,32 +147,33 @@ export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
const clips = Array.isArray(props.clips) ? props.clips : []
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</DialogTitle>
</DialogHeader>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard
key={clip.clip_id || clip.id || idx}
clip={clip}
/>
))}
</div>
</ScrollArea>
)}
</DialogContent>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Music className='h-5 w-5' />
{t('Audio Preview')}
</>
}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
{clips.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{t('None')}
</p>
) : (
<ScrollArea className='max-h-[60vh]'>
<div className='space-y-3 pr-2'>
{clips.map((clip, idx) => (
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
))}
</div>
</ScrollArea>
)}
</Dialog>
)
}
File diff suppressed because it is too large Load Diff
@@ -20,15 +20,9 @@ import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface FailReasonDialogProps {
failReason: string
@@ -45,43 +39,42 @@ export function FailReasonDialog({
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Fail Reason Details')}</DialogTitle>
<DialogDescription>
{t('View the complete error message and details')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(failReason)}
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Fail Reason Details')}
description={t('View the complete error message and details')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(failReason)}
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
</div>
</ScrollArea>
</DialogContent>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
interface ImageDialogProps {
imageUrl: string
@@ -65,56 +59,55 @@ export function ImageDialog({
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Image Preview')}</DialogTitle>
<DialogDescription>
{taskId
? `${t('Task ID:')} ${taskId}`
: t('View the generated image')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Image Preview')}
description={
taskId ? `${t('Task ID:')} ${taskId}` : t('View the generated image')
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -20,15 +20,9 @@ import { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface PromptDialogProps {
prompt: string
@@ -47,69 +41,68 @@ export function PromptDialog({
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Prompt Details')}</DialogTitle>
<DialogDescription>
{t('View the complete prompt and its English translation')}
</DialogDescription>
</DialogHeader>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Prompt Details')}
description={t('View the complete prompt and its English translation')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(prompt)}
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
</p>
</div>
</div>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(prompt)}
onClick={() => copyToClipboard(promptEn)}
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
{promptEn}
</p>
</div>
</div>
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
size='sm'
className='absolute top-2 right-2 h-8 w-8 p-0'
onClick={() => copyToClipboard(promptEn)}
title={t('Copy to clipboard')}
>
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{promptEn}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -21,14 +21,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatQuota, formatCompactNumber } from '@/lib/format'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { getUserInfo } from '../../api'
import type { UserInfo } from '../../types'
@@ -88,104 +82,103 @@ export function UserInfoDialog({
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('User Information')}</DialogTitle>
<DialogDescription>
{t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('User Information')}
description={t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
)}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</div>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</DialogContent>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</Dialog>
)
}
@@ -33,13 +33,6 @@ import { SiGithub, SiDiscord } from 'react-icons/si'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
@@ -49,6 +42,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getUser,
@@ -318,121 +312,124 @@ export function UserBindingDialog(props: Props) {
return (
<>
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Link2 className='h-5 w-5' />
{t('Account Binding Management')}
</DialogTitle>
<DialogDescription className='sr-only'>
{t('Manage account bindings for this user')}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Link2 className='h-5 w-5' />
{t('Account Binding Management')}
</>
}
description={t('Manage account bindings for this user')}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
descriptionClassName='sr-only'
contentHeight='auto'
bodyClassName='space-y-4'
>
{loading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
{user && (
<p className='text-muted-foreground text-sm'>
{user.username} (ID: {user.id})
</p>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
onClick={() => setShowBoundOnly((v) => !v)}
/>
}
>
{showBoundOnly ? (
<Eye className='h-3.5 w-3.5' />
) : (
<EyeOff className='h-3.5 w-3.5' />
)}
{showBoundOnly ? t('Show All') : t('Bound Only')}
</TooltipTrigger>
<TooltipContent>
{showBoundOnly
? t('Show all providers including unbound')
: t('Show only bound providers')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
{user && (
<p className='text-muted-foreground text-sm'>
{user.username} (ID: {user.id})
</p>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Separator />
<ScrollArea className='max-h-[50vh]'>
{displayedBindings.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{showBoundOnly
? t('This user has no bindings')
: t('No providers available')}
</p>
) : (
<div className='grid grid-cols-1 gap-2 pr-3 lg:grid-cols-2'>
{displayedBindings.map((binding) => (
<div
key={binding.key}
className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${
!binding.isBound ? 'opacity-50' : ''
}`}
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='text-muted-foreground shrink-0'>
{binding.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>
{binding.label}
</span>
{!binding.isEnabled && (
<StatusBadge
variant='neutral'
label={t('Disabled')}
copyable={false}
size='sm'
/>
)}
</div>
<p className='text-muted-foreground max-w-[140px] truncate text-xs'>
{binding.isBound ? binding.value : t('Not bound')}
</p>
</div>
</div>
{binding.isBound && (
<Button
variant='ghost'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
onClick={() => setShowBoundOnly((v) => !v)}
/>
}
>
{showBoundOnly ? (
<Eye className='h-3.5 w-3.5' />
) : (
<EyeOff className='h-3.5 w-3.5' />
className='text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='h-3.5 w-3.5' />
</Button>
)}
{showBoundOnly ? t('Show All') : t('Bound Only')}
</TooltipTrigger>
<TooltipContent>
{showBoundOnly
? t('Show all providers including unbound')
: t('Show only bound providers')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
</div>
)}
</ScrollArea>
<Separator />
<ScrollArea className='max-h-[50vh]'>
{displayedBindings.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{showBoundOnly
? t('This user has no bindings')
: t('No providers available')}
</p>
) : (
<div className='grid grid-cols-1 gap-2 pr-3 lg:grid-cols-2'>
{displayedBindings.map((binding) => (
<div
key={binding.key}
className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${
!binding.isBound ? 'opacity-50' : ''
}`}
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='text-muted-foreground shrink-0'>
{binding.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>
{binding.label}
</span>
{!binding.isEnabled && (
<StatusBadge
variant='neutral'
label={t('Disabled')}
copyable={false}
size='sm'
/>
)}
</div>
<p className='text-muted-foreground max-w-[140px] truncate text-xs'>
{binding.isBound ? binding.value : t('Not bound')}
</p>
</div>
</div>
{binding.isBound && (
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='h-3.5 w-3.5' />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<p className='text-muted-foreground text-xs'>
{t('Bound')}: {boundCount} / {allBindings.length}
</p>
</div>
)}
</DialogContent>
<p className='text-muted-foreground text-xs'>
{t('Bound')}: {boundCount} / {allBindings.length}
</p>
</div>
)}
</Dialog>
<ConfirmDialog
@@ -23,16 +23,9 @@ import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { adjustUserQuota } from '../api'
import type { QuotaAdjustMode } from '../types'
@@ -115,73 +108,72 @@ export function UserQuotaDialog(props: UserQuotaDialogProps) {
: t('Enter amount in {{currency}}', { currency: currencyLabel })
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Adjust Quota')}</DialogTitle>
<DialogDescription>
{t('Select an operation mode and enter the amount')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{getPreviewText()}
</div>
<div className='space-y-2'>
<Label>{t('Mode')}</Label>
<div className='flex gap-1'>
{(['add', 'subtract', 'override'] as const).map((m) => (
<Button
key={m}
type='button'
variant='outline'
size='sm'
className={cn(
mode === m &&
'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
)}
onClick={() => {
setMode(m)
setAmount('')
}}
>
{m === 'add'
? t('Add')
: m === 'subtract'
? t('Subtract')
: t('Override')}
</Button>
))}
</div>
</div>
<div className='space-y-2'>
<Label>
{t('Amount')} ({currencyLabel})
</Label>
<Input
type='number'
step={tokensOnly ? 1 : 0.000001}
min={mode === 'override' ? undefined : 0}
placeholder={placeholder}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm()
}}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Adjust Quota')}
description={t('Select an operation mode and enter the amount')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm} disabled={loading}>
{loading ? t('Processing...') : t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>{getPreviewText()}</div>
<div className='space-y-2'>
<Label>{t('Mode')}</Label>
<div className='flex gap-1'>
{(['add', 'subtract', 'override'] as const).map((m) => (
<Button
key={m}
type='button'
variant='outline'
size='sm'
className={cn(
mode === m &&
'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
)}
onClick={() => {
setMode(m)
setAmount('')
}}
>
{m === 'add'
? t('Add')
: m === 'subtract'
? t('Subtract')
: t('Override')}
</Button>
))}
</div>
</div>
<div className='space-y-2'>
<Label>
{t('Amount')} ({currencyLabel})
</Label>
<Input
type='number'
step={tokensOnly ? 1 : 0.000001}
min={mode === 'override' ? undefined : 0}
placeholder={placeholder}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm()
}}
/>
</div>
</div>
</Dialog>
)
}
@@ -33,13 +33,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -52,6 +45,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { useBillingHistory } from '../../hooks/use-billing-history'
import {
@@ -101,222 +95,223 @@ export function BillingHistoryDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>{t('Billing History')}</DialogTitle>
<DialogDescription>
{t('View your topup transaction records and payment history')}
</DialogDescription>
</DialogHeader>
<div className='min-h-0 flex-1 space-y-3 sm:space-y-4'>
{/* Search and Filter Bar */}
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by order number...')}
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
className='h-9 pl-10'
/>
</div>
<Select
items={[
{ value: '10', label: t('10 / page') },
{ value: '20', label: t('20 / page') },
{ value: '50', label: t('50 / page') },
{ value: '100', label: t('100 / page') },
]}
value={pageSize.toString()}
onValueChange={(value) =>
value !== null && handlePageSizeChange(parseInt(value))
}
>
<SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='10'>{t('10 / page')}</SelectItem>
<SelectItem value='20'>{t('20 / page')}</SelectItem>
<SelectItem value='50'>{t('50 / page')}</SelectItem>
<SelectItem value='100'>{t('100 / page')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Billing History')}
description={t(
'View your topup transaction records and payment history'
)}
contentClassName='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-4xl'
contentHeight='auto'
bodyClassName='space-y-3'
>
<div className='min-h-0 space-y-3'>
{/* Search and Filter Bar */}
<div className='flex items-center gap-2'>
<div className='relative flex-1'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search by order number...')}
value={keyword}
onChange={(e) => handleSearch(e.target.value)}
className='h-9 pl-10'
/>
</div>
<Select
items={[
{ value: '10', label: t('10 / page') },
{ value: '20', label: t('20 / page') },
{ value: '50', label: t('50 / page') },
{ value: '100', label: t('100 / page') },
]}
value={pageSize.toString()}
onValueChange={(value) =>
value !== null && handlePageSizeChange(parseInt(value))
}
>
<SelectTrigger className='h-9 w-[92px] sm:w-32'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='10'>{t('10 / page')}</SelectItem>
<SelectItem value='20'>{t('20 / page')}</SelectItem>
<SelectItem value='50'>{t('50 / page')}</SelectItem>
<SelectItem value='100'>{t('100 / page')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Records List */}
<ScrollArea className='h-[calc(100dvh-15rem)] pr-3 sm:h-[500px] sm:pr-4'>
{loading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='rounded-lg border p-3 sm:p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' />
<Skeleton className='h-3 w-32' />
</div>
<Skeleton className='h-5 w-16' />
</div>
<div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
{/* Records List */}
<ScrollArea className='max-h-[min(54vh,520px)] pr-3 sm:pr-4'>
{loading ? (
<div className='space-y-3'>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className='rounded-lg border p-3 sm:p-4'>
<div className='flex items-start justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-48' />
<Skeleton className='h-3 w-32' />
</div>
<Skeleton className='h-5 w-16' />
</div>
<div className='mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4'>
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
<Skeleton className='h-3 w-full' />
</div>
))}
</div>
) : records.length === 0 ? (
<div className='text-muted-foreground flex h-[320px] flex-col items-center justify-center text-center sm:h-[400px]'>
<p className='text-sm font-medium'>
{t('No billing records found')}
</p>
<p className='mt-1 text-xs'>
{keyword
? t('Try adjusting your search')
: t('Your transaction history will appear here')}
</p>
</div>
) : (
<div className='space-y-3'>
{records.map((record) => {
const statusConfig = getStatusConfig(record.status)
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground truncate font-mono text-sm'>
{record.trade_no}
</code>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0'
onClick={() => copyToClipboard(record.trade_no)}
>
{copiedText === record.trade_no ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
{isAdmin && record.user_id != null && (
<StatusBadge
label={`${t('User ID')}: ${record.user_id}`}
variant='neutral'
size='sm'
copyText={String(record.user_id)}
/>
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatTimestamp(record.create_time)}
</div>
</div>
<StatusBadge
label={statusConfig.label}
variant={statusConfig.variant}
showDot
copyable={false}
/>
</div>
{/* Details Grid */}
<div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment Method')}
</Label>
<div className='text-sm font-medium'>
{getPaymentMethodName(record.payment_method, t)}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Amount')}
</Label>
<div className='text-sm font-semibold'>
{formatCurrencyFromUSD(record.amount, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment')}
</Label>
<div className='text-sm font-semibold text-red-600'>
{formatNumber(record.money)}
</div>
</div>
</div>
{/* Admin Actions */}
{isAdmin && record.status === 'pending' && (
<div className='mt-4 flex justify-end'>
<Button
size='sm'
variant='outline'
onClick={() => setConfirmTradeNo(record.trade_no)}
disabled={completing}
>
{t('Complete Order')}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</ScrollArea>
{/* Pagination */}
{!loading && records.length > 0 && (
<div className='flex flex-col items-center gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground text-xs sm:text-sm'>
{t('Showing')} {(page - 1) * pageSize + 1}-
{Math.min(page * pageSize, total)} {t('of')} {total}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className='h-8 w-8 p-0'
>
<ChevronLeft className='h-4 w-4' />
</Button>
<div className='text-muted-foreground flex items-center gap-1 text-sm'>
<span className='font-medium'>{page}</span>
<span>/</span>
<span>{totalPages}</span>
</div>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className='h-8 w-8 p-0'
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
))}
</div>
) : records.length === 0 ? (
<div className='text-muted-foreground flex min-h-40 flex-col items-center justify-center py-10 text-center'>
<p className='text-sm font-medium'>
{t('No billing records found')}
</p>
<p className='mt-1 text-xs'>
{keyword
? t('Try adjusting your search')
: t('Your transaction history will appear here')}
</p>
</div>
) : (
<div className='space-y-3'>
{records.map((record) => {
const statusConfig = getStatusConfig(record.status)
return (
<div
key={record.id}
className='hover:bg-muted/50 rounded-lg border p-3 transition-colors sm:p-4'
>
{/* Header Row */}
<div className='flex items-start justify-between gap-2'>
<div className='flex-1 space-y-1'>
<div className='flex min-w-0 items-center gap-2'>
<code className='text-foreground truncate font-mono text-sm'>
{record.trade_no}
</code>
<Button
variant='ghost'
size='sm'
className='h-5 w-5 p-0'
onClick={() => copyToClipboard(record.trade_no)}
>
{copiedText === record.trade_no ? (
<Check className='h-3 w-3' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
{isAdmin && record.user_id != null && (
<StatusBadge
label={`${t('User ID')}: ${record.user_id}`}
variant='neutral'
size='sm'
copyText={String(record.user_id)}
/>
)}
</div>
<div className='text-muted-foreground text-xs'>
{formatTimestamp(record.create_time)}
</div>
</div>
<StatusBadge
label={statusConfig.label}
variant={statusConfig.variant}
showDot
copyable={false}
/>
</div>
{/* Details Grid */}
<div className='mt-3 grid grid-cols-2 gap-3 sm:mt-4 sm:grid-cols-3 sm:gap-4'>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment Method')}
</Label>
<div className='text-sm font-medium'>
{getPaymentMethodName(record.payment_method, t)}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Amount')}
</Label>
<div className='text-sm font-semibold'>
{formatCurrencyFromUSD(record.amount, {
digitsLarge: 2,
digitsSmall: 2,
abbreviate: false,
})}
</div>
</div>
<div className='space-y-1'>
<Label className='text-muted-foreground text-xs'>
{t('Payment')}
</Label>
<div className='text-sm font-semibold text-red-600'>
{formatNumber(record.money)}
</div>
</div>
</div>
{/* Admin Actions */}
{isAdmin && record.status === 'pending' && (
<div className='mt-4 flex justify-end'>
<Button
size='sm'
variant='outline'
onClick={() => setConfirmTradeNo(record.trade_no)}
disabled={completing}
>
{t('Complete Order')}
</Button>
</div>
)}
</div>
)
})}
</div>
)}
</div>
</DialogContent>
</ScrollArea>
{/* Pagination */}
{!loading && records.length > 0 && (
<div className='flex flex-col items-center gap-3 border-t pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground text-xs sm:text-sm'>
{t('Showing')} {(page - 1) * pageSize + 1}-
{Math.min(page * pageSize, total)} {t('of')} {total}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1}
className='h-8 w-8 p-0'
>
<ChevronLeft className='h-4 w-4' />
</Button>
<div className='text-muted-foreground flex items-center gap-1 text-sm'>
<span className='font-medium'>{page}</span>
<span>/</span>
<span>{totalPages}</span>
</div>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages}
className='h-8 w-8 p-0'
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirm Complete Order Dialog */}
@@ -20,14 +20,7 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatNumber } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { formatCreemPrice } from '../../lib/format'
import type { CreemProduct } from '../../types'
@@ -51,33 +44,17 @@ export function CreemConfirmDialog({
if (!product) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>{t('Confirm Creem Purchase')}</DialogTitle>
<DialogDescription>
{t('Review your purchase details before proceeding.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Price')}</span>
<span className='font-medium text-indigo-600'>
{formatCreemPrice(product.price, product.currency)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium'>{formatNumber(product.quota)}</span>
</div>
</div>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Confirm Creem Purchase')}
description={t('Review your purchase details before proceeding.')}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-[425px]'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -89,8 +66,25 @@ export function CreemConfirmDialog({
{processing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Confirm Payment')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-3 py-3 sm:space-y-4 sm:py-4'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Product')}</span>
<span className='font-medium'>{product.name}</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Price')}</span>
<span className='font-medium text-indigo-600'>
{formatCreemPrice(product.price, product.currency)}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground'>{t('Quota')}</span>
<span className='font-medium'>{formatNumber(product.quota)}</span>
</div>
</div>
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatQuota } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { QUOTA_PER_DOLLAR } from '../../constants'
interface TransferDialogProps {
@@ -66,51 +59,18 @@ export function TransferDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-xl font-semibold'>
{t('Transfer Rewards')}
</DialogTitle>
<DialogDescription>
{t('Move affiliate rewards to your main balance')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')}
</Label>
<div className='text-2xl font-semibold'>
{formatQuota(availableQuota)}
</div>
</div>
<div className='space-y-3'>
<Label
htmlFor='transfer-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
>
{t('Transfer Amount')}
</Label>
<Input
id='transfer-amount'
type='number'
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={QUOTA_PER_DOLLAR}
max={availableQuota}
step={QUOTA_PER_DOLLAR}
className='font-mono text-lg'
/>
<p className='text-muted-foreground text-xs'>
{t('Minimum:')} {formatQuota(QUOTA_PER_DOLLAR)}
</p>
</div>
</div>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Transfer Rewards')}
description={t('Move affiliate rewards to your main balance')}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'
titleClassName='text-xl font-semibold'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -122,8 +82,41 @@ export function TransferDialog({
{transferring && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Transfer')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-3 sm:space-y-6 sm:py-4'>
<div className='space-y-2'>
<Label className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Available Rewards')}
</Label>
<div className='text-2xl font-semibold'>
{formatQuota(availableQuota)}
</div>
</div>
<div className='space-y-3'>
<Label
htmlFor='transfer-amount'
className='text-muted-foreground text-xs font-medium tracking-wider uppercase'
>
{t('Transfer Amount')}
</Label>
<Input
id='transfer-amount'
type='number'
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
min={QUOTA_PER_DOLLAR}
max={availableQuota}
step={QUOTA_PER_DOLLAR}
className='font-mono text-lg'
/>
<p className='text-muted-foreground text-xs'>
{t('Minimum:')} {formatQuota(QUOTA_PER_DOLLAR)}
</p>
</div>
</div>
</Dialog>
)
}