Add filter controls to admin tables
Pokemon (type), Evolutions (trigger), Games (region/generation), and Runs (status/game) now have dropdown filters alongside search. Server-side filtering for paginated tables, client-side for small datasets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-em40
|
# nuzlocke-tracker-em40
|
||||||
title: Add filtering to admin tables
|
title: Add filtering to admin tables
|
||||||
status: todo
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: low
|
priority: low
|
||||||
created_at: 2026-02-08T12:33:46Z
|
created_at: 2026-02-08T12:33:46Z
|
||||||
updated_at: 2026-02-08T12:33:46Z
|
updated_at: 2026-02-08T19:28:04Z
|
||||||
parent: nuzlocke-tracker-iu5b
|
parent: nuzlocke-tracker-iu5b
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ router = APIRouter()
|
|||||||
@router.get("/evolutions", response_model=PaginatedEvolutionResponse)
|
@router.get("/evolutions", response_model=PaginatedEvolutionResponse)
|
||||||
async def list_evolutions(
|
async def list_evolutions(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
trigger: str | None = Query(None),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -44,6 +45,8 @@ async def list_evolutions(
|
|||||||
func.lower(Evolution.item).contains(search_lower),
|
func.lower(Evolution.item).contains(search_lower),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if trigger:
|
||||||
|
base_query = base_query.where(Evolution.trigger == trigger)
|
||||||
|
|
||||||
# Count total (without eager loads)
|
# Count total (without eager loads)
|
||||||
count_base = select(Evolution)
|
count_base = select(Evolution)
|
||||||
@@ -60,6 +63,8 @@ async def list_evolutions(
|
|||||||
func.lower(Evolution.item).contains(search_lower),
|
func.lower(Evolution.item).contains(search_lower),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if trigger:
|
||||||
|
count_base = count_base.where(Evolution.trigger == trigger)
|
||||||
count_query = select(func.count()).select_from(count_base.subquery())
|
count_query = select(func.count()).select_from(count_base.subquery())
|
||||||
total = (await session.execute(count_query)).scalar() or 0
|
total = (await session.execute(count_query)).scalar() or 0
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ router = APIRouter()
|
|||||||
@router.get("/pokemon", response_model=PaginatedPokemonResponse)
|
@router.get("/pokemon", response_model=PaginatedPokemonResponse)
|
||||||
async def list_pokemon(
|
async def list_pokemon(
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
|
type: str | None = Query(None),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -42,6 +43,8 @@ async def list_pokemon(
|
|||||||
base_query = base_query.where(
|
base_query = base_query.where(
|
||||||
func.lower(Pokemon.name).contains(search.lower())
|
func.lower(Pokemon.name).contains(search.lower())
|
||||||
)
|
)
|
||||||
|
if type:
|
||||||
|
base_query = base_query.where(Pokemon.types.any(type))
|
||||||
|
|
||||||
# Get total count
|
# Get total count
|
||||||
count_query = select(func.count()).select_from(base_query.subquery())
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
|||||||
@@ -50,9 +50,10 @@ export const reorderRoutes = (gameId: number, routes: RouteReorderItem[]) =>
|
|||||||
api.put<Route[]>(`/games/${gameId}/routes/reorder`, { routes })
|
api.put<Route[]>(`/games/${gameId}/routes/reorder`, { routes })
|
||||||
|
|
||||||
// Pokemon
|
// Pokemon
|
||||||
export const listPokemon = (search?: string, limit = 50, offset = 0) => {
|
export const listPokemon = (search?: string, limit = 50, offset = 0, type?: string) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
|
if (type) params.set('type', type)
|
||||||
params.set('limit', String(limit))
|
params.set('limit', String(limit))
|
||||||
params.set('offset', String(offset))
|
params.set('offset', String(offset))
|
||||||
return api.get<PaginatedPokemon>(`/pokemon?${params}`)
|
return api.get<PaginatedPokemon>(`/pokemon?${params}`)
|
||||||
@@ -80,9 +81,10 @@ export const bulkImportBosses = (gameId: number, items: unknown[]) =>
|
|||||||
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
api.post<BulkImportResult>(`/games/${gameId}/bosses/bulk-import`, items)
|
||||||
|
|
||||||
// Evolutions
|
// Evolutions
|
||||||
export const listEvolutions = (search?: string, limit = 50, offset = 0) => {
|
export const listEvolutions = (search?: string, limit = 50, offset = 0, trigger?: string) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
|
if (trigger) params.set('trigger', trigger)
|
||||||
params.set('limit', String(limit))
|
params.set('limit', String(limit))
|
||||||
params.set('offset', String(offset))
|
params.set('offset', String(offset))
|
||||||
return api.get<PaginatedEvolutions>(`/evolutions?${params}`)
|
return api.get<PaginatedEvolutions>(`/evolutions?${params}`)
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type {
|
|||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
export function usePokemonList(search?: string, limit = 50, offset = 0) {
|
export function usePokemonList(search?: string, limit = 50, offset = 0, type?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['pokemon', { search, limit, offset }],
|
queryKey: ['pokemon', { search, limit, offset, type }],
|
||||||
queryFn: () => adminApi.listPokemon(search, limit, offset),
|
queryFn: () => adminApi.listPokemon(search, limit, offset, type),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +213,10 @@ export function useBulkImportBosses(gameId: number) {
|
|||||||
|
|
||||||
// --- Evolution Queries & Mutations ---
|
// --- Evolution Queries & Mutations ---
|
||||||
|
|
||||||
export function useEvolutionList(search?: string, limit = 50, offset = 0) {
|
export function useEvolutionList(search?: string, limit = 50, offset = 0, trigger?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['evolutions', { search, limit, offset }],
|
queryKey: ['evolutions', { search, limit, offset, trigger }],
|
||||||
queryFn: () => adminApi.listEvolutions(search, limit, offset),
|
queryFn: () => adminApi.listEvolutions(search, limit, offset, trigger),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ import type { EvolutionAdmin, CreateEvolutionInput, UpdateEvolutionInput } from
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const EVOLUTION_TRIGGERS = [
|
||||||
|
{ value: 'level-up', label: 'Level Up' },
|
||||||
|
{ value: 'trade', label: 'Trade' },
|
||||||
|
{ value: 'use-item', label: 'Use Item' },
|
||||||
|
{ value: 'shed', label: 'Shed' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
export function AdminEvolutions() {
|
export function AdminEvolutions() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [triggerFilter, setTriggerFilter] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const offset = page * PAGE_SIZE
|
const offset = page * PAGE_SIZE
|
||||||
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset)
|
const { data, isLoading } = useEvolutionList(search || undefined, PAGE_SIZE, offset, triggerFilter || undefined)
|
||||||
const evolutions = data?.items ?? []
|
const evolutions = data?.items ?? []
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
@@ -101,7 +110,28 @@ export function AdminEvolutions() {
|
|||||||
placeholder="Search by pokemon name, trigger, or item..."
|
placeholder="Search by pokemon name, trigger, or item..."
|
||||||
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<select
|
||||||
|
value={triggerFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTriggerFilter(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All triggers</option>
|
||||||
|
{EVOLUTION_TRIGGERS.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(search || triggerFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setTriggerFilter(''); setPage(0) }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
{total} evolutions
|
{total} evolutions
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
import { AdminTable, type Column } from '../../components/admin/AdminTable'
|
||||||
import { GameFormModal } from '../../components/admin/GameFormModal'
|
import { GameFormModal } from '../../components/admin/GameFormModal'
|
||||||
import { useGames } from '../../hooks/useGames'
|
import { useGames } from '../../hooks/useGames'
|
||||||
@@ -15,6 +15,24 @@ export function AdminGames() {
|
|||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
const [editing, setEditing] = useState<Game | null>(null)
|
const [editing, setEditing] = useState<Game | null>(null)
|
||||||
|
const [regionFilter, setRegionFilter] = useState('')
|
||||||
|
const [genFilter, setGenFilter] = useState('')
|
||||||
|
|
||||||
|
const regions = useMemo(
|
||||||
|
() => [...new Set(games.map((g) => g.region))].sort(),
|
||||||
|
[games],
|
||||||
|
)
|
||||||
|
const generations = useMemo(
|
||||||
|
() => [...new Set(games.map((g) => g.generation))].sort((a, b) => a - b),
|
||||||
|
[games],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredGames = useMemo(() => {
|
||||||
|
let result = games
|
||||||
|
if (regionFilter) result = result.filter((g) => g.region === regionFilter)
|
||||||
|
if (genFilter) result = result.filter((g) => g.generation === Number(genFilter))
|
||||||
|
return result
|
||||||
|
}, [games, regionFilter, genFilter])
|
||||||
|
|
||||||
const columns: Column<Game>[] = [
|
const columns: Column<Game>[] = [
|
||||||
{ header: 'Name', accessor: (g) => g.name },
|
{ header: 'Name', accessor: (g) => g.name },
|
||||||
@@ -47,11 +65,45 @@ export function AdminGames() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={(e) => setRegionFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All regions</option>
|
||||||
|
{regions.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={genFilter}
|
||||||
|
onChange={(e) => setGenFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All generations</option>
|
||||||
|
{generations.map((g) => (
|
||||||
|
<option key={g} value={g}>Gen {g}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(regionFilter || genFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setRegionFilter(''); setGenFilter('') }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{filteredGames.length} games
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={games}
|
data={filteredGames}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="No games yet. Add one to get started."
|
emptyMessage="No games found."
|
||||||
onRowClick={(g) => setEditing(g)}
|
onRowClick={(g) => setEditing(g)}
|
||||||
keyFn={(g) => g.id}
|
keyFn={(g) => g.id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ import type { Pokemon, CreatePokemonInput, UpdatePokemonInput } from '../../type
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const POKEMON_TYPES = [
|
||||||
|
'bug', 'dark', 'dragon', 'electric', 'fairy', 'fighting', 'fire', 'flying',
|
||||||
|
'ghost', 'grass', 'ground', 'ice', 'normal', 'poison', 'psychic', 'rock',
|
||||||
|
'steel', 'water',
|
||||||
|
]
|
||||||
|
|
||||||
export function AdminPokemon() {
|
export function AdminPokemon() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [typeFilter, setTypeFilter] = useState('')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const offset = page * PAGE_SIZE
|
const offset = page * PAGE_SIZE
|
||||||
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset)
|
const { data, isLoading } = usePokemonList(search || undefined, PAGE_SIZE, offset, typeFilter || undefined)
|
||||||
const pokemon = data?.items ?? []
|
const pokemon = data?.items ?? []
|
||||||
const total = data?.total ?? 0
|
const total = data?.total ?? 0
|
||||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||||
@@ -83,12 +90,33 @@ export function AdminPokemon() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setPage(0) // Reset to first page on search
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
placeholder="Search by name..."
|
placeholder="Search by name..."
|
||||||
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
className="w-full max-w-sm px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTypeFilter(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
{POKEMON_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(search || typeFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setSearch(''); setTypeFilter(''); setPage(0) }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
{total} pokemon
|
{total} pokemon
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,26 @@ export function AdminRuns() {
|
|||||||
const deleteRun = useDeleteRun()
|
const deleteRun = useDeleteRun()
|
||||||
|
|
||||||
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
|
const [deleting, setDeleting] = useState<NuzlockeRun | null>(null)
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
const [gameFilter, setGameFilter] = useState('')
|
||||||
|
|
||||||
const gameMap = useMemo(
|
const gameMap = useMemo(
|
||||||
() => new Map(games.map((g) => [g.id, g.name])),
|
() => new Map(games.map((g) => [g.id, g.name])),
|
||||||
[games],
|
[games],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const filteredRuns = useMemo(() => {
|
||||||
|
let result = runs
|
||||||
|
if (statusFilter) result = result.filter((r) => r.status === statusFilter)
|
||||||
|
if (gameFilter) result = result.filter((r) => r.gameId === Number(gameFilter))
|
||||||
|
return result
|
||||||
|
}, [runs, statusFilter, gameFilter])
|
||||||
|
|
||||||
|
const runGames = useMemo(
|
||||||
|
() => [...new Map(runs.map((r) => [r.gameId, gameMap.get(r.gameId) ?? `Game #${r.gameId}`])).entries()].sort((a, b) => a[1].localeCompare(b[1])),
|
||||||
|
[runs, gameMap],
|
||||||
|
)
|
||||||
|
|
||||||
const columns: Column<NuzlockeRun>[] = [
|
const columns: Column<NuzlockeRun>[] = [
|
||||||
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
|
{ header: 'Run Name', accessor: (r) => r.name, sortKey: (r) => r.name },
|
||||||
{
|
{
|
||||||
@@ -54,11 +68,45 @@ export function AdminRuns() {
|
|||||||
<h2 className="text-xl font-semibold">Runs</h2>
|
<h2 className="text-xl font-semibold">Runs</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-center gap-4">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={gameFilter}
|
||||||
|
onChange={(e) => setGameFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border rounded-md dark:bg-gray-700 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All games</option>
|
||||||
|
{runGames.map(([id, name]) => (
|
||||||
|
<option key={id} value={id}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{(statusFilter || gameFilter) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setStatusFilter(''); setGameFilter('') }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{filteredRuns.length} runs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={runs}
|
data={filteredRuns}
|
||||||
isLoading={runsLoading || gamesLoading}
|
isLoading={runsLoading || gamesLoading}
|
||||||
emptyMessage="No runs yet."
|
emptyMessage="No runs found."
|
||||||
keyFn={(r) => r.id}
|
keyFn={(r) => r.id}
|
||||||
onRowClick={(r) => setDeleting(r)}
|
onRowClick={(r) => setDeleting(r)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user