Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/app/(app)/_layout/components/suspended-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client'

import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
} from '@/frontend/ui'
import { useTRPC } from '@/lib/trpc/react'
import { useQuery } from '@tanstack/react-query'
import { useLogout } from '@/frontend/shared/use-user'

const DISCORD_INVITE_LINK = 'https://discord.com/users/964540226112462858'
const CONTACT_EMAIL = 'contact.vscubing@gmail.com'

export function SuspendedDialog() {
const trpc = useTRPC()
const { data: userMetadata } = useQuery(
trpc.userMetadata.userMetadata.queryOptions(),
)
const { logout, isPending } = useLogout()

const isVisible = userMetadata?.suspended === true

return (
<AlertDialog open={isVisible}>
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogContent>
<AlertDialogTitle className='mb-4'>
Your account was suspended
</AlertDialogTitle>
<AlertDialogDescription className='text-grey-20 text-center'>
Please contact us on{' '}
<a
href={DISCORD_INVITE_LINK}
target='_blank'
rel='noreferrer'
className='text-primary-100 underline'
>
Discord
</a>{' '}
or at{' '}
<a
href={`mailto:${CONTACT_EMAIL}`}
className='text-primary-100 underline'
>
{CONTACT_EMAIL}
</a>{' '}
to help us figure this out together.
</AlertDialogDescription>
<AlertDialogFooter className='mt-8'>
<AlertDialogCancel
onClick={() => logout()}
disabled={isPending}
type='button'
>
Log out
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { CurrentSolve } from './current-solve'
import { Progress } from './progress'
import { SolvePanel } from './solve-panel'
import { useSolveForm } from '@/frontend/shared/use-solve-form'
import { useQuery } from '@tanstack/react-query'
import { useTRPC } from '@/lib/trpc/react'

export function SolveContestForm({
contestSlug,
Expand All @@ -19,32 +17,8 @@ export function SolveContestForm({
{ contestSlug, discipline },
)

const trpc = useTRPC()

const metadataQuery = trpc.userMetadata.userMetadata.queryOptions()
const { data: userMetadata } = useQuery(metadataQuery)

if (!state) return // TODO: remove this if we make useSolveForm use useSuspenseQuery (see https://github.com/t3-oss/create-t3-app/issues/1765)

if (userMetadata?.suspended) {
return (
<p className='title-lg bg-black-100 fixed inset-16 z-[10000] flex flex-1 flex-col items-center justify-center rounded-xl text-center'>
Your account was suspended!
<br /> Go do something productive. <br />
<span className='text-grey-60 mt-2 text-lg'>
P.S. if this is a mistake please{' '}
<a
className='text-primary-100 underline'
href='https://discord.gg/PxFrW9vTAy'
>
let us know
</a>
, we'll fix it ASAP
</span>
</p>
) // this is an ad hoc solution for a friend who asked me to suspend his account
}

const currentSolveNumber = (state?.submittedSolves?.length ?? 0) + 1
return (
<div className='flex flex-1 justify-center pr-12 pl-16 sm:px-3 md:px-8'>
Expand Down
94 changes: 94 additions & 0 deletions src/app/(app)/dev/_components/solve-dnf-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use client'

import { Input, PrimaryButton, SecondaryButton, toast } from '@/frontend/ui'
import { LoadingDots } from '@/frontend/ui/loading-dots'
import { formatSolveTime } from '@/lib/utils/format-solve-time'
import { useTRPC } from '@/lib/trpc/react'
import { tryCatchTRPC } from '@/lib/utils/try-catch'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'

export function SolveDnfToggle() {
const [solveId, setSolveId] = useState('')
const [lookupId, setLookupId] = useState<number | null>(null)

const trpc = useTRPC()
const queryClient = useQueryClient()
const solveQuery = trpc.admin.getSolveById.queryOptions(
{ solveId: lookupId! },
{ enabled: lookupId !== null },
)
const { data: solve, isFetching, error } = useQuery(solveQuery)

const { mutateAsync: setSolveDnf, isPending } = useMutation(
trpc.admin.setSolveDnf.mutationOptions(),
)

async function handleLookup() {
const id = Number(solveId)
if (!Number.isInteger(id)) return
setLookupId(id)
}

async function handleToggle() {
if (!solve) return
const { error: mutationError } = await tryCatchTRPC(
setSolveDnf({ solveId: solve.solveId, isDnf: !solve.isDnf }),
)
if (mutationError) {
toast({ title: 'Failed', description: mutationError.message })
return
}
await queryClient.invalidateQueries({ queryKey: solveQuery.queryKey })
}

return (
<section className='space-y-4'>
<h2 className='title-h2'>DNF a solve</h2>
<form
onSubmit={(e) => {
e.preventDefault()
void handleLookup()
}}
className='flex max-w-[24rem] gap-2'
>
<Input
placeholder='Solve id'
value={solveId}
onChange={(e) => setSolveId(e.target.value)}
required
/>
<SecondaryButton size='sm' type='submit'>
Look up
</SecondaryButton>
</form>

{isFetching ? (
<LoadingDots />
) : error ? (
<p className='text-red-80'>{error.message}</p>
) : solve ? (
<div className='flex items-center gap-3'>
<span>
#{solve.solveId} · {solve.username ?? 'Unknown'} ·{' '}
{solve.discipline} · #{solve.contestSlug} ·{' '}
{solve.isDnf ? (
<span className='text-red-80'>DNF</span>
) : solve.timeMs != null ? (
formatSolveTime(solve.timeMs)
) : (
'-'
)}
</span>
<PrimaryButton
size='sm'
disabled={isPending}
onClick={() => void handleToggle()}
>
{solve.isDnf ? 'Un-DNF' : 'DNF'}
</PrimaryButton>
</div>
) : null}
</section>
)
}
97 changes: 97 additions & 0 deletions src/app/(app)/dev/_components/suspend-users.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client'

import { Input, PrimaryButton, SecondaryButton } from '@/frontend/ui'
import { LoadingDots } from '@/frontend/ui/loading-dots'
import { useTRPC } from '@/lib/trpc/react'
import { tryCatchTRPC } from '@/lib/utils/try-catch'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'

export function SuspendUsers() {
const trpc = useTRPC()
const queryClient = useQueryClient()
const suspendedUsersQuery = trpc.admin.getSuspendedUsers.queryOptions()
const { data: suspendedUsers, isLoading } = useQuery(suspendedUsersQuery)

const { mutateAsync: unsuspendUser } = useMutation(
trpc.admin.unsuspendUser.mutationOptions(),
)

async function handleUnsuspend(userId: string) {
await unsuspendUser({ userId })
await queryClient.invalidateQueries({
queryKey: suspendedUsersQuery.queryKey,
})
}

return (
<section className='space-y-4'>
<h2 className='title-h2'>Suspended users</h2>
<SuspendUserForm
onSuspended={() =>
queryClient.invalidateQueries({
queryKey: suspendedUsersQuery.queryKey,
})
}
/>
{isLoading ? (
<LoadingDots />
) : !suspendedUsers?.length ? (
<p className='text-grey-40'>No suspended users</p>
) : (
<ul className='space-y-2'>
{suspendedUsers.map((user) => (
<li key={user.userId} className='flex items-center gap-3'>
<span>{user.username ?? user.userId}</span>
<SecondaryButton
size='sm'
onClick={() => handleUnsuspend(user.userId)}
>
Unsuspend
</SecondaryButton>
</li>
))}
</ul>
)}
</section>
)
}

function SuspendUserForm({ onSuspended }: { onSuspended: () => void }) {
const { register, handleSubmit, reset, setError, formState } = useForm<{
username: string
}>()
const trpc = useTRPC()
const { mutateAsync: suspendUser, isPending } = useMutation(
trpc.admin.suspendUser.mutationOptions(),
)

async function onSubmit({ username }: { username: string }) {
const { error } = await tryCatchTRPC(suspendUser({ username }))
if (error) {
setError('username', { message: error.message })
return
}
reset()
onSuspended()
}

return (
<form
onSubmit={handleSubmit(onSubmit)}
className='flex max-w-[24rem] flex-col gap-2'
>
<Input
placeholder='Username to suspend'
required
{...register('username')}
/>
<span className='caption text-red-80'>
{formState.errors.username?.message}
</span>
<PrimaryButton disabled={isPending} type='submit' size='sm'>
Suspend
</PrimaryButton>
</form>
)
}
4 changes: 4 additions & 0 deletions src/app/(app)/dev/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { roundTable, scrambleTable } from '@/backend/db/schema'
import { and, eq, or } from 'drizzle-orm'
import { DangerousAdminActions } from './_components/client'
import { ExtraSolves } from './_components/extra-solves'
import { SuspendUsers } from './_components/suspend-users'
import { SolveDnfToggle } from './_components/solve-dnf-toggle'

export default async function DevPage() {
const authorized = await api.admin.authorized()
Expand All @@ -26,6 +28,8 @@ export default async function DevPage() {
<DangerousAdminActions />
</div>
</div>
<SuspendUsers />
<SolveDnfToggle />
<ExtraSolves />
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Navbar } from './_layout/components/navbar'
import { PickUsernameDialog } from './_layout/components/pick-username-dialog'
import { SuspendedDialog } from './_layout/components/suspended-dialog'
import { PopupSidebar } from './_layout/components/popup-sidebar'
import { Sidebar } from './_layout/components/sidebar'
import { LayoutHeader } from './_layout/layout-header'
Expand All @@ -23,6 +24,7 @@ export default function AppLayout({
<>
<Toaster />
<PickUsernameDialog />
<SuspendedDialog />
<PopupSidebar />
<CookieBannerHandler />
<div
Expand Down
Loading
Loading