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 && ( + + )} +
-
- {approvedGames.map((game) => { - const isSelected = selectedGamesForGeneration.includes(game.id) - const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count - return ( - +
+ {(() => { + 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 ( + + ) + }) ) - })} + })()}
)} @@ -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 && ( + + )} +
+ {/* 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) +}