Add search and fetch user account
This commit is contained in:
BIN
frontend/public/telegram_banner.png
Normal file
BIN
frontend/public/telegram_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
123
frontend/src/utils/fuzzySearch.ts
Normal file
123
frontend/src/utils/fuzzySearch.ts
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user