Redesign p1
This commit is contained in:
@@ -2,13 +2,12 @@ import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardR
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { feedApi } from '@/api'
|
||||
import type { Activity, ActivityType } from '@/types'
|
||||
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
|
||||
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
|
||||
import { UserAvatar } from '@/components/ui'
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getActivityIcon,
|
||||
getActivityColor,
|
||||
getActivityBgClass,
|
||||
isEventActivity,
|
||||
formatActivityMessage,
|
||||
} from '@/utils/activity'
|
||||
@@ -100,52 +99,66 @@ export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
<div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
|
||||
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||
<ActivityIcon className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">Активность</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
|
||||
<div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Активность</h3>
|
||||
{total > 0 && (
|
||||
<p className="text-xs text-gray-500">{total} событий</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-gray-500">{total}</span>
|
||||
)}
|
||||
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{activities.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500 text-sm">
|
||||
Пока нет активности
|
||||
<div className="px-5 py-12 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
||||
<Sparkles className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">Пока нет активности</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700/30">
|
||||
{activities.map((activity) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
<div className="divide-y divide-dark-600/50">
|
||||
{activities.map((activity, index) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isNew={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
<div className="p-3 border-t border-gray-700/30">
|
||||
<div className="p-4 border-t border-dark-600/50">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -168,13 +181,13 @@ ActivityFeed.displayName = 'ActivityFeed'
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function ActivityItem({ activity }: ActivityItemProps) {
|
||||
function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
const navigate = useNavigate()
|
||||
const Icon = getActivityIcon(activity.type)
|
||||
const iconColor = getActivityColor(activity.type)
|
||||
const bgClass = getActivityBgClass(activity.type)
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
const { title, details, extra } = formatActivityMessage(activity)
|
||||
|
||||
@@ -187,21 +200,58 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
? activityData.dispute_status
|
||||
: null
|
||||
|
||||
// Determine accent color based on activity type
|
||||
const getAccentConfig = () => {
|
||||
switch (activity.type) {
|
||||
case 'spin':
|
||||
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
|
||||
case 'complete':
|
||||
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
|
||||
case 'drop':
|
||||
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
|
||||
case 'start_marathon':
|
||||
case 'event_start':
|
||||
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
|
||||
case 'finish_marathon':
|
||||
case 'event_end':
|
||||
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
|
||||
case 'swap':
|
||||
case 'rematch':
|
||||
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
|
||||
default:
|
||||
return { border: 'border-l-dark-600', bg: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const accent = getAccentConfig()
|
||||
|
||||
if (isEvent) {
|
||||
return (
|
||||
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className={`w-4 h-4 ${iconColor}`} />
|
||||
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
<div className={`
|
||||
px-5 py-4 border-l-2 transition-colors
|
||||
${accent.border} ${accent.bg}
|
||||
hover:bg-dark-700/30
|
||||
`}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
|
||||
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${
|
||||
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{details && (
|
||||
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
|
||||
<div className={`text-sm ml-8 ${
|
||||
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
|
||||
}`}>
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<div className="text-xs text-gray-600 mt-2 ml-8">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,39 +259,53 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
|
||||
<div className={`
|
||||
px-5 py-4 border-l-2 transition-all duration-200
|
||||
${accent.border} ${isNew ? accent.bg : ''}
|
||||
hover:bg-dark-700/30 group
|
||||
`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 relative">
|
||||
<UserAvatar
|
||||
userId={activity.user.id}
|
||||
hasAvatar={!!activity.user.avatar_url}
|
||||
nickname={activity.user.nickname}
|
||||
size="sm"
|
||||
/>
|
||||
{/* Activity type badge */}
|
||||
<div className={`
|
||||
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
|
||||
border-2 border-dark-800
|
||||
${activity.type === 'complete' ? 'bg-green-500' :
|
||||
activity.type === 'drop' ? 'bg-red-500' :
|
||||
activity.type === 'spin' ? 'bg-accent-500' :
|
||||
'bg-neon-500'}
|
||||
`}>
|
||||
<Icon className="w-2.5 h-2.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
<span className="text-sm font-semibold text-white group-hover:text-neon-400 transition-colors">
|
||||
{activity.user.nickname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-sm text-gray-300">{title}</span>
|
||||
</div>
|
||||
{details && (
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{extra && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
@@ -250,19 +314,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
onClick={() => navigate(`/assignments/${assignmentId}`)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
||||
className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Детали
|
||||
</button>
|
||||
{disputeStatus === 'open' && (
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1">
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Оспаривается
|
||||
</span>
|
||||
)}
|
||||
{disputeStatus === 'valid' && (
|
||||
<span className="text-xs text-red-400 flex items-center gap-1">
|
||||
<span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Отклонено
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock, Sparkles } from 'lucide-react'
|
||||
import type { ActiveEvent, EventType } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
@@ -17,13 +17,55 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, string> = {
|
||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
||||
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
||||
const EVENT_COLORS: Record<EventType, {
|
||||
gradient: string
|
||||
border: string
|
||||
text: string
|
||||
glow: string
|
||||
iconBg: string
|
||||
}> = {
|
||||
golden_hour: {
|
||||
gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent',
|
||||
border: 'border-yellow-500/50',
|
||||
text: 'text-yellow-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(234,179,8,0.3)]',
|
||||
iconBg: 'bg-yellow-500/20',
|
||||
},
|
||||
common_enemy: {
|
||||
gradient: 'from-red-500/20 via-red-500/10 to-transparent',
|
||||
border: 'border-red-500/50',
|
||||
text: 'text-red-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(239,68,68,0.3)]',
|
||||
iconBg: 'bg-red-500/20',
|
||||
},
|
||||
double_risk: {
|
||||
gradient: 'from-purple-500/20 via-purple-500/10 to-transparent',
|
||||
border: 'border-purple-500/50',
|
||||
text: 'text-purple-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(168,85,247,0.3)]',
|
||||
iconBg: 'bg-purple-500/20',
|
||||
},
|
||||
jackpot: {
|
||||
gradient: 'from-green-500/20 via-green-500/10 to-transparent',
|
||||
border: 'border-green-500/50',
|
||||
text: 'text-green-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(34,197,94,0.3)]',
|
||||
iconBg: 'bg-green-500/20',
|
||||
},
|
||||
swap: {
|
||||
gradient: 'from-neon-500/20 via-neon-500/10 to-transparent',
|
||||
border: 'border-neon-500/50',
|
||||
text: 'text-neon-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(0,240,255,0.3)]',
|
||||
iconBg: 'bg-neon-500/20',
|
||||
},
|
||||
game_choice: {
|
||||
gradient: 'from-orange-500/20 via-orange-500/10 to-transparent',
|
||||
border: 'border-orange-500/50',
|
||||
text: 'text-orange-400',
|
||||
glow: 'shadow-[0_0_30px_rgba(249,115,22,0.3)]',
|
||||
iconBg: 'bg-orange-500/20',
|
||||
},
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -68,42 +110,53 @@ export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
||||
const event = activeEvent.event
|
||||
const info = EVENT_INFO[event.type]
|
||||
const icon = EVENT_ICONS[event.type]
|
||||
const colorClass = EVENT_COLORS[event.type]
|
||||
const colors = EVENT_COLORS[event.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border p-4
|
||||
bg-gradient-to-r ${colorClass}
|
||||
relative overflow-hidden rounded-2xl border p-5
|
||||
glass ${colors.border} ${colors.glow}
|
||||
animate-pulse-slow
|
||||
`}
|
||||
>
|
||||
{/* Animated background effect */}
|
||||
{/* Background gradient */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-r ${colors.gradient}`} />
|
||||
|
||||
{/* Animated shimmer effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
||||
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-white/10">
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:20px_20px]" />
|
||||
|
||||
<div className="relative flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl ${colors.iconBg} ${colors.text}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
||||
<p className="text-sm opacity-80">{info.description}</p>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className={`font-bold text-lg ${colors.text}`}>{info.name}</h3>
|
||||
<Sparkles className={`w-4 h-4 ${colors.text} animate-pulse`} />
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<div className="flex items-center gap-2 text-lg font-mono font-bold">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||
<div className={`px-4 py-2 rounded-xl ${colors.iconBg} font-bold ${colors.text} border ${colors.border}`}>
|
||||
x{activeEvent.effects.points_multiplier}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
|
||||
x{activeEvent.effects.points_multiplier}
|
||||
</div>
|
||||
)}
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 border border-dark-600 font-mono font-bold ${colors.text}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
@@ -24,12 +24,21 @@ const EVENT_TYPES: EventType[] = [
|
||||
]
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-4 h-4" />,
|
||||
common_enemy: <Users className="w-4 h-4" />,
|
||||
double_risk: <Shield className="w-4 h-4" />,
|
||||
jackpot: <Gift className="w-4 h-4" />,
|
||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||
game_choice: <Gamepad2 className="w-4 h-4" />,
|
||||
golden_hour: <Zap className="w-5 h-5" />,
|
||||
common_enemy: <Users className="w-5 h-5" />,
|
||||
double_risk: <Shield className="w-5 h-5" />,
|
||||
jackpot: <Gift className="w-5 h-5" />,
|
||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, { selected: string; icon: string }> = {
|
||||
golden_hour: { selected: 'border-yellow-500/50 bg-yellow-500/10', icon: 'text-yellow-400' },
|
||||
common_enemy: { selected: 'border-red-500/50 bg-red-500/10', icon: 'text-red-400' },
|
||||
double_risk: { selected: 'border-purple-500/50 bg-purple-500/10', icon: 'text-purple-400' },
|
||||
jackpot: { selected: 'border-green-500/50 bg-green-500/10', icon: 'text-green-400' },
|
||||
swap: { selected: 'border-neon-500/50 bg-neon-500/10', icon: 'text-neon-400' },
|
||||
game_choice: { selected: 'border-orange-500/50 bg-orange-500/10', icon: 'text-orange-400' },
|
||||
}
|
||||
|
||||
// Default durations for events (in minutes)
|
||||
@@ -107,54 +116,81 @@ export function EventControl({
|
||||
}
|
||||
|
||||
if (activeEvent.event) {
|
||||
const colors = EVENT_COLORS[activeEvent.event.type]
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
<span className="font-medium">
|
||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
<div className={`glass rounded-xl p-4 border ${colors.selected}`}>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-white">
|
||||
{EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
<span className="text-gray-400 text-sm ml-2">активно</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
isLoading={isStopping}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
icon={<Square className="w-4 h-4" />}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Остановить
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
||||
<div className="glass rounded-xl p-5 space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Запустить событие</h3>
|
||||
<p className="text-sm text-gray-400">Выберите тип и настройте параметры</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleTypeChange(type)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all text-left
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{EVENT_ICONS[type]}
|
||||
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{EVENT_TYPES.map((type) => {
|
||||
const colors = EVENT_COLORS[type]
|
||||
const isSelected = selectedType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleTypeChange(type)}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||
${isSelected
|
||||
? `${colors.selected} shadow-lg`
|
||||
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`flex items-center gap-2 mb-2 ${isSelected ? colors.icon : 'text-gray-400'}`}>
|
||||
{EVENT_ICONS[type]}
|
||||
<span className={`font-semibold text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
|
||||
{EVENT_INFO[type].name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</p>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Duration setting */}
|
||||
@@ -170,9 +206,9 @@ export function EventControl({
|
||||
min={1}
|
||||
max={480}
|
||||
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-gray-500 mt-1.5">
|
||||
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
|
||||
</p>
|
||||
</div>
|
||||
@@ -186,7 +222,7 @@ export function EventControl({
|
||||
<select
|
||||
value={selectedChallengeId || ''}
|
||||
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">— Выберите челлендж —</option>
|
||||
{challenges.map((c) => (
|
||||
@@ -198,15 +234,15 @@ export function EventControl({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
<NeonButton
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||
className="w-full"
|
||||
icon={<Play className="w-4 h-4" />}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить {EVENT_INFO[selectedType].name}
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import { Gamepad2, Loader2, Sparkles } from 'lucide-react'
|
||||
import { NeonButton } from './ui'
|
||||
|
||||
interface SpinWheelProps {
|
||||
games: Game[]
|
||||
@@ -82,8 +84,11 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
|
||||
if (games.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Нет доступных игр для прокрутки
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||
<Gamepad2 className="w-8 h-8 text-gray-600" />
|
||||
</div>
|
||||
<p className="text-gray-400">Нет доступных игр для прокрутки</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,119 +96,172 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
|
||||
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
|
||||
|
||||
// Calculate opacity based on distance from center
|
||||
const getItemOpacity = (itemIndex: number) => {
|
||||
// Calculate opacity and scale based on distance from center
|
||||
const getItemStyle = (itemIndex: number) => {
|
||||
const itemPosition = itemIndex * ITEM_HEIGHT - offset
|
||||
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
|
||||
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
|
||||
const maxDistance = containerHeight / 2
|
||||
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
|
||||
return opacity
|
||||
const normalizedDistance = distanceFromCenter / maxDistance
|
||||
|
||||
const opacity = Math.max(0.15, 1 - normalizedDistance * 0.85)
|
||||
const scale = Math.max(0.85, 1 - normalizedDistance * 0.15)
|
||||
|
||||
return { opacity, scale }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Wheel container */}
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* Selection indicator */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none">
|
||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" />
|
||||
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" />
|
||||
</div>
|
||||
<div className="relative w-full max-w-lg">
|
||||
{/* Outer glow effect */}
|
||||
<div className={`absolute -inset-1 rounded-2xl transition-all duration-300 ${
|
||||
isSpinning
|
||||
? 'bg-gradient-to-r from-neon-500 via-accent-500 to-neon-500 opacity-50 blur-xl animate-pulse'
|
||||
: 'bg-gradient-to-r from-neon-500/20 to-accent-500/20 opacity-0'
|
||||
}`} />
|
||||
|
||||
{/* Items container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
{/* Main container with glass effect */}
|
||||
<div className="relative glass rounded-2xl border border-dark-600 overflow-hidden">
|
||||
{/* Selection indicator - center highlight */}
|
||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] z-20 pointer-events-none">
|
||||
{/* Neon border glow */}
|
||||
<div className={`absolute inset-0 border-2 rounded-lg transition-all duration-300 ${
|
||||
isSpinning
|
||||
? 'border-neon-400 shadow-[0_0_20px_rgba(0,240,255,0.5),inset_0_0_20px_rgba(0,240,255,0.1)]'
|
||||
: 'border-neon-500/50 shadow-[0_0_10px_rgba(0,240,255,0.2)]'
|
||||
}`} />
|
||||
|
||||
{/* Side arrows */}
|
||||
<div className="absolute -left-1 top-1/2 -translate-y-1/2">
|
||||
<div className={`w-3 h-6 transition-all duration-300 ${
|
||||
isSpinning ? 'bg-neon-400' : 'bg-neon-500/70'
|
||||
}`} style={{ clipPath: 'polygon(100% 0, 100% 100%, 0 50%)' }} />
|
||||
</div>
|
||||
<div className="absolute -right-1 top-1/2 -translate-y-1/2">
|
||||
<div className={`w-3 h-6 transition-all duration-300 ${
|
||||
isSpinning ? 'bg-neon-400' : 'bg-neon-500/70'
|
||||
}`} style={{ clipPath: 'polygon(0 0, 0 100%, 100% 50%)' }} />
|
||||
</div>
|
||||
|
||||
{/* Inner glow */}
|
||||
<div className={`absolute inset-0 rounded-lg transition-all duration-300 ${
|
||||
isSpinning
|
||||
? 'bg-neon-500/10'
|
||||
: 'bg-neon-500/5'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Top fade gradient */}
|
||||
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-dark-800 via-dark-800/80 to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Bottom fade gradient */}
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-dark-800 via-dark-800/80 to-transparent z-10 pointer-events-none" />
|
||||
|
||||
{/* Items container */}
|
||||
<div
|
||||
className="absolute w-full transition-none"
|
||||
style={{
|
||||
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
|
||||
}}
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
{extendedGames.map((game, index) => {
|
||||
const realIndex = index % games.length
|
||||
const isSelected = !isSpinning && realIndex === currentIndex
|
||||
const opacity = getItemOpacity(index)
|
||||
<div
|
||||
className="absolute w-full"
|
||||
style={{
|
||||
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
|
||||
}}
|
||||
>
|
||||
{extendedGames.map((game, index) => {
|
||||
const realIndex = index % games.length
|
||||
const isSelected = !isSpinning && realIndex === currentIndex
|
||||
const { opacity, scale } = getItemStyle(index)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${game.id}-${index}`}
|
||||
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
|
||||
isSelected ? 'scale-105' : ''
|
||||
}`}
|
||||
style={{ height: ITEM_HEIGHT, opacity }}
|
||||
>
|
||||
{/* Game cover */}
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
|
||||
{game.cover_url ? (
|
||||
<img
|
||||
src={game.cover_url}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
||||
🎮
|
||||
return (
|
||||
<div
|
||||
key={`${game.id}-${index}`}
|
||||
className="px-4 transition-transform duration-200"
|
||||
style={{
|
||||
height: ITEM_HEIGHT,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
<div className={`
|
||||
flex items-center gap-4 h-full px-4 rounded-xl
|
||||
transition-all duration-300
|
||||
${isSelected
|
||||
? 'bg-neon-500/10 border border-neon-500/30'
|
||||
: 'bg-transparent border border-transparent'
|
||||
}
|
||||
`}>
|
||||
{/* Game cover */}
|
||||
<div className={`
|
||||
w-16 h-16 rounded-xl overflow-hidden flex-shrink-0
|
||||
border transition-all duration-300
|
||||
${isSelected
|
||||
? 'border-neon-500/50 shadow-[0_0_15px_rgba(0,240,255,0.3)]'
|
||||
: 'border-dark-600'
|
||||
}
|
||||
`}>
|
||||
{game.cover_url ? (
|
||||
<img
|
||||
src={game.cover_url}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-dark-700 to-dark-800 flex items-center justify-center">
|
||||
<Gamepad2 className={`w-7 h-7 ${isSelected ? 'text-neon-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-white truncate text-lg">
|
||||
{game.title}
|
||||
</h3>
|
||||
{game.genre && (
|
||||
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
|
||||
)}
|
||||
{/* Game info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`
|
||||
font-bold truncate text-lg transition-colors duration-300
|
||||
${isSelected ? 'text-neon-400' : 'text-white'}
|
||||
`}>
|
||||
{game.title}
|
||||
</h3>
|
||||
{game.genre && (
|
||||
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<div className="flex-shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-neon-400 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spinning indicator lines */}
|
||||
{isSpinning && (
|
||||
<>
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-neon-500/50 to-transparent animate-pulse" />
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent animate-pulse" style={{ transform: 'translateY(-50px)' }} />
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent animate-pulse" style={{ transform: 'translateY(50px)' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spin button */}
|
||||
<button
|
||||
<NeonButton
|
||||
onClick={handleSpin}
|
||||
disabled={isSpinning || disabled}
|
||||
className={`
|
||||
relative px-12 py-4 text-xl font-bold rounded-full
|
||||
transition-all duration-300 transform
|
||||
${isSpinning || disabled
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95'
|
||||
}
|
||||
`}
|
||||
size="lg"
|
||||
className="px-12 text-xl"
|
||||
icon={isSpinning ? <Loader2 className="w-6 h-6 animate-spin" /> : <Sparkles className="w-6 h-6" />}
|
||||
>
|
||||
{isSpinning ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Крутится...
|
||||
</span>
|
||||
) : (
|
||||
'КРУТИТЬ!'
|
||||
)}
|
||||
</button>
|
||||
{isSpinning ? 'Крутится...' : 'КРУТИТЬ!'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,42 +1,88 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAuthenticated, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [isScrolled, setIsScrolled] = useState(false)
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false)
|
||||
}, [location])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const isActiveLink = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-800 border-b border-gray-700">
|
||||
<header
|
||||
className={clsx(
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
|
||||
isScrolled
|
||||
? 'bg-dark-900/80 backdrop-blur-lg border-b border-dark-600/50 shadow-lg'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
|
||||
<Gamepad2 className="w-8 h-8 text-primary-500" />
|
||||
<span>Игровой Марафон</span>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 group"
|
||||
>
|
||||
<div className="relative">
|
||||
<Gamepad2 className="w-8 h-8 text-neon-500 transition-all duration-300 group-hover:text-neon-400 group-hover:drop-shadow-[0_0_8px_rgba(0,240,255,0.8)]" />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-white font-display tracking-wider glitch-hover">
|
||||
МАРАФОН
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
to="/marathons"
|
||||
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
isActiveLink('/marathons')
|
||||
? 'text-neon-400 bg-neon-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
|
||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||
isActiveLink('/profile')
|
||||
? 'text-neon-400 bg-neon-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span>{user?.nickname}</span>
|
||||
@@ -46,7 +92,7 @@ export function Layout() {
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-200"
|
||||
title="Выйти"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
@@ -55,27 +101,114 @@ export function Layout() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-gray-300 hover:text-white transition-colors">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-300 hover:text-white transition-colors px-4 py-2"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-primary">
|
||||
<Link
|
||||
to="/register"
|
||||
className="px-4 py-2 bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all duration-200 shadow-[0_0_15px_rgba(0,240,255,0.3)] hover:shadow-[0_0_25px_rgba(0,240,255,0.5)]"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="md:hidden p-2 text-gray-300 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden bg-dark-800/95 backdrop-blur-lg border-t border-dark-600 animate-slide-in-down">
|
||||
<div className="container mx-auto px-4 py-4 space-y-2">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
to="/marathons"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
isActiveLink('/marathons')
|
||||
? 'text-neon-400 bg-neon-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span>Марафоны</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/profile"
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||
isActiveLink('/profile')
|
||||
? 'text-neon-400 bg-neon-500/10'
|
||||
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||
)}
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span>{user?.nickname}</span>
|
||||
</Link>
|
||||
<div className="pt-2 border-t border-dark-600">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/login"
|
||||
className="block px-4 py-3 text-gray-300 hover:text-white hover:bg-dark-700 rounded-lg transition-all"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="block px-4 py-3 text-center bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Spacer for fixed header */}
|
||||
<div className="h-[72px]" />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-800 border-t border-gray-700 py-4">
|
||||
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
|
||||
Игровой Марафон © {new Date().getFullYear()}
|
||||
<footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Gamepad2 className="w-5 h-5 text-neon-500/50" />
|
||||
<span className="text-sm">
|
||||
Игровой Марафон © {new Date().getFullYear()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="text-neon-500/50">v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
{
|
||||
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
|
||||
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
|
||||
'bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold shadow-[0_0_10px_rgba(0,240,255,0.3)] hover:shadow-[0_0_20px_rgba(0,240,255,0.5)]': variant === 'primary',
|
||||
'bg-dark-600 hover:bg-dark-500 text-white border border-dark-500': variant === 'secondary',
|
||||
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
|
||||
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost',
|
||||
'bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white': variant === 'ghost',
|
||||
'px-3 py-1.5 text-sm': size === 'sm',
|
||||
'px-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
|
||||
@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
export function Card({ children, className }: CardProps) {
|
||||
export function Card({ children, className, hover = false }: CardProps) {
|
||||
return (
|
||||
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-dark-800 rounded-xl p-6 border border-dark-600',
|
||||
hover && 'transition-all duration-300 hover:-translate-y-1 hover:border-neon-500/30 hover:shadow-[0_10px_40px_rgba(0,240,255,0.1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
211
frontend/src/components/ui/GlassCard.tsx
Normal file
211
frontend/src/components/ui/GlassCard.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
variant?: 'default' | 'dark' | 'neon' | 'gradient'
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlassCard({
|
||||
children,
|
||||
variant = 'default',
|
||||
hover = false,
|
||||
glow = false,
|
||||
className,
|
||||
...props
|
||||
}: GlassCardProps) {
|
||||
const variantClasses = {
|
||||
default: 'glass',
|
||||
dark: 'glass-dark',
|
||||
neon: 'glass-neon',
|
||||
gradient: 'gradient-border',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl p-6',
|
||||
variantClasses[variant],
|
||||
hover && 'card-hover cursor-pointer',
|
||||
glow && 'neon-glow-pulse',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stats card variant
|
||||
interface StatsCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: ReactNode
|
||||
trend?: {
|
||||
value: number
|
||||
isPositive: boolean
|
||||
}
|
||||
color?: 'neon' | 'purple' | 'pink' | 'default'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StatsCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
trend,
|
||||
color = 'default',
|
||||
className,
|
||||
}: StatsCardProps) {
|
||||
const colorClasses = {
|
||||
neon: 'border-neon-500/30 hover:border-neon-500/50',
|
||||
purple: 'border-accent-500/30 hover:border-accent-500/50',
|
||||
pink: 'border-pink-500/30 hover:border-pink-500/50',
|
||||
default: 'border-dark-600 hover:border-dark-500',
|
||||
}
|
||||
|
||||
const iconColorClasses = {
|
||||
neon: 'text-neon-500 bg-neon-500/10',
|
||||
purple: 'text-accent-500 bg-accent-500/10',
|
||||
pink: 'text-pink-500 bg-pink-500/10',
|
||||
default: 'text-gray-400 bg-dark-700',
|
||||
}
|
||||
|
||||
const valueColorClasses = {
|
||||
neon: 'text-neon-400',
|
||||
purple: 'text-accent-400',
|
||||
pink: 'text-pink-400',
|
||||
default: 'text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'glass rounded-xl p-4 border transition-all duration-300',
|
||||
colorClasses[color],
|
||||
'hover:-translate-y-0.5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">{label}</p>
|
||||
<p className={clsx('text-2xl font-bold', valueColorClasses[color])}>
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<p
|
||||
className={clsx(
|
||||
'text-xs mt-1',
|
||||
trend.isPositive ? 'text-green-400' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{trend.isPositive ? '+' : ''}{trend.value}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-12 h-12 rounded-lg flex items-center justify-center',
|
||||
iconColorClasses[color]
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Feature card variant
|
||||
interface FeatureCardProps {
|
||||
title: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
color?: 'neon' | 'purple' | 'pink'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FeatureCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
color = 'neon',
|
||||
className,
|
||||
}: FeatureCardProps) {
|
||||
const colorClasses = {
|
||||
neon: {
|
||||
icon: 'text-neon-500 bg-neon-500/10 group-hover:bg-neon-500/20',
|
||||
border: 'group-hover:border-neon-500/50',
|
||||
glow: 'group-hover:shadow-[0_0_30px_rgba(0,240,255,0.15)]',
|
||||
},
|
||||
purple: {
|
||||
icon: 'text-accent-500 bg-accent-500/10 group-hover:bg-accent-500/20',
|
||||
border: 'group-hover:border-accent-500/50',
|
||||
glow: 'group-hover:shadow-[0_0_30px_rgba(168,85,247,0.15)]',
|
||||
},
|
||||
pink: {
|
||||
icon: 'text-pink-500 bg-pink-500/10 group-hover:bg-pink-500/20',
|
||||
border: 'group-hover:border-pink-500/50',
|
||||
glow: 'group-hover:shadow-[0_0_30px_rgba(236,72,153,0.15)]',
|
||||
},
|
||||
}
|
||||
|
||||
const colors = colorClasses[color]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
|
||||
'hover:-translate-y-1',
|
||||
colors.border,
|
||||
colors.glow,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
|
||||
colors.icon
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
|
||||
<p className="text-gray-400 text-sm">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Interactive card with animated border
|
||||
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AnimatedBorderCard({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: AnimatedBorderCardProps) {
|
||||
return (
|
||||
<div className={clsx('relative group', className)} {...props}>
|
||||
{/* Animated gradient border */}
|
||||
<div
|
||||
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
|
||||
style={{
|
||||
backgroundSize: '200% 200%',
|
||||
animation: 'gradient-flow 3s linear infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Card content */}
|
||||
<div className="relative glass-dark rounded-xl p-6">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/ui/GlitchText.tsx
Normal file
116
frontend/src/components/ui/GlitchText.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface GlitchTextProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode
|
||||
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
|
||||
intensity?: 'low' | 'medium' | 'high'
|
||||
color?: 'neon' | 'purple' | 'pink' | 'white'
|
||||
hover?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GlitchText({
|
||||
children,
|
||||
as: Component = 'span',
|
||||
intensity = 'medium',
|
||||
color = 'neon',
|
||||
hover = false,
|
||||
className,
|
||||
...props
|
||||
}: GlitchTextProps) {
|
||||
const text = typeof children === 'string' ? children : ''
|
||||
|
||||
const colorClasses = {
|
||||
neon: 'text-neon-500',
|
||||
purple: 'text-accent-500',
|
||||
pink: 'text-pink-500',
|
||||
white: 'text-white',
|
||||
}
|
||||
|
||||
const glowClasses = {
|
||||
neon: 'neon-text',
|
||||
purple: 'neon-text-purple',
|
||||
pink: '[text-shadow:0_0_10px_#ec4899,0_0_20px_#ec4899,0_0_30px_#ec4899]',
|
||||
white: '[text-shadow:0_0_10px_rgba(255,255,255,0.5),0_0_20px_rgba(255,255,255,0.3)]',
|
||||
}
|
||||
|
||||
const intensityClasses = {
|
||||
low: 'animate-[glitch-skew_2s_infinite_linear_alternate-reverse]',
|
||||
medium: '',
|
||||
high: 'animate-glitch',
|
||||
}
|
||||
|
||||
if (hover) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
colorClasses[color],
|
||||
'relative inline-block cursor-pointer transition-all duration-300',
|
||||
'hover:' + glowClasses[color],
|
||||
'glitch-hover',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'glitch relative inline-block',
|
||||
colorClasses[color],
|
||||
glowClasses[color],
|
||||
intensityClasses[intensity],
|
||||
className
|
||||
)}
|
||||
data-text={text}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
// Simpler glitch effect for headings
|
||||
interface GlitchHeadingProps {
|
||||
children: ReactNode
|
||||
level?: 1 | 2 | 3 | 4
|
||||
className?: string
|
||||
gradient?: boolean
|
||||
}
|
||||
|
||||
export function GlitchHeading({
|
||||
children,
|
||||
level = 1,
|
||||
className,
|
||||
gradient = false,
|
||||
}: GlitchHeadingProps) {
|
||||
const text = typeof children === 'string' ? children : ''
|
||||
|
||||
const sizeClasses = {
|
||||
1: 'text-4xl md:text-5xl lg:text-6xl font-bold',
|
||||
2: 'text-3xl md:text-4xl font-bold',
|
||||
3: 'text-2xl md:text-3xl font-semibold',
|
||||
4: 'text-xl md:text-2xl font-semibold',
|
||||
}
|
||||
|
||||
const Component = `h${level}` as keyof JSX.IntrinsicElements
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
'glitch relative inline-block',
|
||||
sizeClasses[level],
|
||||
gradient ? 'gradient-neon-text' : 'text-white neon-text',
|
||||
className
|
||||
)}
|
||||
data-text={text}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'transition-colors',
|
||||
error ? 'border-red-500' : 'border-gray-700',
|
||||
'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
|
||||
'focus:outline-none focus:border-neon-500',
|
||||
'focus:shadow-[0_0_0_3px_rgba(0,240,255,0.1),0_0_10px_rgba(0,240,255,0.2)]',
|
||||
'transition-all duration-200',
|
||||
error ? 'border-red-500' : 'border-dark-600',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="mt-1.5 text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
166
frontend/src/components/ui/NeonButton.tsx
Normal file
166
frontend/src/components/ui/NeonButton.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
color?: 'neon' | 'purple' | 'pink'
|
||||
isLoading?: boolean
|
||||
icon?: ReactNode
|
||||
iconPosition?: 'left' | 'right'
|
||||
glow?: boolean
|
||||
pulse?: boolean
|
||||
}
|
||||
|
||||
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
color = 'neon',
|
||||
isLoading,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
glow = true,
|
||||
pulse = false,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const colorMap = {
|
||||
neon: {
|
||||
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
|
||||
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
|
||||
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
|
||||
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
glow: '0 0 20px rgba(0, 240, 255, 0.5)',
|
||||
glowHover: '0 0 30px rgba(0, 240, 255, 0.7)',
|
||||
},
|
||||
purple: {
|
||||
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
|
||||
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
|
||||
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
|
||||
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
glow: '0 0 20px rgba(168, 85, 247, 0.5)',
|
||||
glowHover: '0 0 30px rgba(168, 85, 247, 0.7)',
|
||||
},
|
||||
pink: {
|
||||
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
|
||||
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
|
||||
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
|
||||
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||
glow: '0 0 20px rgba(236, 72, 153, 0.5)',
|
||||
glowHover: '0 0 30px rgba(236, 72, 153, 0.7)',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2.5 text-base gap-2',
|
||||
lg: 'px-6 py-3 text-lg gap-2.5',
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
|
||||
const colors = colorMap[color]
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center font-semibold rounded-lg',
|
||||
'transition-all duration-300 ease-out',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
|
||||
color === 'neon' && 'focus:ring-neon-500',
|
||||
color === 'purple' && 'focus:ring-accent-500',
|
||||
color === 'pink' && 'focus:ring-pink-500',
|
||||
colors[variant],
|
||||
sizeClasses[size],
|
||||
pulse && 'neon-glow-pulse',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
boxShadow: glow && !disabled && variant !== 'ghost' ? colors.glow : undefined,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (glow && !disabled && variant !== 'ghost') {
|
||||
e.currentTarget.style.boxShadow = colors.glowHover
|
||||
}
|
||||
props.onMouseEnter?.(e)
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (glow && !disabled && variant !== 'ghost') {
|
||||
e.currentTarget.style.boxShadow = colors.glow
|
||||
}
|
||||
props.onMouseLeave?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
|
||||
{!isLoading && icon && iconPosition === 'left' && (
|
||||
<span className={iconSizes[size]}>{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
{!isLoading && icon && iconPosition === 'right' && (
|
||||
<span className={iconSizes[size]}>{icon}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
NeonButton.displayName = 'NeonButton'
|
||||
|
||||
// Gradient button variant
|
||||
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
|
||||
({ className, size = 'md', isLoading, icon, children, disabled, ...props }, ref) => {
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2.5 text-base gap-2',
|
||||
lg: 'px-6 py-3 text-lg gap-2.5',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={clsx(
|
||||
'relative inline-flex items-center justify-center font-semibold rounded-lg',
|
||||
'bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500',
|
||||
'text-white transition-all duration-300',
|
||||
'hover:shadow-[0_0_30px_rgba(168,85,247,0.5)]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
GradientButton.displayName = 'GradientButton'
|
||||
@@ -4,3 +4,8 @@ export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||
export { ToastContainer } from './Toast'
|
||||
export { ConfirmModal } from './ConfirmModal'
|
||||
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
||||
|
||||
// New design system components
|
||||
export { GlitchText, GlitchHeading } from './GlitchText'
|
||||
export { NeonButton, GradientButton } from './NeonButton'
|
||||
export { GlassCard, StatsCard, FeatureCard, AnimatedBorderCard } from './GlassCard'
|
||||
|
||||
Reference in New Issue
Block a user