Add search and fetch user account

This commit is contained in:
2025-12-20 00:17:58 +07:00
parent 13f484e726
commit 2d281d1c8c
5 changed files with 258 additions and 34 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
@@ -60,6 +61,12 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
const banInfo = useAuthStore((state) => state.banInfo) const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const syncUser = useAuthStore((state) => state.syncUser)
// Sync user data with server on app load
useEffect(() => {
syncUser()
}, [syncUser])
// Show banned screen if user is authenticated and banned // Show banned screen if user is authenticated and banned
if (isAuthenticated && banInfo) { if (isAuthenticated && banInfo) {

View File

@@ -6,9 +6,10 @@ import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { fuzzyFilter } from '@/utils/fuzzySearch'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search
} from 'lucide-react' } from 'lucide-react'
export function LobbyPage() { export function LobbyPage() {
@@ -85,6 +86,10 @@ export function LobbyPage() {
// Start marathon // Start marathon
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
// Search
const [searchQuery, setSearchQuery] = useState('')
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [id]) }, [id])
@@ -501,6 +506,7 @@ export function LobbyPage() {
} else { } else {
setPreviewChallenges(result.challenges) setPreviewChallenges(result.challenges)
setShowGenerateSelection(false) setShowGenerateSelection(false)
setGenerateSearchQuery('')
} }
} catch (error) { } catch (error) {
console.error('Failed to generate challenges:', error) console.error('Failed to generate challenges:', error)
@@ -1343,6 +1349,7 @@ export function LobbyPage() {
onClick={() => { onClick={() => {
setShowGenerateSelection(false) setShowGenerateSelection(false)
clearGameSelection() clearGameSelection()
setGenerateSearchQuery('')
}} }}
variant="secondary" variant="secondary"
size="sm" size="sm"
@@ -1376,6 +1383,25 @@ export function LobbyPage() {
{/* Game selection */} {/* Game selection */}
{showGenerateSelection && ( {showGenerateSelection && (
<div className="space-y-3"> <div className="space-y-3">
{/* Search in generation */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={generateSearchQuery}
onChange={(e) => setGenerateSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10 py-2 text-sm"
/>
{generateSearchQuery && (
<button
onClick={() => setGenerateSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<button <button
onClick={selectAllGamesForGeneration} onClick={selectAllGamesForGeneration}
@@ -1390,8 +1416,18 @@ export function LobbyPage() {
Снять выбор Снять выбор
</button> </button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
{approvedGames.map((game) => { {(() => {
const filteredGames = generateSearchQuery
? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
: approvedGames
return filteredGames.length === 0 ? (
<p className="text-center text-gray-500 py-4 text-sm">
Ничего не найдено по запросу "{generateSearchQuery}"
</p>
) : (
filteredGames.map((game) => {
const isSelected = selectedGamesForGeneration.includes(game.id) const isSelected = selectedGamesForGeneration.includes(game.id)
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
return ( return (
@@ -1419,7 +1455,9 @@ export function LobbyPage() {
</div> </div>
</button> </button>
) )
})} })
)
})()}
</div> </div>
</div> </div>
)} )}
@@ -1574,7 +1612,7 @@ export function LobbyPage() {
{/* Games list */} {/* Games list */}
<GlassCard> <GlassCard>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between 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-neon-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-neon-400" /> <Gamepad2 className="w-5 h-5 text-neon-400" />
@@ -1592,6 +1630,26 @@ export function LobbyPage() {
)} )}
</div> </div>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск игры..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input w-full pl-10 pr-10"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Add game form */} {/* Add game form */}
{showAddGame && ( {showAddGame && (
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20"> <div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
@@ -1632,17 +1690,29 @@ export function LobbyPage() {
{/* Games */} {/* Games */}
{(() => { {(() => {
const visibleGames = isOrganizer const baseGames = isOrganizer
? games.filter(g => g.status !== 'pending') ? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id)) : games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
const visibleGames = searchQuery
? fuzzyFilter(baseGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
: baseGames
return visibleGames.length === 0 ? ( return visibleGames.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
{searchQuery ? (
<Search className="w-8 h-8 text-gray-600" />
) : (
<Gamepad2 className="w-8 h-8 text-gray-600" /> <Gamepad2 className="w-8 h-8 text-gray-600" />
)}
</div> </div>
<p className="text-gray-400"> <p className="text-gray-400">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} {searchQuery
? `Ничего не найдено по запросу "${searchQuery}"`
: isOrganizer
? 'Пока нет игр. Добавьте игры, чтобы начать!'
: 'Пока нет одобренных игр. Предложите свою!'}
</p> </p>
</div> </div>
) : ( ) : (

View File

@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
let syncPromise: Promise<void> | null = null
interface Pending2FA { interface Pending2FA {
sessionId: number sessionId: number
} }
@@ -41,6 +43,7 @@ interface AuthState {
bumpAvatarVersion: () => void bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void setBanned: (banInfo: BanInfo) => void
clearBanned: () => void clearBanned: () => void
syncUser: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -181,6 +184,27 @@ export const useAuthStore = create<AuthState>()(
clearBanned: () => { clearBanned: () => {
set({ banInfo: null }) set({ banInfo: null })
}, },
syncUser: async () => {
if (!get().isAuthenticated || !get().token) return
// Prevent duplicate sync calls
if (syncPromise) return syncPromise
syncPromise = (async () => {
try {
const userData = await authApi.me()
set({ user: userData })
} catch {
// Token invalid - logout
get().logout()
} finally {
syncPromise = null
}
})()
return syncPromise
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -0,0 +1,123 @@
// Keyboard layout mapping (RU -> EN and EN -> RU)
const ruToEn: Record<string, string> = {
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k', 'д': 'l',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm',
'х': '[', 'ъ': ']', 'ж': ';', 'э': "'", 'б': ',', 'ю': '.',
}
const enToRu: Record<string, string> = Object.fromEntries(
Object.entries(ruToEn).map(([ru, en]) => [en, ru])
)
function convertLayout(text: string): string {
return text
.split('')
.map(char => {
const lower = char.toLowerCase()
const converted = ruToEn[lower] || enToRu[lower] || char
return char === lower ? converted : converted.toUpperCase()
})
.join('')
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
export interface FuzzyMatch<T> {
item: T
score: number
}
export function fuzzySearch<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): FuzzyMatch<T>[] {
if (!query.trim()) {
return items.map(item => ({ item, score: 1 }))
}
const normalizedQuery = query.toLowerCase().trim()
const convertedQuery = convertLayout(normalizedQuery)
const results: FuzzyMatch<T>[] = []
for (const item of items) {
const field = getSearchField(item).toLowerCase()
// Exact substring match - highest score
if (field.includes(normalizedQuery)) {
results.push({ item, score: 1 })
continue
}
// Converted layout match
if (field.includes(convertedQuery)) {
results.push({ item, score: 0.95 })
continue
}
// Check if query matches start of words
const words = field.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
const startsWithMatch = queryWords.every(qw =>
words.some(w => w.startsWith(qw))
)
if (startsWithMatch) {
results.push({ item, score: 0.9 })
continue
}
// Levenshtein distance for typo tolerance
const distance = levenshteinDistance(normalizedQuery, field)
const maxLen = Math.max(normalizedQuery.length, field.length)
const similarity = 1 - distance / maxLen
// Also check against converted query
const convertedDistance = levenshteinDistance(convertedQuery, field)
const convertedSimilarity = 1 - convertedDistance / maxLen
const bestSimilarity = Math.max(similarity, convertedSimilarity)
// Only include if similarity is reasonable (> 40%)
if (bestSimilarity > 0.4) {
results.push({ item, score: bestSimilarity * 0.8 })
}
}
// Sort by score descending
return results.sort((a, b) => b.score - a.score)
}
export function fuzzyFilter<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): T[] {
return fuzzySearch(items, query, getSearchField).map(r => r.item)
}