Zaebalsya
This commit is contained in:
@@ -13,6 +13,7 @@ from app.schemas import (
|
|||||||
ChallengePreview,
|
ChallengePreview,
|
||||||
ChallengesPreviewResponse,
|
ChallengesPreviewResponse,
|
||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
from app.services.gpt import gpt_service
|
from app.services.gpt import gpt_service
|
||||||
|
|
||||||
@@ -187,7 +188,12 @@ async def create_challenge(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def preview_challenges(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
data: ChallengesGenerateRequest | None = None,
|
||||||
|
):
|
||||||
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
|
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
|
||||||
# Check marathon
|
# Check marathon
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
@@ -202,31 +208,45 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
|
|||||||
await require_organizer(db, current_user, marathon_id)
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
# Get only APPROVED games
|
# Get only APPROVED games
|
||||||
result = await db.execute(
|
query = select(Game).where(
|
||||||
select(Game).where(
|
Game.marathon_id == marathon_id,
|
||||||
Game.marathon_id == marathon_id,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter by specific game IDs if provided
|
||||||
|
if data and data.game_ids:
|
||||||
|
query = query.where(Game.id.in_(data.game_ids))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
games = result.scalars().all()
|
games = result.scalars().all()
|
||||||
|
|
||||||
if not games:
|
if not games:
|
||||||
raise HTTPException(status_code=400, detail="No approved games in marathon")
|
raise HTTPException(status_code=400, detail="No approved games found")
|
||||||
|
|
||||||
# Filter games that don't have challenges yet
|
# Build games list for generation (skip games that already have challenges, unless specific IDs requested)
|
||||||
games_to_generate = []
|
games_to_generate = []
|
||||||
game_map = {}
|
game_map = {}
|
||||||
for game in games:
|
for game in games:
|
||||||
existing = await db.scalar(
|
# If specific games requested, generate even if they have challenges
|
||||||
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
if data and data.game_ids:
|
||||||
)
|
|
||||||
if not existing:
|
|
||||||
games_to_generate.append({
|
games_to_generate.append({
|
||||||
"id": game.id,
|
"id": game.id,
|
||||||
"title": game.title,
|
"title": game.title,
|
||||||
"genre": game.genre
|
"genre": game.genre
|
||||||
})
|
})
|
||||||
game_map[game.id] = game.title
|
game_map[game.id] = game.title
|
||||||
|
else:
|
||||||
|
# Otherwise only generate for games without challenges
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
games_to_generate.append({
|
||||||
|
"id": game.id,
|
||||||
|
"title": game.title,
|
||||||
|
"genre": game.genre
|
||||||
|
})
|
||||||
|
game_map[game.id] = game.title
|
||||||
|
|
||||||
if not games_to_generate:
|
if not games_to_generate:
|
||||||
return ChallengesPreviewResponse(challenges=[])
|
return ChallengesPreviewResponse(challenges=[])
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from app.schemas.challenge import (
|
|||||||
ChallengesPreviewResponse,
|
ChallengesPreviewResponse,
|
||||||
ChallengeSaveItem,
|
ChallengeSaveItem,
|
||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.assignment import (
|
from app.schemas.assignment import (
|
||||||
CompleteAssignment,
|
CompleteAssignment,
|
||||||
@@ -118,6 +119,7 @@ __all__ = [
|
|||||||
"ChallengesPreviewResponse",
|
"ChallengesPreviewResponse",
|
||||||
"ChallengeSaveItem",
|
"ChallengeSaveItem",
|
||||||
"ChallengesSaveRequest",
|
"ChallengesSaveRequest",
|
||||||
|
"ChallengesGenerateRequest",
|
||||||
# Assignment
|
# Assignment
|
||||||
"CompleteAssignment",
|
"CompleteAssignment",
|
||||||
"AssignmentResponse",
|
"AssignmentResponse",
|
||||||
|
|||||||
@@ -88,3 +88,8 @@ class ChallengeSaveItem(BaseModel):
|
|||||||
class ChallengesSaveRequest(BaseModel):
|
class ChallengesSaveRequest(BaseModel):
|
||||||
"""Request to save previewed challenges"""
|
"""Request to save previewed challenges"""
|
||||||
challenges: list[ChallengeSaveItem]
|
challenges: list[ChallengeSaveItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesGenerateRequest(BaseModel):
|
||||||
|
"""Request to generate challenges for specific games"""
|
||||||
|
game_ids: list[int] | None = None # If None, generate for all approved games without challenges
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
await client.delete(`/challenges/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => {
|
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`)
|
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||||
|
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export function LobbyPage() {
|
|||||||
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
|
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||||
|
const [showGenerateSelection, setShowGenerateSelection] = useState(false)
|
||||||
|
const [selectedGamesForGeneration, setSelectedGamesForGeneration] = useState<number[]>([])
|
||||||
|
|
||||||
// View existing challenges
|
// View existing challenges
|
||||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
@@ -253,11 +255,14 @@ export function LobbyPage() {
|
|||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
setGenerateMessage(null)
|
setGenerateMessage(null)
|
||||||
try {
|
try {
|
||||||
const result = await gamesApi.previewChallenges(parseInt(id))
|
// Pass selected games if any, otherwise generate for all games without challenges
|
||||||
|
const gameIds = selectedGamesForGeneration.length > 0 ? selectedGamesForGeneration : undefined
|
||||||
|
const result = await gamesApi.previewChallenges(parseInt(id), gameIds)
|
||||||
if (result.challenges.length === 0) {
|
if (result.challenges.length === 0) {
|
||||||
setGenerateMessage('Все игры уже имеют задания')
|
setGenerateMessage('Нет игр для генерации заданий')
|
||||||
} else {
|
} else {
|
||||||
setPreviewChallenges(result.challenges)
|
setPreviewChallenges(result.challenges)
|
||||||
|
setShowGenerateSelection(false)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate challenges:', error)
|
console.error('Failed to generate challenges:', error)
|
||||||
@@ -267,6 +272,22 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleGameSelection = (gameId: number) => {
|
||||||
|
setSelectedGamesForGeneration(prev =>
|
||||||
|
prev.includes(gameId)
|
||||||
|
? prev.filter(id => id !== gameId)
|
||||||
|
: [...prev, gameId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllGamesForGeneration = () => {
|
||||||
|
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearGameSelection = () => {
|
||||||
|
setSelectedGamesForGeneration([])
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveChallenges = async () => {
|
const handleSaveChallenges = async () => {
|
||||||
if (!id || !previewChallenges) return
|
if (!id || !previewChallenges) return
|
||||||
|
|
||||||
@@ -703,7 +724,7 @@ export function LobbyPage() {
|
|||||||
{/* Generate challenges */}
|
{/* Generate challenges */}
|
||||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||||
<GlassCard className="mb-8">
|
<GlassCard className="mb-8">
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
<Zap className="w-5 h-5 text-accent-400" />
|
<Zap className="w-5 h-5 text-accent-400" />
|
||||||
@@ -711,20 +732,100 @@ export function LobbyPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-white">Генерация заданий</h3>
|
<h3 className="font-semibold text-white">Генерация заданий</h3>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
ИИ создаст задания для игр без заданий
|
{showGenerateSelection
|
||||||
|
? `Выбрано: ${selectedGamesForGeneration.length} из ${approvedGames.length}`
|
||||||
|
: 'Выберите игры для генерации'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NeonButton
|
<div className="flex gap-2">
|
||||||
onClick={handleGenerateChallenges}
|
{showGenerateSelection ? (
|
||||||
isLoading={isGenerating}
|
<>
|
||||||
variant="outline"
|
<NeonButton
|
||||||
color="purple"
|
onClick={() => {
|
||||||
icon={<Sparkles className="w-4 h-4" />}
|
setShowGenerateSelection(false)
|
||||||
>
|
clearGameSelection()
|
||||||
Сгенерировать
|
}}
|
||||||
</NeonButton>
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
onClick={handleGenerateChallenges}
|
||||||
|
isLoading={isGenerating}
|
||||||
|
color="purple"
|
||||||
|
size="sm"
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
disabled={selectedGamesForGeneration.length === 0}
|
||||||
|
>
|
||||||
|
Сгенерировать ({selectedGamesForGeneration.length})
|
||||||
|
</NeonButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
onClick={() => setShowGenerateSelection(true)}
|
||||||
|
variant="outline"
|
||||||
|
color="purple"
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Выбрать игры
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Game selection */}
|
||||||
|
{showGenerateSelection && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<button
|
||||||
|
onClick={selectAllGamesForGeneration}
|
||||||
|
className="text-neon-400 hover:text-neon-300 transition-colors"
|
||||||
|
>
|
||||||
|
Выбрать все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearGameSelection}
|
||||||
|
className="text-gray-400 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Снять выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{approvedGames.map((game) => {
|
||||||
|
const isSelected = selectedGamesForGeneration.includes(game.id)
|
||||||
|
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={game.id}
|
||||||
|
onClick={() => toggleGameSelection(game.id)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent-500 border-accent-500'
|
||||||
|
: 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium truncate">{game.title}</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{generateMessage && (
|
{generateMessage && (
|
||||||
<p className="mt-4 text-sm text-neon-400 p-3 bg-neon-500/10 rounded-lg border border-neon-500/20">
|
<p className="mt-4 text-sm text-neon-400 p-3 bg-neon-500/10 rounded-lg border border-neon-500/20">
|
||||||
{generateMessage}
|
{generateMessage}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
|||||||
import {
|
import {
|
||||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2
|
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
@@ -32,6 +32,8 @@ export function MarathonPage() {
|
|||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
const [isFinishing, setIsFinishing] = useState(false)
|
const [isFinishing, setIsFinishing] = useState(false)
|
||||||
const [showEventControl, setShowEventControl] = useState(false)
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
|
const [showChallenges, setShowChallenges] = useState(false)
|
||||||
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,13 +50,12 @@ export function MarathonPage() {
|
|||||||
const eventData = await eventsApi.getActive(parseInt(id))
|
const eventData = await eventsApi.getActive(parseInt(id))
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
|
|
||||||
if (data.my_participation.role === 'organizer') {
|
// Load challenges for all participants
|
||||||
try {
|
try {
|
||||||
const challengesData = await challengesApi.list(parseInt(id))
|
const challengesData = await challengesApi.list(parseInt(id))
|
||||||
setChallenges(challengesData)
|
setChallenges(challengesData)
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore if no challenges
|
// Ignore if no challenges
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -411,6 +412,108 @@ export function MarathonPage() {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All challenges viewer */}
|
||||||
|
{marathon.status === 'active' && isParticipant && challenges.length > 0 && (
|
||||||
|
<GlassCard>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChallenges(!showChallenges)}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<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 className="text-left">
|
||||||
|
<h3 className="font-semibold text-white">Все задания</h3>
|
||||||
|
<p className="text-sm text-gray-400">{challenges.length} заданий для {new Set(challenges.map(c => c.game.id)).size} игр</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showChallenges ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showChallenges && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600 space-y-4">
|
||||||
|
{/* Group challenges by game */}
|
||||||
|
{Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => {
|
||||||
|
const gameChallenges = challenges.filter(c => c.game.id === gameId)
|
||||||
|
const game = gameChallenges[0]?.game
|
||||||
|
if (!game) return null
|
||||||
|
const isExpanded = expandedGameId === gameId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={gameId} className="glass rounded-xl overflow-hidden border border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedGameId(isExpanded ? null : gameId)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-dark-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-4 h-4 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||||
|
<span className="text-xs text-gray-400">{gameChallenges.length} заданий</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
|
||||||
|
{gameChallenges.map(challenge => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neon-400 font-semibold">
|
||||||
|
+{challenge.points}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{challenge.type === 'completion' ? 'Прохождение' :
|
||||||
|
challenge.type === 'no_death' ? 'Без смертей' :
|
||||||
|
challenge.type === 'speedrun' ? 'Спидран' :
|
||||||
|
challenge.type === 'collection' ? 'Коллекция' :
|
||||||
|
challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||||
|
{challenge.proof_hint && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||||
|
<Target className="w-3 h-3" />
|
||||||
|
Пруф: {challenge.proof_hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity Feed - right sidebar */}
|
{/* Activity Feed - right sidebar */}
|
||||||
|
|||||||
Reference in New Issue
Block a user