* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage. Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx. Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark). Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi. * 🎨 fix(web): align segmented controls with theme radius tokens - Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart toolbars with radius-md so the active state follows --radius when users change Radius in Theme Settings. - Use nested radii consistent with TabsList/TabsTrigger: outer rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px)) so the track and active thumb stay concentric at small scales (e.g. 0.3rem) instead of a squared “focus” block inside a rounded shell. - Apply the same pattern to pricing SegmentedControl and the segmented groups in consumption-distribution-chart, model-charts, and user-charts. Verified: bun run typecheck (web/default) * ✨ feat(pricing): enrich model details with uptime sparkline and API documentation Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with per-day tooltips, surface it in a status row under quick stats and in the per-group performance table, and extend mock data so uptime series are stable and optionally scoped by group. Introduce an API tab with Shiki-highlighted code samples (cURL, Python, TypeScript, JavaScript), endpoint-type switching, authentication guidance, a supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer vendor, tokenizer, license, and data-retention hints for a provider & data privacy card on the Overview tab (capabilities/modalities stay with model identity; rate limits stay with the API tab). Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi. * 🏆 feat(rankings): add comprehensive rankings dashboard Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending. Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales. * fix(theme): correct theme preset selection state - update Base UI Radio selectors to use data-checked/data-unchecked states. - fix unchecked theme options still showing selected indicators. - isolate the default theme preview tokens to prevent preset changes from leaking into it. * fix(setup): correct usage mode radio state - use Base UI data-checked/data-unchecked states for RadioGroup styling. - hide radio indicators when options are unchecked to avoid setup page display issues. - drive usage mode card and icon selection styles from Base UI state. * fix(auth): submit sign-in and sign-up forms * 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`) and reinstall full component registry with `--overwrite`, including Hugeicons-backed widgets and newly added registry components. - Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies, preset UI from config drawer); rely on official semantic CSS tokens + light/dark only. - Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and `@theme inline` mappings (plus skeleton token vars for existing shimmer usage). - Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`; clarify scroll-lock override comment. Application compatibility: - Keep minimal shims where app code diverged from official APIs (popover collision props, combobox legacy `options` callers, Spinner prop typing). - Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.) Tooling / docs / build: - Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`. - Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack. - Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.) Verified: `bun run typecheck` passes. Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere; not addressed in this change. * 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters - Refactor DataTableToolbar to a single wrapping flex row with a right-aligned action cluster (Reset / Search / View / Expand) for a cleaner Ant Design Pro–style filter bar; remove the dedicated stats row and the toolbar `stats` prop. - Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive- data visibility toggle into the page header via CommonLogsHeaderActions and SectionPageLayout.Actions so the toolbar stays focused on filters. - Slim CommonLogsFilterBar props (no stats / preActions eye control). - Improve CompactDateTimeRangePicker: show minute-precision labels on the trigger (seconds omitted; aligns with datetime-local inputs); widen the trigger on sm+ breakpoints so the full range is visible without truncation; apply the same width in task logs filters. - Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon. - Earlier layout tweak: extra top padding on SectionPageLayout scroll content so control focus rings are not clipped by overflow. * feat(web/default): Base UI migration and component foundation Migrate from Radix UI to Base UI, rewrite core UI primitives, update dependencies (recharts, date-fns, next-themes), add shadcn agent skill documentation, and refresh AI element components. This is the foundational work from the v2/localmain lineage that was not covered by the individual feature commits above. --------- Co-authored-by: t0ng7u <dev@aiass.cc> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
1116 lines
35 KiB
TypeScript
Vendored
1116 lines
35 KiB
TypeScript
Vendored
import { useState, useMemo, memo } from 'react'
|
|
import { Pencil, Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card'
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { safeJsonParse } from '../utils/json-parser'
|
|
|
|
type GroupRatioVisualEditorProps = {
|
|
groupRatio: string
|
|
topupGroupRatio: string
|
|
userUsableGroups: string
|
|
groupGroupRatio: string
|
|
autoGroups: string
|
|
onChange: (field: string, value: string) => void
|
|
}
|
|
|
|
type SimpleGroup = {
|
|
name: string
|
|
value: string
|
|
}
|
|
|
|
type UsableGroup = {
|
|
name: string
|
|
description: string
|
|
}
|
|
|
|
type GroupOverride = {
|
|
targetGroup: string
|
|
ratio: number
|
|
}
|
|
|
|
export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({
|
|
groupRatio,
|
|
topupGroupRatio,
|
|
userUsableGroups,
|
|
groupGroupRatio,
|
|
autoGroups,
|
|
onChange,
|
|
}: GroupRatioVisualEditorProps) {
|
|
const { t } = useTranslation()
|
|
const [simpleDialogOpen, setSimpleDialogOpen] = useState(false)
|
|
const [simpleDialogType, setSimpleDialogType] = useState<
|
|
'groupRatio' | 'topupGroupRatio' | null
|
|
>(null)
|
|
const [simpleEditData, setSimpleEditData] = useState<SimpleGroup | null>(null)
|
|
|
|
const [usableDialogOpen, setUsableDialogOpen] = useState(false)
|
|
const [usableEditData, setUsableEditData] = useState<UsableGroup | null>(null)
|
|
|
|
const [autoGroupDialogOpen, setAutoGroupDialogOpen] = useState(false)
|
|
const [autoGroupInput, setAutoGroupInput] = useState('')
|
|
|
|
const [groupOverrideDialogOpen, setGroupOverrideDialogOpen] = useState(false)
|
|
const [groupOverrideUserGroup, setGroupOverrideUserGroup] = useState<
|
|
string | null
|
|
>(null)
|
|
const [groupOverrideEditData, setGroupOverrideEditData] =
|
|
useState<GroupOverride | null>(null)
|
|
|
|
const [userGroupDialogOpen, setUserGroupDialogOpen] = useState(false)
|
|
const [userGroupInput, setUserGroupInput] = useState('')
|
|
|
|
// Parse group ratios
|
|
const groupRatioList = useMemo(() => {
|
|
const map = safeJsonParse<Record<string, number>>(groupRatio, {
|
|
fallback: {},
|
|
context: 'group ratios',
|
|
})
|
|
return Object.entries(map).map(([name, value]) => ({
|
|
name,
|
|
value: String(value),
|
|
}))
|
|
}, [groupRatio])
|
|
|
|
// Parse topup group ratios
|
|
const topupRatioList = useMemo(() => {
|
|
const map = safeJsonParse<Record<string, number>>(topupGroupRatio, {
|
|
fallback: {},
|
|
context: 'topup group ratios',
|
|
})
|
|
return Object.entries(map).map(([name, value]) => ({
|
|
name,
|
|
value: String(value),
|
|
}))
|
|
}, [topupGroupRatio])
|
|
|
|
// Parse usable groups
|
|
const usableGroupsList = useMemo(() => {
|
|
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
|
fallback: {},
|
|
context: 'user usable groups',
|
|
})
|
|
return Object.entries(map).map(([name, description]) => ({
|
|
name,
|
|
description: String(description),
|
|
}))
|
|
}, [userUsableGroups])
|
|
|
|
// Parse auto groups
|
|
const autoGroupsList = useMemo(() => {
|
|
return safeJsonParse<string[]>(autoGroups, {
|
|
fallback: [],
|
|
context: 'auto groups',
|
|
})
|
|
}, [autoGroups])
|
|
|
|
// Parse group-group ratios
|
|
const groupGroupRatioList = useMemo(() => {
|
|
const map = safeJsonParse<Record<string, Record<string, number>>>(
|
|
groupGroupRatio,
|
|
{
|
|
fallback: {},
|
|
context: 'group-group ratios',
|
|
}
|
|
)
|
|
return Object.entries(map).map(([userGroup, overrides]) => ({
|
|
userGroup,
|
|
overrides: Object.entries(overrides).map(([targetGroup, ratio]) => ({
|
|
targetGroup,
|
|
ratio,
|
|
})),
|
|
}))
|
|
}, [groupGroupRatio])
|
|
|
|
// Simple group handlers (for groupRatio and topupGroupRatio)
|
|
const handleSimpleAdd = (type: 'groupRatio' | 'topupGroupRatio') => {
|
|
setSimpleDialogType(type)
|
|
setSimpleEditData(null)
|
|
setSimpleDialogOpen(true)
|
|
}
|
|
|
|
const handleSimpleEdit = (
|
|
type: 'groupRatio' | 'topupGroupRatio',
|
|
group: SimpleGroup
|
|
) => {
|
|
setSimpleDialogType(type)
|
|
setSimpleEditData(group)
|
|
setSimpleDialogOpen(true)
|
|
}
|
|
|
|
const handleSimpleSave = (name: string, value: string) => {
|
|
if (!simpleDialogType) return
|
|
|
|
const fieldName =
|
|
simpleDialogType === 'groupRatio' ? groupRatio : topupGroupRatio
|
|
const map = safeJsonParse<Record<string, number>>(fieldName, {
|
|
fallback: {},
|
|
silent: true,
|
|
})
|
|
|
|
if (simpleEditData && simpleEditData.name !== name) {
|
|
delete map[simpleEditData.name]
|
|
}
|
|
|
|
map[name] = parseFloat(value)
|
|
|
|
const field =
|
|
simpleDialogType === 'groupRatio' ? 'GroupRatio' : 'TopupGroupRatio'
|
|
onChange(field, JSON.stringify(map, null, 2))
|
|
setSimpleDialogOpen(false)
|
|
}
|
|
|
|
const handleSimpleDelete = (
|
|
type: 'groupRatio' | 'topupGroupRatio',
|
|
name: string
|
|
) => {
|
|
const fieldName = type === 'groupRatio' ? groupRatio : topupGroupRatio
|
|
const map = safeJsonParse<Record<string, number>>(fieldName, {
|
|
fallback: {},
|
|
silent: true,
|
|
})
|
|
delete map[name]
|
|
|
|
const field = type === 'groupRatio' ? 'GroupRatio' : 'TopupGroupRatio'
|
|
onChange(field, JSON.stringify(map, null, 2))
|
|
}
|
|
|
|
// Usable groups handlers
|
|
const handleUsableAdd = () => {
|
|
setUsableEditData(null)
|
|
setUsableDialogOpen(true)
|
|
}
|
|
|
|
const handleUsableEdit = (group: UsableGroup) => {
|
|
setUsableEditData(group)
|
|
setUsableDialogOpen(true)
|
|
}
|
|
|
|
const handleUsableSave = (name: string, description: string) => {
|
|
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
|
fallback: {},
|
|
silent: true,
|
|
})
|
|
|
|
if (usableEditData && usableEditData.name !== name) {
|
|
delete map[usableEditData.name]
|
|
}
|
|
|
|
map[name] = description
|
|
|
|
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
|
|
setUsableDialogOpen(false)
|
|
}
|
|
|
|
const handleUsableDelete = (name: string) => {
|
|
const map = safeJsonParse<Record<string, string>>(userUsableGroups, {
|
|
fallback: {},
|
|
silent: true,
|
|
})
|
|
delete map[name]
|
|
onChange('UserUsableGroups', JSON.stringify(map, null, 2))
|
|
}
|
|
|
|
// Auto groups handlers
|
|
const handleAutoGroupAdd = () => {
|
|
setAutoGroupInput('')
|
|
setAutoGroupDialogOpen(true)
|
|
}
|
|
|
|
const handleAutoGroupSave = () => {
|
|
if (!autoGroupInput.trim()) return
|
|
|
|
const list = [...autoGroupsList, autoGroupInput.trim()]
|
|
onChange('AutoGroups', JSON.stringify(list, null, 2))
|
|
setAutoGroupDialogOpen(false)
|
|
}
|
|
|
|
const handleAutoGroupDelete = (index: number) => {
|
|
const list = autoGroupsList.filter((_, i) => i !== index)
|
|
onChange('AutoGroups', JSON.stringify(list, null, 2))
|
|
}
|
|
|
|
const handleAutoGroupMove = (index: number, direction: 'up' | 'down') => {
|
|
const list = [...autoGroupsList]
|
|
const newIndex = direction === 'up' ? index - 1 : index + 1
|
|
|
|
if (newIndex < 0 || newIndex >= list.length) return
|
|
;[list[index], list[newIndex]] = [list[newIndex], list[index]]
|
|
onChange('AutoGroups', JSON.stringify(list, null, 2))
|
|
}
|
|
|
|
// Group-group ratio handlers
|
|
const handleUserGroupAdd = () => {
|
|
setUserGroupInput('')
|
|
setUserGroupDialogOpen(true)
|
|
}
|
|
|
|
const handleUserGroupSave = () => {
|
|
if (!userGroupInput.trim()) return
|
|
|
|
const map = safeJsonParse<Record<string, Record<string, number>>>(
|
|
groupGroupRatio,
|
|
{
|
|
fallback: {},
|
|
silent: true,
|
|
}
|
|
)
|
|
|
|
if (!map[userGroupInput.trim()]) {
|
|
map[userGroupInput.trim()] = {}
|
|
}
|
|
|
|
onChange('GroupGroupRatio', JSON.stringify(map, null, 2))
|
|
setUserGroupDialogOpen(false)
|
|
}
|
|
|
|
const handleUserGroupDelete = (userGroup: string) => {
|
|
const map = safeJsonParse<Record<string, Record<string, number>>>(
|
|
groupGroupRatio,
|
|
{
|
|
fallback: {},
|
|
silent: true,
|
|
}
|
|
)
|
|
delete map[userGroup]
|
|
onChange('GroupGroupRatio', JSON.stringify(map, null, 2))
|
|
}
|
|
|
|
const handleOverrideAdd = (userGroup: string) => {
|
|
setGroupOverrideUserGroup(userGroup)
|
|
setGroupOverrideEditData(null)
|
|
setGroupOverrideDialogOpen(true)
|
|
}
|
|
|
|
const handleOverrideEdit = (userGroup: string, override: GroupOverride) => {
|
|
setGroupOverrideUserGroup(userGroup)
|
|
setGroupOverrideEditData(override)
|
|
setGroupOverrideDialogOpen(true)
|
|
}
|
|
|
|
const handleOverrideSave = (
|
|
targetGroup: string,
|
|
ratio: number,
|
|
oldTargetGroup?: string
|
|
) => {
|
|
if (!groupOverrideUserGroup) return
|
|
|
|
const map = safeJsonParse<Record<string, Record<string, number>>>(
|
|
groupGroupRatio,
|
|
{
|
|
fallback: {},
|
|
silent: true,
|
|
}
|
|
)
|
|
|
|
if (!map[groupOverrideUserGroup]) {
|
|
map[groupOverrideUserGroup] = {}
|
|
}
|
|
|
|
if (oldTargetGroup && oldTargetGroup !== targetGroup) {
|
|
delete map[groupOverrideUserGroup][oldTargetGroup]
|
|
}
|
|
|
|
map[groupOverrideUserGroup][targetGroup] = ratio
|
|
|
|
onChange('GroupGroupRatio', JSON.stringify(map, null, 2))
|
|
setGroupOverrideDialogOpen(false)
|
|
}
|
|
|
|
const handleOverrideDelete = (userGroup: string, targetGroup: string) => {
|
|
const map = safeJsonParse<Record<string, Record<string, number>>>(
|
|
groupGroupRatio,
|
|
{
|
|
fallback: {},
|
|
silent: true,
|
|
}
|
|
)
|
|
|
|
if (map[userGroup]) {
|
|
delete map[userGroup][targetGroup]
|
|
if (Object.keys(map[userGroup]).length === 0) {
|
|
delete map[userGroup]
|
|
}
|
|
}
|
|
|
|
onChange('GroupGroupRatio', JSON.stringify(map, null, 2))
|
|
}
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
{/* Group Ratios */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('Group ratios')}</CardTitle>
|
|
<CardDescription>
|
|
{t('Base multipliers applied when users select specific groups.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
<Button onClick={() => handleSimpleAdd('groupRatio')} size='sm'>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Add group')}
|
|
</Button>
|
|
{groupRatioList.length > 0 && (
|
|
<div className='rounded-md border'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Group name')}</TableHead>
|
|
<TableHead>{t('Ratio')}</TableHead>
|
|
<TableHead className='text-right'>
|
|
{t('Actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groupRatioList.map((group) => (
|
|
<TableRow key={group.name}>
|
|
<TableCell className='font-medium'>
|
|
{group.name}
|
|
</TableCell>
|
|
<TableCell>{group.value}</TableCell>
|
|
<TableCell className='text-right'>
|
|
<div className='flex justify-end gap-2'>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() =>
|
|
handleSimpleEdit('groupRatio', group)
|
|
}
|
|
>
|
|
<Pencil className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() =>
|
|
handleSimpleDelete('groupRatio', group.name)
|
|
}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Topup Group Ratios */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('Top-up group ratios')}</CardTitle>
|
|
<CardDescription>
|
|
{t('Multipliers for recharge pricing based on user groups.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
<Button
|
|
onClick={() => handleSimpleAdd('topupGroupRatio')}
|
|
size='sm'
|
|
>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Add group')}
|
|
</Button>
|
|
{topupRatioList.length > 0 && (
|
|
<div className='rounded-md border'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Group name')}</TableHead>
|
|
<TableHead>{t('Multiplier')}</TableHead>
|
|
<TableHead className='text-right'>
|
|
{t('Actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{topupRatioList.map((group) => (
|
|
<TableRow key={group.name}>
|
|
<TableCell className='font-medium'>
|
|
{group.name}
|
|
</TableCell>
|
|
<TableCell>{group.value}</TableCell>
|
|
<TableCell className='text-right'>
|
|
<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>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Inter-group ratio overrides */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('Inter-group ratio overrides')}</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
'Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using "edit_this" group tokens.'
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
<Button onClick={handleUserGroupAdd} size='sm'>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Add user group')}
|
|
</Button>
|
|
{groupGroupRatioList.length > 0 && (
|
|
<div className='space-y-3'>
|
|
{groupGroupRatioList.map((userGroupData) => (
|
|
<Collapsible key={userGroupData.userGroup}>
|
|
<div className='rounded-lg border'>
|
|
<div className='flex items-center justify-between p-4'>
|
|
<div className='flex items-center gap-2'>
|
|
<CollapsibleTrigger
|
|
render={<Button variant='ghost' size='sm' />}
|
|
>
|
|
<ChevronDown className='h-4 w-4' />
|
|
</CollapsibleTrigger>
|
|
<span className='font-semibold'>
|
|
{userGroupData.userGroup}
|
|
</span>
|
|
<span className='text-muted-foreground text-sm'>
|
|
{t('{{count}} override', {
|
|
count: userGroupData.overrides.length,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className='flex gap-2'>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() =>
|
|
handleOverrideAdd(userGroupData.userGroup)
|
|
}
|
|
>
|
|
<Plus className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() =>
|
|
handleUserGroupDelete(userGroupData.userGroup)
|
|
}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<CollapsibleContent>
|
|
{userGroupData.overrides.length > 0 && (
|
|
<div className='border-t'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Target group')}</TableHead>
|
|
<TableHead>{t('Ratio')}</TableHead>
|
|
<TableHead className='text-right'>
|
|
{t('Actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{userGroupData.overrides.map((override) => (
|
|
<TableRow key={override.targetGroup}>
|
|
<TableCell className='font-medium'>
|
|
{override.targetGroup}
|
|
</TableCell>
|
|
<TableCell>{override.ratio}</TableCell>
|
|
<TableCell className='text-right'>
|
|
<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>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CollapsibleContent>
|
|
</div>
|
|
</Collapsible>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Usable Groups */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('Selectable groups')}</CardTitle>
|
|
<CardDescription>
|
|
{t('Groups that users can select when creating API keys.')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
<Button onClick={handleUsableAdd} size='sm'>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Add group')}
|
|
</Button>
|
|
{usableGroupsList.length > 0 && (
|
|
<div className='rounded-md border'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Group name')}</TableHead>
|
|
<TableHead>{t('Description')}</TableHead>
|
|
<TableHead className='text-right'>
|
|
{t('Actions')}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{usableGroupsList.map((group) => (
|
|
<TableRow key={group.name}>
|
|
<TableCell className='font-medium'>
|
|
{group.name}
|
|
</TableCell>
|
|
<TableCell>{group.description}</TableCell>
|
|
<TableCell className='text-right'>
|
|
<div className='flex justify-end gap-2'>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => handleUsableEdit(group)}
|
|
>
|
|
<Pencil className='h-4 w-4' />
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => handleUsableDelete(group.name)}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Auto Groups */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('Auto assignment order')}</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
'Priority order for automatic group assignment. New tokens rotate through this list.'
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className='space-y-4'>
|
|
<Button onClick={handleAutoGroupAdd} size='sm'>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Add group')}
|
|
</Button>
|
|
{autoGroupsList.length > 0 && (
|
|
<div className='space-y-2'>
|
|
{autoGroupsList.map((group, index) => (
|
|
<div
|
|
key={index}
|
|
className='flex items-center gap-2 rounded-md border p-3'
|
|
>
|
|
<GripVertical className='text-muted-foreground h-4 w-4' />
|
|
<span className='flex-1 font-medium'>{group}</span>
|
|
<div className='flex gap-1'>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
disabled={index === 0}
|
|
onClick={() => handleAutoGroupMove(index, 'up')}
|
|
>
|
|
↑
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
disabled={index === autoGroupsList.length - 1}
|
|
onClick={() => handleAutoGroupMove(index, 'down')}
|
|
>
|
|
↓
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => handleAutoGroupDelete(index)}
|
|
>
|
|
<Trash2 className='h-4 w-4' />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Simple Group Dialog */}
|
|
<SimpleGroupDialog
|
|
open={simpleDialogOpen}
|
|
onOpenChange={setSimpleDialogOpen}
|
|
onSave={handleSimpleSave}
|
|
editData={simpleEditData}
|
|
type={simpleDialogType}
|
|
/>
|
|
|
|
{/* Usable Group Dialog */}
|
|
<UsableGroupDialog
|
|
open={usableDialogOpen}
|
|
onOpenChange={setUsableDialogOpen}
|
|
onSave={handleUsableSave}
|
|
editData={usableEditData}
|
|
/>
|
|
|
|
{/* Auto Group Dialog */}
|
|
<Dialog open={autoGroupDialogOpen} onOpenChange={setAutoGroupDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('Add auto group')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('Add a group identifier to the auto assignment list.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='space-y-4 py-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Group identifier')}</Label>
|
|
<Input
|
|
value={autoGroupInput}
|
|
onChange={(e) => setAutoGroupInput(e.target.value)}
|
|
placeholder={t('default')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() => setAutoGroupDialogOpen(false)}
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleAutoGroupSave}>{t('Add')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* User Group Dialog */}
|
|
<Dialog open={userGroupDialogOpen} onOpenChange={setUserGroupDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('Add user group')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('Create a new user group to configure ratio overrides for.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='space-y-4 py-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('User group name')}</Label>
|
|
<Input
|
|
value={userGroupInput}
|
|
onChange={(e) => setUserGroupInput(e.target.value)}
|
|
placeholder={t('vip')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant='outline'
|
|
onClick={() => setUserGroupDialogOpen(false)}
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleUserGroupSave}>{t('Add')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Group Override Dialog */}
|
|
<GroupOverrideDialog
|
|
open={groupOverrideDialogOpen}
|
|
onOpenChange={setGroupOverrideDialogOpen}
|
|
onSave={handleOverrideSave}
|
|
editData={groupOverrideEditData}
|
|
userGroup={groupOverrideUserGroup}
|
|
/>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
// Simple Group Dialog Component
|
|
type SimpleGroupDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onSave: (name: string, value: string) => void
|
|
editData: SimpleGroup | null
|
|
type: 'groupRatio' | 'topupGroupRatio' | null
|
|
}
|
|
|
|
function SimpleGroupDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSave,
|
|
editData,
|
|
type,
|
|
}: SimpleGroupDialogProps) {
|
|
const { t } = useTranslation()
|
|
const [name, setName] = useState('')
|
|
const [value, setValue] = useState('')
|
|
|
|
const title = type === 'groupRatio' ? t('group ratio') : t('top-up ratio')
|
|
|
|
const handleSave = () => {
|
|
if (!name.trim() || !value.trim()) return
|
|
onSave(name.trim(), value.trim())
|
|
setName('')
|
|
setValue('')
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
onOpenChange(open)
|
|
if (open && editData) {
|
|
setName(editData.name)
|
|
setValue(editData.value)
|
|
} else {
|
|
setName('')
|
|
setValue('')
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editData
|
|
? t('Edit {{title}}', { title })
|
|
: t('Add {{title}}', { title })}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t('Configure the ratio for this group.')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='space-y-4 py-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Group name')}</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t('default')}
|
|
disabled={!!editData}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Ratio')}</Label>
|
|
<Input
|
|
value={value}
|
|
onChange={(e) => {
|
|
const val = e.target.value
|
|
if (val === '' || !isNaN(parseFloat(val))) {
|
|
setValue(val)
|
|
}
|
|
}}
|
|
placeholder='1.0'
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{editData ? t('Update') : t('Add')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// Usable Group Dialog Component
|
|
type UsableGroupDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onSave: (name: string, description: string) => void
|
|
editData: UsableGroup | null
|
|
}
|
|
|
|
function UsableGroupDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSave,
|
|
editData,
|
|
}: UsableGroupDialogProps) {
|
|
const { t } = useTranslation()
|
|
const [name, setName] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
|
|
const handleSave = () => {
|
|
if (!name.trim() || !description.trim()) return
|
|
onSave(name.trim(), description.trim())
|
|
setName('')
|
|
setDescription('')
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
onOpenChange(open)
|
|
if (open && editData) {
|
|
setName(editData.name)
|
|
setDescription(editData.description)
|
|
} else {
|
|
setName('')
|
|
setDescription('')
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editData ? t('Edit selectable group') : t('Add selectable group')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
'Configure a group that users can select when creating API keys.'
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='space-y-4 py-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Group name')}</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t('vip')}
|
|
disabled={!!editData}
|
|
/>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Description')}</Label>
|
|
<Input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('VIP users with premium access')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{editData ? t('Update') : t('Add')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// Group Override Dialog Component
|
|
type GroupOverrideDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onSave: (targetGroup: string, ratio: number, oldTargetGroup?: string) => void
|
|
editData: GroupOverride | null
|
|
userGroup: string | null
|
|
}
|
|
|
|
function GroupOverrideDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSave,
|
|
editData,
|
|
userGroup,
|
|
}: GroupOverrideDialogProps) {
|
|
const { t } = useTranslation()
|
|
const [targetGroup, setTargetGroup] = useState('')
|
|
const [ratio, setRatio] = useState('')
|
|
|
|
const handleSave = () => {
|
|
if (!targetGroup.trim() || !ratio.trim()) return
|
|
const parsedRatio = parseFloat(ratio)
|
|
if (isNaN(parsedRatio)) return
|
|
|
|
onSave(targetGroup.trim(), parsedRatio, editData?.targetGroup)
|
|
setTargetGroup('')
|
|
setRatio('')
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(open) => {
|
|
onOpenChange(open)
|
|
if (open && editData) {
|
|
setTargetGroup(editData.targetGroup)
|
|
setRatio(String(editData.ratio))
|
|
} else {
|
|
setTargetGroup('')
|
|
setRatio('')
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editData ? t('Edit ratio override') : t('Add ratio override')}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{userGroup
|
|
? t(
|
|
'Configure a custom ratio for "{{userGroup}}" users when using a specific token group.',
|
|
{ userGroup }
|
|
)
|
|
: t(
|
|
'Configure a custom ratio for when users use a specific token group.'
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className='space-y-4 py-4'>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Target group')}</Label>
|
|
<Input
|
|
value={targetGroup}
|
|
onChange={(e) => setTargetGroup(e.target.value)}
|
|
placeholder={t('edit_this')}
|
|
disabled={!!editData}
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t('The token group that will have a custom ratio')}
|
|
</p>
|
|
</div>
|
|
<div className='space-y-2'>
|
|
<Label>{t('Ratio')}</Label>
|
|
<Input
|
|
value={ratio}
|
|
onChange={(e) => {
|
|
const val = e.target.value
|
|
if (val === '' || !isNaN(parseFloat(val))) {
|
|
setRatio(val)
|
|
}
|
|
}}
|
|
placeholder='0.9'
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', {
|
|
userGroup: userGroup || t('this user group'),
|
|
targetGroup: targetGroup || t('this token group'),
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{editData ? t('Update') : t('Add')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|