313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { useState } from 'react'
|
||
import { useNavigate, Link } from 'react-router-dom'
|
||
import { useForm } from 'react-hook-form'
|
||
import { zodResolver } from '@hookform/resolvers/zod'
|
||
import { z } from 'zod'
|
||
import { marathonsApi } from '@/api'
|
||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
|
||
import type { GameProposalMode } from '@/types'
|
||
|
||
const createSchema = z.object({
|
||
title: z.string().min(1, 'Название обязательно').max(100),
|
||
description: z.string().optional(),
|
||
start_date: z.string().min(1, 'Дата начала обязательна'),
|
||
duration_days: z.number().min(1).max(365).default(30),
|
||
is_public: z.boolean().default(false),
|
||
game_proposal_mode: z.enum(['all_participants', 'organizer_only']).default('all_participants'),
|
||
})
|
||
|
||
type CreateForm = z.infer<typeof createSchema>
|
||
|
||
export function CreateMarathonPage() {
|
||
const navigate = useNavigate()
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
watch,
|
||
setValue,
|
||
formState: { errors },
|
||
} = useForm<CreateForm>({
|
||
resolver: zodResolver(createSchema),
|
||
defaultValues: {
|
||
duration_days: 30,
|
||
is_public: false,
|
||
game_proposal_mode: 'all_participants',
|
||
},
|
||
})
|
||
|
||
const isPublic = watch('is_public')
|
||
const gameProposalMode = watch('game_proposal_mode')
|
||
|
||
const onSubmit = async (data: CreateForm) => {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
try {
|
||
const marathon = await marathonsApi.create({
|
||
title: data.title,
|
||
description: data.description,
|
||
start_date: new Date(data.start_date).toISOString(),
|
||
duration_days: data.duration_days,
|
||
is_public: data.is_public,
|
||
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
|
||
})
|
||
navigate(`/marathons/${marathon.id}/lobby`)
|
||
} catch (err: unknown) {
|
||
const apiError = err as { response?: { data?: { detail?: string } } }
|
||
setError(apiError.response?.data?.detail || 'Не удалось создать марафон')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-xl mx-auto">
|
||
{/* Back button */}
|
||
<Link
|
||
to="/marathons"
|
||
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||
>
|
||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||
К списку марафонов
|
||
</Link>
|
||
|
||
<GlassCard variant="neon">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-4 mb-8">
|
||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/30">
|
||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-white">Создать марафон</h1>
|
||
<p className="text-gray-400 text-sm">Настройте свой игровой марафон</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||
{error && (
|
||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||
<span>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Basic info */}
|
||
<div className="space-y-4">
|
||
<Input
|
||
label="Название"
|
||
placeholder="Введите название марафона"
|
||
error={errors.title?.message}
|
||
{...register('title')}
|
||
/>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||
Описание (необязательно)
|
||
</label>
|
||
<textarea
|
||
className="input min-h-[100px] resize-none"
|
||
placeholder="Расскажите о вашем марафоне..."
|
||
{...register('description')}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Date and duration */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||
<Calendar className="w-4 h-4 text-neon-400" />
|
||
Дата начала
|
||
</label>
|
||
<input
|
||
type="datetime-local"
|
||
className="input w-full"
|
||
{...register('start_date')}
|
||
/>
|
||
{errors.start_date && (
|
||
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||
<Clock className="w-4 h-4 text-accent-400" />
|
||
Длительность (дней)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
className="input w-full"
|
||
min={1}
|
||
max={365}
|
||
{...register('duration_days', { valueAsNumber: true })}
|
||
/>
|
||
{errors.duration_days && (
|
||
<p className="text-red-400 text-xs mt-1">{errors.duration_days.message}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Marathon type */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||
Тип марафона
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setValue('is_public', false)}
|
||
className={`
|
||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||
${!isPublic
|
||
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
||
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||
}
|
||
`}
|
||
>
|
||
<div className={`
|
||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||
`}>
|
||
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
|
||
</div>
|
||
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||
Закрытый
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Вход только по коду приглашения
|
||
</div>
|
||
{!isPublic && (
|
||
<div className="absolute top-3 right-3">
|
||
<Sparkles className="w-4 h-4 text-neon-400" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setValue('is_public', true)}
|
||
className={`
|
||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||
${isPublic
|
||
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
|
||
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||
}
|
||
`}
|
||
>
|
||
<div className={`
|
||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||
`}>
|
||
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
|
||
</div>
|
||
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||
Открытый
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Виден всем пользователям
|
||
</div>
|
||
{isPublic && (
|
||
<div className="absolute top-3 right-3">
|
||
<Sparkles className="w-4 h-4 text-accent-400" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Game proposal mode */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||
Кто может предлагать игры
|
||
</label>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setValue('game_proposal_mode', 'all_participants')}
|
||
className={`
|
||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||
${gameProposalMode === 'all_participants'
|
||
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
||
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||
}
|
||
`}
|
||
>
|
||
<div className={`
|
||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||
`}>
|
||
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
|
||
</div>
|
||
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
||
Все участники
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
С модерацией организатором
|
||
</div>
|
||
{gameProposalMode === 'all_participants' && (
|
||
<div className="absolute top-3 right-3">
|
||
<Sparkles className="w-4 h-4 text-neon-400" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
|
||
className={`
|
||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||
${gameProposalMode === 'organizer_only'
|
||
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
|
||
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||
}
|
||
`}
|
||
>
|
||
<div className={`
|
||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||
`}>
|
||
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
|
||
</div>
|
||
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
||
Только организатор
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Без модерации
|
||
</div>
|
||
{gameProposalMode === 'organizer_only' && (
|
||
<div className="absolute top-3 right-3">
|
||
<Sparkles className="w-4 h-4 text-accent-400" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-3 pt-4 border-t border-dark-600">
|
||
<NeonButton
|
||
type="button"
|
||
variant="outline"
|
||
className="flex-1"
|
||
onClick={() => navigate('/marathons')}
|
||
>
|
||
Отмена
|
||
</NeonButton>
|
||
<NeonButton
|
||
type="submit"
|
||
className="flex-1"
|
||
isLoading={isLoading}
|
||
icon={<Sparkles className="w-4 h-4" />}
|
||
>
|
||
Создать
|
||
</NeonButton>
|
||
</div>
|
||
</form>
|
||
</GlassCard>
|
||
</div>
|
||
)
|
||
}
|