Skip to content

Commit

Permalink
develop -> main (#5)
Browse files Browse the repository at this point in the history
* add rate limiter for question submission

* add past questions page

* adjust layout

* finish v1 of question page

* cleanup, fixes for question page

* fix logged out experience

* misc cleanup
  • Loading branch information
NoahTK7 authored Jun 20, 2024
1 parent 205e883 commit 069ca99
Show file tree
Hide file tree
Showing 18 changed files with 412 additions and 250 deletions.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"next": "^14.2.4",
"next-axiom": "^0.18.1",
"patch-package": "^8.0.0",
"rate-limiter-flexible": "^5.0.3",
"react": "18.2.0",
"react-collapsed": "^4.0.4",
"react-dom": "18.2.0",
Expand Down
10 changes: 8 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ datasource db {
model Question {
id Int @id @default(autoincrement())
questionNum Int @unique
num Int @unique
title String @default("") @db.VarChar(128)
startsAt DateTime
endsAt DateTime
Expand All @@ -26,7 +26,7 @@ model Question {
startEvents StartEvent[]
questionStats QuestionStats[]
@@index([id])
@@index([num])
@@index([startsAt, endsAt])
}

Expand Down Expand Up @@ -94,3 +94,9 @@ model UserStats {
@@index([userId])
}

model RateLimiter {
key String @id
points Int
expire DateTime?
}
8 changes: 8 additions & 0 deletions src/components/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ export const skipBatchOpts = {
}
}
}

export const statusColors = {
ERROR: "text-rose-700",
TIMEOUT: "text-rose-700",
INCORRECT: "text-yellow-500",
CORRECT: "text-emerald-600",
UNKNOWN: "text-slate-500"
};
55 changes: 23 additions & 32 deletions src/components/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { SignInButton, SignedIn, SignedOut, UserButton, useAuth } from "@clerk/nextjs";
import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
import Head from "next/head";
import Link from "next/link";
import { type PropsWithChildren } from "react";
import { createContext, type PropsWithChildren } from "react";
import { api, getBaseUrl } from "~/utils/api";
import { LinkedInLogoIcon } from '@radix-ui/react-icons';
import { useTimer } from "react-timer-hook";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { noRefreshOpts, skipBatchOpts } from "./constants";
import { GlobalLeaderboard, PersonalStats } from "./sidebar";
import { MetaHeadEmbed } from "@phntms/react-share";
import { type PublicQuestionInfo } from "~/server/helpers/filter";

const Account = () => {
return (
Expand Down Expand Up @@ -45,7 +45,7 @@ export const GreenSignInButton = () => {
}

const QuestionCountDown = ({ expTimestamp }: { expTimestamp: Date }) => {
const ctx = api.useContext()
const ctx = api.useUtils()
const router = useRouter()

const displayNewQuestionAvailable = () => {
Expand Down Expand Up @@ -94,8 +94,9 @@ const QuestionCountDown = ({ expTimestamp }: { expTimestamp: Date }) => {
return <span>Next question in {`${remainingTime.hours}h ${remainingTime.minutes}m ${remainingTime.seconds}s`}.</span>
}

export const GlobalStatusContext = createContext<PublicQuestionInfo | undefined>(undefined)

export const PageLayout = (props: PropsWithChildren) => {
const { isSignedIn } = useAuth()
const { data: globalStatus } = api.status.global.useQuery(undefined, {
...noRefreshOpts,
...skipBatchOpts
Expand All @@ -106,7 +107,7 @@ export const PageLayout = (props: PropsWithChildren) => {
})

return (
<>
<GlobalStatusContext.Provider value={globalStatus}>
<Head>
<MetaHeadEmbed
render={(meta: React.ReactNode) => <>{meta}</>}
Expand Down Expand Up @@ -137,16 +138,12 @@ export const PageLayout = (props: PropsWithChildren) => {
<div className="flex flex-shrink-0 items-center">
<Link href="/" className="text-gray-700 hover:bg-slate-300 hover:text-gray-800 rounded-md px-3 py-2 text-xl font-mono font-bold" >CodeReal</Link>
</div>
{isSignedIn &&
<div className="flex space-x-4 items-center">
<Link href="/past-submissions" className="text-gray-700 hover:bg-slate-300 hover:text-gray-800 rounded-md px-3 py-2 text-sm font-small">Past Submissions</Link>
</div>
}
<div className="flex space-x-4 items-center">
<Link href="/past-questions" className="text-gray-700 hover:bg-slate-300 hover:text-gray-800 rounded-md px-3 py-2 text-sm font-small">Past Questions</Link>
</div>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<div className="relative ml-3">
<Account />
</div>
<Account />
</div>
</div>
</div>
Expand All @@ -167,35 +164,29 @@ export const PageLayout = (props: PropsWithChildren) => {
</main>

<footer className="grid grid-cols-1 divide-y divide-slate-400 bg-slate-200 text-gray-800">
<div className="flex justify-center ">
<div className="max-w-7xl px-4 py-2 font-mono">
<div className="px-4 py-2 font-mono flex justify-center">
<div className="text-center">
{globalStatus && globalStats ? (
<>
<span>{globalStats.numAnswered} people have solved today&apos;s question. </span>
<QuestionCountDown expTimestamp={globalStatus.questionExpiration} />
<QuestionCountDown expTimestamp={globalStatus.questionExp} />
</>
)
: 'Loading...'}
</div>
</div>
<div className="flex justify-between">
<div className="max-w-7xl px-4 py-2 justify-left">
<p className="align-middle">Copyright © {new Date().getFullYear()} Noah Kurrack. All rights reserved.</p>
</div>
<div className="max-w-7xl px-4 py-2 justify-right items-center">
<div className="hover:bg-white duration-200 px-1 py-1 rounded-sm">
<a
href="https://www.linkedin.com/in/noahkurrack/"
target="_blank"
rel="noopener noreferrer"
>
<LinkedInLogoIcon height={20} width={20} />
</a>
</div>
<div className="px-4 py-2 flex justify-center">
<div>
<p className="align-middle">Copyright © {new Date().getFullYear()} <a
href="https://www.linkedin.com/in/noahkurrack/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>Noah Kurrack</a>. All rights reserved.</p>
</div>
</div>
</footer>
</div>
</>
</GlobalStatusContext.Provider>
)
}
92 changes: 62 additions & 30 deletions src/components/question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { useStopwatch } from "react-timer-hook"
import { api } from "~/utils/api"
import { noRefreshOpts, codeEditorExtensions, codeEditorTheme } from "./constants"
import { SubmissionDisplay } from "./submission"
import { type PersonalStatusData } from "~/server/api/routers/status"
import toast from "react-hot-toast"
import { useAuth } from "@clerk/nextjs"
import { GreenSignInButton } from "./layout"
import { LoadingSpinner } from "./loading"
import { type PublicQuestionInfo } from "~/server/helpers/filter"

const Question = ({ questionId }: { questionId: number }) => {
const [isSubmitTimeout, setIsSubmitTimeout] = useState(false)
const { data, isLoading, isError } = api.question.get.useQuery({ id: questionId }, noRefreshOpts)
const { mutate: submitQuestion } = api.submission.submit.useMutation({
const { data, isLoading, isError } = api.question.getPrivate.useQuery({ id: questionId }, noRefreshOpts)
const { mutate: submitQuestion, isLoading: isSubmitLoading } = api.submission.submit.useMutation({
onSuccess: (data) => {
if (data.error) {
toast.error(`Execution error: ${data.execResult.errorMessage ?? 'unknown'}`, {
Expand All @@ -19,7 +22,8 @@ const Question = ({ questionId }: { questionId: number }) => {
return
}
toast.success('Your submission was recorded!')
void utils.status.personal.invalidate()
void utils.status.question.invalidate()
void utils.question.getInfinite.invalidate()
void utils.submission.invalidate(undefined, {
type: 'all' // refresh queries on other pages
})
Expand All @@ -39,18 +43,17 @@ const Question = ({ questionId }: { questionId: number }) => {

if (isError) return <p>Question could not be loaded. Please refresh the page.</p>

const initialValue = data.funcSignature + ' {\n \n}'
const initialValue = data.signature + ' {\n \n}'
if (code == "") setCode(initialValue)

return (
<div>
<p className="text-xl mb-4">Question #{data.id}: {data.title}</p>
<p>{data.questionDescription}</p>
<p>{data.description}</p>
<ReactCodeMirror
value={code}
extensions={codeEditorExtensions}
autoFocus={true}
readOnly={isSubmitTimeout}
readOnly={isSubmitLoading}
theme={codeEditorTheme}
maxHeight="8rem"
onChange={(value, _update) => {
Expand Down Expand Up @@ -90,48 +93,77 @@ const ElapsedTimeCounter = ({ startTime }: { startTime: Date }) => {
)
}

export const QuestionHandler = (props: PersonalStatusData) => {
export const QuestionHandler = (questionInfo: PublicQuestionInfo) => {
const { isSignedIn } = useAuth()

if (!isSignedIn) {
return (
<div className="px-2 py-2 space-y-4">
<p className="text-xl mb-4">Question #{questionInfo.questionNum}: {questionInfo.questionTitle}</p>
<p>Sign in to get started!</p>
<GreenSignInButton />
</div>
)
}

return <QuestionSignedIn {...questionInfo} />
}

const QuestionSignedIn = (questionInfo: PublicQuestionInfo) => {
const {
data: questionStatus,
isLoading: isQuestionStatusLoading,
isError: isQuestionStatusError
} = api.status.question.useQuery({ id: questionInfo.questionId }, noRefreshOpts)
const utils = api.useUtils()
const { mutate: startQuestion, isLoading: isQuestionLoading } = api.question.start.useMutation({
onSuccess: () => {
void utils.status.personal.invalidate()
void utils.status.question.invalidate()
},
onError: (error) => {
toast.error(`An error occured: ${error.message}`)
}
})

if (props.isCompleted) {
if (isQuestionStatusError) return <p>Error</p>

if (isQuestionStatusLoading) return <div className="flex justify-center"><LoadingSpinner size={48} /></div>

if (questionStatus.isCompleted) {
return (
<>
<p>You&apos;ve already completed today&apos;s question!</p>
{props.submissionId
? <SubmissionDisplay id={props.submissionId} />
<div className="px-2 py-2 space-y-4">
<p className="text-xl mb-4">Question #{questionInfo.questionNum}: {questionInfo.questionTitle}</p>
<p>You&apos;ve already completed this question!</p>
{questionStatus.submissionId
? <SubmissionDisplay id={questionStatus.submissionId} />
: <p>There was an error retrieving your submission.</p>}
</>
</div>
)
}

if (props.isStarted) {
if (questionStatus.isStarted) {
return (
<div className="px-2 py-2 space-y-4">
<p className="text-xl font-mono font-bold">Today&apos;s Question</p>
<Question questionId={props.questionId} />
{props.startTime && <p>Elapsed time: <ElapsedTimeCounter startTime={props.startTime} /></p>}
<p className="text-xl mb-4">Question #{questionInfo.questionNum}: {questionInfo.questionTitle}</p>
<Question questionId={questionStatus.questionId} />
{questionStatus.startTime && <p>Elapsed time: <ElapsedTimeCounter startTime={questionStatus.startTime} /></p>}
</div>
)
}

return (
<div className="space-y-4">
<p>Click button to start today&apos;s question (#{props.questionId})!</p>
<button
disabled={isQuestionLoading}
onClick={(_e) => startQuestion({ questionId: props.questionId })}
className="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-green active:bg-green-800 ease-out duration-300"
>
Start!
</button>
</div>
<>
<div className="px-2 py-2 space-y-4">
<p className="text-xl mb-4">Question #{questionInfo.questionNum}: {questionInfo.questionTitle}</p>
<p>Click the button to start this question:</p>
<button
disabled={isQuestionLoading}
onClick={(_e) => startQuestion({ questionId: questionStatus.questionId })}
className="bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline-green active:bg-green-800 ease-out duration-300"
>
Start!
</button>
</div>
</>
)
}
34 changes: 8 additions & 26 deletions src/components/submission.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,18 @@
import ReactCodeMirror from "@uiw/react-codemirror";
import { api, type RouterOutputs } from "~/utils/api";
import { codeEditorExtensions, codeEditorTheme, noRefreshOpts } from "./constants";
import { useCollapse } from "react-collapsed";
import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";
import { codeEditorExtensions, codeEditorTheme, noRefreshOpts, statusColors } from "./constants";
import { LoadingSpinner } from "./loading";

type SubmissionItem = RouterOutputs['submission']['getInfinite']['submissions'][number]
export const Submission = ({ data, solo: isSolo }: { data: SubmissionItem, solo: boolean }) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { getCollapseProps, getToggleProps, isExpanded } = useCollapse({
defaultExpanded: isSolo
})

const statusColors = {
ERROR: "text-rose-700",
TIMEOUT: "text-rose-700",
INCORRECT: "text-yellow-500",
CORRECT: "text-emerald-600",
UNKNOWN: "text-slate-500"
};
export const Submission = ({ data }: { data: SubmissionItem }) => {
const statusColor = statusColors[data.runResult] || "";

return (
<div className="space-y-4 py-4 px-2">
<div {...getToggleProps({ disabled: isSolo })} className="flex justify-between">
<p className="space-x-2 inline-block"><span className="text-xl">Question #{data.questionId} Submission</span> <span className={`font-bold text-md ${statusColor}`}>({data.runResult})</span></p>
{!isSolo &&
<div className="flex-right">
{isExpanded ? <ChevronDownIcon height={24} width={24} /> : <ChevronRightIcon height={24} width={24} />}
</div>}
<>
<div className="flex justify-between">
<p className="space-x-2 inline-block"><span className="text-lg">Result: </span> <span className={`font-bold text-lg ${statusColor}`}>{data.runResult}</span></p>
</div>
<section {...getCollapseProps()} className="space-y-4 !mb-4">
<section className="space-y-4 !mb-4">
{data.errorMessage &&
<div className="rounded-md border border-stroke py-2 px-4 shadow-1 bg-rose-100 border-red-400">
<p>Error: &quot;{data.errorMessage}&quot;</p>
Expand Down Expand Up @@ -73,7 +55,7 @@ export const Submission = ({ data, solo: isSolo }: { data: SubmissionItem, solo:
/>
{/* improvement: add share score button */}
</section>
</div>
</>
)
}

Expand All @@ -84,7 +66,7 @@ export const SubmissionDisplay = ({ id }: { id: number }) => {

if (isError) return <p>There was an error retrieving your submission.</p>

return <Submission data={data} solo={true} />
return <Submission data={data} />
}

const convertSeconds = (seconds: number) => {
Expand Down
Loading

0 comments on commit 069ca99

Please sign in to comment.