diff --git a/js/src/__mock__/submissions/index.ts b/js/src/__mock__/submissions/index.ts
index 4b1e5cdd8..89959c3e3 100644
--- a/js/src/__mock__/submissions/index.ts
+++ b/js/src/__mock__/submissions/index.ts
@@ -1,5 +1,9 @@
import { ApiURL } from "@/lib/api/common/apiURL";
-import { QuestionDifficulty } from "@/lib/api/types/schema";
+import {
+ components,
+ LeetcodeTopicEnum,
+ QuestionDifficulty,
+} from "@/lib/api/types/schema";
import { http, HttpResponse } from "msw";
export const MOCK_SUBMISSION_ID = "submission-1";
@@ -80,6 +84,82 @@ export const submissionDetailsProcessingHandler = http.get(
},
);
+const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000;
+
+export const submissionDetailsVerifiedHandler = http.get(
+ submissionDetails.url.toString(),
+ () => {
+ return HttpResponse.json({
+ success: true,
+ message: "Submission details loaded!",
+ payload: {
+ id: MOCK_SUBMISSION_ID,
+ userId: "user-1",
+ questionSlug: "two-sum",
+ questionNumber: 1,
+ questionTitle: "Two Sum",
+ questionLink: "https://leetcode.com/problems/two-sum",
+ questionDifficulty: QuestionDifficulty.Medium,
+ description: "
Given an array of integers, return indices.
",
+ acceptanceRate: 0.49,
+ pointsAwarded: 10,
+ discordName: "aphrodite",
+ nickname: "Aphrodite",
+ code: "class Solution {}",
+ runtime: "1ms",
+ memory: "44MB",
+ language: "java",
+ createdAt: "",
+ submittedAt: new Date(Date.now() - TWO_DAYS_MS).toISOString(),
+ submissionId: null,
+ topics: [
+ {
+ id: "topic-1",
+ questionId: MOCK_SUBMISSION_ID,
+ topicSlug: "array",
+ topic: LeetcodeTopicEnum.ARRAY,
+ createdAt: "",
+ },
+ ],
+ leetcodeUsername: null,
+ },
+ } satisfies components["schemas"]["ApiResponderQuestionWithUserDto"]);
+ },
+);
+
+export const submissionDetailsSparseHandler = http.get(
+ submissionDetails.url.toString(),
+ () => {
+ return HttpResponse.json({
+ success: true,
+ message: "Submission details loaded!",
+ payload: {
+ id: MOCK_SUBMISSION_ID,
+ userId: "user-1",
+ questionSlug: "two-sum",
+ questionNumber: 1,
+ questionTitle: "Two Sum",
+ questionLink: "https://leetcode.com/problems/two-sum",
+ questionDifficulty: QuestionDifficulty.Hard,
+ description: null,
+ acceptanceRate: 0.2,
+ pointsAwarded: null,
+ discordName: null,
+ nickname: null,
+ code: "public class Solution {}",
+ runtime: "3ms",
+ memory: "2MB",
+ language: "csharp",
+ createdAt: "",
+ submittedAt: "",
+ submissionId: null,
+ topics: [],
+ leetcodeUsername: null,
+ },
+ } satisfies components["schemas"]["ApiResponderQuestionWithUserDto"]);
+ },
+);
+
export const submissionDetailsFailedHandler = http.get(
submissionDetails.url.toString(),
() => {
diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css
index 6eb472a13..b43d54c16 100644
--- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css
+++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css
@@ -1,12 +1,63 @@
+.title {
+ font-size: clamp(1.375rem, 4vw, 2rem);
+}
+
+.spoilerRoot[data-has-spoiler] {
+ margin-bottom: calc(24px + var(--mantine-spacing-md));
+}
+
+.spoilerControl {
+ margin-top: var(--mantine-spacing-md);
+}
+
+.author {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.stat {
+ padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
+ border: 1px solid var(--mantine-color-dark-4);
+ border-radius: var(--mantine-radius-md);
+ background-color: var(--mantine-color-dark-7);
+}
+
.description {
min-width: 0;
+ overflow-wrap: anywhere;
+ line-height: 1.6;
+}
+
+.description img {
+ max-width: 100%;
+ height: auto;
+ border-radius: var(--mantine-radius-sm);
+}
+
+.description code {
+ padding: 1px 5px;
+ border-radius: var(--mantine-radius-xs);
+ background-color: var(--mantine-color-dark-5);
+ font-size: 0.9em;
}
.description > pre {
- background-color: rgba(51, 48, 48, 0.8);
- border-radius: 1rem;
- padding: 2rem;
min-width: 0;
+ padding: var(--mantine-spacing-md);
+ border-left: 3px solid var(--mantine-color-patina-6);
+ border-radius: var(--mantine-radius-md);
+ background-color: var(--mantine-color-dark-8);
white-space: pre-wrap;
- word-wrap: break-word;
+ overflow-wrap: anywhere;
+}
+
+.description > pre code {
+ padding: 0;
+ background-color: transparent;
+}
+
+.code {
+ border-radius: var(--mantine-radius-md);
+ font-size: 0.875rem;
}
diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx
index 63c54af4e..cd7c22a1a 100644
--- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx
+++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.test.tsx
@@ -3,6 +3,8 @@ import {
submissionDetailsFailedHandler,
submissionDetailsErrorHandler,
submissionDetailsProcessingHandler,
+ submissionDetailsSparseHandler,
+ submissionDetailsVerifiedHandler,
MOCK_SUBMISSION_ID,
} from "@/__mock__/submissions";
import SubmissionDetailsContent from "@/app/submission/[submissionId]/_components/SubmissionDetailsContent";
@@ -51,7 +53,7 @@ describe("SubmissionDetailsContent API Succeeded", () => {
);
await waitFor(() => {
- const link = screen.getByRole("link");
+ const link = screen.getByRole("link", { name: /open on leetcode/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
@@ -102,34 +104,17 @@ describe("SubmissionDetailsContent API Succeeded", () => {
});
});
- it("should render go to profile button after successful API call", async () => {
+ it("should link author name to user profile after successful API call", async () => {
renderProviderFn?.(
,
);
await waitFor(() => {
- expect(
- screen.getByRole("button", { name: /go to profile/i }),
- ).toBeInTheDocument();
- });
- });
-
- it("should navigate to user profile when go to profile button is clicked", async () => {
- const user = userEvent.setup();
- renderProviderFn?.(
- ,
- );
-
- await waitFor(() => {
- expect(
- screen.queryByTestId("submission-skeleton-question-title"),
- ).not.toBeInTheDocument();
- });
-
- const profileButton = screen.getByRole("button", {
- name: /go to profile/i,
+ expect(screen.getByRole("link", { name: "aphrodite" })).toHaveAttribute(
+ "href",
+ "/user/user-1",
+ );
});
- await user.click(profileButton);
});
it("should render language after successful API call", async () => {
@@ -148,7 +133,7 @@ describe("SubmissionDetailsContent API Succeeded", () => {
);
await waitFor(() => {
- expect(screen.getByText("Runtime: 50ms")).toBeInTheDocument();
+ expect(screen.getByText("50ms")).toBeInTheDocument();
});
});
@@ -158,7 +143,7 @@ describe("SubmissionDetailsContent API Succeeded", () => {
);
await waitFor(() => {
- expect(screen.getByText("Memory: 16MB")).toBeInTheDocument();
+ expect(screen.getByText("16MB")).toBeInTheDocument();
});
});
@@ -213,4 +198,60 @@ describe("SubmissionDetailsContent API Succeeded", () => {
expect(screen.getByText("Submission not found.")).toBeInTheDocument();
});
});
+
+ it("should render verified nickname, submitted time and topics when available", async () => {
+ server.use(submissionDetailsVerifiedHandler);
+ renderProviderFn?.(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole("link", { name: "Aphrodite" })).toHaveAttribute(
+ "href",
+ "/user/user-1",
+ );
+ });
+ expect(screen.queryByText("aphrodite")).not.toBeInTheDocument();
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
+ expect(screen.getByText("2d ago")).toBeInTheDocument();
+ expect(screen.getByText("Array")).toBeInTheDocument();
+ });
+
+ it("should render fallbacks when optional submission fields are missing", async () => {
+ server.use(submissionDetailsSparseHandler);
+ renderProviderFn?.(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("link", { name: "Unknown user" }),
+ ).toBeInTheDocument();
+ });
+ expect(screen.getByText("—")).toBeInTheDocument();
+ expect(
+ screen.getByText("No available description found."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Csharp")).toBeInTheDocument();
+ expect(screen.queryByText("Submitted")).not.toBeInTheDocument();
+ });
+
+ it("should copy the solution code to the clipboard", async () => {
+ const user = userEvent.setup();
+ renderProviderFn?.(
+ ,
+ );
+
+ const copyButton = await screen.findByRole("button", {
+ name: "Copy code",
+ });
+ await user.click(copyButton);
+
+ await waitFor(async () => {
+ expect(await navigator.clipboard.readText()).toBe(
+ "def twoSum(nums, target):\n pass",
+ );
+ });
+ expect(await screen.findByText("Copied")).toBeInTheDocument();
+ });
});
diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx
index d1d6d02f7..71836ef69 100644
--- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx
+++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContent.tsx
@@ -1,16 +1,46 @@
import classes from "@/app/submission/[submissionId]/_components/SubmissionDetailsContent.module.css";
import SubmissionDetailsContentSkeleton from "@/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton";
import CodebloomCard from "@/components/ui/CodebloomCard";
+import {
+ langNameKey,
+ langNameToIcon,
+} from "@/components/ui/langname-to-icon/LangNameToIcon";
import DocumentDescription from "@/components/ui/title/DocumentDescription";
import DocumentTitle from "@/components/ui/title/DocumentTitle";
import Toast from "@/components/ui/toast/Toast";
import ToastWithRedirect from "@/components/ui/toast/ToastWithRedirect";
import { useSubmissionDetailsQuery } from "@/lib/api/queries/submissions";
+import { ApiUtils } from "@/lib/api/utils";
import { capitalize } from "@/lib/helper/capitalize";
-import { Badge, Box, Button, Center, Flex, Text, Title } from "@mantine/core";
+import { theme } from "@/lib/theme";
+import { timeDiff } from "@/lib/timeDiff";
+import {
+ ActionIcon,
+ Anchor,
+ Badge,
+ Box,
+ Button,
+ CopyButton,
+ Flex,
+ Group,
+ SimpleGrid,
+ Spoiler,
+ Stack,
+ Text,
+ Title,
+ Tooltip,
+} from "@mantine/core";
+import {
+ IconCheck,
+ IconCircleCheckFilled,
+ IconClock,
+ IconCopy,
+ IconDatabase,
+ IconExternalLink,
+ IconHourglassHigh,
+} from "@tabler/icons-react";
+import { ReactNode } from "react";
import { FaDiscord } from "react-icons/fa";
-import { FiExternalLink } from "react-icons/fi";
-import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import ShikiHighlighter from "react-shiki";
@@ -19,8 +49,6 @@ export default function SubmissionDetailsContent({
}: {
submissionId: string;
}) {
- const navigate = useNavigate();
-
const { data, status } = useSubmissionDetailsQuery({ submissionId });
if (status === "pending") {
@@ -37,17 +65,21 @@ export default function SubmissionDetailsContent({
const {
questionTitle,
+ questionNumber,
questionLink,
pointsAwarded,
questionDifficulty,
description,
acceptanceRate,
discordName,
+ nickname,
code,
runtime,
memory,
language,
userId,
+ submittedAt,
+ topics,
} = data.payload;
const badgeAcceptedColor = (() => {
@@ -58,10 +90,7 @@ export default function SubmissionDetailsContent({
if (ac >= 50) {
return "yellow";
}
- if (ac >= 30) {
- return "red";
- }
- return undefined;
+ return "red";
})();
const badgeDifficultyColor = (() => {
@@ -84,132 +113,248 @@ export default function SubmissionDetailsContent({
const isProcessing =
!code || !language || language === "Unknown" || !runtime || !memory;
+ const submittedDate = new Date(submittedAt);
+ const hasSubmittedDate = !Number.isNaN(submittedDate.getTime());
+ const authorName = nickname ?? discordName ?? "Unknown user";
+
+ const stats: { label: string; value: ReactNode }[] = [
+ {
+ label: "Difficulty",
+ value: {questionDifficulty},
+ },
+ {
+ label: "Acceptance",
+ value: (
+
+ {Math.round(acceptanceRate * 100)}%
+
+ ),
+ },
+ {
+ label: "Points",
+ value: (
+
+ {pointsAwarded ?? "—"}
+
+ ),
+ },
+ ];
+ if (hasSubmittedDate) {
+ stats.push({
+ label: "Submitted",
+ value: (
+
+
+ {timeDiff(submittedDate)}
+
+
+ ),
+ });
+ }
+
return (
<>
-
-
-
- {questionTitle}
-
-
-
-
-
-
- Solved by{" "}
- {" "}
-
- {discordName}
-
+
+
+
+
+
+
+
+
+ {questionNumber}.
+ {" "}
+ {questionTitle}
+
+
+ Solved by
+
+ {nickname ?
+
+
+
+ : }
+ {authorName}
+
+
+
+ }
+ >
+ Open on LeetCode
+
+
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+
+ {stat.value}
+
+ ))}
+
+ {topics.length > 0 && (
+
+ {topics.map((topic) => (
+
+ {
+ ApiUtils.getTopicEnumMetadataByTopicEnum(topic.topic)
+ .name
+ }
+
+ ))}
+
+ )}
+
+
+
+
+ Problem
-
-
-
-
- Points:
-
- {pointsAwarded}
-
-
-
-
-
- Difficulty:
-
-
- {questionDifficulty}
-
-
-
-
-
-
- Acceptance Rate:
-
-
- {Math.round(acceptanceRate * 100)}%
-
-
-
-
-
-
-
-
+
+
-
+
{isProcessing ?
-
-
+
+
+
Data Currently Not Available
-
+
This submission is still being processed.
Please check back later.
-
- : <>
-
- {capitalize(language ?? "Unknown")}
- Runtime: {runtime ?? ""}
- Memory: {memory ?? ""}
-
-
- {code.trim() ?? "No code available."}
-
- >
+
+ :
}
-
+
>
);
}
+
+function SolutionCode({
+ code,
+ language,
+ parsedLanguage,
+ runtime,
+ memory,
+}: {
+ code: string;
+ language: string;
+ parsedLanguage: string | undefined;
+ runtime: string;
+ memory: string;
+}) {
+ const LanguageIcon =
+ langNameToIcon[language as langNameKey] ?? langNameToIcon.default;
+
+ return (
+
+
+
+
+ {capitalize(language)}
+
+
+
+
+
+ Runtime
+
+
+ {runtime}
+
+
+
+
+
+ Memory
+
+
+ {memory}
+
+
+
+ {({ copied, copy }) => (
+
+
+ {copied ?
+
+ : }
+
+
+ )}
+
+
+
+
+ {code}
+
+
+ );
+}
diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx
index 30f906e7c..a6186dfe1 100644
--- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx
+++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.test.tsx
@@ -50,15 +50,6 @@ describe("SubmissionDetailsContentSkeleton", () => {
expect(info3).toBeVisible();
});
- it("should render user profile button skeleton", () => {
- renderProviderFn?.();
- const element = screen.getByTestId(
- "submission-skeleton-user-profile-button",
- );
- expect(element).toBeInTheDocument();
- expect(element).toBeVisible();
- });
-
it("should render question details skeleton", () => {
renderProviderFn?.();
const element = screen.getByTestId("submission-skeleton-question-details");
diff --git a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx
index 22dfa5b04..d4eb5780e 100644
--- a/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx
+++ b/js/src/app/submission/[submissionId]/_components/SubmissionDetailsContentSkeleton.tsx
@@ -1,95 +1,75 @@
import CodebloomCard from "@/components/ui/CodebloomCard";
-import { Box, Center, Flex, Skeleton } from "@mantine/core";
+import { Box, Flex, SimpleGrid, Skeleton, Stack } from "@mantine/core";
export default function SubmissionDetailsContentSkeleton() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
+
);
}