Filters
This commit is contained in:
@@ -125,6 +125,14 @@ export function LobbyPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
|
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Games list filters
|
||||||
|
const [filterProposer, setFilterProposer] = useState<number | 'all'>('all')
|
||||||
|
const [filterChallenges, setFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
|
||||||
|
|
||||||
|
// Generation filters
|
||||||
|
const [generateFilterProposer, setGenerateFilterProposer] = useState<number | 'all'>('all')
|
||||||
|
const [generateFilterChallenges, setGenerateFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
|
||||||
|
|
||||||
// Settings modal
|
// Settings modal
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
@@ -563,10 +571,6 @@ export function LobbyPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllGamesForGeneration = () => {
|
|
||||||
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearGameSelection = () => {
|
const clearGameSelection = () => {
|
||||||
setSelectedGamesForGeneration([])
|
setSelectedGamesForGeneration([])
|
||||||
}
|
}
|
||||||
@@ -644,6 +648,22 @@ export function LobbyPage() {
|
|||||||
const approvedGames = games.filter(g => g.status === 'approved')
|
const approvedGames = games.filter(g => g.status === 'approved')
|
||||||
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
||||||
|
|
||||||
|
// Get unique proposers for generation filter (from approved games)
|
||||||
|
const uniqueProposers = approvedGames.reduce((acc, game) => {
|
||||||
|
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
|
||||||
|
acc.push(game.proposed_by)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as { id: number; nickname: string }[])
|
||||||
|
|
||||||
|
// Get unique proposers for games list filter (from all games)
|
||||||
|
const allGamesProposers = games.reduce((acc, game) => {
|
||||||
|
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
|
||||||
|
acc.push(game.proposed_by)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as { id: number; nickname: string }[])
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
@@ -1422,6 +1442,8 @@ export function LobbyPage() {
|
|||||||
setShowGenerateSelection(false)
|
setShowGenerateSelection(false)
|
||||||
clearGameSelection()
|
clearGameSelection()
|
||||||
setGenerateSearchQuery('')
|
setGenerateSearchQuery('')
|
||||||
|
setGenerateFilterProposer('all')
|
||||||
|
setGenerateFilterChallenges('all')
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1455,7 +1477,7 @@ export function LobbyPage() {
|
|||||||
{/* Game selection */}
|
{/* Game selection */}
|
||||||
{showGenerateSelection && (
|
{showGenerateSelection && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Search in generation */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<input
|
||||||
@@ -1474,63 +1496,116 @@ export function LobbyPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
{/* Filters */}
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={selectAllGamesForGeneration}
|
<select
|
||||||
className="text-neon-400 hover:text-neon-300 transition-colors"
|
value={generateFilterProposer === 'all' ? 'all' : generateFilterProposer}
|
||||||
|
onChange={(e) => setGenerateFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
>
|
>
|
||||||
Выбрать все
|
<option value="all">Все участники</option>
|
||||||
</button>
|
{uniqueProposers.map(u => (
|
||||||
<button
|
<option key={u.id} value={u.id}>{u.nickname}</option>
|
||||||
onClick={clearGameSelection}
|
))}
|
||||||
className="text-gray-400 hover:text-gray-300 transition-colors"
|
</select>
|
||||||
|
<select
|
||||||
|
value={generateFilterChallenges}
|
||||||
|
onChange={(e) => setGenerateFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
>
|
>
|
||||||
Снять выбор
|
<option value="all">Все игры</option>
|
||||||
</button>
|
<option value="with">С заданиями</option>
|
||||||
|
<option value="without">Без заданий</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
|
{(() => {
|
||||||
{(() => {
|
// Compute filtered games
|
||||||
const filteredGames = generateSearchQuery
|
let filteredGames = approvedGames
|
||||||
? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
|
||||||
: approvedGames
|
|
||||||
|
|
||||||
return filteredGames.length === 0 ? (
|
// Apply proposer filter
|
||||||
<p className="text-center text-gray-500 py-4 text-sm">
|
if (generateFilterProposer !== 'all') {
|
||||||
Ничего не найдено по запросу "{generateSearchQuery}"
|
filteredGames = filteredGames.filter(g => g.proposed_by?.id === generateFilterProposer)
|
||||||
</p>
|
}
|
||||||
) : (
|
|
||||||
filteredGames.map((game) => {
|
// Apply challenges filter
|
||||||
const isSelected = selectedGamesForGeneration.includes(game.id)
|
if (generateFilterChallenges === 'with') {
|
||||||
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
filteredGames = filteredGames.filter(g => {
|
||||||
return (
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
<button
|
return count > 0
|
||||||
key={game.id}
|
})
|
||||||
onClick={() => toggleGameSelection(game.id)}
|
} else if (generateFilterChallenges === 'without') {
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
|
filteredGames = filteredGames.filter(g => {
|
||||||
isSelected
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
? 'bg-accent-500/20 border-accent-500/50'
|
return count === 0
|
||||||
: '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 ${
|
// Apply search filter
|
||||||
isSelected
|
if (generateSearchQuery) {
|
||||||
? 'bg-accent-500 border-accent-500'
|
filteredGames = fuzzyFilter(filteredGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
: 'border-gray-500'
|
}
|
||||||
}`}>
|
|
||||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
return (
|
||||||
</div>
|
<>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<p className="text-white font-medium truncate">{game.title}</p>
|
<button
|
||||||
<p className="text-xs text-gray-400">
|
onClick={() => setSelectedGamesForGeneration(filteredGames.map(g => g.id))}
|
||||||
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
|
className="text-neon-400 hover:text-neon-300 transition-colors"
|
||||||
</p>
|
>
|
||||||
</div>
|
Выбрать все ({filteredGames.length})
|
||||||
</button>
|
</button>
|
||||||
)
|
<button
|
||||||
})
|
onClick={clearGameSelection}
|
||||||
)
|
className="text-gray-400 hover:text-gray-300 transition-colors"
|
||||||
})()}
|
>
|
||||||
</div>
|
Снять выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
|
Ничего не найдено
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredGames.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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-white font-medium truncate">{game.title}</p>
|
||||||
|
{game.proposed_by && (
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">от {game.proposed_by.nickname}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1702,24 +1777,47 @@ export function LobbyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search and filters */}
|
||||||
<div className="relative mb-6">
|
<div className="space-y-3 mb-6">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
<div className="relative">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
type="text"
|
<input
|
||||||
placeholder="Поиск игры..."
|
type="text"
|
||||||
value={searchQuery}
|
placeholder="Поиск игры..."
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
className="input w-full pl-10 pr-10"
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
className="input w-full pl-10 pr-10"
|
||||||
{searchQuery && (
|
/>
|
||||||
<button
|
{searchQuery && (
|
||||||
onClick={() => setSearchQuery('')}
|
<button
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
|
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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterProposer === 'all' ? 'all' : filterProposer}
|
||||||
|
onChange={(e) => setFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<option value="all">Все участники</option>
|
||||||
</button>
|
{allGamesProposers.map(u => (
|
||||||
)}
|
<option key={u.id} value={u.id}>{u.nickname}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterChallenges}
|
||||||
|
onChange={(e) => setFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
|
>
|
||||||
|
<option value="all">Все игры</option>
|
||||||
|
<option value="with">С заданиями</option>
|
||||||
|
<option value="without">Без заданий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add game form */}
|
{/* Add game form */}
|
||||||
@@ -1763,26 +1861,47 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
{/* Games */}
|
{/* Games */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const baseGames = isOrganizer
|
let filteredGames = 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
|
// Apply proposer filter
|
||||||
? fuzzyFilter(baseGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
if (filterProposer !== 'all') {
|
||||||
: baseGames
|
filteredGames = filteredGames.filter(g => g.proposed_by?.id === filterProposer)
|
||||||
|
}
|
||||||
|
|
||||||
return visibleGames.length === 0 ? (
|
// Apply challenges filter
|
||||||
|
if (filterChallenges === 'with') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count > 0
|
||||||
|
})
|
||||||
|
} else if (filterChallenges === 'without') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count === 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchQuery) {
|
||||||
|
filteredGames = fuzzyFilter(filteredGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = searchQuery || filterProposer !== 'all' || filterChallenges !== 'all'
|
||||||
|
|
||||||
|
return filteredGames.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 ? (
|
{hasFilters ? (
|
||||||
<Search className="w-8 h-8 text-gray-600" />
|
<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">
|
||||||
{searchQuery
|
{hasFilters
|
||||||
? `Ничего не найдено по запросу "${searchQuery}"`
|
? 'Ничего не найдено по заданным фильтрам'
|
||||||
: isOrganizer
|
: isOrganizer
|
||||||
? 'Пока нет игр. Добавьте игры, чтобы начать!'
|
? 'Пока нет игр. Добавьте игры, чтобы начать!'
|
||||||
: 'Пока нет одобренных игр. Предложите свою!'}
|
: 'Пока нет одобренных игр. Предложите свою!'}
|
||||||
@@ -1790,7 +1909,7 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visibleGames.map((game) => renderGameCard(game, false))}
|
{filteredGames.map((game) => renderGameCard(game, false))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user