diff --git a/frontend/public/telegram_banner.png b/frontend/public/telegram_banner.png
new file mode 100644
index 0000000..e4c09f7
Binary files /dev/null and b/frontend/public/telegram_banner.png differ
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ea0a209..1cb4718 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
@@ -60,6 +61,12 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() {
const banInfo = useAuthStore((state) => state.banInfo)
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
if (isAuthenticated && banInfo) {
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx
index eb39482..9f3cd5a 100644
--- a/frontend/src/pages/LobbyPage.tsx
+++ b/frontend/src/pages/LobbyPage.tsx
@@ -6,9 +6,10 @@ import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
+import { fuzzyFilter } from '@/utils/fuzzySearch'
import {
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'
export function LobbyPage() {
@@ -85,6 +86,10 @@ export function LobbyPage() {
// Start marathon
const [isStarting, setIsStarting] = useState(false)
+ // Search
+ const [searchQuery, setSearchQuery] = useState('')
+ const [generateSearchQuery, setGenerateSearchQuery] = useState('')
+
useEffect(() => {
loadData()
}, [id])
@@ -501,6 +506,7 @@ export function LobbyPage() {
} else {
setPreviewChallenges(result.challenges)
setShowGenerateSelection(false)
+ setGenerateSearchQuery('')
}
} catch (error) {
console.error('Failed to generate challenges:', error)
@@ -1343,6 +1349,7 @@ export function LobbyPage() {
onClick={() => {
setShowGenerateSelection(false)
clearGameSelection()
+ setGenerateSearchQuery('')
}}
variant="secondary"
size="sm"
@@ -1376,6 +1383,25 @@ export function LobbyPage() {
{/* Game selection */}
{showGenerateSelection && (
+ {/* Search in generation */}
+
+
+ setGenerateSearchQuery(e.target.value)}
+ className="input w-full pl-10 pr-10 py-2 text-sm"
+ />
+ {generateSearchQuery && (
+ setGenerateSearchQuery('')}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
+ >
+
+
+ )}
+
-
- {approvedGames.map((game) => {
- const isSelected = selectedGamesForGeneration.includes(game.id)
- const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
- return (
-
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'
- }`}
- >
-
- {isSelected && }
-
-
-
{game.title}
-
- {challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
-
-
-
+
+ {(() => {
+ const filteredGames = generateSearchQuery
+ ? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
+ : approvedGames
+
+ return filteredGames.length === 0 ? (
+
+ Ничего не найдено по запросу "{generateSearchQuery}"
+
+ ) : (
+ filteredGames.map((game) => {
+ const isSelected = selectedGamesForGeneration.includes(game.id)
+ const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
+ return (
+
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'
+ }`}
+ >
+
+ {isSelected && }
+
+
+
{game.title}
+
+ {challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
+
+
+
+ )
+ })
)
- })}
+ })()}
)}
@@ -1574,7 +1612,7 @@ export function LobbyPage() {
{/* Games list */}
-
+
@@ -1592,6 +1630,26 @@ export function LobbyPage() {
)}
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="input w-full pl-10 pr-10"
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
+ >
+
+
+ )}
+
+
{/* Add game form */}
{showAddGame && (
@@ -1632,17 +1690,29 @@ export function LobbyPage() {
{/* Games */}
{(() => {
- const visibleGames = isOrganizer
+ const baseGames = isOrganizer
? games.filter(g => g.status !== 'pending')
: 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 ? (
-
+ {searchQuery ? (
+
+ ) : (
+
+ )}
- {isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
+ {searchQuery
+ ? `Ничего не найдено по запросу "${searchQuery}"`
+ : isOrganizer
+ ? 'Пока нет игр. Добавьте игры, чтобы начать!'
+ : 'Пока нет одобренных игр. Предложите свою!'}
) : (
diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts
index 76969f0..de86341 100644
--- a/frontend/src/store/auth.ts
+++ b/frontend/src/store/auth.ts
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
+let syncPromise: Promise
| null = null
+
interface Pending2FA {
sessionId: number
}
@@ -41,6 +43,7 @@ interface AuthState {
bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void
clearBanned: () => void
+ syncUser: () => Promise
}
export const useAuthStore = create()(
@@ -181,6 +184,27 @@ export const useAuthStore = create()(
clearBanned: () => {
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',
diff --git a/frontend/src/utils/fuzzySearch.ts b/frontend/src/utils/fuzzySearch.ts
new file mode 100644
index 0000000..6de1bdf
--- /dev/null
+++ b/frontend/src/utils/fuzzySearch.ts
@@ -0,0 +1,123 @@
+// Keyboard layout mapping (RU -> EN and EN -> RU)
+const ruToEn: Record = {
+ 'й': '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 = 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 {
+ item: T
+ score: number
+}
+
+export function fuzzySearch(
+ items: T[],
+ query: string,
+ getSearchField: (item: T) => string
+): FuzzyMatch[] {
+ if (!query.trim()) {
+ return items.map(item => ({ item, score: 1 }))
+ }
+
+ const normalizedQuery = query.toLowerCase().trim()
+ const convertedQuery = convertLayout(normalizedQuery)
+
+ const results: FuzzyMatch[] = []
+
+ 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(
+ items: T[],
+ query: string,
+ getSearchField: (item: T) => string
+): T[] {
+ return fuzzySearch(items, query, getSearchField).map(r => r.item)
+}