Добавлен Skip with Exile, модерация марафонов и выдача предметов
## Skip with Exile (новый расходник) - Новая модель ExiledGame для хранения изгнанных игр - Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда - Фильтрация изгнанных игр при выдаче заданий - UI кнопка в PlayPage для использования skip_exile ## Модерация марафонов (для организаторов) - Эндпоинты: skip-assignment, exiled-games, restore-exiled-game - UI в LeaderboardPage: кнопка скипа у каждого участника - Выбор типа скипа (обычный/с изгнанием) + причина - Telegram уведомления о модерации ## Админская выдача предметов - Эндпоинты: admin grant/remove items, get user inventory - Новая страница AdminGrantItemPage (как магазин) - Telegram уведомление при получении подарка ## Исправления миграций - Миграции 029/030 теперь идемпотентны (проверка существования таблиц) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
AdminPromoCodesPage,
|
||||
AdminGrantItemPage,
|
||||
} from '@/pages/admin'
|
||||
|
||||
// Protected route wrapper
|
||||
@@ -241,6 +242,7 @@ function App() {
|
||||
>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="users/:userId/grant-item" element={<AdminGrantItemPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="promo" element={<AdminPromoCodesPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute, ExiledGame } from '@/types'
|
||||
|
||||
export interface CreateMarathonData {
|
||||
title: string
|
||||
@@ -112,4 +112,36 @@ export const marathonsApi = {
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Moderation ===
|
||||
|
||||
// Skip participant's assignment (organizer only)
|
||||
skipParticipantAssignment: async (
|
||||
marathonId: number,
|
||||
userId: number,
|
||||
exile: boolean = false,
|
||||
reason?: string
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(
|
||||
`/marathons/${marathonId}/participants/${userId}/skip-assignment`,
|
||||
{ exile, reason }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get participant's exiled games (organizer only)
|
||||
getExiledGames: async (marathonId: number, userId: number): Promise<ExiledGame[]> => {
|
||||
const response = await client.get<ExiledGame[]>(
|
||||
`/marathons/${marathonId}/participants/${userId}/exiled-games`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Restore exiled game (organizer only)
|
||||
restoreExiledGame: async (marathonId: number, userId: number, gameId: number): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(
|
||||
`/marathons/${marathonId}/participants/${userId}/exiled-games/${gameId}/restore`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,4 +106,31 @@ export const shopApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// === Админские функции ===
|
||||
|
||||
// Получить инвентарь пользователя (админ)
|
||||
adminGetUserInventory: async (userId: number, itemType?: ShopItemType): Promise<InventoryItem[]> => {
|
||||
const params = itemType ? { item_type: itemType } : {}
|
||||
const response = await client.get<InventoryItem[]>(`/shop/admin/users/${userId}/inventory`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Выдать предмет пользователю (админ)
|
||||
adminGrantItem: async (userId: number, itemId: number, quantity: number, reason: string): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(`/shop/admin/users/${userId}/items/grant`, {
|
||||
item_id: itemId,
|
||||
quantity,
|
||||
reason,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Удалить предмет из инвентаря пользователя (админ)
|
||||
adminRemoveItem: async (userId: number, inventoryId: number, quantity: number = 1): Promise<{ message: string }> => {
|
||||
const response = await client.delete<{ message: string }>(`/shop/admin/users/${userId}/inventory/${inventoryId}`, {
|
||||
params: { quantity },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { LeaderboardEntry, ShopItemPublic, User } from '@/types'
|
||||
import { GlassCard, UserAvatar } from '@/components/ui'
|
||||
import type { LeaderboardEntry, ShopItemPublic, User, Marathon } from '@/types'
|
||||
import { GlassCard, UserAvatar, NeonButton } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target, SkipForward, X, Ban } from 'lucide-react'
|
||||
|
||||
// Helper to get name color styles and animation class
|
||||
function getNameColorData(nameColor: ShopItemPublic | null | undefined): { styles: React.CSSProperties; className: string } {
|
||||
@@ -80,25 +81,67 @@ function StyledNickname({ user, className = '' }: { user: User; className?: stri
|
||||
export function LeaderboardPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const toast = useToast()
|
||||
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Skip modal state
|
||||
const [skipModalUser, setSkipModalUser] = useState<User | null>(null)
|
||||
const [skipExile, setSkipExile] = useState(false)
|
||||
const [skipReason, setSkipReason] = useState('')
|
||||
const [isSkipping, setIsSkipping] = useState(false)
|
||||
|
||||
const isOrganizer = marathon?.my_participation?.role === 'organizer' || user?.role === 'admin'
|
||||
|
||||
useEffect(() => {
|
||||
loadLeaderboard()
|
||||
loadData()
|
||||
}, [id])
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const data = await marathonsApi.getLeaderboard(parseInt(id))
|
||||
setLeaderboard(data)
|
||||
const [leaderboardData, marathonData] = await Promise.all([
|
||||
marathonsApi.getLeaderboard(parseInt(id)),
|
||||
marathonsApi.get(parseInt(id)),
|
||||
])
|
||||
setLeaderboard(leaderboardData)
|
||||
setMarathon(marathonData)
|
||||
} catch (error) {
|
||||
console.error('Failed to load leaderboard:', error)
|
||||
console.error('Failed to load data:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = async () => {
|
||||
if (!skipModalUser || !id) return
|
||||
|
||||
setIsSkipping(true)
|
||||
try {
|
||||
await marathonsApi.skipParticipantAssignment(
|
||||
parseInt(id),
|
||||
skipModalUser.id,
|
||||
skipExile,
|
||||
skipReason || undefined
|
||||
)
|
||||
toast.success(
|
||||
skipExile
|
||||
? `Задание ${skipModalUser.nickname} пропущено, игра изгнана`
|
||||
: `Задание ${skipModalUser.nickname} пропущено`
|
||||
)
|
||||
setSkipModalUser(null)
|
||||
setSkipExile(false)
|
||||
setSkipReason('')
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось пропустить задание')
|
||||
} finally {
|
||||
setIsSkipping(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRankConfig = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
@@ -366,6 +409,20 @@ export function LeaderboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skip button for organizers */}
|
||||
{isOrganizer && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setSkipModalUser(entry.user)
|
||||
}}
|
||||
className="relative p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||
title="Скипнуть задание"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Points */}
|
||||
<div className="relative text-right">
|
||||
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
||||
@@ -380,6 +437,104 @@ export function LeaderboardPage() {
|
||||
</GlassCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skip Modal */}
|
||||
{skipModalUser && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<SkipForward className="w-5 h-5 text-orange-400" />
|
||||
Скипнуть задание {skipModalUser.nickname}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSkipModalUser(null)
|
||||
setSkipExile(false)
|
||||
setSkipReason('')
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip type */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||
Тип скипа
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-orange-500/30 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="skipType"
|
||||
checked={!skipExile}
|
||||
onChange={() => setSkipExile(false)}
|
||||
className="w-4 h-4 text-orange-500 bg-dark-700 border-dark-500 focus:ring-orange-500/50"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium">Обычный скип</div>
|
||||
<div className="text-sm text-gray-400">Задание пропускается, игра может выпасть снова</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 p-3 rounded-xl bg-dark-700/50 border border-dark-600 cursor-pointer hover:border-red-500/30 transition-colors">
|
||||
<input
|
||||
type="radio"
|
||||
name="skipType"
|
||||
checked={skipExile}
|
||||
onChange={() => setSkipExile(true)}
|
||||
className="w-4 h-4 text-red-500 bg-dark-700 border-dark-500 focus:ring-red-500/50"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-white font-medium flex items-center gap-2">
|
||||
Скип с изгнанием
|
||||
<Ban className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Игра навсегда удаляется из пула участника</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Причина (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
value={skipReason}
|
||||
onChange={(e) => setSkipReason(e.target.value)}
|
||||
placeholder="Причина скипа..."
|
||||
rows={2}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSkipModalUser(null)
|
||||
setSkipExile(false)
|
||||
setSkipReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
color={skipExile ? 'pink' : 'neon'}
|
||||
onClick={handleSkip}
|
||||
disabled={isSkipping}
|
||||
isLoading={isSkipping}
|
||||
icon={<SkipForward className="w-4 h-4" />}
|
||||
>
|
||||
{skipExile ? 'Скипнуть и изгнать' : 'Скипнуть'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -494,6 +494,35 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseSkipExile = async () => {
|
||||
if (!currentAssignment || !id) return
|
||||
const confirmed = await confirm({
|
||||
title: 'Скип с изгнанием?',
|
||||
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
|
||||
confirmText: 'Использовать',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'warning',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
setIsUsingConsumable('skip_exile')
|
||||
try {
|
||||
await shopApi.useConsumable({
|
||||
item_code: 'skip_exile',
|
||||
marathon_id: parseInt(id),
|
||||
assignment_id: currentAssignment.id,
|
||||
})
|
||||
toast.success('Задание пропущено, игра изгнана из пула!')
|
||||
await loadData()
|
||||
useShopStore.getState().loadBalance()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
|
||||
} finally {
|
||||
setIsUsingConsumable(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseBoost = async () => {
|
||||
if (!id) return
|
||||
setIsUsingConsumable('boost')
|
||||
@@ -826,6 +855,28 @@ export function PlayPage() {
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Skip with Exile */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-white font-medium">Skip + Изгнание</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{consumablesStatus.skip_exiles_available} шт.</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseSkipExile}
|
||||
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
|
||||
isLoading={isUsingConsumable === 'skip_exile'}
|
||||
className="w-full"
|
||||
>
|
||||
Использовать
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{/* Boost */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal file
404
frontend/src/pages/admin/AdminGrantItemPage.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { shopApi, adminApi } from '@/api'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { GlassCard, NeonButton, FramePreview } from '@/components/ui'
|
||||
import {
|
||||
Loader2, Gift, ArrowLeft, Package,
|
||||
Frame, Type, Palette, Image, Zap, SkipForward,
|
||||
Minus, Plus, Shuffle, Dice5, Copy, Undo2, X, XCircle
|
||||
} from 'lucide-react'
|
||||
import type { ShopItem, ShopItemType, ShopItemPublic, AdminUser } from '@/types'
|
||||
import { RARITY_COLORS, RARITY_NAMES } from '@/types'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const ITEM_TYPE_ICONS: Record<ShopItemType, React.ReactNode> = {
|
||||
frame: <Frame className="w-5 h-5" />,
|
||||
title: <Type className="w-5 h-5" />,
|
||||
name_color: <Palette className="w-5 h-5" />,
|
||||
background: <Image className="w-5 h-5" />,
|
||||
consumable: <Zap className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const CONSUMABLE_ICONS: Record<string, React.ReactNode> = {
|
||||
skip: <SkipForward className="w-8 h-8" />,
|
||||
skip_exile: <XCircle className="w-8 h-8" />,
|
||||
boost: <Zap className="w-8 h-8" />,
|
||||
wild_card: <Shuffle className="w-8 h-8" />,
|
||||
lucky_dice: <Dice5 className="w-8 h-8" />,
|
||||
copycat: <Copy className="w-8 h-8" />,
|
||||
undo: <Undo2 className="w-8 h-8" />,
|
||||
}
|
||||
|
||||
const ITEM_TYPE_LABELS: Record<ShopItemType | 'all', string> = {
|
||||
all: 'Все',
|
||||
consumable: 'Расходники',
|
||||
frame: 'Рамки',
|
||||
title: 'Титулы',
|
||||
name_color: 'Цвета',
|
||||
background: 'Фоны',
|
||||
}
|
||||
|
||||
interface GrantItemCardProps {
|
||||
item: ShopItem
|
||||
onGrant: (item: ShopItem) => void
|
||||
}
|
||||
|
||||
function GrantItemCard({ item, onGrant }: GrantItemCardProps) {
|
||||
const rarityColors = RARITY_COLORS[item.rarity]
|
||||
|
||||
const getItemPreview = () => {
|
||||
if (item.item_type === 'consumable') {
|
||||
return CONSUMABLE_ICONS[item.code] || <Package className="w-8 h-8" />
|
||||
}
|
||||
|
||||
if (item.item_type === 'name_color') {
|
||||
const data = item.asset_data as { style?: string; color?: string; gradient?: string[] } | null
|
||||
|
||||
if (data?.style === 'gradient' && data.gradient) {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.style === 'animated') {
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600 animate-rainbow-rotate"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ff0000, #ff7f00, #ffff00, #00ff00, #0000ff, #9400d3, #ff0000)',
|
||||
backgroundSize: '400% 400%'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const solidColor = data?.color || '#ffffff'
|
||||
return (
|
||||
<div
|
||||
className="w-12 h-12 rounded-full border-2 border-dark-600"
|
||||
style={{ backgroundColor: solidColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.item_type === 'background') {
|
||||
const data = item.asset_data as { type?: string; color?: string; gradient?: string[] } | null
|
||||
let bgStyle: React.CSSProperties = {}
|
||||
|
||||
if (data?.type === 'solid' && data.color) {
|
||||
bgStyle = { backgroundColor: data.color }
|
||||
} else if (data?.type === 'gradient' && data.gradient) {
|
||||
bgStyle = { background: `linear-gradient(135deg, ${data.gradient.join(', ')})` }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-16 h-12 rounded-lg border-2 border-dark-600"
|
||||
style={bgStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.item_type === 'frame') {
|
||||
const frameItem: ShopItemPublic = {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
item_type: item.item_type,
|
||||
rarity: item.rarity,
|
||||
asset_data: item.asset_data,
|
||||
}
|
||||
return <FramePreview frame={frameItem} size="lg" />
|
||||
}
|
||||
|
||||
if (item.item_type === 'title' && item.asset_data?.text) {
|
||||
return (
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: (item.asset_data.color as string) || '#ffffff' }}
|
||||
>
|
||||
{item.asset_data.text as string}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return ITEM_TYPE_ICONS[item.item_type]
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassCard
|
||||
className={clsx(
|
||||
'p-4 border transition-all duration-300 hover:scale-[1.02]',
|
||||
rarityColors.border
|
||||
)}
|
||||
>
|
||||
{/* Rarity badge */}
|
||||
<div className={clsx('text-xs font-medium mb-2', rarityColors.text)}>
|
||||
{RARITY_NAMES[item.rarity]}
|
||||
</div>
|
||||
|
||||
{/* Item preview */}
|
||||
<div className="flex justify-center items-center h-20 mb-3">
|
||||
{getItemPreview()}
|
||||
</div>
|
||||
|
||||
{/* Item info */}
|
||||
<h3 className="text-white font-semibold text-center mb-1">{item.name}</h3>
|
||||
<p className="text-gray-400 text-xs text-center mb-3 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Grant button */}
|
||||
<NeonButton
|
||||
size="sm"
|
||||
color="neon"
|
||||
onClick={() => onGrant(item)}
|
||||
className="w-full"
|
||||
icon={<Gift className="w-4 h-4" />}
|
||||
>
|
||||
Выдать
|
||||
</NeonButton>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminGrantItemPage() {
|
||||
const { userId } = useParams<{ userId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
const [user, setUser] = useState<AdminUser | null>(null)
|
||||
const [items, setItems] = useState<ShopItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<ShopItemType | 'all'>('all')
|
||||
|
||||
// Grant modal
|
||||
const [grantItem, setGrantItem] = useState<ShopItem | null>(null)
|
||||
const [grantQuantity, setGrantQuantity] = useState(1)
|
||||
const [grantReason, setGrantReason] = useState('')
|
||||
const [isGranting, setIsGranting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [userId])
|
||||
|
||||
const loadData = async () => {
|
||||
if (!userId) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [userData, itemsData] = await Promise.all([
|
||||
adminApi.getUser(parseInt(userId)),
|
||||
shopApi.getItems(),
|
||||
])
|
||||
setUser(userData)
|
||||
setItems(itemsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
toast.error('Ошибка загрузки данных')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!grantItem || !userId || !grantReason.trim()) return
|
||||
|
||||
setIsGranting(true)
|
||||
try {
|
||||
await shopApi.adminGrantItem(
|
||||
parseInt(userId),
|
||||
grantItem.id,
|
||||
grantQuantity,
|
||||
grantReason
|
||||
)
|
||||
toast.success(`Выдано ${grantItem.name} x${grantQuantity} для ${user?.nickname}`)
|
||||
setGrantItem(null)
|
||||
setGrantQuantity(1)
|
||||
setGrantReason('')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Ошибка выдачи предмета')
|
||||
} finally {
|
||||
setIsGranting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = activeTab === 'all'
|
||||
? items
|
||||
: items.filter(item => item.item_type === activeTab)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-center py-24">
|
||||
<p className="text-gray-400">Пользователь не найден</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/users')}
|
||||
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Gift className="w-7 h-7 text-green-400" />
|
||||
Выдать предмет
|
||||
</h1>
|
||||
<p className="text-gray-400">
|
||||
Получатель: <span className="text-white font-medium">{user.nickname}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{(Object.keys(ITEM_TYPE_LABELS) as (ShopItemType | 'all')[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveTab(type)}
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-xl text-sm font-medium transition-all',
|
||||
activeTab === type
|
||||
? 'bg-neon-500/20 text-neon-400 border border-neon-500/30'
|
||||
: 'bg-dark-700/50 text-gray-400 border border-dark-600 hover:text-white hover:border-dark-500'
|
||||
)}
|
||||
>
|
||||
{ITEM_TYPE_LABELS[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Items grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredItems.map(item => (
|
||||
<GrantItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onGrant={setGrantItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<p className="text-gray-400">Нет предметов в этой категории</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grant Modal */}
|
||||
{grantItem && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Gift className="w-5 h-5 text-green-400" />
|
||||
Выдать {grantItem.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGrantItem(null)
|
||||
setGrantQuantity(1)
|
||||
setGrantReason('')
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-400 mb-4">
|
||||
Получатель: <span className="text-white">{user.nickname}</span>
|
||||
</p>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Количество
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setGrantQuantity(Math.max(1, grantQuantity - 1))}
|
||||
disabled={grantQuantity <= 1}
|
||||
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Minus className="w-5 h-5" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={grantQuantity}
|
||||
onChange={(e) => setGrantQuantity(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
|
||||
className="w-20 text-center bg-dark-700/50 border border-dark-600 rounded-xl px-3 py-2 text-white font-bold text-lg focus:outline-none focus:border-neon-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setGrantQuantity(Math.min(100, grantQuantity + 1))}
|
||||
disabled={grantQuantity >= 100}
|
||||
className="w-10 h-10 rounded-xl bg-dark-700 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Причина <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={grantReason}
|
||||
onChange={(e) => setGrantReason(e.target.value)}
|
||||
placeholder="Причина выдачи предмета..."
|
||||
rows={3}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-neon-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setGrantItem(null)
|
||||
setGrantQuantity(1)
|
||||
setGrantReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
color="neon"
|
||||
onClick={handleGrant}
|
||||
disabled={!grantReason.trim() || isGranting}
|
||||
isLoading={isGranting}
|
||||
icon={<Gift className="w-4 h-4" />}
|
||||
>
|
||||
Выдать x{grantQuantity}
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminUser, UserRole } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
|
||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff, Gift } from 'lucide-react'
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
@@ -319,6 +320,14 @@ export function AdminUsersPage() {
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to={`/admin/users/${user.id}/grant-item`}
|
||||
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||
title="Выдать предмет"
|
||||
>
|
||||
<Gift className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -512,6 +521,7 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
export { AdminPromoCodesPage } from './AdminPromoCodesPage'
|
||||
export { AdminGrantItemPage } from './AdminGrantItemPage'
|
||||
|
||||
@@ -181,6 +181,15 @@ export interface GameShort {
|
||||
game_type?: GameType
|
||||
}
|
||||
|
||||
export interface ExiledGame {
|
||||
id: number
|
||||
game_id: number
|
||||
game_title: string
|
||||
exiled_at: string
|
||||
exiled_by: 'user' | 'organizer' | 'admin'
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface AvailableGamesCount {
|
||||
available: number
|
||||
total: number
|
||||
@@ -719,7 +728,7 @@ export interface PasswordChangeData {
|
||||
|
||||
export type ShopItemType = 'frame' | 'title' | 'name_color' | 'background' | 'consumable'
|
||||
export type ItemRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'
|
||||
export type ConsumableType = 'skip' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
|
||||
export type ConsumableType = 'skip' | 'skip_exile' | 'boost' | 'wild_card' | 'lucky_dice' | 'copycat' | 'undo'
|
||||
|
||||
export interface ShopItemPublic {
|
||||
id: number
|
||||
@@ -806,6 +815,7 @@ export interface CoinsBalance {
|
||||
|
||||
export interface ConsumablesStatus {
|
||||
skips_available: number
|
||||
skip_exiles_available: number
|
||||
skips_used: number
|
||||
skips_remaining: number | null
|
||||
boosts_available: number
|
||||
|
||||
Reference in New Issue
Block a user