Commit 9ba251ce by QuentinHsu Committed by GitHub

perf(web): streamline table actions and destructive dialogs (#5645)

* perf(data-table): autosize action columns

- exclude actions columns from shared table width calculations so action cells size to their content.
- remove fixed size and w-* width overrides from feature action columns to preserve content-based layout.

* perf(data-table): streamline row action controls

- expose common edit and status actions directly while moving secondary actions into overflow menus.
- add shared row action menu helpers so static and table rows use consistent action controls.
- let action columns size to their content instead of relying on fixed widths.

* fix(web): localize destructive dialog copy

- route delete, reset, and batch update confirmation text through i18n.
- add locale entries for affected channel, model, system settings, and user dialogs.

* perf(web): unify destructive dialog actions

- align delete and cleanup confirmation buttons with the shared destructive variant.
- replace custom destructive color overrides with semantic button variants.
- clean up lint errors in touched dialog files before committing.

* fix(web): add user action success translations

- add localized success messages for user delete, status, and role changes.
- keep user management toast copy available across all frontend locales.

* fix(data-table): prevent mobile badge clipping

- expose badge cell slots so mobile card styles can target nested badge wrappers.
- reset badge margins in card rows to keep provider icons fully visible on small screens.
parent b191f473
......@@ -31,7 +31,7 @@
],
"import/first": "warn",
"import/newline-after-import": "warn",
"import/no-cycle": "warn",
"import/no-cycle": "error",
"import/no-duplicates": [
"error",
{
......
......@@ -76,6 +76,7 @@
- **可读性**:控制函数圈复杂度,复杂逻辑拆成小函数;变量与函数命名需有意义,遵循驼峰等常规约定。
- **TypeScript**:避免 `any`,优先具体类型或 `unknown`;为参数与返回值显式标注类型;仅类型用途的导入使用 `import type { X } from '...'`
- **类型检查**:每次改动 TypeScript 或 TSX 代码后都要执行类型检查(如 `bun run typecheck`);若出现类型错误,须修复至无错误为止,不得遗留。
- **Lint 检查**:每次完成代码改动前,必须对所涉及文件执行 lint 检查,并修复这些文件中的所有 lint error;不得遗留 error。warning 可按变更范围与风险评估处理。
- **解构**:对象非必要不要进行解构,特别是组件的 props;直接使用 `props.xxx` 更清晰,避免不必要的解构增加代码复杂度。
### 3.3 组件
......@@ -176,3 +177,4 @@
- **2026-01-28**:补充状态管理、API、表单、路由、错误处理、样式、文件组织、可访问性、安全、测试、依赖与构建部署规范。
- **2026-01-29**:重组文档结构,合并重复内容,明确主次与交叉引用。
- **2026-01-31**:在 3.2 中补充「类型检查」要求:改动 TS/TSX 后须执行 typecheck 并修复至无错。
- **2026-06-21**:在 3.2 中补充「Lint 检查」要求:完成代码改动前须修复所涉及文件的所有 lint error。
......@@ -24,6 +24,7 @@ type BadgeCellProps = React.HTMLAttributes<HTMLDivElement>
export function BadgeCell({ className, ...props }: BadgeCellProps) {
return (
<div
data-slot='badge-cell'
className={cn(
'-ml-1.5 flex max-w-full min-w-0 items-center gap-1 overflow-hidden [&_[data-slot=status-badge]]:max-w-full [&_[data-slot=status-badge]]:min-w-0',
className
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export function isContentSizedColumn(columnId: string): boolean {
return columnId === 'actions'
}
......@@ -18,27 +18,36 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { Table as TanstackTable } from '@tanstack/react-table'
import { isContentSizedColumn } from './content-sized-columns'
export function DataTableColgroup<TData>({
table,
}: {
table: TanstackTable<TData>
}) {
const columns = table.getVisibleLeafColumns()
const totalSize = columns.reduce((sum, col) => sum + col.getSize(), 0)
const sizedColumns = columns.filter(
(column) => !isContentSizedColumn(column.id)
)
const totalSize = sizedColumns.reduce((sum, col) => sum + col.getSize(), 0)
return (
<colgroup>
{columns.map((column) => (
<col
key={column.id}
style={{
width:
totalSize > 0
? `${(column.getSize() / totalSize) * 100}%`
: undefined,
}}
/>
))}
{columns.map((column) => {
const width = isContentSizedColumn(column.id)
? undefined
: getColumnWidth(column.getSize(), totalSize)
return <col key={column.id} style={{ width }} />
})}
</colgroup>
)
}
function getColumnWidth(columnSize: number, totalSize: number) {
if (totalSize <= 0) {
return undefined
}
return `${(columnSize / totalSize) * 100}%`
}
......@@ -23,6 +23,7 @@ import {
} from '@tanstack/react-table'
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { DataTableColumnHeader } from './column-header'
import { isContentSizedColumn } from './content-sized-columns'
import type { DataTableColumnClassName } from './types'
type DataTableHeaderProps<TData> = {
......@@ -49,7 +50,7 @@ export function DataTableHeader<TData>({
key={header.id}
colSpan={header.colSpan}
className={getColumnClassName?.(header.column.id, 'header')}
style={applyHeaderSize ? { width: header.getSize() } : undefined}
style={getHeaderSizeStyle(header, applyHeaderSize)}
>
{renderHeaderContent(header)}
</TableHead>
......@@ -60,6 +61,17 @@ export function DataTableHeader<TData>({
)
}
function getHeaderSizeStyle<TData>(
header: Header<TData, unknown>,
applyHeaderSize: boolean | undefined
) {
if (!applyHeaderSize || isContentSizedColumn(header.column.id)) {
return undefined
}
return { width: header.getSize() }
}
function renderHeaderContent<TData>(header: Header<TData, unknown>) {
if (header.isPlaceholder) return null
const { header: headerDef, meta } = header.column.columnDef
......
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
/*
Copyright (C) 2023-2026 QuantumNous
......@@ -18,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
import { cn } from '@/lib/utils'
......@@ -45,6 +45,7 @@ export type {
DataTableViewProps,
} from './types'
export { DataTableRow } from './data-table-row'
export { DataTableRowActionMenu } from './row-action-menu'
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
const rows = props.rows ?? props.table.getRowModel().rows
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { MoreHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
type DataTableRowActionMenuProps = {
children: React.ReactNode
ariaLabel: string
contentClassName?: string
modal?: boolean
onOpenChange?: (open: boolean) => void
}
export function DataTableRowActionMenu(props: DataTableRowActionMenuProps) {
return (
<DropdownMenu modal={props.modal} onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
size='icon'
className='data-popup-open:bg-muted'
aria-label={props.ariaLabel}
/>
}
>
<MoreHorizontal aria-hidden='true' />
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
className={cn('w-48', props.contentClassName)}
>
{props.children}
</DropdownMenuContent>
</DropdownMenu>
)
}
......@@ -19,11 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
import type * as React from 'react'
import type { Table as TanstackTable } from '@tanstack/react-table'
import { isContentSizedColumn } from './content-sized-columns'
export function getTableSizeStyle<TData>(
table: TanstackTable<TData>
): React.CSSProperties {
const width = table
.getVisibleLeafColumns()
.filter((column) => !isContentSizedColumn(column.id))
.reduce((total, column) => total + column.getSize(), 0)
return {
......
......@@ -28,9 +28,11 @@ export {
StaticDataTable,
type StaticDataTableColumn,
} from './static/static-data-table'
export { StaticRowActions } from './static/static-row-actions'
export { staticDataTableClassNames } from './static/static-data-table-classnames'
export {
DataTableRow,
DataTableRowActionMenu,
DataTableView,
type DataTableColumnClassName,
type DataTablePinnedColumn,
......
......@@ -96,7 +96,7 @@ function CompactContent<TData>({ row }: { row: Row<TData> }) {
{label}
</div>
)}
<div className='min-w-0 overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<div className='min-w-0 overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
......@@ -146,7 +146,7 @@ function FallbackContent<TData>({ row }: { row: Row<TData> }) {
return (
<div
key={cell.id}
className='flex justify-end overflow-hidden [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'
className='flex justify-end overflow-hidden [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'
>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell)}
......@@ -163,7 +163,7 @@ function FallbackContent<TData>({ row }: { row: Row<TData> }) {
<span className='text-muted-foreground shrink-0 text-[10px] font-medium select-none'>
{label}
</span>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_[data-slot=provider-badge]]:ml-0 [&_[data-slot=status-badge]]:ml-0'>
<div className='flex min-w-0 flex-1 items-center justify-end overflow-hidden text-xs [&_:is([data-slot=badge-cell],[data-slot=provider-badge],[data-slot=status-badge])]:ml-0'>
<StatusBadgeTypeContext.Provider value='text'>
{renderCellContent(cell) ?? '-'}
</StatusBadgeTypeContext.Provider>
......
......@@ -23,7 +23,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { PageFooterPortal } from '@/components/layout'
import { PageFooterPortal } from '@/components/layout/components/page-footer'
import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
......
......@@ -42,6 +42,6 @@ export const staticDataTableClassNames = {
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
topNumericCell: 'py-2 text-right font-mono',
mediumCell: 'font-medium',
actionHeaderCell: 'text-right',
actionCell: 'text-right',
actionHeaderCell: 'w-auto max-w-none text-right',
actionCell: 'w-auto max-w-none text-right',
} as const
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '../core/row-action-menu'
type StaticRowActionsProps = {
editLabel: string
deleteLabel: string
menuLabel: string
onEdit: () => void
onDelete: () => void
editDisabled?: boolean
deleteDisabled?: boolean
}
export function StaticRowActions(props: StaticRowActionsProps) {
return (
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='icon-sm'
onClick={props.onEdit}
disabled={props.editDisabled}
aria-label={props.editLabel}
>
<Pencil />
</Button>
<DataTableRowActionMenu ariaLabel={props.menuLabel}>
<DropdownMenuItem
onClick={props.onDelete}
disabled={props.deleteDisabled}
className='text-destructive focus:text-destructive'
>
{props.deleteLabel}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
import { cn } from '@/lib/utils'
import { TruncatedCell } from '@/components/data-table'
import { TruncatedCell } from '@/components/data-table/core/truncated-cell'
interface TruncatedTextProps {
text: string
......
......@@ -200,8 +200,11 @@ function PriorityCell({ channel }: { channel: Channel }) {
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={`This will update the priority to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
confirmText='Update'
desc={t(
'This will update the priority to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'priority', pendingValue, queryClient)
......@@ -255,8 +258,11 @@ function WeightCell({ channel }: { channel: Channel }) {
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Confirm Batch Update')}
desc={`This will update the weight to ${pendingValue} for all ${channelCount} channel(s) with tag "${tag}". Continue?`}
confirmText='Update'
desc={t(
'This will update the weight to {{value}} for all {{count}} channel(s) with tag "{{tag}}". Continue?',
{ value: pendingValue, count: channelCount, tag }
)}
confirmText={t('Update')}
handleConfirm={() => {
if (pendingValue !== null) {
handleUpdateTagField(tag, 'weight', pendingValue, queryClient)
......@@ -1108,7 +1114,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
return <DataTableRowActions row={row} />
},
size: 132,
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
......
......@@ -226,7 +226,9 @@ export function ChannelsPrimaryButtons() {
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
title={t('Delete All Disabled Channels?')}
desc='This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
desc={t(
'This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
)}
destructive
handleConfirm={() => {
handleDeleteAllDisabled(queryClient, (_count) => {
......
......@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useContext, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import type { Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Boxes,
......@@ -36,6 +36,8 @@ import {
Loader2,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
......@@ -50,7 +52,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { MODEL_FETCHABLE_TYPES } from '../constants'
import {
channelsQueryKeys,
......@@ -96,13 +98,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
e.stopPropagation()
setIsTesting(true)
try {
await handleTestChannel(
channel.id,
{ channelName: channel.name },
() => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
}
)
await handleTestChannel(channel.id, { channelName: channel.name }, () => {
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
})
} finally {
setIsTesting(false)
}
......@@ -145,6 +143,13 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
}
let statusIcon = <Power className='size-4' />
if (isTogglingStatus) {
statusIcon = <Loader2 className='size-4 animate-spin' />
} else if (isEnabled) {
statusIcon = <PowerOff className='size-4' />
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
......@@ -153,6 +158,25 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<Button
variant='ghost'
size='icon-sm'
onClick={(e) => {
e.stopPropagation()
handleEdit()
}}
aria-label={t('Edit')}
/>
}
>
<Pencil className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
......@@ -206,13 +230,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
/>
}
>
{isTogglingStatus ? (
<Loader2 className='size-4 animate-spin' />
) : isEnabled ? (
<PowerOff className='size-4' />
) : (
<Power className='size-4' />
)}
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
......@@ -232,14 +250,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{/* Test Connection */}
<DropdownMenuItem onClick={handleTest}>
{t('Test Connection')}
......@@ -343,8 +353,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Channel')}
desc={`Are you sure you want to delete "${channel.name}"? This action cannot be undone.`}
confirmText='Delete'
desc={t(
'Are you sure you want to delete channel "{{name}}"? This action cannot be undone.',
{ name: channel.name }
)}
confirmText={t('Delete')}
destructive
handleConfirm={() => {
handleDeleteChannel(channel.id, queryClient)
......
......@@ -17,18 +17,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal, Power, PowerOff, Pencil, Edit } from 'lucide-react'
import type { Row } from '@tanstack/react-table'
import { Power, PowerOff, Pencil, Edit } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { handleEnableTagChannels, handleDisableTagChannels } from '../lib'
import type { Channel } from '../types'
import { useChannels } from './channels-provider'
......@@ -64,27 +67,24 @@ export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit Tag */}
<DropdownMenuItem onClick={handleEditTag}>
{t('Edit Tag')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleEditTag}
aria-label={t('Edit Tag')}
/>
}
>
<Edit />
</TooltipTrigger>
<TooltipContent>{t('Edit Tag')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
{/* Batch Edit */}
<DropdownMenuItem onClick={handleBatchEdit}>
{t('Batch Edit')}
......@@ -110,7 +110,7 @@ export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DataTableRowActionMenu>
</div>
)
}
......@@ -872,7 +872,6 @@ function ChannelTestDialogContent({
)
},
enableSorting: false,
size: 120,
},
],
[
......@@ -1068,7 +1067,6 @@ function ChannelTestDialogContent({
{
columnId: 'actions',
side: 'right',
className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
cellClassName: 'bg-popover',
},
]}
......@@ -1077,7 +1075,7 @@ function ChannelTestDialogContent({
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
<col className='w-auto' />
</colgroup>
}
getColumnClassName={(columnId) =>
......
......@@ -387,7 +387,7 @@ export function MultiKeyManageDialog({
{
id: 'actions',
header: t('Actions'),
className: 'w-44 text-right',
className: 'text-right',
cell: (key) => (
<MultiKeyTableRowActions
keyIndex={key.index}
......
......@@ -186,7 +186,7 @@ export function OllamaModelsDialog({
setSelected((prev) => {
const next = new Set(prev)
filteredModels.forEach((m) => next.add(m.id))
return Array.from(next)
return [...next]
})
}
......@@ -201,8 +201,8 @@ export function OllamaModelsDialog({
const next =
mode === 'replace'
? Array.from(new Set(selected))
: Array.from(new Set([...existingModels, ...selected]))
? [...new Set(selected)]
: [...new Set([...existingModels, ...selected])]
try {
const res = await updateChannel(currentRow.id, { models: next.join(',') })
......@@ -587,7 +587,7 @@ export function OllamaModelsDialog({
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
variant='destructive'
disabled={isDeleting || !deleteTarget}
onClick={() => {
if (!deleteTarget) return
......
......@@ -314,7 +314,6 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
size: 88,
},
]
}
......@@ -51,7 +51,7 @@ export function ApiKeysDeleteDialog() {
} else {
toast.error(result.message || t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsDeleting(false)
......@@ -79,7 +79,7 @@ export function ApiKeysDeleteDialog() {
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
variant='destructive'
>
{isDeleting ? t('Deleting...') : t('Delete')}
</AlertDialogAction>
......
......@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useState } from 'react'
import { type Row } from '@tanstack/react-table'
import type { Row } from '@tanstack/react-table'
import {
Trash2,
Edit,
......@@ -28,22 +28,19 @@ import {
Copy,
Link,
Loader2,
MoreHorizontal as DotsHorizontalIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
......@@ -53,6 +50,8 @@ import {
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { updateApiKeyStatus } from '../api'
import { API_KEY_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import { apiKeySchema } from '../types'
......@@ -104,6 +103,7 @@ export function DataTableRowActions<TData>({
const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
const hasChatPresets = chatPresets.length > 0
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
const handleMenuOpenChange = useCallback(
(open: boolean) => {
......@@ -189,6 +189,13 @@ export function DataTableRowActions<TData>({
}
}
let statusIcon = <Power className='size-4' />
if (isTogglingStatus) {
statusIcon = <Loader2 className='size-4 animate-spin' />
} else if (isEnabled) {
statusIcon = <PowerOff className='size-4' />
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
......@@ -199,7 +206,7 @@ export function DataTableRowActions<TData>({
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
aria-label={toggleLabel}
className={
isEnabled
? 'text-destructive hover:text-destructive'
......@@ -208,123 +215,112 @@ export function DataTableRowActions<TData>({
/>
}
>
{isTogglingStatus ? (
<Loader2 className='size-4 animate-spin' />
) : isEnabled ? (
<PowerOff className='size-4' />
) : (
<Power className='size-4' />
)}
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
<TooltipContent>{toggleLabel}</TooltipContent>
</Tooltip>
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
size='icon-sm'
onClick={() => {
setCurrentRow(apiKey)
setOpen('update')
}}
aria-label={t('Edit')}
/>
}
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[200px]'>
<DropdownMenuItem
onClick={async () => {
const realKey = getCachedRealKey()
if (!realKey) return
const ok = await copyToClipboard(realKey)
if (ok) toast.success(t('Copied'))
}}
>
{t('Copy Key')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
const realKey = getCachedRealKey()
if (!realKey) return
const connStr = encodeConnectionString(
realKey,
getServerAddress()
)
const ok = await copyToClipboard(connStr)
if (ok) toast.success(t('Copied'))
}}
>
{t('Copy Connection Info')}
<DropdownMenuShortcut>
<Link size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(apiKey)
setOpen('update')
}}
>
{t('Edit')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
setResolvedKey(realKey)
setCurrentRow(apiKey)
setOpen('cc-switch')
}}
>
{t('CC Switch')}
<DropdownMenuShortcut>
<ArrowRightLeft size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{hasChatPresets && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{chatPresets.map((preset) => (
<DropdownMenuItem
key={preset.id}
onClick={() => handleOpenChatPreset(preset)}
>
{preset.name}
{preset.type !== 'web' && (
<DropdownMenuShortcut>
<ExternalLink size={16} />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(apiKey)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Edit />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu
ariaLabel={t('Open menu')}
contentClassName='w-[200px]'
modal={false}
onOpenChange={handleMenuOpenChange}
>
<DropdownMenuItem
onClick={async () => {
const realKey = getCachedRealKey()
if (!realKey) return
const ok = await copyToClipboard(realKey)
if (ok) toast.success(t('Copied'))
}}
>
{t('Copy Key')}
<DropdownMenuShortcut>
<Copy size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
const realKey = getCachedRealKey()
if (!realKey) return
const connStr = encodeConnectionString(realKey, getServerAddress())
const ok = await copyToClipboard(connStr)
if (ok) toast.success(t('Copied'))
}}
>
{t('Copy Connection Info')}
<DropdownMenuShortcut>
<Link size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
setResolvedKey(realKey)
setCurrentRow(apiKey)
setOpen('cc-switch')
}}
>
{t('CC Switch')}
<DropdownMenuShortcut>
<ArrowRightLeft size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{hasChatPresets && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>{t('Chat')}</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{chatPresets.map((preset) => (
<DropdownMenuItem
key={preset.id}
onClick={() => handleOpenChatPreset(preset)}
>
{preset.name}
{preset.type !== 'web' && (
<DropdownMenuShortcut>
<ExternalLink size={16} />
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(apiKey)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
......@@ -18,18 +18,20 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal, Pencil, Power, PowerOff, Trash2 } from 'lucide-react'
import type { Row } from '@tanstack/react-table'
import { Pencil, Power, PowerOff, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
handleDeleteModel,
......@@ -61,80 +63,77 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
handleToggleModelStatus(model.id, model.status, queryClient)
}
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
return (
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
size='icon-sm'
onClick={handleEdit}
aria-label={t('Edit')}
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* Edit */}
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Enable/Disable */}
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
<Pencil />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DropdownMenuSeparator />
{/* Delete */}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
aria-label={toggleLabel}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{isEnabled ? <PowerOff /> : <Power />}
</TooltipTrigger>
<TooltipContent>{toggleLabel}</TooltipContent>
</Tooltip>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Model')}
desc={`Are you sure you want to delete "${model.model_name}"? This action cannot be undone.`}
confirmText='Delete'
destructive
handleConfirm={() => {
handleDeleteModel(model.id, queryClient)
setDeleteConfirmOpen(false)
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setDeleteConfirmOpen(true)
}}
/>
</DropdownMenu>
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
<ConfirmDialog
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
title={t('Delete Model')}
desc={t(
'Are you sure you want to delete model "{{name}}"? This action cannot be undone.',
{ name: model.model_name }
)}
confirmText={t('Delete')}
destructive
handleConfirm={() => {
handleDeleteModel(model.id, queryClient)
setDeleteConfirmOpen(false)
}}
/>
</div>
)
}
......@@ -16,13 +16,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ColumnDef } from '@tanstack/react-table'
import type { ColumnDef } from '@tanstack/react-table'
import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import { formatTimestampToDate } from '@/lib/format'
import { getDeploymentStatusConfig } from '../constants'
import {
formatRemainingMinutes,
......@@ -113,8 +121,9 @@ export function useDeploymentsColumns(opts: {
header: t('Provider'),
cell: ({ row }) => {
const provider = row.original.provider
if (!provider)
if (!provider) {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<StatusBadge
label={String(provider)}
......@@ -194,8 +203,9 @@ export function useDeploymentsColumns(opts: {
typeof row.original.hardware_quantity === 'number'
? row.original.hardware_quantity
: null
if (!hardware)
if (!hardware) {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-full min-w-0 flex-nowrap items-center gap-2 overflow-hidden'>
<StatusBadge
......@@ -218,12 +228,12 @@ export function useDeploymentsColumns(opts: {
header: t('Created'),
meta: { mobileHidden: true },
cell: ({ row }) => {
const ts =
typeof row.original.created_at === 'number'
? row.original.created_at
: typeof row.original.created_at === 'string'
? Number(row.original.created_at)
: undefined
let ts: number | undefined
if (typeof row.original.created_at === 'number') {
ts = row.original.created_at
} else if (typeof row.original.created_at === 'string') {
ts = Number(row.original.created_at)
}
return (
<div className='min-w-[140px] font-mono text-sm'>
{formatTimestampToDate(ts)}
......@@ -249,56 +259,51 @@ export function useDeploymentsColumns(opts: {
<div className='-ml-2.5 flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
size='icon-sm'
onClick={() => opts.onViewLogs(id)}
title={t('View logs')}
aria-label={t('View logs')}
>
<Eye className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => opts.onViewDetails(id)}
title={t('View details')}
>
<Info className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => opts.onUpdateConfig(id)}
title={t('Update configuration')}
>
<Settings2 className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => opts.onExtend(id)}
title={t('Extend deployment')}
>
<Timer className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => opts.onRename(id, String(currentName))}
title={t('Rename deployment')}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => opts.onDelete(row.original)}
title={t('Delete')}
>
<Trash2 className='h-4 w-4 text-red-500' />
<Eye />
</Button>
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
<DropdownMenuItem onClick={() => opts.onViewDetails(id)}>
{t('View details')}
<DropdownMenuShortcut>
<Info size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => opts.onUpdateConfig(id)}>
{t('Update configuration')}
<DropdownMenuShortcut>
<Settings2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => opts.onExtend(id)}>
{t('Extend deployment')}
<DropdownMenuShortcut>
<Timer size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => opts.onRename(id, currentName)}>
{t('Rename deployment')}
<DropdownMenuShortcut>
<Pencil size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => opts.onDelete(row.original)}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
},
size: 180,
meta: { pinned: 'right' as const },
},
]
......
......@@ -308,7 +308,7 @@ export function DeploymentsTable() {
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
variant='destructive'
>
{isDeleting ? t('Deleting...') : t('Delete')}
</AlertDialogAction>
......
......@@ -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 { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Layers3,
......@@ -28,8 +28,13 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
......@@ -46,11 +51,9 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
import { prefillGroupsQueryKeys } from '../../lib'
import type { PrefillGroup } from '../../types'
......@@ -140,21 +143,245 @@ export function PrefillGroupManagementDialog({
try {
const response = await deletePrefillGroup(deleteState.group.id)
if (response.success) {
toast.success(`Deleted "${deleteState.group.name}"`)
toast.success(
t('Deleted "{{name}}"', { name: deleteState.group.name })
)
queryClient.invalidateQueries({
queryKey: prefillGroupsQueryKeys.lists(),
})
setDeleteState({ open: false, group: null })
} else {
toast.error(response.message || 'Failed to delete group')
toast.error(response.message || t('Failed to delete group'))
}
} catch (err: unknown) {
toast.error((err as Error)?.message || 'Failed to delete group')
toast.error((err as Error)?.message || t('Failed to delete group'))
} finally {
setIsDeleting(false)
}
}
let groupsContent: ReactNode
if (isLoading) {
groupsContent = (
<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>
)
} else if (normalizedGroups.length === 0) {
groupsContent = (
<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 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>
)
} else if (isMobile) {
groupsContent = (
<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>
<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'>{t('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'>{t('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>
)
} else {
groupsContent = (
<StaticDataTable
tableClassName='min-w-[680px]'
data={normalizedGroups}
getRowKey={({ group }) => group.id}
columns={[
{
id: 'group',
header: t('Group'),
cellClassName: 'align-top whitespace-normal',
cell: ({ group }) => (
<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>
),
},
{
id: 'type',
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
),
},
{
id: 'items',
header: t('Items'),
className: 'min-w-[240px]',
cellClassName: 'align-top whitespace-normal',
cell: ({ group, parsedItems }) => (
<>
<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>
</>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'text-right',
cellClassName: 'align-top',
cell: ({ group }) => (
<StaticRowActions
editLabel={t('Edit group')}
deleteLabel={t('Delete group')}
menuLabel={t('Open menu')}
onEdit={() => onEditGroup(group)}
onDelete={() => handleDeleteClick(group)}
/>
),
},
]}
/>
)
}
return (
<>
<Dialog
......@@ -219,236 +446,7 @@ export function PrefillGroupManagementDialog({
</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 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-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>
<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>
) : (
<StaticDataTable
tableClassName='min-w-[680px]'
data={normalizedGroups}
getRowKey={({ group }) => group.id}
columns={[
{
id: 'group',
header: t('Group'),
cellClassName: 'align-top whitespace-normal',
cell: ({ group }) => (
<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>
),
},
{
id: 'type',
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
),
},
{
id: 'items',
header: t('Items'),
className: 'min-w-[240px]',
cellClassName: 'align-top whitespace-normal',
cell: ({ group, parsedItems }) => (
<>
<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>
</>
),
},
{
id: 'actions',
header: t('Actions'),
className: 'w-[120px] text-right',
cellClassName: 'align-top',
cell: ({ group }) => (
<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>
),
},
]}
/>
)}
{groupsContent}
</div>
</Dialog>
......@@ -458,13 +456,14 @@ export function PrefillGroupManagementDialog({
title={t('Delete group')}
desc={
<p>
{t('Are you sure you want to delete')}{' '}
<span className='font-medium'>{deleteState.group?.name}</span>
{t('? This action cannot be undone.')}
{t(
'Are you sure you want to delete group "{{name}}"? This action cannot be undone.',
{ name: deleteState.group?.name ?? '' }
)}
</p>
}
destructive
confirmText={isDeleting ? 'Deleting...' : 'Delete'}
confirmText={isDeleting ? t('Deleting...') : t('Delete')}
isLoading={isDeleting}
handleConfirm={handleDeleteConfirm}
/>
......
......@@ -470,7 +470,6 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
cell: ({ row }) => {
return <DataTableRowActions row={row} />
},
size: 100,
enableSorting: false,
enableHiding: false,
meta: { pinned: 'right' as const },
......
......@@ -125,11 +125,12 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
const handleRemove = useCallback(async () => {
const methods = await fetchVerificationMethods()
const required: VerificationMethod | null = methods.has2FA
? '2fa'
: methods.hasPasskey
? 'passkey'
: null
let required: VerificationMethod | null = null
if (methods.has2FA) {
required = '2fa'
} else if (methods.hasPasskey) {
required = 'passkey'
}
if (!required) {
toast.error(
......@@ -205,6 +206,24 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
: t('Not used yet')
const showUnsupportedNotice = !supported && !enabled
let backupStatus: {
label: string
variant: 'success' | 'warning' | 'neutral'
} | null = null
if (status?.backup_eligible !== undefined) {
backupStatus = {
label: t('No backup'),
variant: 'neutral',
}
if (status.backup_eligible) {
backupStatus = {
label: status.backup_state ? t('Backed up') : t('Not backed up'),
variant: status.backup_state ? 'success' : 'warning',
}
}
}
return (
<>
......@@ -234,22 +253,10 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
showDot
copyable={false}
/>
{status?.backup_eligible !== undefined && (
{backupStatus && (
<StatusBadge
label={
status.backup_eligible
? status.backup_state
? t('Backed up')
: t('Not backed up')
: t('No backup')
}
variant={
status.backup_eligible
? status.backup_state
? 'success'
: 'warning'
: 'neutral'
}
label={backupStatus.label}
variant={backupStatus.variant}
showDot
copyable={false}
/>
......@@ -310,7 +317,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground'
variant='destructive'
disabled={removing}
onClick={(event) => {
event.preventDefault()
......
......@@ -16,25 +16,27 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type Row } from '@tanstack/react-table'
import type { Row } from '@tanstack/react-table'
import {
Trash2,
Edit,
Power,
PowerOff,
MoreHorizontal as DotsHorizontalIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { updateRedemptionStatus } from '../api'
import { REDEMPTION_STATUS, SUCCESS_MESSAGES } from '../constants'
import { isRedemptionExpired } from '../lib'
......@@ -77,66 +79,61 @@ export function DataTableRowActions<TData>({
const canToggle = !isUsed && !isExpired
return (
<div className='-ml-2'>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
size='icon-sm'
onClick={() => {
setCurrentRow(redemption)
setOpen('update')
}}
disabled={!canEdit}
aria-label={t('Edit')}
/>
}
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('update')
}}
disabled={!canEdit}
>
{t('Edit')}
<DropdownMenuShortcut>
<Edit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{canToggle && (
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
<Edit />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')} modal={false}>
{canToggle && (
<DropdownMenuItem onClick={handleToggleStatus}>
{isEnabled ? (
<>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</>
) : (
<>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{canToggle && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => {
setCurrentRow(redemption)
setOpen('delete')
}}
className='text-destructive focus:text-destructive'
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
</div>
)
}
......@@ -255,7 +255,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
size: 88,
},
]
}
......@@ -75,7 +75,7 @@ export function RedemptionsDeleteDialog() {
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
variant='destructive'
>
{isDeleting ? t('Deleting...') : t('Delete')}
</AlertDialogAction>
......
......@@ -16,16 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type Row } from '@tanstack/react-table'
import { MoreHorizontal, Pencil, Power, PowerOff } from 'lucide-react'
import type { Row } from '@tanstack/react-table'
import { Pencil, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import type { PlanRecord } from '../types'
import { useSubscriptions } from './subscriptions-provider'
......@@ -36,47 +35,59 @@ interface DataTableRowActionsProps {
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow, complianceConfirmed } = useSubscriptions()
const isEnabled = row.original.plan.enabled
const toggleLabel = isEnabled ? t('Disable') : t('Enable')
const handleEdit = () => {
setCurrentRow(row.original)
setOpen('update')
}
const handleToggleStatus = () => {
setCurrentRow(row.original)
setOpen('toggle-status')
}
return (
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant='ghost' className='h-8 w-8 p-0' />}
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
disabled={!complianceConfirmed}
onClick={handleEdit}
aria-label={t('Edit')}
/>
}
>
<Pencil />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
disabled={!complianceConfirmed}
onClick={handleToggleStatus}
aria-label={toggleLabel}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('update')
}}
>
<Pencil className='mr-2 h-4 w-4' />
{t('Edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!complianceConfirmed}
onClick={() => {
setCurrentRow(row.original)
setOpen('toggle-status')
}}
>
{row.original.plan.enabled ? (
<>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable')}
</>
) : (
<>
<Power className='mr-2 h-4 w-4' />
{t('Enable')}
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEnabled ? <PowerOff /> : <Power />}
</TooltipTrigger>
<TooltipContent>{toggleLabel}</TooltipContent>
</Tooltip>
</div>
)
}
......@@ -196,7 +196,6 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
size: 80,
},
],
[t]
......
......@@ -17,11 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { Pencil, Trash2, Plus } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BadgeCell, StaticDataTable } from '@/components/data-table'
import { BadgeCell } from '@/components/data-table/core/badge-cell'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { StatusBadge } from '@/components/status-badge'
import { useDeleteProvider } from '../hooks/use-custom-oauth-mutations'
import type { CustomOAuthProvider } from '../types'
......@@ -118,22 +120,13 @@ export function ProviderTable(props: ProviderTableProps) {
className: 'text-right',
cellClassName: 'text-right',
cell: (provider) => (
<div className='flex justify-end gap-1'>
<Button
variant='ghost'
size='sm'
onClick={() => props.onEdit(provider)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setDeleteTarget(provider)}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => props.onEdit(provider)}
onDelete={() => setDeleteTarget(provider)}
/>
),
},
]}
......
......@@ -20,7 +20,7 @@ import { useEffect, useMemo, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, Save } from 'lucide-react'
import { Plus, Trash2, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import dayjs from '@/lib/dayjs'
......@@ -55,7 +55,8 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
......@@ -419,24 +420,14 @@ export function AnnouncementsSection({
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (announcement) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(announcement)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(announcement)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(announcement)}
onDelete={() => handleDelete(announcement)}
/>
),
},
]}
......@@ -600,13 +591,16 @@ export function AnnouncementsSection({
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget === 'single'
? 'This announcement will be removed from the list.'
: `${selectedIds.length} announcements will be removed from the list.`}
? t('This announcement will be removed from the list.')
: t(
'{{count}} announcements will be removed from the list.',
{ count: selectedIds.length }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
<AlertDialogAction variant='destructive' onClick={confirmDelete}>
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
......
......@@ -20,7 +20,7 @@ import { useMemo, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, Save } from 'lucide-react'
import { Plus, Trash2, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getBgColorClass } from '@/lib/colors'
......@@ -54,7 +54,9 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { BadgeCell, StaticDataTable } from '@/components/data-table'
import { BadgeCell } from '@/components/data-table/core/badge-cell'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
......@@ -369,24 +371,14 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (apiInfo) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(apiInfo)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(apiInfo)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(apiInfo)}
onDelete={() => handleDelete(apiInfo)}
/>
),
},
]}
......@@ -526,13 +518,16 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget === 'single'
? 'This API shortcut will be removed from the list.'
: `${selectedIds.length} API shortcuts will be removed from the list.`}
? t('This API shortcut will be removed from the list.')
: t(
'{{count}} API shortcuts will be removed from the list.',
{ count: selectedIds.length }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
<AlertDialogAction variant='destructive' onClick={confirmDelete}>
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
......
......@@ -17,11 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo } from 'react'
import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators'
import { ChatDialog, type ChatEntryData } from './chat-dialog'
......@@ -171,22 +172,13 @@ export function ChatSettingsVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (chat) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(chat)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(chat.name)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(chat)}
onDelete={() => handleDelete(chat.name)}
/>
),
},
]}
......
......@@ -20,7 +20,7 @@ import { useEffect, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, Save } from 'lucide-react'
import { Plus, Trash2, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
......@@ -46,7 +46,8 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
......@@ -302,24 +303,14 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (faq) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(faq)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(faq)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(faq)}
onDelete={() => handleDelete(faq)}
/>
),
},
]}
......@@ -406,13 +397,15 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget === 'single'
? 'This FAQ entry will be removed from the list.'
: `${selectedIds.length} FAQ entries will be removed from the list.`}
? t('This FAQ entry will be removed from the list.')
: t('{{count}} FAQ entries will be removed from the list.', {
count: selectedIds.length,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
<AlertDialogAction variant='destructive' onClick={confirmDelete}>
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
......
......@@ -20,7 +20,7 @@ import { useEffect, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Plus, Edit, Trash2, Save } from 'lucide-react'
import { Plus, Trash2, Save } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
......@@ -45,7 +45,8 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
......@@ -319,24 +320,14 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
{
id: 'actions',
header: t('Actions'),
className: 'w-32',
cell: (group) => (
<div className='flex gap-2'>
<Button
onClick={() => handleEdit(group)}
size='sm'
variant='ghost'
>
<Edit className='h-4 w-4' />
</Button>
<Button
onClick={() => handleDelete(group)}
size='sm'
variant='ghost'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(group)}
onDelete={() => handleDelete(group)}
/>
),
},
]}
......@@ -445,13 +436,16 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget === 'single'
? 'This Uptime Kuma group will be removed from the list.'
: `${selectedIds.length} Uptime Kuma groups will be removed from the list.`}
? t('This Uptime Kuma group will be removed from the list.')
: t(
'{{count}} Uptime Kuma groups will be removed from the list.',
{ count: selectedIds.length }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
<AlertDialogAction variant='destructive' onClick={confirmDelete}>
{t('Delete')}
</AlertDialogAction>
</AlertDialogFooter>
......
......@@ -20,7 +20,8 @@ import { useState, useMemo } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { StatusBadge } from '@/components/status-badge'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isObjectRecord } from '../utils/json-validators'
......@@ -52,9 +53,9 @@ export function AmountDiscountVisualEditor({
return Object.entries(parsed)
.map(([amount, rate]) => ({
amount: parseInt(amount, 10),
amount: Number.parseInt(amount, 10),
discountRate:
typeof rate === 'number' ? rate : parseFloat(String(rate)),
typeof rate === 'number' ? rate : Number.parseFloat(String(rate)),
}))
.filter((item) => !isNaN(item.amount) && !isNaN(item.discountRate))
.sort((a, b) => a.amount - b.amount)
......@@ -180,32 +181,13 @@ export function AmountDiscountVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (discount) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(discount)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(discount.amount)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(discount)}
onDelete={() => handleDelete(discount.amount)}
/>
),
},
]}
......
......@@ -21,7 +21,8 @@ import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import {
formatCreemPrice,
formatQuotaShort,
......@@ -220,32 +221,13 @@ export function CreemProductsVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (product) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(product)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(product)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(product)}
onDelete={() => handleDelete(product)}
/>
),
},
]}
......
......@@ -19,6 +19,10 @@ For commercial licensing, please contact support@quantumnous.com
import { useState, useMemo } from 'react'
import { Lightbulb, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { ReactIconByName } from '@/components/react-icon-by-name'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
......@@ -26,8 +30,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { StaticDataTable } from '@/components/data-table'
import { ReactIconByName } from '@/components/react-icon-by-name'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators'
import {
......@@ -362,32 +365,13 @@ export function PaymentMethodsVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (method) => (
<div className='flex justify-end gap-2'>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleEdit(method)
}}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleDelete(method)
}}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(method)}
onDelete={() => handleDelete(method)}
/>
),
},
]}
......@@ -395,11 +379,20 @@ export function PaymentMethodsVisualEditor({
{/* Mobile card view */}
<div className='divide-y md:hidden'>
{filteredMethods.map((method, index) => {
{filteredMethods.map((method) => {
const iconName = getEffectiveIconName(method)
const methodKey = [
method.type,
method.name,
method.icon,
method.min_topup,
method.color,
]
.filter(Boolean)
.join('-')
return (
<div key={`${method.type}-${index}`} className='p-4'>
<div key={methodKey} className='p-4'>
<div className='mb-3 flex items-start justify-between'>
<div className='flex-1'>
<div className='mb-1 font-medium'>{method.name}</div>
......
......@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ChangeEvent, useRef, type SetStateAction, useState } from 'react'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
......@@ -26,7 +26,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
......@@ -364,30 +365,17 @@ export function WaffoSettingsSection({
className: 'text-right',
cellClassName: 'text-right',
cell: (_m, idx) => (
<div className='flex justify-end gap-1'>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() => openEdit(idx)}
>
<Pencil className='h-3 w-3' />
</Button>
<Button
type='button'
variant='ghost'
size='icon'
className='h-7 w-7'
onClick={() =>
onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx)
)
}
>
<Trash2 className='h-3 w-3' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => openEdit(idx)}
onDelete={() =>
onPayMethodsChange((prev) =>
prev.filter((_, i) => i !== idx)
)
}
/>
),
},
]}
......
......@@ -220,14 +220,15 @@ export function LogSettingsSection({
)
const logCleanupProcessed = logCleanupState?.processed ?? 0
const logCleanupTotal = logCleanupState?.total ?? 0
const logCleanupTaskId = logCleanupTask?.task_id
useEffect(() => {
if (!logCleanupTask || !isActiveLogCleanupTask(logCleanupTask)) return
if (!logCleanupTaskId || !logCleanupActive) return
let cancelled = false
const interval = window.setInterval(async () => {
try {
const res = await getSystemTask(logCleanupTask.task_id)
const res = await getSystemTask(logCleanupTaskId)
if (cancelled || !res.success || !res.data) return
setLogCleanupTask(res.data)
......@@ -253,7 +254,7 @@ export function LogSettingsSection({
cancelled = true
window.clearInterval(interval)
}
}, [logCleanupTask?.task_id, logCleanupTask?.status, t])
}, [logCleanupActive, logCleanupTaskId, t])
const onSubmit = async (values: LogSettingsFormValues) => {
if (values.LogConsumeEnabled === defaultEnabled) return
......@@ -558,7 +559,10 @@ export function LogSettingsSection({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={cleanupServerLogFiles}>
<AlertDialogAction
variant='destructive'
onClick={cleanupServerLogFiles}
>
{t('Confirm Cleanup')}
</AlertDialogAction>
</AlertDialogFooter>
......@@ -598,6 +602,7 @@ export function LogSettingsSection({
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={handleCleanLogs}
disabled={isStartingLogCleanup}
>
......
......@@ -553,7 +553,10 @@ export function PerformanceSection(props: Props) {
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={clearDiskCache}>
<AlertDialogAction
variant='destructive'
onClick={clearDiskCache}
>
{t('Confirm')}
</AlertDialogAction>
</AlertDialogFooter>
......
......@@ -17,8 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo, useEffect, useCallback, memo } from 'react'
import { Pencil, Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react'
import { Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Card,
......@@ -35,8 +39,7 @@ import {
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { safeJsonParse } from '../utils/json-parser'
type GroupRatioVisualEditorProps = {
......@@ -95,11 +98,11 @@ function buildGroupPricingRows(
})
const names = new Set([...Object.keys(ratioMap), ...Object.keys(usableMap)])
return Array.from(names).map((name) => ({
return [...names].map((name) => ({
_id: createGroupPricingId(),
name,
ratio: normalizeRatio(ratioMap[name]),
selectable: Object.prototype.hasOwnProperty.call(usableMap, name),
selectable: Object.hasOwn(usableMap, name),
description: String(usableMap[name] ?? ''),
}))
}
......@@ -246,7 +249,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
delete map[simpleEditData.name]
}
map[name] = parseFloat(value)
map[name] = Number.parseFloat(value)
const field =
simpleDialogType === 'groupRatio' ? 'GroupRatio' : 'TopupGroupRatio'
......@@ -441,26 +444,17 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (group) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleEdit('topupGroupRatio', group)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleSimpleDelete('topupGroupRatio', group.name)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() =>
handleSimpleEdit('topupGroupRatio', group)
}
onDelete={() =>
handleSimpleDelete('topupGroupRatio', group.name)
}
/>
),
},
]}
......@@ -553,32 +547,23 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (override) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideEdit(
userGroupData.userGroup,
override
)
}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleOverrideDelete(
userGroupData.userGroup,
override.targetGroup
)
}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() =>
handleOverrideEdit(
userGroupData.userGroup,
override
)
}
onDelete={() =>
handleOverrideDelete(
userGroupData.userGroup,
override.targetGroup
)
}
/>
),
},
]}
......@@ -615,7 +600,7 @@ export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
<div className='space-y-2'>
{autoGroupsList.map((group, index) => (
<div
key={index}
key={group}
className='flex items-center gap-2 rounded-md border p-3'
>
<GripVertical className='text-muted-foreground h-4 w-4' />
......@@ -826,7 +811,7 @@ function GroupPricingTable({
if (!name) continue
counts.set(name, (counts.get(name) ?? 0) + 1)
}
return Array.from(counts.entries())
return [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([name]) => name)
}, [rows])
......@@ -929,7 +914,7 @@ function GroupPricingTable({
{
id: 'actions',
header: t('Actions'),
className: 'w-16 text-right',
className: 'text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button
......@@ -1037,7 +1022,7 @@ function SimpleGroupDialog({
value={value}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
if (val === '' || !isNaN(Number.parseFloat(val))) {
setValue(val)
}
}}
......@@ -1082,7 +1067,7 @@ function GroupOverrideDialog({
const handleSave = () => {
if (!targetGroup.trim() || !ratio.trim()) return
const parsedRatio = parseFloat(ratio)
const parsedRatio = Number.parseFloat(ratio)
if (isNaN(parsedRatio)) return
onSave(targetGroup.trim(), parsedRatio, editData?.targetGroup)
......@@ -1137,7 +1122,7 @@ function GroupOverrideDialog({
value={ratio}
onChange={(e) => {
const val = e.target.value
if (val === '' || !isNaN(parseFloat(val))) {
if (val === '' || !isNaN(Number.parseFloat(val))) {
setRatio(val)
}
}}
......
......@@ -16,12 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ColumnDef } from '@tanstack/react-table'
import { Pencil, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import type { ColumnDef } from '@tanstack/react-table'
import { DataTableColumnHeader } from '@/components/data-table/core/column-header'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
getModeLabel,
getModeVariant,
......@@ -144,22 +144,13 @@ export function buildModelRatioColumns({
id: 'actions',
header: () => <div>{t('Actions')}</div>,
cell: ({ row }) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => onEdit(row.original)}
>
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => onDelete(row.original.name)}
>
<Trash2 />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => onEdit(row.original)}
onDelete={() => onDelete(row.original.name)}
/>
),
enableHiding: false,
},
......
......@@ -683,7 +683,6 @@ const ModelRatioVisualEditorComponent = forwardRef<
{
columnId: 'actions',
side: 'right',
className: 'w-24 min-w-24',
},
]}
colgroup={
......@@ -692,7 +691,7 @@ const ModelRatioVisualEditorComponent = forwardRef<
<col className='w-[300px]' />
<col className='w-[120px]' />
<col className='w-[300px]' />
<col className='w-24' />
<col className='w-auto' />
</colgroup>
}
renderRow={(row, { getCellClassName }) => (
......
......@@ -289,7 +289,7 @@ export const ToolPriceSettings = memo(function ToolPriceSettings({
{
id: 'actions',
header: t('Actions'),
className: 'w-[80px] text-right',
className: 'text-right',
cellClassName: 'text-right',
cell: (row) => (
<Button
......
......@@ -17,11 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useMemo } from 'react'
import { Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { StaticDataTable } from '@/components/data-table'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isObjectRecord } from '../utils/json-validators'
import { RateLimitDialog, type RateLimitEntryData } from './rate-limit-dialog'
......@@ -182,22 +183,13 @@ export function RateLimitVisualEditor({
className: 'text-right',
cellClassName: 'text-right',
cell: (limit) => (
<div className='flex justify-end gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleEdit(limit)}
>
<Pencil className='h-4 w-4' />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(limit.groupName)}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
<StaticRowActions
editLabel={t('Edit')}
deleteLabel={t('Delete')}
menuLabel={t('Open menu')}
onEdit={() => handleEdit(limit)}
onDelete={() => handleDelete(limit.groupName)}
/>
),
},
]}
......
......@@ -17,9 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { type Row } from '@tanstack/react-table'
import type { Row } from '@tanstack/react-table'
import {
MoreHorizontal,
Pencil,
Trash2,
Power,
......@@ -35,13 +34,16 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { UserSubscriptionsDialog } from '@/features/subscriptions/components/dialogs/user-subscriptions-dialog'
import { manageUser, resetUserPasskey, resetUserTwoFA } from '../api'
......@@ -52,7 +54,7 @@ import {
isUserDeleted,
} from '../constants'
import { getUserActionMessage } from '../lib'
import { type User, type ManageUserAction } from '../types'
import type { User, ManageUserAction } from '../types'
import { UserBindingDialog } from './dialogs/user-binding-dialog'
import { useUsers } from './users-provider'
......@@ -90,7 +92,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
result.message || t('Failed to {{action}} user', { action })
)
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
}
}
......@@ -104,7 +106,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} else {
toast.error(result.message || t('Failed to reset Passkey'))
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setResetPasskeyOpen(false)
......@@ -120,7 +122,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
} else {
toast.error(result.message || t('Failed to reset 2FA'))
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setResetTwoFAOpen(false)
......@@ -136,47 +138,42 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<div className='-ml-2'>
<DropdownMenu>
<DropdownMenuTrigger
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
size='icon-sm'
onClick={handleEdit}
aria-label={t('Edit')}
/>
}
>
<MoreHorizontal className='h-4 w-4' />
<span className='sr-only'>{t('Open menu')}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[180px]'>
<DropdownMenuItem onClick={handleEdit}>
{t('Edit')}
<Pencil />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu ariaLabel={t('Open menu')} contentClassName='w-48'>
{isDisabled ? (
<DropdownMenuItem onClick={() => handleManage('enable')}>
{t('Enable')}
<DropdownMenuShortcut>
<Pencil size={16} />
<Power size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{isDisabled ? (
<DropdownMenuItem onClick={() => handleManage('enable')}>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => handleManage('disable')}
disabled={isRoot}
>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
) : (
<DropdownMenuItem
onClick={() => handleManage('disable')}
disabled={isRoot}
>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{isAdmin && !isRoot && (
<DropdownMenuItem onClick={() => handleManage('demote')}>
......@@ -260,15 +257,17 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DataTableRowActionMenu>
<ConfirmDialog
open={resetPasskeyOpen}
onOpenChange={setResetPasskeyOpen}
title={t('Reset Passkey')}
desc={`Reset Passkey for ${user.username}? The user will need to register a new Passkey before using passwordless login.`}
confirmText='Reset Passkey'
desc={t(
'Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.',
{ username: user.username }
)}
confirmText={t('Reset Passkey')}
handleConfirm={handleResetPasskey}
/>
......@@ -276,8 +275,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
open={resetTwoFAOpen}
onOpenChange={setResetTwoFAOpen}
title={t('Reset Two-Factor Authentication')}
desc={`Reset 2FA for ${user.username}? The user must set up 2FA again to continue using it.`}
confirmText='Reset 2FA'
desc={t(
'Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.',
{ username: user.username }
)}
confirmText={t('Reset 2FA')}
handleConfirm={handleResetTwoFA}
/>
......
......@@ -19,16 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { deleteUser } from '../api'
import { ERROR_MESSAGES } from '../constants'
import { getUserActionMessage } from '../lib'
......@@ -52,7 +43,7 @@ export function UsersDeleteDialog() {
} else {
toast.error(result.message || t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch (_error) {
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsDeleting(false)
......@@ -60,32 +51,21 @@ export function UsersDeleteDialog() {
}
return (
<AlertDialog
<ConfirmDialog
open={open === 'delete'}
onOpenChange={(open) => !open && setOpen(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Are you sure?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('This will permanently delete user')}{' '}
<span className='font-semibold'>{currentRow?.username}</span>
{t('. This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t('Are you sure?')}
desc={
<>
{t('This will permanently delete user')}{' '}
<span className='font-semibold'>{currentRow?.username}</span>
{t('. This action cannot be undone.')}
</>
}
confirmText={isDeleting ? t('Deleting...') : t('Delete')}
destructive
isLoading={isDeleting}
handleConfirm={handleDelete}
/>
)
}
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} Models",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} completed",
"{{count}} announcements will be removed from the list.": "{{count}} announcements will be removed from the list.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} API shortcuts will be removed from the list.",
"{{count}} channel(s) deleted": "{{count}} channel(s) deleted",
"{{count}} channel(s) disabled": "{{count}} channel(s) disabled",
"{{count}} channel(s) enabled": "{{count}} channel(s) enabled",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "{{count}} days ago",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "{{count}} disabled channel(s) deleted",
"{{count}} FAQ entries will be removed from the list.": "{{count}} FAQ entries will be removed from the list.",
"{{count}} hours ago": "{{count}} hours ago",
"{{count}} incidents": "{{count}} incidents",
"{{count}} incidents in the last 24 hours": "{{count}} incidents in the last 24 hours",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} override",
"{{count}} selected targets available for bulk copy.": "{{count}} selected targets available for bulk copy.",
"{{count}} tiers": "{{count}} tiers",
"{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} Uptime Kuma groups will be removed from the list.",
"{{count}} vendors": "{{count}} vendors",
"{{count}} weeks ago": "{{count}} weeks ago",
"{{field}} updated to {{value}}": "{{field}} updated to {{value}}",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "Are you sure you want to delete",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Are you sure you want to delete {{count}} model(s)? This action cannot be undone.",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Are you sure you want to delete all auto-disabled keys? This action cannot be undone.",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.",
"Are you sure you want to delete this key? This action cannot be undone.": "Are you sure you want to delete this key? This action cannot be undone.",
"Are you sure you want to disable all enabled keys?": "Are you sure you want to disable all enabled keys?",
"Are you sure you want to enable all keys?": "Are you sure you want to enable all keys?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "Delete selected channels",
"Delete selected models": "Delete selected models",
"Deleted": "Deleted",
"Deleted \"{{name}}\"": "Deleted \"{{name}}\"",
"Deleted ({{id}})": "Deleted ({{id}})",
"Deleted {{count}} failed models": "Deleted {{count}} failed models",
"Deleted a custom OAuth provider": "Deleted a custom OAuth provider",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "Edit chat preset",
"Edit discount tier": "Edit discount tier",
"Edit FAQ": "Edit FAQ",
"Edit group": "Edit group",
"Edit group rate limit": "Edit group rate limit",
"Edit JSON object directly. Suitable for simple parameter overrides.": "Edit JSON object directly. Suitable for simple parameter overrides.",
"Edit JSON text directly. Format will be validated on save.": "Edit JSON text directly. Format will be validated on save.",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "Failed to delete channel",
"Failed to delete disabled channels": "Failed to delete disabled channels",
"Failed to delete failed models": "Failed to delete failed models",
"Failed to delete group": "Failed to delete group",
"Failed to delete invalid redemption codes": "Failed to delete invalid redemption codes",
"Failed to delete model": "Failed to delete model",
"Failed to delete provider": "Failed to delete provider",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "Resend ({{seconds}}s)",
"Reset": "Reset",
"Reset 2FA": "Reset 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.",
"Reset all model prices?": "Reset all model prices?",
"Reset all model ratios?": "Reset all model ratios?",
"Reset all settings to default values": "Reset all settings to default values",
......@@ -3610,6 +3621,7 @@
"Reset failed": "Reset failed",
"Reset model ratios": "Reset model ratios",
"Reset Passkey": "Reset Passkey",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.",
"Reset password": "Reset password",
"Reset Period": "Reset Period",
"Reset prices": "Reset prices",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "This action cannot be undone.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "This action cannot be undone. This will permanently delete your account and remove all your data from our servers.",
"This action will permanently remove 2FA protection from your account.": "This action will permanently remove 2FA protection from your account.",
"This announcement will be removed from the list.": "This announcement will be removed from the list.",
"This API shortcut will be removed from the list.": "This API shortcut will be removed from the list.",
"This channel has no configured models.": "This channel has no configured models.",
"This channel is not an Ollama channel.": "This channel is not an Ollama channel.",
"This channel type does not support fetching models": "This channel type does not support fetching models",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "This device does not support Passkey",
"This device does not support Passkey verification.": "This device does not support Passkey verification.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "This expression is too complex for the visual editor. Please switch to expression mode to edit.",
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.",
"This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.",
"This feature requires server-side WeChat configuration": "This feature requires server-side WeChat configuration",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "This site currently has {{count}} models enabled",
"This tier catches any request that did not match earlier tiers.": "This tier catches any request that did not match earlier tiers.",
"this token group": "this token group",
"This Uptime Kuma group will be removed from the list.": "This Uptime Kuma group will be removed from the list.",
"this user group": "this user group",
"This user has no bindings": "This user has no bindings",
"This week": "This week",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "This will delete all channel affinity cache entries still in memory.",
"This will delete temporary cache files that have not been used for more than 10 minutes": "This will delete temporary cache files that have not been used for more than 10 minutes",
"This will extend the deployment by the specified hours.": "This will extend the deployment by the specified hours.",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "This will permanently delete all manually and automatically disabled channels. This action cannot be undone.",
"This will permanently delete API key": "This will permanently delete API key",
"This will permanently delete redemption code": "This will permanently delete redemption code",
"This will permanently delete user": "This will permanently delete user",
"This will permanently remove all log entries created before {{date}}.": "This will permanently remove all log entries created before {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "This will permanently remove log entries before the selected timestamp.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?",
"This year": "This year",
"Three steps to get started": "Three steps to get started",
"Throughput": "Throughput",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "User Consumption Trend",
"User created successfully": "User created successfully",
"User dashboard and quota controls.": "User dashboard and quota controls.",
"User deleted successfully": "User deleted successfully",
"User demoted to regular user successfully": "User demoted to regular user successfully",
"User disabled successfully": "User disabled successfully",
"User enabled successfully": "User enabled successfully",
"User Exclusive Ratio": "User Exclusive Ratio",
"User group": "User group",
"User Group": "User Group",
......@@ -4653,6 +4676,7 @@
"User Information": "User Information",
"User Menu": "User Menu",
"User personal functions": "User personal functions",
"User promoted to admin successfully": "User promoted to admin successfully",
"User selectable": "User selectable",
"User Subscription Management": "User Subscription Management",
"User updated successfully": "User updated successfully",
......
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Modèles {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} terminé(s)",
"{{count}} announcements will be removed from the list.": "{{count}} annonces seront retirées de la liste.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} raccourcis API seront retirés de la liste.",
"{{count}} channel(s) deleted": "{{count}} canal(canaux) supprimé(s)",
"{{count}} channel(s) disabled": "{{count}} canal(canaux) désactivé(s)",
"{{count}} channel(s) enabled": "{{count}} canal(canaux) activé(s)",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "il y a {{count}} jours",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "{{count}} canal(canaux) désactivé(s) supprimé(s)",
"{{count}} FAQ entries will be removed from the list.": "{{count}} entrées de FAQ seront retirées de la liste.",
"{{count}} hours ago": "il y a {{count}} heures",
"{{count}} incidents": "{{count}} incidents",
"{{count}} incidents in the last 24 hours": "{{count}} incidents au cours des dernières 24 heures",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} remplacement",
"{{count}} selected targets available for bulk copy.": "{{count}} cibles sélectionnées disponibles pour la copie en lot.",
"{{count}} tiers": "{{count}} paliers",
"{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} groupes Uptime Kuma seront retirés de la liste.",
"{{count}} vendors": "{{count}} fournisseurs",
"{{count}} weeks ago": "il y a {{count}} semaines",
"{{field}} updated to {{value}}": "{{field}} mis à jour en {{value}}",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "Êtes-vous sûr de vouloir supprimer",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer {{count}} modèle(s) ? Cette action ne peut pas être annulée.",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer toutes les clés automatiquement désactivées ? Cette action ne peut pas être annulée.",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer le canal \"{{name}}\" ? Cette action ne peut pas être annulée.",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer le déploiement \"{{name}}\" ? Cette action est irréversible.",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "Voulez-vous vraiment supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "Voulez-vous vraiment supprimer le modèle \"{{name}}\" ? Cette action est irréversible.",
"Are you sure you want to delete this key? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer cette clé ? Cette action ne peut pas être annulée.",
"Are you sure you want to disable all enabled keys?": "Êtes-vous sûr de vouloir désactiver toutes les clés activées ?",
"Are you sure you want to enable all keys?": "Êtes-vous sûr de vouloir activer toutes les clés ?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "Supprimer les canaux sélectionnés",
"Delete selected models": "Supprimer les modèles sélectionnés",
"Deleted": "Supprimé",
"Deleted \"{{name}}\"": "\"{{name}}\" supprimé",
"Deleted ({{id}})": "Supprimé ({{id}})",
"Deleted {{count}} failed models": "{{count}} modèles en échec supprimés",
"Deleted a custom OAuth provider": "Fournisseur OAuth personnalisé supprimé",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "Modifier le préréglage de chat",
"Edit discount tier": "Modifier le palier de remise",
"Edit FAQ": "Modifier la FAQ",
"Edit group": "Modifier le groupe",
"Edit group rate limit": "Modifier la limite de taux de groupe",
"Edit JSON object directly. Suitable for simple parameter overrides.": "Modifier l'objet JSON directement. Adapté aux substitutions de paramètres simples.",
"Edit JSON text directly. Format will be validated on save.": "Modifier le texte JSON directement. Le format sera validé à l'enregistrement.",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "Échec de la suppression du canal",
"Failed to delete disabled channels": "Échec de la suppression des canaux désactivés",
"Failed to delete failed models": "Échec de la suppression des modèles en échec",
"Failed to delete group": "Échec de la suppression du groupe",
"Failed to delete invalid redemption codes": "Échec de la suppression des codes d'échange invalides",
"Failed to delete model": "Échec de la suppression du modèle",
"Failed to delete provider": "Échec de la suppression du fournisseur",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "Renvoyer ({{seconds}}s)",
"Reset": "Réinitialiser",
"Reset 2FA": "Réinitialiser la 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Réinitialiser la 2FA de {{username}} ? L’utilisateur devra configurer à nouveau la 2FA pour continuer à l’utiliser.",
"Reset all model prices?": "Réinitialiser tous les prix des modèles ?",
"Reset all model ratios?": "Réinitialiser tous les ratios de modèle ?",
"Reset all settings to default values": "Réinitialiser tous les paramètres aux valeurs par défaut",
......@@ -3610,6 +3621,7 @@
"Reset failed": "Échec de la réinitialisation",
"Reset model ratios": "Ratios de modèle réinitialisés",
"Reset Passkey": "Réinitialiser le Passkey",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "Réinitialiser la Passkey de {{username}} ? L’utilisateur devra enregistrer une nouvelle Passkey avant d’utiliser la connexion sans mot de passe.",
"Reset password": "Réinitialiser le mot de passe",
"Reset Period": "Période de réinitialisation",
"Reset prices": "Réinitialiser les prix",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "Cette action est irréversible.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Cette action est irréversible. Cela supprimera définitivement votre compte et toutes vos données de nos serveurs.",
"This action will permanently remove 2FA protection from your account.": "Cette action supprimera définitivement la protection 2FA de votre compte.",
"This announcement will be removed from the list.": "Cette annonce sera retirée de la liste.",
"This API shortcut will be removed from the list.": "Ce raccourci API sera retiré de la liste.",
"This channel has no configured models.": "Ce canal n'a aucun modèle configuré.",
"This channel is not an Ollama channel.": "Ce canal n'est pas un canal Ollama.",
"This channel type does not support fetching models": "Ce type de canal ne prend pas en charge la récupération de modèles",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "Cet appareil ne prend pas en charge Passkey",
"This device does not support Passkey verification.": "Cet appareil ne prend pas en charge la vérification par clé d'accès.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Cette expression est trop complexe pour l'éditeur visuel. Passez en mode expression pour la modifier.",
"This FAQ entry will be removed from the list.": "Cette entrée de FAQ sera retirée de la liste.",
"This feature is experimental. Configuration format and behavior may change.": "Cette fonctionnalité est expérimentale. Le format de configuration et le comportement peuvent changer.",
"This feature requires server-side WeChat configuration": "Cette fonctionnalité nécessite une configuration WeChat côté serveur",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Cet identifiant est envoyé au backend de paiement lors de la création d’une commande. Utilisez alipay pour Alipay, wxpay pour WeChat Pay, stripe pour Stripe. Les valeurs personnalisées doivent être prises en charge par votre fournisseur de paiement.",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "Ce site compte actuellement {{count}} modèles activés",
"This tier catches any request that did not match earlier tiers.": "Ce palier récupère toute requête qui ne correspond à aucun palier précédent.",
"this token group": "ce groupe de jetons",
"This Uptime Kuma group will be removed from the list.": "Ce groupe Uptime Kuma sera retiré de la liste.",
"this user group": "ce groupe d'utilisateurs",
"This user has no bindings": "Cet utilisateur n'a aucune liaison",
"This week": "Cette semaine",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "Cela supprimera toutes les entrées de cache d'affinité de canal encore en mémoire.",
"This will delete temporary cache files that have not been used for more than 10 minutes": "Cela supprimera les fichiers de cache temporaires inutilisés depuis plus de 10 minutes",
"This will extend the deployment by the specified hours.": "Cela prolongera le déploiement du nombre d'heures spécifié.",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "Cela supprimera définitivement tous les canaux désactivés manuellement et automatiquement. Cette action ne peut pas être annulée.",
"This will permanently delete API key": "Cela supprimera définitivement la clé API",
"This will permanently delete redemption code": "Cela supprimera définitivement le code d'échange",
"This will permanently delete user": "Cela supprimera définitivement l'utilisateur",
"This will permanently remove all log entries created before {{date}}.": "Cela supprimera définitivement toutes les entrées de journal créées avant le {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Cela supprimera définitivement les entrées de journal antérieures à l'horodatage sélectionné.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "La priorité de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mise à jour à {{value}}. Continuer ?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Le poids de tous les {{count}} canaux avec le tag \"{{tag}}\" sera mis à jour à {{value}}. Continuer ?",
"This year": "Cette année",
"Three steps to get started": "Trois étapes pour commencer",
"Throughput": "Débit",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "Tendance de consommation",
"User created successfully": "Utilisateur créé avec succès",
"User dashboard and quota controls.": "Tableau de bord utilisateur et contrôles de quotas.",
"User deleted successfully": "Utilisateur supprimé avec succès",
"User demoted to regular user successfully": "Utilisateur rétrogradé en utilisateur standard avec succès",
"User disabled successfully": "Utilisateur désactivé avec succès",
"User enabled successfully": "Utilisateur activé avec succès",
"User Exclusive Ratio": "Ratio exclusif utilisateur",
"User group": "Groupe utilisateur",
"User Group": "Groupe d'utilisateurs",
......@@ -4653,6 +4676,7 @@
"User Information": "Informations utilisateur",
"User Menu": "Menu utilisateur",
"User personal functions": "Fonctions personnelles de l'utilisateur",
"User promoted to admin successfully": "Utilisateur promu administrateur avec succès",
"User selectable": "Sélectionnable par l'utilisateur",
"User Subscription Management": "Gestion des abonnements utilisateur",
"User updated successfully": "Utilisateur mis à jour avec succès",
......
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\" original - model \":\" replacement - model \"}",
"{{category}} Models": "{{category}} モデル",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} 完了",
"{{count}} announcements will be removed from the list.": "{{count}} 件のお知らせがリストから削除されます。",
"{{count}} API shortcuts will be removed from the list.": "{{count}} 件の API ショートカットがリストから削除されます。",
"{{count}} channel(s) deleted": "{{count}} 個のチャネルを削除しました",
"{{count}} channel(s) disabled": "{{count}} 個のチャネルを無効にしました",
"{{count}} channel(s) enabled": "{{count}} 個のチャネルを有効にしました",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "{{count}} 日前",
"{{count}} days remaining": "残り {{count}} 日",
"{{count}} disabled channel(s) deleted": "{{count}} 個の無効チャネルを削除しました",
"{{count}} FAQ entries will be removed from the list.": "{{count}} 件の FAQ 項目がリストから削除されます。",
"{{count}} hours ago": "{{count}} 時間前",
"{{count}} incidents": "{{count}} 件のインシデント",
"{{count}} incidents in the last 24 hours": "過去 24 時間に {{count}} 件のインシデント",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} 個のオーバーライド",
"{{count}} selected targets available for bulk copy.": "一括コピーに使用できる対象が {{count}} 個選択されています。",
"{{count}} tiers": "{{count}} 段階",
"{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} 件の Uptime Kuma グループがリストから削除されます。",
"{{count}} vendors": "{{count}} ベンダー",
"{{count}} weeks ago": "{{count}} 週間前",
"{{field}} updated to {{value}}": "{{field}} を {{value}} に更新しました",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "削除してもよろしいですか",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "{{count}} 個のモデルを削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "すべての自動無効化されたキーを削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "チャネル \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "デプロイ \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "グループ \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "モデル \"{{name}}\" を削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to delete this key? This action cannot be undone.": "このキーを削除してもよろしいですか?この操作は元に戻せません。",
"Are you sure you want to disable all enabled keys?": "すべての有効なキーを無効にすることをよろしいですか?",
"Are you sure you want to enable all keys?": "すべてのキーを有効にすることをよろしいですか?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "選択したチャネルを削除",
"Delete selected models": "選択したモデルを削除",
"Deleted": "削除済み",
"Deleted \"{{name}}\"": "\"{{name}}\" を削除しました",
"Deleted ({{id}})": "削除済み ({{id}})",
"Deleted {{count}} failed models": "失敗したモデルを {{count}} 個削除しました",
"Deleted a custom OAuth provider": "カスタム OAuth プロバイダーを削除しました",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "チャットプリセットを編集",
"Edit discount tier": "割引ティアを編集",
"Edit FAQ": "FAQ を編集",
"Edit group": "グループを編集",
"Edit group rate limit": "グループレート制限を編集",
"Edit JSON object directly. Suitable for simple parameter overrides.": "JSONオブジェクトを直接編集します。シンプルなパラメータオーバーライドに適しています。",
"Edit JSON text directly. Format will be validated on save.": "JSONテキストを直接編集します。保存時にフォーマットが検証されます。",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "チャネルの削除に失敗しました",
"Failed to delete disabled channels": "無効化されたチャネルの削除に失敗しました",
"Failed to delete failed models": "失敗したモデルの削除に失敗しました",
"Failed to delete group": "グループの削除に失敗しました",
"Failed to delete invalid redemption codes": "無効な引き換えコードの削除に失敗しました",
"Failed to delete model": "モデルの削除に失敗しました",
"Failed to delete provider": "プロバイダーの削除に失敗しました",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "再送信 ({{seconds}}秒)",
"Reset": "リセット",
"Reset 2FA": "2FAをリセット",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "{{username}} の 2FA をリセットしますか?引き続き使用するには、2FA を再設定する必要があります。",
"Reset all model prices?": "すべてのモデル価格をリセットしますか?",
"Reset all model ratios?": "すべてのモデル比率をリセットしますか?",
"Reset all settings to default values": "すべての設定をデフォルト値にリセット",
......@@ -3610,6 +3621,7 @@
"Reset failed": "リセット失敗",
"Reset model ratios": "モデル倍率をリセットしました",
"Reset Passkey": "Passkeyリセット",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "{{username}} の Passkey をリセットしますか?パスワードレスログインを使用するには、新しい Passkey の登録が必要です。",
"Reset password": "パスワードをリセット",
"Reset Period": "リセット期間",
"Reset prices": "価格をリセット",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "この操作は元に戻せません。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "この操作は元に戻せません。これにより、あなたのアカウントは完全に削除され、すべてのデータがサーバーから削除されます。",
"This action will permanently remove 2FA protection from your account.": "この操作により、アカウントから2FA保護が完全に削除されます。",
"This announcement will be removed from the list.": "このお知らせはリストから削除されます。",
"This API shortcut will be removed from the list.": "この API ショートカットはリストから削除されます。",
"This channel has no configured models.": "このチャンネルには構成されたモデルがありません。",
"This channel is not an Ollama channel.": "このチャネルはOllamaチャネルではありません。",
"This channel type does not support fetching models": "このチャネルタイプはモデルの取得をサポートしていません",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "このデバイスはPasskeyをサポートしていません",
"This device does not support Passkey verification.": "このデバイスはPasskey認証をサポートしていません。",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "この式はビジュアルエディタでは扱いにくいです。式モードに切り替えて編集してください。",
"This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。",
"This feature is experimental. Configuration format and behavior may change.": "この機能は実験的です。設定フォーマットや動作は変更される可能性があります。",
"This feature requires server-side WeChat configuration": "この機能にはサーバー側のWeChat設定が必要です",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "注文作成時に、この識別子が決済バックエンドへ送信されます。Alipay は alipay、WeChat Pay は wxpay、Stripe は stripe を使ってください。カスタム値は決済サービス側で対応している必要があります。",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "このサイトでは現在 {{count}} 個のモデルが有効です",
"This tier catches any request that did not match earlier tiers.": "この段階は、前の段階に一致しなかったすべてのリクエストを受け取ります。",
"this token group": "このトークングループ",
"This Uptime Kuma group will be removed from the list.": "この Uptime Kuma グループはリストから削除されます。",
"this user group": "このユーザーグループ",
"This user has no bindings": "このユーザーには連携がありません",
"This week": "今週",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "メモリ内のすべてのチャネルアフィニティキャッシュエントリが削除されます。",
"This will delete temporary cache files that have not been used for more than 10 minutes": "10分以上使用されていない一時キャッシュファイルが削除されます",
"This will extend the deployment by the specified hours.": "これにより、デプロイメントを指定された時間分延長します。",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "手動および自動で無効化されたすべてのチャネルを完全に削除します。この操作は元に戻せません。",
"This will permanently delete API key": "これによりAPIキーが完全に削除されます",
"This will permanently delete redemption code": "これにより引き換えコードが完全に削除されます",
"This will permanently delete user": "これによりユーザーが完全に削除されます",
"This will permanently remove all log entries created before {{date}}.": "{{date}} より前に作成されたすべてのログエントリが完全に削除されます。",
"This will permanently remove log entries before the selected timestamp.": "選択したタイムスタンプより前のログエントリが完全に削除されます。",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの優先度を {{value}} に更新します。続行しますか?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "タグ \"{{tag}}\" の {{count}} 件すべてのチャネルの重みを {{value}} に更新します。続行しますか?",
"This year": "今年",
"Three steps to get started": "3ステップで始める",
"Throughput": "スループット",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "ユーザー消費トレンド",
"User created successfully": "ユーザーの作成に成功しました",
"User dashboard and quota controls.": "ユーザーダッシュボードとクォータ制御。",
"User deleted successfully": "ユーザーを削除しました",
"User demoted to regular user successfully": "ユーザーを通常ユーザーに降格しました",
"User disabled successfully": "ユーザーを無効化しました",
"User enabled successfully": "ユーザーを有効化しました",
"User Exclusive Ratio": "専用倍率",
"User group": "ユーザーグループ",
"User Group": "ユーザーグループ",
......@@ -4653,6 +4676,7 @@
"User Information": "ユーザー情報",
"User Menu": "ユーザーメニュー",
"User personal functions": "ユーザー個人機能",
"User promoted to admin successfully": "ユーザーを管理者に昇格しました",
"User selectable": "ユーザー選択可",
"User Subscription Management": "ユーザーサブスクリプション管理",
"User updated successfully": "ユーザーの更新に成功しました",
......
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Модели {{category}}",
"{{completed}}/{{total}} completed": "{{completed}}/{{total}} завершено",
"{{count}} announcements will be removed from the list.": "{{count}} объявлений будут удалены из списка.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} ярлыков API будут удалены из списка.",
"{{count}} channel(s) deleted": "Удалено {{count}} каналов",
"{{count}} channel(s) disabled": "Отключено {{count}} каналов",
"{{count}} channel(s) enabled": "Включено {{count}} каналов",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "{{count}} дней назад",
"{{count}} days remaining": "Осталось {{count}} дней",
"{{count}} disabled channel(s) deleted": "Удалено {{count}} отключённых каналов",
"{{count}} FAQ entries will be removed from the list.": "{{count}} записей FAQ будут удалены из списка.",
"{{count}} hours ago": "{{count}} часов назад",
"{{count}} incidents": "{{count}} инцидентов",
"{{count}} incidents in the last 24 hours": "{{count}} инцидентов за последние 24 часа",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} переопределений",
"{{count}} selected targets available for bulk copy.": "Для массового копирования выбрано целей: {{count}}.",
"{{count}} tiers": "{{count}} уровней",
"{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} групп Uptime Kuma будут удалены из списка.",
"{{count}} vendors": "поставщиков: {{count}}",
"{{count}} weeks ago": "{{count}} недель назад",
"{{field}} updated to {{value}}": "{{field}} обновлено на {{value}}",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "Вы уверены, что хотите удалить",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Вы уверены, что хотите удалить {{count}} модел(ей)? Это действие нельзя отменить.",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Вы уверены, что хотите удалить все автоматически отключённые ключи? Это действие нельзя отменить.",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "Вы уверены, что хотите удалить канал \"{{name}}\"? Это действие нельзя отменить.",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Вы уверены, что хотите удалить развертывание \"{{name}}\"? Это действие нельзя отменить.",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "Удалить группу \"{{name}}\"? Это действие нельзя отменить.",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "Удалить модель \"{{name}}\"? Это действие нельзя отменить.",
"Are you sure you want to delete this key? This action cannot be undone.": "Вы уверены, что хотите удалить этот ключ? Это действие нельзя отменить.",
"Are you sure you want to disable all enabled keys?": "Вы уверены, что хотите отключить все включённые ключи?",
"Are you sure you want to enable all keys?": "Вы уверены, что хотите включить все ключи?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "Удалить выбранные каналы",
"Delete selected models": "Удалить выбранные модели",
"Deleted": "Удалён",
"Deleted \"{{name}}\"": "\"{{name}}\" удалено",
"Deleted ({{id}})": "Удалён ({{id}})",
"Deleted {{count}} failed models": "Удалено неуспешных моделей: {{count}}",
"Deleted a custom OAuth provider": "Удалён пользовательский провайдер OAuth",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "Редактировать пресет чата",
"Edit discount tier": "Редактировать уровень скидки",
"Edit FAQ": "Редактировать FAQ",
"Edit group": "Редактировать группу",
"Edit group rate limit": "Редактировать лимит скорости группы",
"Edit JSON object directly. Suitable for simple parameter overrides.": "Редактируйте JSON-объект напрямую. Подходит для простых переопределений параметров.",
"Edit JSON text directly. Format will be validated on save.": "Редактируйте JSON-текст напрямую. Формат будет проверен при сохранении.",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "Не удалось удалить канал",
"Failed to delete disabled channels": "Не удалось удалить отключённые каналы",
"Failed to delete failed models": "Не удалось удалить неуспешные модели",
"Failed to delete group": "Не удалось удалить группу",
"Failed to delete invalid redemption codes": "Не удалось удалить недействительные коды активации",
"Failed to delete model": "Не удалось удалить модель",
"Failed to delete provider": "Не удалось удалить поставщика",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "Отправить повторно ({{seconds}}с)",
"Reset": "Сброс",
"Reset 2FA": "Сбросить 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Сбросить 2FA для {{username}}? Пользователь должен будет настроить 2FA заново, чтобы продолжить ее использовать.",
"Reset all model prices?": "Сбросить все цены моделей?",
"Reset all model ratios?": "Сбросить все соотношения моделей?",
"Reset all settings to default values": "Сбросить все настройки до значений по умолчанию",
......@@ -3610,6 +3621,7 @@
"Reset failed": "Ошибка сброса",
"Reset model ratios": "Коэффициенты моделей сброшены",
"Reset Passkey": "Сброс Passkey",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "Сбросить Passkey для {{username}}? Пользователю нужно будет зарегистрировать новый Passkey перед входом без пароля.",
"Reset password": "Сбросить пароль",
"Reset Period": "Период сброса",
"Reset prices": "Сбросить цены",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "Это действие невозможно отменить.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Это действие невозможно отменить. Это безвозвратно удалит вашу учетную запись и все ваши данные с наших серверов.",
"This action will permanently remove 2FA protection from your account.": "Это действие безвозвратно удалит защиту 2FA из вашей учетной записи.",
"This announcement will be removed from the list.": "Это объявление будет удалено из списка.",
"This API shortcut will be removed from the list.": "Этот ярлык API будет удален из списка.",
"This channel has no configured models.": "У этого канала нет настроенных моделей.",
"This channel is not an Ollama channel.": "Этот канал не является каналом Ollama.",
"This channel type does not support fetching models": "Этот тип канала не поддерживает получение моделей",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "Это устройство не поддерживает Passkey",
"This device does not support Passkey verification.": "Это устройство не поддерживает проверку с помощью Passkey.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Для визуального редактора это выражение слишком сложно. Переключитесь в режим выражения для правки.",
"This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.",
"This feature is experimental. Configuration format and behavior may change.": "Эта функция является экспериментальной. Формат конфигурации и поведение могут измениться.",
"This feature requires server-side WeChat configuration": "Эта функция требует серверной конфигурации WeChat",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Этот идентификатор отправляется в платежный backend при создании заказа. Для Alipay используйте alipay, для WeChat Pay — wxpay, для Stripe — stripe. Пользовательские значения должны поддерживаться вашим платежным провайдером.",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "На этом сайте сейчас включено моделей: {{count}}",
"This tier catches any request that did not match earlier tiers.": "Этот уровень обрабатывает все запросы, которые не совпали с предыдущими уровнями.",
"this token group": "эта группа токенов",
"This Uptime Kuma group will be removed from the list.": "Эта группа Uptime Kuma будет удалена из списка.",
"this user group": "эта группа пользователей",
"This user has no bindings": "У этого пользователя нет привязок",
"This week": "На этой неделе",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "Это удалит все записи кэша привязки каналов из памяти.",
"This will delete temporary cache files that have not been used for more than 10 minutes": "Будут удалены временные файлы кэша, не использовавшиеся более 10 минут",
"This will extend the deployment by the specified hours.": "Это продлит развертывание на указанное количество часов.",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "Это навсегда удалит все каналы, отключённые вручную и автоматически. Это действие нельзя отменить.",
"This will permanently delete API key": "Это безвозвратно удалит ключ API",
"This will permanently delete redemption code": "Это безвозвратно удалит код активации",
"This will permanently delete user": "Это безвозвратно удалит пользователя",
"This will permanently remove all log entries created before {{date}}.": "Это безвозвратно удалит все записи журнала, созданные до {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Это безвозвратно удалит записи журнала до выбранной временной метки.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Приоритет всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Вес всех каналов ({{count}}) с тегом \"{{tag}}\" будет изменен на {{value}}. Продолжить?",
"This year": "Этот год",
"Three steps to get started": "Три шага для начала работы",
"Throughput": "Пропускная способность",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "Тренд потребления",
"User created successfully": "Пользователь успешно создан",
"User dashboard and quota controls.": "Панель пользователя и управление квотами.",
"User deleted successfully": "Пользователь успешно удален",
"User demoted to regular user successfully": "Пользователь успешно понижен до обычного пользователя",
"User disabled successfully": "Пользователь успешно отключен",
"User enabled successfully": "Пользователь успешно включен",
"User Exclusive Ratio": "Эксклюзивный коэффициент",
"User group": "Группа пользователя",
"User Group": "Группа пользователей",
......@@ -4653,6 +4676,7 @@
"User Information": "Информация о пользователе",
"User Menu": "Меню пользователя",
"User personal functions": "Личные функции пользователя",
"User promoted to admin successfully": "Пользователь успешно повышен до администратора",
"User selectable": "Доступно пользователю",
"User Subscription Management": "Управление подписками пользователя",
"User updated successfully": "Пользователь успешно обновлен",
......
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "Mô hình {{category}}",
"{{completed}}/{{total}} completed": "Đã hoàn tất {{completed}}/{{total}}",
"{{count}} announcements will be removed from the list.": "{{count}} thông báo sẽ bị xóa khỏi danh sách.",
"{{count}} API shortcuts will be removed from the list.": "{{count}} lối tắt API sẽ bị xóa khỏi danh sách.",
"{{count}} channel(s) deleted": "Đã xóa {{count}} kênh",
"{{count}} channel(s) disabled": "Đã tắt {{count}} kênh",
"{{count}} channel(s) enabled": "Đã bật {{count}} kênh",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "{{count}} ngày trước",
"{{count}} days remaining": "{{count}} days remaining",
"{{count}} disabled channel(s) deleted": "Đã xóa {{count}} kênh đã tắt",
"{{count}} FAQ entries will be removed from the list.": "{{count}} mục FAQ sẽ bị xóa khỏi danh sách.",
"{{count}} hours ago": "{{count}} giờ trước",
"{{count}} incidents": "{{count}} sự cố",
"{{count}} incidents in the last 24 hours": "{{count}} sự cố trong 24 giờ qua",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} ghi đè",
"{{count}} selected targets available for bulk copy.": "Có {{count}} mục tiêu đã chọn để sao chép hàng loạt.",
"{{count}} tiers": "{{count}} bậc",
"{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} nhóm Uptime Kuma sẽ bị xóa khỏi danh sách.",
"{{count}} vendors": "{{count}} nhà cung cấp",
"{{count}} weeks ago": "{{count}} tuần trước",
"{{field}} updated to {{value}}": "{{field}} đã cập nhật thành {{value}}",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "Bạn có chắc chắn muốn xóa ",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "Bạn có chắc muốn xóa {{count}} mô hình không? Hành động này không thể hoàn tác.",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "Bạn có chắc chắn muốn xóa tất cả các khóa bị tắt tự động? Hành động này không thể hoàn tác.",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa kênh \"{{name}}\" không? Hành động này không thể hoàn tác.",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa triển khai \"{{name}}\" không? Hành động này không thể hoàn tác.",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa nhóm \"{{name}}\" không? Không thể hoàn tác thao tác này.",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "Bạn có chắc muốn xóa mô hình \"{{name}}\" không? Không thể hoàn tác thao tác này.",
"Are you sure you want to delete this key? This action cannot be undone.": "Bạn có chắc chắn muốn xóa khóa này? Hành động này không thể hoàn tác.",
"Are you sure you want to disable all enabled keys?": "Bạn có chắc chắn muốn vô hiệu hóa tất cả các khóa đang bật không?",
"Are you sure you want to enable all keys?": "Bạn có chắc chắn muốn bật tất cả các khóa không?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "Xóa các kênh đã chọn",
"Delete selected models": "Xóa các mô hình đã chọn",
"Deleted": "Đã xóa",
"Deleted \"{{name}}\"": "Đã xóa \"{{name}}\"",
"Deleted ({{id}})": "Đã xóa ({{id}})",
"Deleted {{count}} failed models": "Đã xóa {{count}} mô hình thất bại",
"Deleted a custom OAuth provider": "Đã xóa nhà cung cấp OAuth tùy chỉnh",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "Chỉnh sửa cài đặt trước trò chuyện",
"Edit discount tier": "Chỉnh sửa bậc giảm giá",
"Edit FAQ": "Chỉnh sửa câu hỏi thường gặp",
"Edit group": "Sửa nhóm",
"Edit group rate limit": "Chỉnh sửa giới hạn tốc độ nhóm",
"Edit JSON object directly. Suitable for simple parameter overrides.": "Chỉnh sửa đối tượng JSON trực tiếp. Phù hợp cho ghi đè tham số đơn giản.",
"Edit JSON text directly. Format will be validated on save.": "Chỉnh sửa văn bản JSON trực tiếp. Định dạng sẽ được kiểm tra khi lưu.",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "Không thể xóa kênh",
"Failed to delete disabled channels": "Không thể xóa các kênh đã vô hiệu hóa",
"Failed to delete failed models": "Xóa các mô hình thất bại không thành công",
"Failed to delete group": "Không thể xóa nhóm",
"Failed to delete invalid redemption codes": "Không thể xóa các mã đổi thưởng không hợp lệ",
"Failed to delete model": "Không thể xóa mô hình",
"Failed to delete provider": "Xóa nhà cung cấp thất bại",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "Gửi lại ({{seconds}}s)",
"Reset": "Đặt lại",
"Reset 2FA": "Đặt lại 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "Đặt lại 2FA cho {{username}}? Người dùng phải thiết lập lại 2FA để tiếp tục sử dụng.",
"Reset all model prices?": "Đặt lại tất cả giá mô hình?",
"Reset all model ratios?": "Đặt lại tất cả tỷ lệ mô hình?",
"Reset all settings to default values": "Đặt lại tất cả cài đặt về giá trị mặc định",
......@@ -3610,6 +3621,7 @@
"Reset failed": "Đặt lại thất bại",
"Reset model ratios": "Đã đặt lại tỷ lệ mô hình",
"Reset Passkey": "Đặt lại Khóa truy cập",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "Đặt lại Passkey cho {{username}}? Người dùng cần đăng ký Passkey mới trước khi dùng đăng nhập không mật khẩu.",
"Reset password": "Đặt lại mật khẩu",
"Reset Period": "Chu kỳ đặt lại",
"Reset prices": "Đặt lại giá",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "Hành động này không thể hoàn tác.",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "Hành động này không thể hoàn tác. Việc này sẽ xóa vĩnh viễn tài khoản của bạn và loại bỏ tất cả dữ liệu của bạn khỏi máy chủ của chúng tôi.",
"This action will permanently remove 2FA protection from your account.": "Hành động này sẽ vĩnh viễn gỡ bỏ tính năng bảo vệ",
"This announcement will be removed from the list.": "Thông báo này sẽ bị xóa khỏi danh sách.",
"This API shortcut will be removed from the list.": "Lối tắt API này sẽ bị xóa khỏi danh sách.",
"This channel has no configured models.": "Kênh này không có mô hình nào được cấu hình.",
"This channel is not an Ollama channel.": "Kênh này không phải là kênh Ollama.",
"This channel type does not support fetching models": "Loại kênh này không hỗ trợ lấy mô hình",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "Thiết bị này không hỗ trợ Passkey",
"This device does not support Passkey verification.": "Thiết bị này không hỗ trợ xác minh Passkey.",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "Biểu thức này quá phức tạp cho trình sửa trực quan. Hãy chuyển sang chế độ biểu thức để chỉnh sửa.",
"This FAQ entry will be removed from the list.": "Mục FAQ này sẽ bị xóa khỏi danh sách.",
"This feature is experimental. Configuration format and behavior may change.": "Tính năng này đang ở giai đoạn thử nghiệm. Định dạng cấu hình và hành vi có thể thay đổi.",
"This feature requires server-side WeChat configuration": "Tính năng này yêu cầu cấu hình WeChat phía máy chủ",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Mã định danh này được gửi tới backend thanh toán khi tạo đơn hàng. Dùng alipay cho Alipay, wxpay cho WeChat Pay, stripe cho Stripe. Giá trị tùy chỉnh phải được nhà cung cấp thanh toán hỗ trợ.",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "Trang này hiện đã bật {{count}} mô hình",
"This tier catches any request that did not match earlier tiers.": "Tầng này bắt mọi yêu cầu không khớp với các tầng trước.",
"this token group": "nhóm token này",
"This Uptime Kuma group will be removed from the list.": "Nhóm Uptime Kuma này sẽ bị xóa khỏi danh sách.",
"this user group": "nhóm người dùng này",
"This user has no bindings": "Người dùng này không có liên kết nào",
"This week": "Tuần này",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "Thao tác này sẽ xóa tất cả mục bộ nhớ đệm ưu tiên kênh còn trong bộ nhớ.",
"This will delete temporary cache files that have not been used for more than 10 minutes": "Thao tác này sẽ xóa các tệp bộ nhớ đệm tạm không được sử dụng hơn 10 phút",
"This will extend the deployment by the specified hours.": "Thao tác này sẽ kéo dài triển khai thêm số giờ được chỉ định.",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "Thao tác này sẽ xóa vĩnh viễn tất cả các kênh bị tắt thủ công và tự động. Hành động này không thể hoàn tác.",
"This will permanently delete API key": "Thao tác này sẽ xóa vĩnh viễn khóa API",
"This will permanently delete redemption code": "Thao tác này sẽ xóa vĩnh viễn mã đổi thưởng.",
"This will permanently delete user": "Thao tác này sẽ xóa vĩnh viễn người dùng",
"This will permanently remove all log entries created before {{date}}.": "Thao tác này sẽ xóa vĩnh viễn tất cả các mục nhật ký được tạo trước {{date}}.",
"This will permanently remove log entries before the selected timestamp.": "Thao tác này sẽ xóa vĩnh viễn các mục nhật ký trước mốc thời gian đã chọn.",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật mức ưu tiên thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "Thao tác này sẽ cập nhật trọng số thành {{value}} cho tất cả {{count}} kênh có thẻ \"{{tag}}\". Tiếp tục?",
"This year": "Năm nay",
"Three steps to get started": "Ba bước để bắt đầu",
"Throughput": "Thông lượng",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "Xu hướng tiêu thụ",
"User created successfully": "Tạo người dùng thành công",
"User dashboard and quota controls.": "Bảng điều khiển người dùng và kiểm soát hạn ngạch.",
"User deleted successfully": "Xóa người dùng thành công",
"User demoted to regular user successfully": "Đã hạ cấp người dùng xuống người dùng thường thành công",
"User disabled successfully": "Tắt người dùng thành công",
"User enabled successfully": "Bật người dùng thành công",
"User Exclusive Ratio": "Tỷ lệ riêng",
"User group": "Nhóm người dùng",
"User Group": "Nhóm người dùng",
......@@ -4653,6 +4676,7 @@
"User Information": "Thông tin người dùng",
"User Menu": "Menu người dùng",
"User personal functions": "Chức năng cá nhân người dùng",
"User promoted to admin successfully": "Đã nâng cấp người dùng lên quản trị viên thành công",
"User selectable": "Người dùng có thể chọn",
"User Subscription Management": "Quản lý đăng ký người dùng",
"User updated successfully": "Cập nhật người dùng thành công",
......
......@@ -24,6 +24,8 @@
"{\"original-model\": \"replacement-model\"}": "{\"original-model\": \"replacement-model\"}",
"{{category}} Models": "{{category}} 模型",
"{{completed}}/{{total}} completed": "已完成 {{completed}}/{{total}}",
"{{count}} announcements will be removed from the list.": "将从列表中移除 {{count}} 条公告。",
"{{count}} API shortcuts will be removed from the list.": "将从列表中移除 {{count}} 个 API 快捷方式。",
"{{count}} channel(s) deleted": "已删除 {{count}} 个渠道",
"{{count}} channel(s) disabled": "已禁用 {{count}} 个渠道",
"{{count}} channel(s) enabled": "已启用 {{count}} 个渠道",
......@@ -32,6 +34,7 @@
"{{count}} days ago": "{{count}} 天前",
"{{count}} days remaining": "剩余 {{count}} 天",
"{{count}} disabled channel(s) deleted": "已删除 {{count}} 个已禁用的渠道",
"{{count}} FAQ entries will be removed from the list.": "将从列表中移除 {{count}} 个 FAQ 条目。",
"{{count}} hours ago": "{{count}} 小时前",
"{{count}} incidents": "{{count}} 起事件",
"{{count}} incidents in the last 24 hours": "最近 24 小时 {{count}} 个异常桶",
......@@ -44,6 +47,7 @@
"{{count}} override": "{{count}} 个覆盖",
"{{count}} selected targets available for bulk copy.": "已选择 {{count}} 个目标,可用于批量复制。",
"{{count}} tiers": "{{count}} 档",
"{{count}} Uptime Kuma groups will be removed from the list.": "将从列表中移除 {{count}} 个 Uptime Kuma 分组。",
"{{count}} vendors": "{{count}} 家厂商",
"{{count}} weeks ago": "{{count}} 周前",
"{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}",
......@@ -414,7 +418,10 @@
"Are you sure you want to delete": "您确定要删除吗",
"Are you sure you want to delete {{count}} model(s)? This action cannot be undone.": "您确定要删除 {{count}} 个模型吗?此操作无法撤销。",
"Are you sure you want to delete all auto-disabled keys? This action cannot be undone.": "您确定要删除所有自动禁用的密钥吗?此操作无法撤销。",
"Are you sure you want to delete channel \"{{name}}\"? This action cannot be undone.": "确定要删除渠道 \"{{name}}\" 吗?此操作无法撤销。",
"Are you sure you want to delete deployment \"{{name}}\"? This action cannot be undone.": "确定要删除部署 \"{{name}}\" 吗?此操作不可撤销。",
"Are you sure you want to delete group \"{{name}}\"? This action cannot be undone.": "确定要删除分组 \"{{name}}\" 吗?此操作无法撤销。",
"Are you sure you want to delete model \"{{name}}\"? This action cannot be undone.": "确定要删除模型 \"{{name}}\" 吗?此操作无法撤销。",
"Are you sure you want to delete this key? This action cannot be undone.": "您确定要删除此密钥吗?此操作无法撤销。",
"Are you sure you want to disable all enabled keys?": "您确定要禁用所有已启用的密钥吗?",
"Are you sure you want to enable all keys?": "您确定要启用所有密钥吗?",
......@@ -1237,6 +1244,7 @@
"Delete selected channels": "删除所选渠道",
"Delete selected models": "删除选定的模型",
"Deleted": "已注销",
"Deleted \"{{name}}\"": "已删除 \"{{name}}\"",
"Deleted ({{id}})": "已删除({{id}})",
"Deleted {{count}} failed models": "已删除 {{count}} 个失败模型",
"Deleted a custom OAuth provider": "删除了一个自定义 OAuth 提供方",
......@@ -1443,6 +1451,7 @@
"Edit chat preset": "编辑聊天预设",
"Edit discount tier": "编辑折扣档位",
"Edit FAQ": "编辑常见问题",
"Edit group": "编辑分组",
"Edit group rate limit": "编辑组速率限制",
"Edit JSON object directly. Suitable for simple parameter overrides.": "直接编辑 JSON 对象。适合简单覆盖参数的场景。",
"Edit JSON text directly. Format will be validated on save.": "直接编辑 JSON 文本,保存时会校验格式。",
......@@ -1718,6 +1727,7 @@
"Failed to delete channel": "删除渠道失败",
"Failed to delete disabled channels": "删除已禁用渠道失败",
"Failed to delete failed models": "删除失败模型失败",
"Failed to delete group": "删除分组失败",
"Failed to delete invalid redemption codes": "删除无效兑换码失败",
"Failed to delete model": "删除模型失败",
"Failed to delete provider": "删除提供商失败",
......@@ -3595,6 +3605,7 @@
"Resend ({{seconds}}s)": "重新发送 ({{seconds}}s)",
"Reset": "重置",
"Reset 2FA": "重置 2FA",
"Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.": "要重置 {{username}} 的 2FA 吗?该用户必须重新设置 2FA 后才能继续使用。",
"Reset all model prices?": "重置所有模型价格吗?",
"Reset all model ratios?": "重置所有模型比例吗?",
"Reset all settings to default values": "将所有设置重置为默认值",
......@@ -3610,6 +3621,7 @@
"Reset failed": "重置失败",
"Reset model ratios": "重置模型倍率",
"Reset Passkey": "重置 Passkey",
"Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.": "要重置 {{username}} 的 Passkey 吗?该用户需要重新注册 Passkey 后才能使用无密码登录。",
"Reset password": "重置密码",
"Reset Period": "重置周期",
"Reset prices": "重置价格",
......@@ -4259,6 +4271,8 @@
"This action cannot be undone.": "此操作无法撤消。",
"This action cannot be undone. This will permanently delete your account and remove all your data from our servers.": "此操作无法撤消。这将永久删除您的账户并从我们的服务器中移除您的所有数据。",
"This action will permanently remove 2FA protection from your account.": "此操作将永久移除您账户的 2FA 保护。",
"This announcement will be removed from the list.": "此公告将从列表中移除。",
"This API shortcut will be removed from the list.": "此 API 快捷方式将从列表中移除。",
"This channel has no configured models.": "该渠道没有配置模型。",
"This channel is not an Ollama channel.": "该渠道不是 Ollama 渠道。",
"This channel type does not support fetching models": "此渠道类型不支持获取模型",
......@@ -4269,6 +4283,7 @@
"This device does not support Passkey": "此设备不支持 Passkey",
"This device does not support Passkey verification.": "此设备不支持 Passkey 验证。",
"This expression is too complex for the visual editor. Please switch to expression mode to edit.": "此表达式对可视化编辑器过于复杂,请切换到表达式模式进行编辑。",
"This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。",
"This feature is experimental. Configuration format and behavior may change.": "此功能为实验性功能。配置格式和行为可能会发生变化。",
"This feature requires server-side WeChat configuration": "此功能需要服务器端微信配置",
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "创建订单时会把这个标识提交给支付后端。支付宝填 alipay,微信填 wxpay,Stripe 填 stripe。自定义值必须是支付服务支持的标识。",
......@@ -4288,6 +4303,7 @@
"This site currently has {{count}} models enabled": "本站当前已启用模型,总计 {{count}} 个",
"This tier catches any request that did not match earlier tiers.": "此阶梯会兜底处理未匹配前面阶梯的请求。",
"this token group": "此令牌分组",
"This Uptime Kuma group will be removed from the list.": "此 Uptime Kuma 分组将从列表中移除。",
"this user group": "此用户分组",
"This user has no bindings": "该用户无任何绑定",
"This week": "本周",
......@@ -4297,11 +4313,14 @@
"This will delete all channel affinity cache entries still in memory.": "这将删除内存中所有的渠道亲和性缓存条目。",
"This will delete temporary cache files that have not been used for more than 10 minutes": "这将删除超过 10 分钟未使用的临时缓存文件",
"This will extend the deployment by the specified hours.": "这将通过指定的小时数延长部署。",
"This will permanently delete all manually and automatically disabled channels. This action cannot be undone.": "这将永久删除所有手动禁用和自动禁用的渠道。此操作无法撤销。",
"This will permanently delete API key": "这将永久删除 API 密钥",
"This will permanently delete redemption code": "这将永久删除兑换码",
"This will permanently delete user": "这将永久删除用户",
"This will permanently remove all log entries created before {{date}}.": "这将永久删除 {{date}} 之前创建的所有日志条目。",
"This will permanently remove log entries before the selected timestamp.": "这将永久删除所选时间戳之前的日志条目。",
"This will update the priority to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的优先级更新为 {{value}}。继续吗?",
"This will update the weight to {{value}} for all {{count}} channel(s) with tag \"{{tag}}\". Continue?": "这会将标签 \"{{tag}}\" 下所有 {{count}} 个渠道的权重更新为 {{value}}。继续吗?",
"This year": "本年",
"Three steps to get started": "三步快速上手",
"Throughput": "吞吐量",
......@@ -4545,7 +4564,7 @@
"Updated system setting {{key}}": "修改系统设置 {{key}}",
"Updated user {{username}} (ID: {{id}})": "更新用户 {{username}}(ID: {{id}})",
"Updating all channel balances. This may take a while. Please refresh to see results.": "正在更新所有渠道余额。这可能需要一段时间。请刷新以查看结果。",
"Updating...": "更新中...",
"Updating...": "正在更新...",
"Upgrade Group": "升级分组",
"Upgrade plaintext SMTP connection with STARTTLS before authentication": "在身份验证前使用 STARTTLS 升级明文 SMTP 连接",
"Upload": "上传",
......@@ -4638,6 +4657,10 @@
"User Consumption Trend": "用户消耗趋势",
"User created successfully": "用户创建成功",
"User dashboard and quota controls.": "用户仪表板和配额控制。",
"User deleted successfully": "用户删除成功",
"User demoted to regular user successfully": "已成功降级为普通用户",
"User disabled successfully": "用户禁用成功",
"User enabled successfully": "用户启用成功",
"User Exclusive Ratio": "专属倍率",
"User group": "用户分组",
"User Group": "用户分组",
......@@ -4653,6 +4676,7 @@
"User Information": "用户信息",
"User Menu": "用户菜单",
"User personal functions": "用户个人功能",
"User promoted to admin successfully": "已成功提升为管理员",
"User selectable": "用户可选",
"User Subscription Management": "用户订阅管理",
"User updated successfully": "用户更新成功",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment