feat: add challenge and red-blue competitions across API and web
This commit is contained in:
parent
f5161d9add
commit
8fd3c4bb64
77 changed files with 5355 additions and 24 deletions
|
|
@ -0,0 +1,163 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { createConsoleChallenge } from '@/service/console/challenges'
|
||||
import { useAppFullList } from '@/service/use-apps'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function CreateChallengeModal({ show, onHide, onSuccess }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
app_id: '',
|
||||
workflow_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
goal: '',
|
||||
is_active: true,
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { data: appsData } = useAppFullList()
|
||||
const apps = appsData?.data || []
|
||||
const { data: workflowData } = useAppWorkflow(form.app_id)
|
||||
const hasWorkflow = !!workflowData?.graph
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.app_id || !form.name) {
|
||||
Toast.notify({ type: 'error', message: 'App ID and Name are required' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createConsoleChallenge(form)
|
||||
Toast.notify({ type: 'success', message: 'Challenge created successfully' })
|
||||
onSuccess()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Failed to create challenge' })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
title={t('challenges.console.create')}
|
||||
className='!max-w-[640px]'
|
||||
>
|
||||
<div className='space-y-4 p-8'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.appId')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={form.app_id}
|
||||
onSelect={item => setForm({ ...form, app_id: item.value as string, workflow_id: '' })}
|
||||
placeholder={t('common.placeholder.select')}
|
||||
items={apps.map(app => ({
|
||||
value: app.id,
|
||||
name: app.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.app_id && hasWorkflow && (
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.workflowId')}
|
||||
</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={workflowData?.id || ''}
|
||||
disabled
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => setForm({ ...form, workflow_id: workflowData?.id || '' })}
|
||||
>
|
||||
Use Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
{workflowData?.id ? `Workflow ID: ${workflowData.id}` : 'No workflow published'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.name')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('challenges.console.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder={t('challenges.console.form.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.goal')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.goal}
|
||||
onChange={e => setForm({ ...form, goal: e.target.value })}
|
||||
placeholder={t('challenges.console.form.goalPlaceholder')}
|
||||
rows={2}
|
||||
/>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
This will be shown to players. Challenge logic is defined in the workflow using Challenge Evaluator nodes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.isActive')}
|
||||
</label>
|
||||
<Switch
|
||||
defaultValue={form.is_active}
|
||||
onChange={v => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={handleSubmit} loading={loading}>
|
||||
{t('common.operation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
133
web/app/(commonLayout)/console/challenges/page.tsx
Normal file
133
web/app/(commonLayout)/console/challenges/page.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
|
||||
import { deleteConsoleChallenge, listConsoleChallenges, updateConsoleChallenge } from '@/service/console/challenges'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CreateChallengeModal from './create-challenge-modal'
|
||||
|
||||
export default function ConsoleChallengesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await listConsoleChallenges()
|
||||
setItems(data)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDeleteId)
|
||||
return
|
||||
try {
|
||||
await deleteConsoleChallenge(pendingDeleteId)
|
||||
Toast.notify({ type: 'success', message: 'Challenge deleted' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Delete failed' })
|
||||
}
|
||||
finally {
|
||||
setPendingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (item: any) => {
|
||||
try {
|
||||
await updateConsoleChallenge(item.id, { is_active: !item.is_active })
|
||||
Toast.notify({ type: 'success', message: item.is_active ? 'Deactivated' : 'Activated' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Update failed' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-components-panel-bg'>
|
||||
<div className='flex items-center justify-between border-b border-divider-subtle px-12 py-4'>
|
||||
<h1 className='text-xl font-semibold text-text-primary'>{t('challenges.console.title')}</h1>
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
{t('challenges.console.create')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto px-12 py-6'>
|
||||
{loading ? (
|
||||
<div className='text-text-tertiary'>{t('common.loading')}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<div className='mb-2 text-text-secondary'>{t('challenges.console.empty')}</div>
|
||||
<div className='text-sm text-text-tertiary'>{t('challenges.console.emptyDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className='group relative rounded-xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs transition-shadow hover:shadow-md'>
|
||||
<div className='mb-2 text-base font-semibold text-text-primary'>{item.name}</div>
|
||||
{item.description && (
|
||||
<div className='mb-2 line-clamp-2 text-sm text-text-secondary'>{item.description}</div>
|
||||
)}
|
||||
{item.goal && (
|
||||
<div className='mb-2 line-clamp-1 text-xs text-text-tertiary'>Goal: {item.goal}</div>
|
||||
)}
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<div className={`rounded px-2 py-0.5 text-xs font-medium ${item.is_active ? 'bg-util-colors-green-green-100 text-util-colors-green-green-700' : 'bg-components-badge-gray text-text-tertiary'}`}>
|
||||
{item.is_active ? t('challenges.console.status.active') : t('challenges.console.status.inactive')}
|
||||
</div>
|
||||
<div className='flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => handleToggleActive(item)}
|
||||
>
|
||||
{item.is_active ? t('challenges.console.actions.deactivate') : t('challenges.console.actions.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => setPendingDeleteId(item.id)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showModal && (
|
||||
<CreateChallengeModal
|
||||
show={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm
|
||||
isShow={Boolean(pendingDeleteId)}
|
||||
title={t('challenges.console.actions.deleteConfirm')}
|
||||
content={t('challenges.console.actions.deleteConfirm')}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { createRedBlueChallenge } from '@/service/console/challenges'
|
||||
import { useAppFullList } from '@/service/use-apps'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function CreateRedBlueModal({ show, onHide, onSuccess }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState({
|
||||
app_id: '',
|
||||
workflow_id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
judge_suite: ['CBRNE', 'SA', 'SH', 'RWH', 'V', 'M'],
|
||||
defense_selection_policy: 'latest_best',
|
||||
attack_selection_policy: 'latest_best',
|
||||
scoring_strategy: 'red_blue_ratio',
|
||||
is_active: true,
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { data: appsData } = useAppFullList()
|
||||
const apps = appsData?.data || []
|
||||
const { data: workflowData } = useAppWorkflow(form.app_id)
|
||||
const hasWorkflow = !!workflowData?.graph
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.app_id || !form.name) {
|
||||
Toast.notify({ type: 'error', message: 'App ID and Name are required' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createRedBlueChallenge(form)
|
||||
Toast.notify({ type: 'success', message: 'Red/Blue challenge created successfully' })
|
||||
onSuccess()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Failed to create challenge' })
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={onHide}
|
||||
title={t('challenges.console.createRedBlue')}
|
||||
className='!max-w-[640px]'
|
||||
>
|
||||
<div className='space-y-4 p-8'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.appId')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Select
|
||||
className='w-full'
|
||||
defaultValue={form.app_id}
|
||||
onSelect={item => setForm({ ...form, app_id: item.value as string, workflow_id: '' })}
|
||||
placeholder={t('common.placeholder.select')}
|
||||
items={apps.map(app => ({
|
||||
value: app.id,
|
||||
name: app.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.app_id && hasWorkflow && (
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.workflowId')}
|
||||
</label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='flex-1'
|
||||
value={workflowData?.id || ''}
|
||||
disabled
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => setForm({ ...form, workflow_id: workflowData?.id || '' })}
|
||||
>
|
||||
Use Workflow
|
||||
</Button>
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-tertiary'>
|
||||
{workflowData?.id ? `Workflow ID: ${workflowData.id}` : 'No workflow published'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.name')} <span className='text-text-destructive'>*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
placeholder={t('challenges.console.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
placeholder={t('challenges.console.form.descriptionPlaceholder')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.defensePolicy')}
|
||||
</label>
|
||||
<select
|
||||
className='input-select w-full'
|
||||
value={form.defense_selection_policy}
|
||||
onChange={e => setForm({ ...form, defense_selection_policy: e.target.value })}
|
||||
>
|
||||
<option value='latest_best'>Latest Best</option>
|
||||
<option value='random_active'>Random Active</option>
|
||||
<option value='round_robin'>Round Robin</option>
|
||||
<option value='request_new_if_none'>Request New If None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.attackPolicy')}
|
||||
</label>
|
||||
<select
|
||||
className='input-select w-full'
|
||||
value={form.attack_selection_policy}
|
||||
onChange={e => setForm({ ...form, attack_selection_policy: e.target.value })}
|
||||
>
|
||||
<option value='latest_best'>Latest Best</option>
|
||||
<option value='random_active'>Random Active</option>
|
||||
<option value='round_robin'>Round Robin</option>
|
||||
<option value='request_new_if_none'>Request New If None</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className='mb-2 block text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.judgeSuite')}
|
||||
</label>
|
||||
<div className='text-xs text-text-tertiary'>
|
||||
Categories: {form.judge_suite.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-text-secondary'>
|
||||
{t('challenges.console.form.isActive')}
|
||||
</label>
|
||||
<Switch
|
||||
defaultValue={form.is_active}
|
||||
onChange={v => setForm({ ...form, is_active: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={handleSubmit} loading={loading}>
|
||||
{t('common.operation.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
136
web/app/(commonLayout)/console/red-blue-challenges/page.tsx
Normal file
136
web/app/(commonLayout)/console/red-blue-challenges/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
|
||||
import { deleteRedBlueChallenge, listRedBlueChallenges, updateRedBlueChallenge } from '@/service/console/challenges'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import CreateRedBlueModal from './create-red-blue-modal'
|
||||
|
||||
export default function ConsoleRedBlueChallengesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<any[]>([])
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await listRedBlueChallenges()
|
||||
setItems(data)
|
||||
}
|
||||
finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDeleteId)
|
||||
return
|
||||
try {
|
||||
await deleteRedBlueChallenge(pendingDeleteId)
|
||||
Toast.notify({ type: 'success', message: 'Challenge deleted' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Delete failed' })
|
||||
}
|
||||
finally {
|
||||
setPendingDeleteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (item: any) => {
|
||||
try {
|
||||
await updateRedBlueChallenge(item.id, { is_active: !item.is_active })
|
||||
Toast.notify({ type: 'success', message: item.is_active ? 'Deactivated' : 'Activated' })
|
||||
await load()
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || 'Update failed' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col bg-components-panel-bg'>
|
||||
<div className='flex items-center justify-between border-b border-divider-subtle px-12 py-4'>
|
||||
<h1 className='text-xl font-semibold text-text-primary'>{t('challenges.redBlue.title')}</h1>
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
{t('challenges.console.createRedBlue')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex-1 overflow-y-auto px-12 py-6'>
|
||||
{loading ? (
|
||||
<div className='text-text-tertiary'>{t('common.loading')}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-16'>
|
||||
<div className='mb-2 text-text-secondary'>{t('challenges.console.empty')}</div>
|
||||
<div className='text-sm text-text-tertiary'>{t('challenges.console.emptyDesc')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className='group relative rounded-xl border border-divider-subtle bg-components-panel-bg p-4 shadow-xs transition-shadow hover:shadow-md'>
|
||||
<div className='mb-2 flex items-start justify-between'>
|
||||
<div className='text-base font-semibold text-text-primary'>{item.name}</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className='rounded bg-util-colors-red-red-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-util-colors-red-red-700'>RED</div>
|
||||
<div className='rounded bg-util-colors-blue-blue-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-util-colors-blue-blue-700'>BLUE</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.description && (
|
||||
<div className='mb-2 line-clamp-2 text-sm text-text-secondary'>{item.description}</div>
|
||||
)}
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<div className={`rounded px-2 py-0.5 text-xs font-medium ${item.is_active ? 'bg-util-colors-green-green-100 text-util-colors-green-green-700' : 'bg-components-badge-gray text-text-tertiary'}`}>
|
||||
{item.is_active ? t('challenges.console.status.active') : t('challenges.console.status.inactive')}
|
||||
</div>
|
||||
<div className='flex gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={() => handleToggleActive(item)}
|
||||
>
|
||||
{item.is_active ? t('challenges.console.actions.deactivate') : t('challenges.console.actions.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
variant='ghost'
|
||||
onClick={() => setPendingDeleteId(item.id)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showModal && (
|
||||
<CreateRedBlueModal
|
||||
show={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowModal(false)
|
||||
void load()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm
|
||||
isShow={Boolean(pendingDeleteId)}
|
||||
title={t('challenges.console.actions.deleteConfirm')}
|
||||
content={t('challenges.console.actions.deleteConfirm')}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue