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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ DB_PASSWORD=
DB_NAME=ciscode_db

PORT=3000
JWT_SECRET=default_secret_key
JWT_SECRET=default_secret_key
# Study Hub base URL (no trailing slash); defaults to https://studyhub.dcism.org
NEXT_PUBLIC_STUDYHUB_URL=
11 changes: 11 additions & 0 deletions src/app/problem/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { TestCaseResponseType } from "@/dtos/testcase.dto";
import { runTestCase } from "@/lib/fetchers/code.fetchers";
import { fetchProblem } from "@/lib/fetchers/problem.fetchers";
import { submitCode } from "@/lib/fetchers/submission.fetchers";
import { studyHubTraceUrl } from "@/lib/studyhub";
import { ProblemPageEnum } from "@/lib/types/enums/problempage.enum";
import SubmissionStatusEnum from "@/lib/types/enums/problemstatus.enum";
import TestCaseSubmissionStatusEnum from "@/lib/types/enums/submissionstatus.enum";
Expand Down Expand Up @@ -278,6 +279,11 @@ export default function Problem() {
code={code}
onCodeChange={handleCodeChange}
handleReset={handleReset}
studyHubTraceUrl={studyHubTraceUrl({
problemId: problem.id,
title: problem.title,
code,
})}
/>
}
TestCases={
Expand All @@ -288,6 +294,11 @@ export default function Problem() {
onCheckCode={handleCheckCode}
submitted={submitted}
sending={!code || sending}
studyHubTrace={{
problemId: problem.id,
title: problem.title,
code,
}}
/>
}
/>
Expand Down
39 changes: 32 additions & 7 deletions src/components/problem/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { useEditorSettings } from "@/hooks/use-editor-settings";
import ProblemCard from "./problem-card";
import EditorSettings from "./editor-settings";
import { CodeXml, RefreshCcw } from "lucide-react";
import { CodeXml, Footprints, RefreshCcw } from "lucide-react";
import {
HoverCard,
HoverCardContent,
Expand All @@ -17,12 +17,14 @@ interface CodeEditorProps {
code: string;
onCodeChange?: (value: string | undefined) => void;
handleReset?: () => void;
studyHubTraceUrl?: string;
}

const CodeEditor: React.FC<CodeEditorProps> = ({
code,
onCodeChange,
handleReset,
studyHubTraceUrl,
}) => {
const { settings, update_setting, get_extensions, reset_settings } =
useEditorSettings();
Expand All @@ -41,12 +43,35 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
<p className="text-sm font-semibold">main.c</p>
</div>

{/* editor settings */}
<EditorSettings
settings={settings}
update_setting={update_setting}
reset_settings={reset_settings}
/>
<div className="flex items-center gap-2">
{studyHubTraceUrl && (
<Button
asChild
variant="outline"
size="sm"
className="h-8 bg-background/90 shadow-lg hover:bg-background"
>
<a
href={studyHubTraceUrl}
target="_blank"
rel="noopener noreferrer"
title="See your code run step by step, with its variables and data structures drawn, in Study Hub"
>
<Footprints className="size-4 text-primary" />
<span className="text-xs">
Visualize and trace in Study Hub
</span>
</a>
</Button>
)}

{/* editor settings */}
<EditorSettings
settings={settings}
update_setting={update_setting}
reset_settings={reset_settings}
/>
</div>
</div>

<div className="flex-1 min-h-0 relative">
Expand Down
4 changes: 4 additions & 0 deletions src/components/problem/test-case-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Button } from "../ui/button";
import TestCase from "./test-case";
import { TestCaseResponseType } from "@/dtos/testcase.dto";
import { CheckCodeResponseType } from "@/dtos/code.dto";
import { StudyHubTraceContext } from "./test-case";

interface TestCaseBarProps {
testCases: TestCaseResponseType[];
Expand All @@ -17,6 +18,7 @@ interface TestCaseBarProps {
submitted?: boolean;
sending: boolean;
overrideHidden?: boolean;
studyHubTrace?: StudyHubTraceContext;
}

const TestCaseBar: React.FC<TestCaseBarProps> = ({
Expand All @@ -27,6 +29,7 @@ const TestCaseBar: React.FC<TestCaseBarProps> = ({
submitted,
sending,
overrideHidden = false,
studyHubTrace,
}) => {
return (
<ProblemCard className="relative overflow-hidden">
Expand All @@ -46,6 +49,7 @@ const TestCaseBar: React.FC<TestCaseBarProps> = ({
submitted={submitted ?? false}
sending={sending}
overrideHidden={overrideHidden}
studyHubTrace={studyHubTrace}
/>
))}
</div>
Expand Down
54 changes: 52 additions & 2 deletions src/components/problem/test-case.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CheckCodeResponseType } from "@/dtos/code.dto";
import { TestCaseResponseType } from "@/dtos/testcase.dto";
import TestCaseSubmissionStatusEnum from "@/lib/types/enums/submissionstatus.enum";
import { cn } from "@/lib/utils";
import { Circle, CircleCheck, CircleX } from "lucide-react";
import { Circle, CircleCheck, CircleX, Footprints } from "lucide-react";
import React, { useEffect, useState } from "react";
import { FaPlay } from "react-icons/fa";
import { ClipLoader } from "react-spinners";
Expand All @@ -11,6 +11,13 @@ import { Button } from "../ui/button";
import { getHighlightedDiff } from "../shared/diff";
import { DialogTitle } from "@radix-ui/react-dialog";
import Info from "../shared/info";
import { studyHubTraceUrl } from "@/lib/studyhub";

export interface StudyHubTraceContext {
problemId: number;
title: string;
code: string;
}

interface TestCaseProps {
testCaseNumber: number;
Expand All @@ -22,6 +29,7 @@ interface TestCaseProps {
submitted: boolean;
sending: boolean;
overrideHidden?: boolean;
studyHubTrace?: StudyHubTraceContext;
}

const TestCase: React.FC<TestCaseProps> = ({
Expand All @@ -32,6 +40,7 @@ const TestCase: React.FC<TestCaseProps> = ({
submitted,
sending,
overrideHidden,
studyHubTrace,
}) => {
const [showDetails, setShowDetails] = useState(false);
const [checking, setChecking] = useState(submitted);
Expand Down Expand Up @@ -82,6 +91,17 @@ const TestCase: React.FC<TestCaseProps> = ({
setChecking(false);
};

// Only visible test cases may be sent to the Study Hub, never hidden ones.
const traceUrl =
studyHubTrace && !testCase.hidden
? studyHubTraceUrl({
...studyHubTrace,
input: testCase.input ?? "",
expected: testCase.output ?? "",
})
: null;
const failed = testCase.status === TestCaseSubmissionStatusEnum.FAILED;

useEffect(() => {
setChecking(submitted);
}, [submitted]);
Expand All @@ -101,6 +121,24 @@ const TestCase: React.FC<TestCaseProps> = ({
</div>
</button>

{traceUrl && (
<a
href={traceUrl}
target="_blank"
rel="noopener noreferrer"
title="Visualize and trace this test case in Study Hub"
aria-label={`Visualize and trace test case #${testCaseNumber} in Study Hub`}
className={cn(
"absolute top-4 right-10 rounded-full flex items-center justify-center border size-6 transition-colors",
failed
? "border-red-500 text-red-500 hover:bg-red-100 dark:hover:bg-red-900"
: "border-primary text-primary hover:bg-blue-100 dark:hover:bg-blue-900",
)}
>
<Footprints className="size-3" />
</a>
)}

{checking ? (
<ClipLoader
size={20}
Expand Down Expand Up @@ -146,7 +184,19 @@ const TestCase: React.FC<TestCaseProps> = ({
</p>
)}
</div>
{testCase.status === TestCaseSubmissionStatusEnum.FAILED && (
{failed && traceUrl && (
<Button
asChild
variant="outline"
className="mt-2 w-full border-red-500 text-red-600 dark:text-red-400"
>
<a href={traceUrl} target="_blank" rel="noopener noreferrer">
<Footprints className="size-4" />
Visualize and trace in Study Hub
</a>
</Button>
)}
{failed && (
<Dialog>
<DialogTrigger asChild>
<Button className="mt-2 cursor-pointer w-full bg-red-500 dark:bg-red-900 hover:dark:bg-red-800 text-white hover:bg-red-600">
Expand Down
45 changes: 45 additions & 0 deletions src/lib/studyhub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const DEFAULT_STUDYHUB_URL = "https://studyhub.dcism.org";

export const STUDYHUB_URL = (
process.env.NEXT_PUBLIC_STUDYHUB_URL || DEFAULT_STUDYHUB_URL
).replace(/\/+$/, "");

interface StudyHubTraceOptions {
problemId: number;
title: string;
code: string;
input?: string;
expected?: string;
}

// base64url (RFC 4648 §5) of the UTF-8 bytes of the JSON payload, no padding.
const encodePayload = (payload: object): string => {
const bytes = new TextEncoder().encode(JSON.stringify(payload));
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
};

export const studyHubTraceUrl = ({
problemId,
title,
code,
input,
expected,
}: StudyHubTraceOptions): string => {
const payload = {
v: 1,
kind: "trace",
problemId,
title,
code,
...(input !== undefined && { input }),
...(expected !== undefined && { expected }),
};
return `${STUDYHUB_URL}/tracer#ciscode=${encodePayload(payload)}`;
};
Loading