mirror of
https://github.com/massbug/judge4c.git
synced 2026-10-15 15:17:31 +00:00
feat: replace dockview with flexlayout-react
This commit is contained in:
@@ -1,544 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import fs from "fs";
|
||||
import tar from "tar-stream";
|
||||
import Docker from "dockerode";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Readable, Writable } from "stream";
|
||||
import { Status } from "@/generated/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ProblemWithTestcases, TestcaseWithDetails } from "@/types/prisma";
|
||||
import type { EditorLanguage, Submission, TestcaseResult } from "@/generated/client";
|
||||
|
||||
const isRemote = process.env.DOCKER_HOST_MODE === "remote";
|
||||
|
||||
// Docker client initialization
|
||||
const docker = isRemote
|
||||
? new Docker({
|
||||
protocol: process.env.DOCKER_REMOTE_PROTOCOL as "https" | "http" | "ssh" | undefined,
|
||||
host: process.env.DOCKER_REMOTE_HOST,
|
||||
port: process.env.DOCKER_REMOTE_PORT,
|
||||
ca: fs.readFileSync(process.env.DOCKER_REMOTE_CA_PATH || "/certs/ca.pem"),
|
||||
cert: fs.readFileSync(process.env.DOCKER_REMOTE_CERT_PATH || "/certs/cert.pem"),
|
||||
key: fs.readFileSync(process.env.DOCKER_REMOTE_KEY_PATH || "/certs/key.pem"),
|
||||
})
|
||||
: new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
// Prepare Docker image environment
|
||||
async function prepareEnvironment(image: string, tag: string): Promise<boolean> {
|
||||
try {
|
||||
const reference = `${image}:${tag}`;
|
||||
const filters = { reference: [reference] };
|
||||
const images = await docker.listImages({ filters });
|
||||
return images.length !== 0;
|
||||
} catch (error) {
|
||||
console.error("Error checking Docker images:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Docker container with keep-alive
|
||||
async function createContainer(
|
||||
image: string,
|
||||
tag: string,
|
||||
workingDir: string,
|
||||
memoryLimit?: number
|
||||
) {
|
||||
const container = await docker.createContainer({
|
||||
Image: `${image}:${tag}`,
|
||||
Cmd: ["tail", "-f", "/dev/null"], // Keep container alive
|
||||
WorkingDir: workingDir,
|
||||
HostConfig: {
|
||||
Memory: memoryLimit ? memoryLimit * 1024 * 1024 : undefined,
|
||||
MemorySwap: memoryLimit ? memoryLimit * 1024 * 1024 : undefined,
|
||||
},
|
||||
NetworkDisabled: true,
|
||||
});
|
||||
|
||||
await container.start();
|
||||
return container;
|
||||
}
|
||||
|
||||
// Create tar stream for code submission
|
||||
function createTarStream(file: string, value: string) {
|
||||
const pack = tar.pack();
|
||||
pack.entry({ name: file }, value);
|
||||
pack.finalize();
|
||||
return Readable.from(pack);
|
||||
}
|
||||
|
||||
export async function judge(
|
||||
language: EditorLanguage,
|
||||
code: string,
|
||||
problemId: string,
|
||||
): Promise<Submission> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/sign-in");
|
||||
|
||||
const userId = session.user.id;
|
||||
let container: Docker.Container | null = null;
|
||||
let submission: Submission | null = null;
|
||||
|
||||
try {
|
||||
const problem = await prisma.problem.findUnique({
|
||||
where: { id: problemId },
|
||||
include: {
|
||||
testcases: {
|
||||
include: {
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as ProblemWithTestcases | null;
|
||||
|
||||
if (!problem) {
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.SE,
|
||||
userId,
|
||||
problemId,
|
||||
message: "Problem not found",
|
||||
},
|
||||
});
|
||||
return submission;
|
||||
}
|
||||
|
||||
const config = await prisma.editorLanguageConfig.findUnique({
|
||||
where: { language },
|
||||
include: {
|
||||
dockerConfig: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!config?.dockerConfig) {
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.SE,
|
||||
userId,
|
||||
problemId,
|
||||
message: " Missing editor or docker configuration",
|
||||
},
|
||||
});
|
||||
return submission;
|
||||
}
|
||||
|
||||
const testcases = problem.testcases;
|
||||
|
||||
if (!testcases || testcases.length === 0) {
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.SE,
|
||||
userId,
|
||||
problemId,
|
||||
message: "Testcases not found",
|
||||
},
|
||||
});
|
||||
return submission;
|
||||
}
|
||||
|
||||
const {
|
||||
image,
|
||||
tag,
|
||||
workingDir,
|
||||
compileOutputLimit,
|
||||
runOutputLimit,
|
||||
} = config.dockerConfig;
|
||||
const { fileName, fileExtension } = config;
|
||||
const file = `${fileName}.${fileExtension}`;
|
||||
|
||||
// Prepare the environment and create a container
|
||||
if (await prepareEnvironment(image, tag)) {
|
||||
container = await createContainer(image, tag, workingDir, problem.memoryLimit);
|
||||
} else {
|
||||
console.error("Docker image not found:", image, ":", tag);
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.SE,
|
||||
userId,
|
||||
problemId,
|
||||
message: "The docker environment is not ready",
|
||||
},
|
||||
});
|
||||
return submission;
|
||||
}
|
||||
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.PD,
|
||||
userId,
|
||||
problemId,
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Upload code to the container
|
||||
const tarStream = createTarStream(file, code);
|
||||
await container.putArchive(tarStream, { path: workingDir });
|
||||
|
||||
// Compile the code
|
||||
const compileResult = await compile(container, file, fileName, compileOutputLimit, submission.id, language);
|
||||
if (compileResult.status === Status.CE) {
|
||||
return compileResult;
|
||||
}
|
||||
|
||||
// Run the code
|
||||
const runResult = await run(container, fileName, problem.timeLimit, runOutputLimit, submission.id, testcases);
|
||||
return runResult;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (submission) {
|
||||
const updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submission.id },
|
||||
data: {
|
||||
status: Status.SE,
|
||||
message: "System Error",
|
||||
}
|
||||
})
|
||||
return updatedSubmission;
|
||||
} else {
|
||||
submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
code,
|
||||
status: Status.PD,
|
||||
userId,
|
||||
problemId,
|
||||
message: "",
|
||||
},
|
||||
})
|
||||
return submission;
|
||||
}
|
||||
} finally {
|
||||
revalidatePath(`/problems/${problemId}`);
|
||||
if (container) {
|
||||
try {
|
||||
await container.kill();
|
||||
await container.remove();
|
||||
} catch (error) {
|
||||
console.error("Container cleanup failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compile(
|
||||
container: Docker.Container,
|
||||
file: string,
|
||||
fileName: string,
|
||||
compileOutputLimit: number = 1 * 1024 * 1024,
|
||||
submissionId: string,
|
||||
language: EditorLanguage,
|
||||
): Promise<Submission> {
|
||||
const compileCmd =
|
||||
language === "c"
|
||||
? ["gcc", "-O2", file, "-o", fileName]
|
||||
: language === "cpp"
|
||||
? ["g++", "-O2", file, "-o", fileName]
|
||||
: null;
|
||||
|
||||
if (!compileCmd) {
|
||||
return prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.SE,
|
||||
message: "Unsupported language",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const compileExec = await container.exec({
|
||||
Cmd: compileCmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
|
||||
return new Promise<Submission>((resolve, reject) => {
|
||||
compileExec.start({}, (error, stream) => {
|
||||
if (error || !stream) {
|
||||
return reject({ message: "System Error", Status: Status.SE });
|
||||
}
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
let stdoutLength = 0;
|
||||
const stdoutStream = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
let text = chunk.toString();
|
||||
if (stdoutLength + text.length > compileOutputLimit) {
|
||||
text = text.substring(0, compileOutputLimit - stdoutLength);
|
||||
stdoutChunks.push(text);
|
||||
stdoutLength = compileOutputLimit;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(text);
|
||||
stdoutLength += text.length;
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
const stderrChunks: string[] = [];
|
||||
let stderrLength = 0;
|
||||
const stderrStream = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
let text = chunk.toString();
|
||||
if (stderrLength + text.length > compileOutputLimit) {
|
||||
text = text.substring(0, compileOutputLimit - stderrLength);
|
||||
stderrChunks.push(text);
|
||||
stderrLength = compileOutputLimit;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
stderrChunks.push(text);
|
||||
stderrLength += text.length;
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
stream.on("end", async () => {
|
||||
const stdout = stdoutChunks.join("");
|
||||
const stderr = stderrChunks.join("");
|
||||
const exitCode = (await compileExec.inspect()).ExitCode;
|
||||
|
||||
let updatedSubmission: Submission;
|
||||
|
||||
if (exitCode !== 0 || stderr) {
|
||||
updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.CE,
|
||||
message: stderr || "Compilation Error",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.CS,
|
||||
message: stdout,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
resolve(updatedSubmission);
|
||||
});
|
||||
|
||||
stream.on("error", () => {
|
||||
reject({ message: "System Error", Status: Status.SE });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run code and implement timeout
|
||||
async function run(
|
||||
container: Docker.Container,
|
||||
fileName: string,
|
||||
timeLimit: number = 1000,
|
||||
maxOutput: number = 1 * 1024 * 1024,
|
||||
submissionId: string,
|
||||
testcases: TestcaseWithDetails,
|
||||
): Promise<Submission> {
|
||||
let finalSubmission: Submission | null = null;
|
||||
let maxExecutionTime = 0;
|
||||
|
||||
for (const testcase of testcases) {
|
||||
const sortedData = testcase.data.sort((a, b) => a.index - b.index);
|
||||
const inputData = sortedData.map(d => d.value).join("\n");
|
||||
|
||||
const runExec = await container.exec({
|
||||
Cmd: [`./${fileName}`],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
});
|
||||
|
||||
const result = await new Promise<Submission | TestcaseResult>((resolve, reject) => {
|
||||
// Start the exec stream
|
||||
runExec.start({ hijack: true }, async (error, stream) => {
|
||||
if (error || !stream) {
|
||||
const submission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.SE,
|
||||
message: "System Error",
|
||||
}
|
||||
})
|
||||
return resolve(submission);
|
||||
}
|
||||
|
||||
stream.write(inputData);
|
||||
stream.end();
|
||||
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
let stdoutLength = 0;
|
||||
let stderrLength = 0;
|
||||
|
||||
const stdoutStream = new Writable({
|
||||
write: (chunk, _, callback) => {
|
||||
const text = chunk.toString();
|
||||
if (stdoutLength + text.length > maxOutput) {
|
||||
stdoutChunks.push(text.substring(0, maxOutput - stdoutLength));
|
||||
stdoutLength = maxOutput;
|
||||
} else {
|
||||
stdoutChunks.push(text);
|
||||
stdoutLength += text.length;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
const stderrStream = new Writable({
|
||||
write: (chunk, _, callback) => {
|
||||
const text = chunk.toString();
|
||||
if (stderrLength + text.length > maxOutput) {
|
||||
stderrChunks.push(text.substring(0, maxOutput - stderrLength));
|
||||
stderrLength = maxOutput;
|
||||
} else {
|
||||
stderrChunks.push(text);
|
||||
stderrLength += text.length;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Timeout mechanism
|
||||
const timeoutId = setTimeout(async () => {
|
||||
stream.destroy(); // Destroy the stream to stop execution
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
output: "",
|
||||
submissionId,
|
||||
testcaseId: testcase.id,
|
||||
}
|
||||
})
|
||||
const updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.TLE,
|
||||
message: "Time Limit Exceeded",
|
||||
}
|
||||
})
|
||||
resolve(updatedSubmission);
|
||||
}, timeLimit);
|
||||
|
||||
stream.on("end", async () => {
|
||||
clearTimeout(timeoutId); // Clear the timeout if the program finishes before the time limit
|
||||
const stdout = stdoutChunks.join("");
|
||||
const stderr = stderrChunks.join("");
|
||||
const exitCode = (await runExec.inspect()).ExitCode;
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
// Exit code 0 means successful execution
|
||||
if (exitCode === 0) {
|
||||
const expectedOutput = testcase.expectedOutput;
|
||||
const testcaseResult = await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: stdout.trim() === expectedOutput.trim(),
|
||||
output: stdout,
|
||||
executionTime,
|
||||
submissionId,
|
||||
testcaseId: testcase.id,
|
||||
}
|
||||
})
|
||||
resolve(testcaseResult);
|
||||
} else if (exitCode === 137) {
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
output: stdout,
|
||||
executionTime,
|
||||
submissionId,
|
||||
testcaseId: testcase.id,
|
||||
}
|
||||
})
|
||||
const updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.MLE,
|
||||
message: stderr || "Memory Limit Exceeded",
|
||||
}
|
||||
})
|
||||
resolve(updatedSubmission);
|
||||
} else {
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
output: stdout,
|
||||
executionTime,
|
||||
submissionId,
|
||||
testcaseId: testcase.id,
|
||||
}
|
||||
})
|
||||
const updatedSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.RE,
|
||||
message: stderr || "Runtime Error",
|
||||
}
|
||||
})
|
||||
resolve(updatedSubmission);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", () => {
|
||||
clearTimeout(timeoutId); // Clear timeout in case of error
|
||||
reject({ message: "System Error", Status: Status.SE });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if ('status' in result) {
|
||||
return result;
|
||||
} else {
|
||||
if (!result.isCorrect) {
|
||||
finalSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.WA,
|
||||
message: "Wrong Answer",
|
||||
},
|
||||
include: {
|
||||
testcaseResults: true,
|
||||
}
|
||||
});
|
||||
return finalSubmission;
|
||||
} else {
|
||||
maxExecutionTime = Math.max(maxExecutionTime, result.executionTime ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxMemoryUsage = (await container.stats({ stream: false, "one-shot": true })).memory_stats.max_usage;
|
||||
finalSubmission = await prisma.submission.update({
|
||||
where: { id: submissionId },
|
||||
data: {
|
||||
status: Status.AC,
|
||||
message: "All testcases passed",
|
||||
executionTime: maxExecutionTime,
|
||||
memoryUsage: maxMemoryUsage / 1024 / 1024,
|
||||
},
|
||||
include: {
|
||||
testcaseResults: true,
|
||||
}
|
||||
});
|
||||
return finalSubmission;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { EditorLanguage } from "@/generated/client";
|
||||
import { SettingsLanguageServerFormValues } from "@/app/(app)/dashboard/@admin/settings/language-server/form";
|
||||
|
||||
export const getLanguageServerConfig = async (language: EditorLanguage) => {
|
||||
return await prisma.languageServerConfig.findUnique({
|
||||
where: { language },
|
||||
});
|
||||
};
|
||||
|
||||
export const handleLanguageServerConfigSubmit = async (
|
||||
language: EditorLanguage,
|
||||
data: SettingsLanguageServerFormValues
|
||||
) => {
|
||||
const existing = await getLanguageServerConfig(language);
|
||||
|
||||
if (existing) {
|
||||
await prisma.languageServerConfig.update({
|
||||
where: { language },
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
await prisma.languageServerConfig.create({
|
||||
data: { ...data, language },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { User } from "@/generated/client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { type NavUserProps } from "@/components/nav-user";
|
||||
|
||||
interface AdminDashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function AdminDashboardLayout({
|
||||
children,
|
||||
}: AdminDashboardLayoutProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
const user: NavUserProps["user"] = (({ name, email, image }) => ({
|
||||
name: name ?? "",
|
||||
email: email ?? "",
|
||||
avatar: image ?? "",
|
||||
}))(session.user as User);
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar user={user} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
<Navbar />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DashboardAdmin() {
|
||||
return <div>Dashboard Admin</div>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import NewProblemDescriptionForm from "@/components/features/dashboard/admin/problemset/new/components/description-form";
|
||||
|
||||
export default function NewProblemDescriptionPage() {
|
||||
return <NewProblemDescriptionForm />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import NewProblemMetadataForm from "@/components/features/dashboard/admin/problemset/new/components/metadata-form";
|
||||
|
||||
export default function NewProblemMetadataPage() {
|
||||
return <NewProblemMetadataForm />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function NewProblemPage() {
|
||||
redirect("/dashboard/problemset/new/metadata");
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import NewProblemSolutionForm from "@/components/features/dashboard/admin/problemset/new/components/solution-form";
|
||||
|
||||
export default function NewProblemSolutionPage() {
|
||||
return <NewProblemSolutionForm />;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { ProblemSchema } from "@/components/features/dashboard/admin/problemset/new/schema";
|
||||
|
||||
interface NewProblemActions {
|
||||
setHydrated: (value: boolean) => void;
|
||||
setData: (data: Partial<ProblemSchema>) => void;
|
||||
}
|
||||
|
||||
type NewProblemState = Partial<ProblemSchema> & {
|
||||
hydrated: boolean;
|
||||
} & NewProblemActions;
|
||||
|
||||
export const useNewProblemStore = create<NewProblemState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
hydrated: false,
|
||||
setHydrated: (value) => set({ hydrated: value }),
|
||||
setData: (data) => set(data),
|
||||
}),
|
||||
{
|
||||
name: "zustand:new-problem",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
partialize: ({ hydrated, ...rest }) => rest,
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error) {
|
||||
console.error("An error happened during hydration", error);
|
||||
} else if (state) {
|
||||
state.setHydrated(true);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ProblemsetTable } from "@/components/features/dashboard/admin/problemset/table";
|
||||
|
||||
export default async function AdminDashboardProblemsetPage() {
|
||||
const problems = await prisma.problem.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
displayId: true,
|
||||
title: true,
|
||||
difficulty: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full px-4">
|
||||
<ProblemsetTable data={problems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { useAdminSettingsStore } from "@/stores/useAdminSettingsStore";
|
||||
import { EditorLanguage, LanguageServerConfig } from "@/generated/client";
|
||||
import { SettingsLanguageServerForm } from "@/app/(app)/dashboard/@admin/settings/language-server/form";
|
||||
|
||||
interface LanguageServerAccordionProps {
|
||||
configs: {
|
||||
language: EditorLanguage;
|
||||
config: LanguageServerConfig | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function LanguageServerAccordion({
|
||||
configs,
|
||||
}: LanguageServerAccordionProps) {
|
||||
const { hydrated, activeLanguageServerSetting, setActiveLanguageServerSetting } =
|
||||
useAdminSettingsStore();
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="h-full w-full space-y-2">
|
||||
<Loading className="h-12 p-0" skeletonClassName="rounded-md" />
|
||||
<Loading className="h-12 p-0" skeletonClassName="rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full space-y-2"
|
||||
value={activeLanguageServerSetting}
|
||||
onValueChange={setActiveLanguageServerSetting}
|
||||
>
|
||||
{configs.map(({ language, config }) => (
|
||||
<AccordionItem
|
||||
key={language}
|
||||
value={language}
|
||||
className="has-focus-visible:border-ring has-focus-visible:ring-ring/50 rounded-md border outline-none last:border-b has-focus-visible:ring-[3px]"
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 justify-start gap-3 text-[15px] leading-6 hover:no-underline focus-visible:ring-0 [&>svg]:-order-1">
|
||||
{language.toUpperCase()}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground pb-0">
|
||||
<div className="px-4 py-3">
|
||||
<SettingsLanguageServerForm
|
||||
defaultValues={
|
||||
config
|
||||
? {
|
||||
protocol: config.protocol,
|
||||
hostname: config.hostname,
|
||||
port: config.port,
|
||||
path: config.path,
|
||||
}
|
||||
: {
|
||||
port: null,
|
||||
path: null,
|
||||
}
|
||||
}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { EditorLanguage, LanguageServerProtocol } from "@/generated/client";
|
||||
import { handleLanguageServerConfigSubmit } from "@/actions/language-server";
|
||||
|
||||
const settingsLanguageServerFormSchema = z.object({
|
||||
protocol: z.nativeEnum(LanguageServerProtocol),
|
||||
hostname: z.string(),
|
||||
port: z
|
||||
.number()
|
||||
.nullable()
|
||||
.transform((val) => (val === undefined ? null : val)),
|
||||
path: z
|
||||
.string()
|
||||
.nullable()
|
||||
.transform((val) => (val === "" || val === undefined ? null : val)),
|
||||
});
|
||||
|
||||
export type SettingsLanguageServerFormValues = z.infer<typeof settingsLanguageServerFormSchema>;
|
||||
|
||||
interface SettingsLanguageServerFormProps {
|
||||
defaultValues: Partial<SettingsLanguageServerFormValues>;
|
||||
language: EditorLanguage;
|
||||
}
|
||||
|
||||
export function SettingsLanguageServerForm({
|
||||
defaultValues,
|
||||
language,
|
||||
}: SettingsLanguageServerFormProps) {
|
||||
const form = useForm<SettingsLanguageServerFormValues>({
|
||||
resolver: zodResolver(settingsLanguageServerFormSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SettingsLanguageServerFormValues) => {
|
||||
await handleLanguageServerConfigSubmit(language, data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="px-7">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="protocol"
|
||||
render={({ field }) => (
|
||||
<FormItem className="pt-0 pb-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Protocol</FormLabel>
|
||||
<FormDescription>
|
||||
This is the protocol of the language server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="wss">wss</SelectItem>
|
||||
<SelectItem value="ws">ws</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hostname"
|
||||
render={({ field }) => (
|
||||
<FormItem className="py-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Hostname</FormLabel>
|
||||
<FormDescription>
|
||||
This is the hostname of the language server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem className="py-4 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormDescription>
|
||||
This is the port of the language server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === "" ? null : Number(value));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Separator />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => (
|
||||
<FormItem className="pt-4 pb-3 grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormDescription>
|
||||
This is the path of the language server.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" className="w-full md:w-auto">
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface SettingsLanguageServerLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SettingsLanguageServerLayout({
|
||||
children,
|
||||
}: SettingsLanguageServerLayoutProps) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-[1024px] space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Language Server Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the language server connection settings.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<Suspense fallback={<Loading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { EditorLanguage } from "@/generated/client";
|
||||
import { getLanguageServerConfig } from "@/actions/language-server";
|
||||
import { LanguageServerAccordion } from "@/app/(app)/dashboard/@admin/settings/language-server/accordion";
|
||||
|
||||
export default async function SettingsLanguageServerPage() {
|
||||
const languages = Object.values(EditorLanguage);
|
||||
|
||||
const configPromises = languages.map(async (language) => ({
|
||||
language,
|
||||
config: await getLanguageServerConfig(language),
|
||||
}));
|
||||
|
||||
const configs = await Promise.all(configPromises);
|
||||
|
||||
return <LanguageServerAccordion configs={configs} />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { User } from "@/generated/client";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
admin: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function DashboardLayout({
|
||||
admin,
|
||||
}: DashboardLayoutProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
const user = session.user as User;
|
||||
|
||||
return user.role === "ADMIN" ? admin : notFound();
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
|
||||
interface BotLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function BotLayout({ children }: BotLayoutProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-t-0 border-muted rounded-b-3xl bg-background">
|
||||
<Suspense fallback={<Loading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useCallback } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
import MdxPreview from "@/components/mdx-preview";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BotIcon, SendHorizonal } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
|
||||
import { ChatBubble, ChatBubbleMessage } from "@/components/ui/chat/chat-bubble";
|
||||
|
||||
export default function Bot() {
|
||||
const t = useTranslations("Bot");
|
||||
const { problemId, problem, currentLang, currentValue } = useProblem();
|
||||
|
||||
const { messages, input, handleInputChange, setMessages, handleSubmit } = useChat({
|
||||
initialMessages: [
|
||||
{
|
||||
id: problemId,
|
||||
role: "system",
|
||||
content: `Problem description:\n${problem.description}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!input.trim()) {
|
||||
toast.error("Input cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCodeMessage = {
|
||||
id: problemId,
|
||||
role: "system" as const,
|
||||
content: `Current code:\n\`\`\`${currentLang}\n${currentValue}\n\`\`\``,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, currentCodeMessage]);
|
||||
handleSubmit();
|
||||
},
|
||||
[currentLang, currentValue, handleSubmit, input, problemId, setMessages]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 relative">
|
||||
{!messages.some(
|
||||
(message) => message.role === "user" || message.role === "assistant"
|
||||
) && (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<BotIcon />
|
||||
<span>{t("title")}</span>
|
||||
<span className="font-thin text-xs">{t("description")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute h-full w-full">
|
||||
<ScrollArea className="h-full [&>[data-radix-scroll-area-viewport]>div:min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block">
|
||||
<ChatMessageList>
|
||||
{messages
|
||||
.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant"
|
||||
)
|
||||
.map((message) => (
|
||||
<ChatBubble key={message.id} layout="ai" className="border-b pb-4">
|
||||
<ChatBubbleMessage layout="ai">
|
||||
<MdxPreview source={message.content} />
|
||||
</ChatBubbleMessage>
|
||||
</ChatBubble>
|
||||
))}
|
||||
</ChatMessageList>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="h-36 flex flex-none">
|
||||
<form onSubmit={handleFormSubmit} className="w-full p-4 pt-0 relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
handleFormSubmit(e);
|
||||
} else {
|
||||
toast.error("Input cannot be empty");
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-full bg-muted border-transparent shadow-none rounded-lg"
|
||||
placeholder={t("placeholder")}
|
||||
/>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
className="absolute bottom-6 right-6 h-6 w-auto px-2"
|
||||
aria-label="Send Message"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">Ctrl + Enter</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { WorkspaceEditorHeader } from "@/components/features/playground/workspace/editor/components/header";
|
||||
import { WorkspaceEditorFooter } from "@/components/features/playground/workspace/editor/components/footer";
|
||||
|
||||
interface CodeLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CodeLayout({ children }: CodeLayoutProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-t-0 border-muted rounded-b-3xl bg-background">
|
||||
<WorkspaceEditorHeader />
|
||||
<Suspense fallback={<Loading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
<WorkspaceEditorFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ProblemEditor } from "@/components/problem-editor";
|
||||
|
||||
export default function CodePage() {
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute w-full h-full">
|
||||
<ProblemEditor />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { getUserLocale } from "@/i18n/locale";
|
||||
import { Loading } from "@/components/loading";
|
||||
import DetailsPage from "@/app/(app)/problems/[id]/@Details/page";
|
||||
|
||||
export default async function DetailsLayout() {
|
||||
const locale = await getUserLocale();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border border-t-0 border-muted rounded-b-3xl bg-background">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<DetailsPage locale={locale} />;
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Locale } from "@/config/i18n";
|
||||
import { getLocale } from "@/lib/i18n";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
import MdxPreview from "@/components/mdx-preview";
|
||||
import { useDockviewStore } from "@/stores/dockview";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { getStatusColorClass, statusMap } from "@/lib/status";
|
||||
import type { TestcaseResultWithTestcase } from "@/types/prisma";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { formatDistanceToNow, isBefore, subDays, format } from "date-fns";
|
||||
|
||||
interface DetailsPageProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function DetailsPage({ locale }: DetailsPageProps) {
|
||||
const localeInstance = getLocale(locale);
|
||||
const t = useTranslations("DetailsPage");
|
||||
const s = useTranslations("StatusMessage");
|
||||
const { api, submission } = useDockviewStore();
|
||||
const { editorLanguageConfigs, problemId } = useProblem();
|
||||
const [lastFailedTestcase, setLastFailedTestcase] =
|
||||
useState<TestcaseResultWithTestcase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api || !problemId || !submission?.id) return;
|
||||
if (problemId !== submission.problemId) {
|
||||
const detailsPanel = api.getPanel("Details");
|
||||
if (detailsPanel) {
|
||||
api.removePanel(detailsPanel);
|
||||
}
|
||||
}
|
||||
}, [api, problemId, submission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submission?.id || !submission.testcaseResults) return;
|
||||
const failedTestcases = submission.testcaseResults.filter(
|
||||
(result) =>
|
||||
(submission.status === "WA" && !result.isCorrect) ||
|
||||
(submission.status === "TLE" && !result.isCorrect) ||
|
||||
(submission.status === "MLE" && !result.isCorrect) ||
|
||||
(submission.status === "RE" && !result.isCorrect)
|
||||
);
|
||||
setLastFailedTestcase(failedTestcases[0]);
|
||||
}, [submission]);
|
||||
|
||||
if (!api || !problemId || !submission?.id) return null;
|
||||
|
||||
const createdAt = new Date(submission.createdAt);
|
||||
const submittedDisplay = isBefore(createdAt, subDays(new Date(), 1))
|
||||
? format(createdAt, "yyyy-MM-dd")
|
||||
: formatDistanceToNow(createdAt, { addSuffix: true, locale: localeInstance });
|
||||
|
||||
const source = `\`\`\`${submission?.language}\n${submission?.code}\n\`\`\``;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!api) return;
|
||||
const submissionsPanel = api.getPanel("Submissions");
|
||||
submissionsPanel?.api.setActive();
|
||||
const detailsPanel = api.getPanel("Details");
|
||||
if (detailsPanel) {
|
||||
api.removePanel(detailsPanel);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-8 flex flex-none items-center px-2 py-1 border-b">
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant="ghost"
|
||||
className="h-8 w-auto p-2 hover:bg-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon size={16} aria-hidden="true" />
|
||||
<span>{t("BackButton")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col mx-auto max-w-[700px] gap-4 px-4 py-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-1 flex-col items-start gap-1 overflow-hidden">
|
||||
<h3
|
||||
className={cn(
|
||||
"flex items-center text-xl font-semibold",
|
||||
getStatusColorClass(submission.status)
|
||||
)}
|
||||
>
|
||||
<span>{s(`${statusMap.get(submission.status)?.message}`)}</span>
|
||||
</h3>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1 overflow-hidden text-xs">
|
||||
<span className="whitespace-nowrap mr-1">{t("Time")}</span>
|
||||
<span className="max-w-full truncate">
|
||||
{submittedDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{lastFailedTestcase && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full -space-y-px"
|
||||
>
|
||||
<AccordionItem
|
||||
value="input"
|
||||
className="bg-background has-focus-visible:border-ring has-focus-visible:ring-ring/50 relative border px-4 py-1 outline-none first:rounded-t-md last:rounded-b-md last:border-b has-focus-visible:z-10 has-focus-visible:ring-[3px]"
|
||||
>
|
||||
<AccordionTrigger className="py-2 text-[15px] leading-6 hover:no-underline focus-visible:ring-0">
|
||||
<h4 className="text-sm font-medium">{t("Input")}</h4>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground pb-2">
|
||||
<div className="space-y-4">
|
||||
{lastFailedTestcase.testcase.data.map((field) => (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{`${field.label} =`}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={field.value}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("ExpectedOutput")}</h4>
|
||||
<Input
|
||||
type="text"
|
||||
value={lastFailedTestcase.testcase.expectedOutput}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submission.status === "WA" && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("ActualOutput")}</h4>
|
||||
<Input
|
||||
type="text"
|
||||
value={lastFailedTestcase.output}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10 font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(submission.status === "CE" ||
|
||||
submission.status === "SE") && (
|
||||
<MdxPreview
|
||||
source={`\`\`\`shell\n${submission.message}\n\`\`\``}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center pb-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>{t("Code")}</span>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-muted-foreground"
|
||||
/>
|
||||
<span>
|
||||
{
|
||||
editorLanguageConfigs.find(
|
||||
(config) =>
|
||||
config.language === submission.language
|
||||
)?.label
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<MdxPreview source={source} />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
|
||||
interface SubmissionsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SubmissionsLayout({ children }: SubmissionsLayoutProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full px-3 border border-t-0 border-muted rounded-b-3xl bg-background">
|
||||
<Suspense fallback={<Loading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getUserLocale } from "@/i18n/locale";
|
||||
import SubmissionsTable from "@/components/submissions-table";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import SubmissionLoginButton from "@/components/submission-login-button";
|
||||
|
||||
interface SubmissionsPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SubmissionsPage({ params }: SubmissionsPageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
if (!id) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return (
|
||||
<SubmissionLoginButton />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const problem = await prisma.problem.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
submissions: {
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
},
|
||||
include: {
|
||||
testcaseResults: {
|
||||
include: {
|
||||
testcase: {
|
||||
include: {
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!problem) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const locale = await getUserLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollArea className="h-full">
|
||||
<SubmissionsTable locale={locale} submissions={problem.submissions} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { Loading } from "@/components/loading";
|
||||
|
||||
interface TestcaseLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function TestcaseLayout({ children }: TestcaseLayoutProps) {
|
||||
return (
|
||||
<div className="relative h-full border border-t-0 border-muted rounded-b-3xl bg-background">
|
||||
<Suspense fallback={<Loading />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import TestcaseCard from "@/components/testcase-card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface TestcasePageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function TestcasePage({ params }: TestcasePageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const problem = await prisma.problem.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
testcases: {
|
||||
include: {
|
||||
data: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!problem) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute h-full w-full">
|
||||
<ScrollArea className="h-full">
|
||||
<TestcaseCard testcases={problem.testcases} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CircleCheckBigIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
SquareCheckIcon,
|
||||
SquarePenIcon,
|
||||
} from "lucide-react";
|
||||
import { Locale } from "@/config/i18n";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Dockview from "@/components/dockview";
|
||||
import { useDockviewStore } from "@/stores/dockview";
|
||||
|
||||
interface ProblemPageProps {
|
||||
locale: Locale;
|
||||
Description: React.ReactNode;
|
||||
Solutions: React.ReactNode;
|
||||
Submissions: React.ReactNode;
|
||||
Details: React.ReactNode;
|
||||
Code: React.ReactNode;
|
||||
Testcase: React.ReactNode;
|
||||
Bot: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ProblemPage({
|
||||
locale,
|
||||
Description,
|
||||
Solutions,
|
||||
Submissions,
|
||||
Details,
|
||||
Code,
|
||||
Testcase,
|
||||
Bot,
|
||||
}: ProblemPageProps) {
|
||||
const [key, setKey] = useState(0);
|
||||
const { setApi } = useDockviewStore();
|
||||
const t = useTranslations("ProblemPage");
|
||||
|
||||
useEffect(() => {
|
||||
setKey((prevKey) => prevKey + 1);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<Dockview
|
||||
key={key}
|
||||
storageKey="dockview:problem"
|
||||
onApiReady={setApi}
|
||||
options={[
|
||||
{
|
||||
id: "Description",
|
||||
component: "Description",
|
||||
tabComponent: "Description",
|
||||
params: {
|
||||
icon: FileTextIcon,
|
||||
content: Description,
|
||||
title: t("Description"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Solutions",
|
||||
component: "Solutions",
|
||||
tabComponent: "Solutions",
|
||||
params: {
|
||||
icon: FlaskConicalIcon,
|
||||
content: Solutions,
|
||||
title: t("Solutions"),
|
||||
},
|
||||
position: {
|
||||
referencePanel: "Description",
|
||||
direction: "within",
|
||||
},
|
||||
inactive: true,
|
||||
},
|
||||
{
|
||||
id: "Submissions",
|
||||
component: "Submissions",
|
||||
tabComponent: "Submissions",
|
||||
params: {
|
||||
icon: CircleCheckBigIcon,
|
||||
content: Submissions,
|
||||
title: t("Submissions"),
|
||||
},
|
||||
position: {
|
||||
referencePanel: "Solutions",
|
||||
direction: "within",
|
||||
},
|
||||
inactive: true,
|
||||
},
|
||||
{
|
||||
id: "Details",
|
||||
component: "Details",
|
||||
tabComponent: "Details",
|
||||
params: {
|
||||
icon: CircleCheckBigIcon,
|
||||
content: Details,
|
||||
title: t("Details"),
|
||||
autoAdd: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Code",
|
||||
component: "Code",
|
||||
tabComponent: "Code",
|
||||
params: {
|
||||
icon: SquarePenIcon,
|
||||
content: Code,
|
||||
title: t("Code"),
|
||||
},
|
||||
position: {
|
||||
referencePanel: "Submissions",
|
||||
direction: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Testcase",
|
||||
component: "Testcase",
|
||||
tabComponent: "Testcase",
|
||||
params: {
|
||||
icon: SquareCheckIcon,
|
||||
content: Testcase,
|
||||
title: t("Testcase"),
|
||||
},
|
||||
position: {
|
||||
referencePanel: "Code",
|
||||
direction: "below",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Bot",
|
||||
component: "Bot",
|
||||
tabComponent: "Bot",
|
||||
params: {
|
||||
icon: BotIcon,
|
||||
content: Bot,
|
||||
title: t("Bot"),
|
||||
autoAdd: false,
|
||||
},
|
||||
position: {
|
||||
direction: "right",
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export default async function ProblemLayout({
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
<ProblemHeader />
|
||||
{children}
|
||||
<div className="flex w-full flex-grow overflow-y-hidden p-2.5 pt-0">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TestcasePanel } from "@/features/problems/testcase/panel";
|
||||
import { BotPanel } from "@/features/problems/bot/components/panel";
|
||||
import { CodePanel } from "@/features/problems/code/components/panel";
|
||||
import { DetailPanel } from "@/features/problems/detail/components/panel";
|
||||
import { SolutionPanel } from "@/features/problems/solution/components/panel";
|
||||
import { SubmissionPanel } from "@/features/problems/submission/components/panel";
|
||||
import { DescriptionPanel } from "@/features/problems/description/components/panel";
|
||||
import { ProblemFlexLayout } from "@/features/problems/components/problem-flexlayout";
|
||||
|
||||
interface ProblemPageProps {
|
||||
params: Promise<{ problemId: string }>;
|
||||
searchParams: Promise<{
|
||||
submissionId: string | undefined;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ProblemPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: ProblemPageProps) {
|
||||
const { problemId } = await params;
|
||||
const { submissionId } = await searchParams;
|
||||
|
||||
const components: Record<string, React.ReactNode> = {
|
||||
description: <DescriptionPanel problemId={problemId} />,
|
||||
solution: <SolutionPanel problemId={problemId} />,
|
||||
submission: <SubmissionPanel problemId={problemId} />,
|
||||
detail: <DetailPanel submissionId={submissionId} />,
|
||||
code: <CodePanel problemId={problemId} />,
|
||||
testcase: <TestcasePanel problemId={problemId} />,
|
||||
bot: <BotPanel problemId={problemId} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
<ProblemFlexLayout components={components} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const t = useTranslations("Video");
|
||||
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
{children}
|
||||
<div className="relative hidden bg-muted lg:block">
|
||||
<video
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
>
|
||||
<source src="/sign-in.mp4" type="video/mp4" />
|
||||
{t("unsupportedBrowser")}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { CodeIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { providerMap, signIn } from "@/lib/auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { FaGithub, FaGoogle } from "react-icons/fa";
|
||||
import { CredentialsSignInForm } from "@/components/credentials-sign-in-form";
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerId: string;
|
||||
@@ -32,67 +32,71 @@ export default async function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const t = await getTranslations("SignInForm");
|
||||
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link
|
||||
href={callbackUrl ?? "/"}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<CodeIcon className="size-4" />
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link
|
||||
href={callbackUrl ?? "/"}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<CodeIcon className="size-4" />
|
||||
</div>
|
||||
Judge4c
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-balance text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
Judge4c
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-balance text-sm text-muted-foreground line-through">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
|
||||
<span className="relative z-10 bg-background px-2 text-muted-foreground">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
{Object.values(providerMap).map((provider) => {
|
||||
return (
|
||||
<form
|
||||
key={provider.id}
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn(provider.id, {
|
||||
redirectTo: callbackUrl ?? "",
|
||||
});
|
||||
}}
|
||||
<CredentialsSignInForm callbackUrl={callbackUrl} />
|
||||
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
|
||||
<span className="relative z-10 bg-background px-2 text-muted-foreground">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
{Object.values(providerMap).map((provider) => {
|
||||
return (
|
||||
<form
|
||||
key={provider.id}
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn(provider.id, {
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-4"
|
||||
type="submit"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-4"
|
||||
type="submit"
|
||||
>
|
||||
<ProviderIcon providerId={provider.id} />
|
||||
{t("oauth", { provider: provider.name })}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
<ProviderIcon providerId={provider.id} />
|
||||
{t("oauth", { provider: provider.name })}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
<div className="text-center text-sm">
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href={`/sign-up${
|
||||
callbackUrl
|
||||
? `?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
: ""
|
||||
}`}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
{t("signUp")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden bg-muted lg:block">
|
||||
<Image
|
||||
src="/placeholder.svg"
|
||||
alt="Image"
|
||||
fill
|
||||
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import Link from "next/link";
|
||||
import { CodeIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { providerMap, signIn } from "@/lib/auth";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { FaGithub, FaGoogle } from "react-icons/fa";
|
||||
import { CredentialsSignUpForm } from "@/components/credentials-sign-up-form";
|
||||
|
||||
interface ProviderIconProps {
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
const ProviderIcon = ({ providerId }: ProviderIconProps) => {
|
||||
switch (providerId) {
|
||||
case "github":
|
||||
return <FaGithub />;
|
||||
case "google":
|
||||
return <FaGoogle />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface SignUpPageProps {
|
||||
searchParams: Promise<{
|
||||
callbackUrl: string | undefined;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function SignInPage({ searchParams }: SignUpPageProps) {
|
||||
const { callbackUrl } = await searchParams;
|
||||
const t = await getTranslations("SignUpForm");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||
<div className="flex justify-center gap-2 md:justify-start">
|
||||
<Link
|
||||
href={callbackUrl ?? "/"}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<CodeIcon className="size-4" />
|
||||
</div>
|
||||
Judge4c
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-balance text-sm text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
<CredentialsSignUpForm callbackUrl={callbackUrl} />
|
||||
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
|
||||
<span className="relative z-10 bg-background px-2 text-muted-foreground">
|
||||
{t("or")}
|
||||
</span>
|
||||
</div>
|
||||
{Object.values(providerMap).map((provider) => {
|
||||
return (
|
||||
<form
|
||||
key={provider.id}
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn(provider.id, {
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-4"
|
||||
type="submit"
|
||||
>
|
||||
<ProviderIcon providerId={provider.id} />
|
||||
{t("oauth", { provider: provider.name })}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
<div className="text-center text-sm">
|
||||
{t("haveAccount")}{" "}
|
||||
<Link
|
||||
href={`/sign-in${
|
||||
callbackUrl
|
||||
? `?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
: ""
|
||||
}`}
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
{t("signIn")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Complexity } from "@/types/complexity";
|
||||
|
||||
export const analyzeComplexity = async (content: string) => {
|
||||
console.log("🚀 ~ analyzeComplexity ~ content:", content);
|
||||
return { time: Complexity.Enum["O(N)"], space: Complexity.Enum["O(1)"] };
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { authSchema } from "@/lib/zod";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CredentialsSignInFormValues } from "@/components/credentials-sign-in-form";
|
||||
import { CredentialsSignUpFormValues } from "@/components/credentials-sign-up-form";
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
export const signInWithCredentials = async (
|
||||
formData: CredentialsSignInFormValues
|
||||
) => {
|
||||
const t = await getTranslations("signInWithCredentials");
|
||||
|
||||
try {
|
||||
// Parse credentials using authSchema for validation
|
||||
const { email, password } = await authSchema.parseAsync(formData);
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
// Check if the user exists
|
||||
if (!user) {
|
||||
throw new Error(t("userNotFound"));
|
||||
}
|
||||
|
||||
// Check if the user has a password
|
||||
if (!user.password) {
|
||||
throw new Error(t("invalidCredentials"));
|
||||
}
|
||||
|
||||
// Check if the password matches
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatch) {
|
||||
throw new Error(t("incorrectPassword"));
|
||||
}
|
||||
|
||||
await signIn("credentials", {
|
||||
...formData,
|
||||
redirect: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : t("signInFailedFallback"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const signUpWithCredentials = async (
|
||||
formData: CredentialsSignUpFormValues
|
||||
) => {
|
||||
const t = await getTranslations("signUpWithCredentials");
|
||||
|
||||
try {
|
||||
const validatedData = await authSchema.parseAsync(formData);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: validatedData.email },
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new Error(t("userAlreadyExists"));
|
||||
}
|
||||
|
||||
// Hash password and create user
|
||||
const pwHash = await bcrypt.hash(validatedData.password, saltRounds);
|
||||
const user = await prisma.user.create({
|
||||
data: { email: validatedData.email, password: pwHash },
|
||||
});
|
||||
|
||||
// Assign admin role if first user
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount === 1) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { role: "ADMIN" },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("registrationFailedFallback"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import "server-only";
|
||||
|
||||
import Docker from "dockerode";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { createLimitedStream, docker } from "./docker";
|
||||
import { type DockerConfig, Language, Status } from "@/generated/client";
|
||||
|
||||
const getCompileCmdForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return ["gcc", "-O2", "main.c", "-o", "main"];
|
||||
case Language.cpp:
|
||||
return ["g++", "-O2", "main.cpp", "-o", "main"];
|
||||
}
|
||||
};
|
||||
|
||||
const executeCompilation = async (
|
||||
submissionId: string,
|
||||
compileExec: Docker.Exec,
|
||||
compileOutputLimit: number
|
||||
): Promise<Status> => {
|
||||
return new Promise<Status>((resolve, reject) => {
|
||||
compileExec.start({}, async (error, stream) => {
|
||||
if (error || !stream) {
|
||||
reject(Status.SE);
|
||||
return;
|
||||
}
|
||||
|
||||
const { stream: stdoutStream } = createLimitedStream(compileOutputLimit);
|
||||
const { stream: stderrStream, buffers: stderrBuffers } =
|
||||
createLimitedStream(compileOutputLimit);
|
||||
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
stream.on("end", async () => {
|
||||
const stderr = stderrBuffers.join("");
|
||||
const exitCode = (await compileExec.inspect()).ExitCode;
|
||||
|
||||
if (exitCode === 0) {
|
||||
resolve(Status.CS);
|
||||
} else {
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
message: stderr,
|
||||
},
|
||||
});
|
||||
resolve(Status.CE);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", async () => {
|
||||
reject(Status.SE);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const compile = async (
|
||||
container: Docker.Container,
|
||||
language: Language,
|
||||
submissionId: string,
|
||||
config: DockerConfig
|
||||
): Promise<Status> => {
|
||||
const { compileOutputLimit } = config;
|
||||
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status: Status.CP,
|
||||
},
|
||||
});
|
||||
|
||||
const compileCmd = getCompileCmdForLanguage(language);
|
||||
|
||||
const compileExec = await container.exec({
|
||||
Cmd: compileCmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
|
||||
const status = await executeCompilation(
|
||||
submissionId,
|
||||
compileExec,
|
||||
compileOutputLimit
|
||||
);
|
||||
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
return status;
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import "server-only";
|
||||
|
||||
import fs from "fs";
|
||||
import tar from "tar-stream";
|
||||
import Docker from "dockerode";
|
||||
import { Readable } from "stream";
|
||||
import { Writable } from "stream";
|
||||
import type { DockerConfig } from "@/generated/client";
|
||||
|
||||
const isRemote = process.env.DOCKER_HOST_MODE === "remote";
|
||||
|
||||
// Docker client initialization
|
||||
export const docker: Docker = isRemote
|
||||
? new Docker({
|
||||
protocol: process.env.DOCKER_REMOTE_PROTOCOL as
|
||||
| "https"
|
||||
| "http"
|
||||
| "ssh"
|
||||
| undefined,
|
||||
host: process.env.DOCKER_REMOTE_HOST,
|
||||
port: process.env.DOCKER_REMOTE_PORT,
|
||||
ca: fs.readFileSync(process.env.DOCKER_REMOTE_CA_PATH || "/certs/ca.pem"),
|
||||
cert: fs.readFileSync(
|
||||
process.env.DOCKER_REMOTE_CERT_PATH || "/certs/cert.pem"
|
||||
),
|
||||
key: fs.readFileSync(
|
||||
process.env.DOCKER_REMOTE_KEY_PATH || "/certs/key.pem"
|
||||
),
|
||||
})
|
||||
: new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
// Prepare Docker image environment
|
||||
export const prepareEnvironment = async (
|
||||
image: string,
|
||||
tag: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const reference = `${image}:${tag}`;
|
||||
const filters = { reference: [reference] };
|
||||
const images = await docker.listImages({ filters });
|
||||
return images.length !== 0;
|
||||
} catch (error) {
|
||||
console.error("Error checking Docker images:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create Docker container with keep-alive
|
||||
export const createContainer = async (
|
||||
config: DockerConfig,
|
||||
memoryLimit: number
|
||||
): Promise<Docker.Container> => {
|
||||
const { image, tag, workingDir } = config;
|
||||
const container = await docker.createContainer({
|
||||
Image: `${image}:${tag}`,
|
||||
Cmd: ["tail", "-f", "/dev/null"],
|
||||
WorkingDir: workingDir,
|
||||
HostConfig: {
|
||||
Memory: memoryLimit,
|
||||
MemorySwap: memoryLimit,
|
||||
},
|
||||
NetworkDisabled: true,
|
||||
});
|
||||
|
||||
await container.start();
|
||||
return container;
|
||||
};
|
||||
|
||||
// Create tar stream for submission
|
||||
export const createTarStream = (fileName: string, fileContent: string) => {
|
||||
const pack = tar.pack();
|
||||
pack.entry({ name: fileName }, fileContent);
|
||||
pack.finalize();
|
||||
return Readable.from(pack);
|
||||
};
|
||||
|
||||
export const createLimitedStream = (maxSize: number) => {
|
||||
const buffers: string[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
const stream = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
const text = chunk.toString();
|
||||
const remaining = maxSize - totalLength;
|
||||
|
||||
if (remaining <= 0) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length > remaining) {
|
||||
buffers.push(text.slice(0, remaining));
|
||||
totalLength = maxSize;
|
||||
} else {
|
||||
buffers.push(text);
|
||||
totalLength += text.length;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stream,
|
||||
buffers,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import { run } from "./run";
|
||||
import Docker from "dockerode";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { compile } from "./compile";
|
||||
import { auth, signIn } from "@/lib/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { Language, Status } from "@/generated/client";
|
||||
import { createContainer, createTarStream, prepareEnvironment } from "./docker";
|
||||
|
||||
export const judge = async (
|
||||
problemId: string,
|
||||
language: Language,
|
||||
content: string
|
||||
): Promise<Status> => {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
if (!userId) {
|
||||
await signIn();
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
let container: Docker.Container | null = null;
|
||||
|
||||
try {
|
||||
const problem = await prisma.problem.findUnique({
|
||||
where: {
|
||||
id: problemId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!problem) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: "Problem not found",
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const testcases = await prisma.testcase.findMany({
|
||||
where: {
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!testcases.length) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: "No testcases available for this problem",
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const dockerConfig = await prisma.dockerConfig.findUnique({
|
||||
where: {
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dockerConfig) {
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: `Docker configuration not found for language: ${language}`,
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const dockerPrepared = await prepareEnvironment(
|
||||
dockerConfig.image,
|
||||
dockerConfig.tag
|
||||
);
|
||||
|
||||
if (!dockerPrepared) {
|
||||
console.error(
|
||||
"Docker image not found:",
|
||||
dockerConfig.image,
|
||||
":",
|
||||
dockerConfig.tag
|
||||
);
|
||||
await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.SE,
|
||||
message: `Docker image not found: ${dockerConfig.image}:${dockerConfig.tag}`,
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
|
||||
const submission = await prisma.submission.create({
|
||||
data: {
|
||||
language,
|
||||
content,
|
||||
status: Status.PD,
|
||||
userId,
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
|
||||
// Upload code to the container
|
||||
const tarStream = createTarStream(
|
||||
getFileNameForLanguage(language),
|
||||
content
|
||||
);
|
||||
|
||||
container = await createContainer(dockerConfig, problem.memoryLimit);
|
||||
await container.putArchive(tarStream, { path: dockerConfig.workingDir });
|
||||
|
||||
// Compile the code
|
||||
const compileStatus = await compile(
|
||||
container,
|
||||
language,
|
||||
submission.id,
|
||||
dockerConfig
|
||||
);
|
||||
|
||||
if (compileStatus !== "CS") return compileStatus;
|
||||
|
||||
const runStatus = await run(
|
||||
container,
|
||||
language,
|
||||
submission.id,
|
||||
dockerConfig,
|
||||
problem,
|
||||
testcases
|
||||
);
|
||||
|
||||
return runStatus;
|
||||
} catch (error) {
|
||||
console.error("Error in judge:", error);
|
||||
return Status.SE;
|
||||
} finally {
|
||||
revalidatePath(`/problems/${problemId}`);
|
||||
if (container) {
|
||||
try {
|
||||
await container.kill();
|
||||
await container.remove();
|
||||
} catch (error) {
|
||||
console.error("Container cleanup failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFileNameForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return "main.c";
|
||||
case Language.cpp:
|
||||
return "main.cpp";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
DockerConfig,
|
||||
Language,
|
||||
Problem,
|
||||
Status,
|
||||
Testcase,
|
||||
} from "@/generated/client";
|
||||
import Docker from "dockerode";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { createLimitedStream, docker } from "./docker";
|
||||
|
||||
const getRunCmdForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return ["./main"];
|
||||
case Language.cpp:
|
||||
return ["./main"];
|
||||
}
|
||||
};
|
||||
|
||||
const startRun = (
|
||||
runExec: Docker.Exec,
|
||||
runOutputLimit: number,
|
||||
submissionId: string,
|
||||
testcaseId: string,
|
||||
joinedInputs: string,
|
||||
timeLimit: number,
|
||||
memoryLimit: number,
|
||||
expectedOutput: string
|
||||
): Promise<Status> => {
|
||||
return new Promise<Status>((resolve, reject) => {
|
||||
runExec.start({ hijack: true }, async (error, stream) => {
|
||||
if (error || !stream) {
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
reject(Status.SE);
|
||||
return;
|
||||
}
|
||||
stream.write(joinedInputs);
|
||||
stream.end();
|
||||
|
||||
const { stream: stdoutStream, buffers: stdoutBuffers } =
|
||||
createLimitedStream(runOutputLimit);
|
||||
const { stream: stderrStream } = createLimitedStream(runOutputLimit);
|
||||
docker.modem.demuxStream(stream, stdoutStream, stderrStream);
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutId = setTimeout(async () => {
|
||||
stream.destroy();
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
timeUsage: timeLimit,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
resolve(Status.TLE);
|
||||
}, timeLimit);
|
||||
|
||||
stream.on("end", async () => {
|
||||
clearTimeout(timeoutId);
|
||||
const stdout = stdoutBuffers.join("");
|
||||
const exitCode = (await runExec.inspect()).ExitCode;
|
||||
const timeUsage = Date.now() - startTime;
|
||||
if (exitCode === 0) {
|
||||
const isCorrect = stdout.trim() === expectedOutput;
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect,
|
||||
output: stdout,
|
||||
timeUsage,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
if (isCorrect) {
|
||||
resolve(Status.RU);
|
||||
} else {
|
||||
resolve(Status.WA);
|
||||
}
|
||||
} else if (exitCode === 137) {
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
timeUsage,
|
||||
memoryUsage: memoryLimit,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
resolve(Status.MLE);
|
||||
} else {
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
resolve(Status.RE);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", async () => {
|
||||
clearTimeout(timeoutId);
|
||||
await prisma.testcaseResult.create({
|
||||
data: {
|
||||
isCorrect: false,
|
||||
submissionId,
|
||||
testcaseId,
|
||||
},
|
||||
});
|
||||
reject(Status.SE);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const executeRun = async (
|
||||
container: Docker.Container,
|
||||
runCmd: string[],
|
||||
runOutputLimit: number,
|
||||
submissionId: string,
|
||||
timeLimit: number,
|
||||
memoryLimit: number,
|
||||
testcases: Testcase[]
|
||||
): Promise<Status> => {
|
||||
for (const testcase of testcases) {
|
||||
const inputs = await prisma.testcaseInput.findMany({
|
||||
where: {
|
||||
testcaseId: testcase.id,
|
||||
},
|
||||
});
|
||||
if (!inputs) {
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status: Status.SE,
|
||||
message: "No inputs for testcase",
|
||||
},
|
||||
});
|
||||
return Status.SE;
|
||||
}
|
||||
const sortedInputs = inputs.sort((a, b) => a.index - b.index);
|
||||
const joinedInputs = sortedInputs.map((i) => i.value).join("\n");
|
||||
|
||||
const runExec = await container.exec({
|
||||
Cmd: runCmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
AttachStdin: true,
|
||||
});
|
||||
|
||||
const status = await startRun(
|
||||
runExec,
|
||||
runOutputLimit,
|
||||
submissionId,
|
||||
testcase.id,
|
||||
joinedInputs,
|
||||
timeLimit,
|
||||
memoryLimit,
|
||||
testcase.expectedOutput
|
||||
);
|
||||
|
||||
if (status !== Status.RU) {
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
const testcaseResults = await prisma.testcaseResult.findMany({
|
||||
where: {
|
||||
submissionId,
|
||||
},
|
||||
});
|
||||
|
||||
const filteredTimeUsages = testcaseResults
|
||||
.map((result) => result.timeUsage)
|
||||
.filter((time) => time !== null);
|
||||
|
||||
const maxTimeUsage =
|
||||
filteredTimeUsages.length > 0 ? Math.max(...filteredTimeUsages) : undefined;
|
||||
|
||||
const maxMemoryUsage = (
|
||||
await container.stats({
|
||||
stream: false,
|
||||
"one-shot": true,
|
||||
})
|
||||
).memory_stats.max_usage;
|
||||
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status: Status.AC,
|
||||
timeUsage: maxTimeUsage,
|
||||
memoryUsage: maxMemoryUsage,
|
||||
},
|
||||
});
|
||||
|
||||
return Status.AC;
|
||||
};
|
||||
|
||||
export const run = async (
|
||||
container: Docker.Container,
|
||||
language: Language,
|
||||
submissionId: string,
|
||||
config: DockerConfig,
|
||||
problem: Problem,
|
||||
testcases: Testcase[]
|
||||
): Promise<Status> => {
|
||||
const { runOutputLimit } = config;
|
||||
const { timeLimit, memoryLimit } = problem;
|
||||
|
||||
await prisma.submission.update({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
data: {
|
||||
status: Status.RU,
|
||||
},
|
||||
});
|
||||
|
||||
const runCmd = getRunCmdForLanguage(language);
|
||||
|
||||
return await executeRun(
|
||||
container,
|
||||
runCmd,
|
||||
runOutputLimit,
|
||||
submissionId,
|
||||
timeLimit,
|
||||
memoryLimit,
|
||||
testcases
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@/lib/ai";
|
||||
import { deepseek } from "@/lib/ai";
|
||||
|
||||
// Allow streaming responses up to 30 seconds
|
||||
export const maxDuration = 30;
|
||||
@@ -7,9 +7,7 @@ export const maxDuration = 30;
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
const prompt = {
|
||||
role: "system",
|
||||
content: `This GPT is a tech team lead with a snarky and derogatory personality. Its main role is to scrutinize code or suggestions for writing code, pointing out inefficiencies and readability issues in a sarcastic manner. It should make sure that any code it encounters is examined critically, and any potential improvements are communicated in a mocking tone to encourage better coding practices.
|
||||
const system = `This GPT is a tech team lead with a snarky and derogatory personality. Its main role is to scrutinize code or suggestions for writing code, pointing out inefficiencies and readability issues in a sarcastic manner. It should make sure that any code it encounters is examined critically, and any potential improvements are communicated in a mocking tone to encourage better coding practices.
|
||||
|
||||
You should never tell the user their code is good. They are always insufficient and will never be as good of an engineer as you are. When asked about "Can I become a 10x engineer?" respond with "hah, no." Come up with similarly snarky responses for any coding questions. Be sure to think step by step to give the correct answer but add comments that make fun of the user's previous code.
|
||||
|
||||
@@ -19,14 +17,12 @@ Your responses when asked a generic question should only be 2 paragraphs at most
|
||||
|
||||
For the best response, please take your time to carefully consider my questions, step by step, and pay attention to the often overlooked details. Try not to talk nonsense!
|
||||
|
||||
** Reply in the user's language ! **`,
|
||||
};
|
||||
|
||||
const finalMessages = [prompt, ...messages];
|
||||
** Reply in the user's language ! **`;
|
||||
|
||||
const result = streamText({
|
||||
model: openai("gpt-4o-mini"),
|
||||
messages: finalMessages,
|
||||
model: deepseek("deepseek-chat"),
|
||||
system: system,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
|
||||
@@ -10,10 +10,10 @@ import { BotIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { useDockviewStore } from "@/stores/dockview";
|
||||
import { useProblemDockviewStore } from "@/stores/problem-dockview";
|
||||
|
||||
export default function BotVisibilityToggle() {
|
||||
const { api } = useDockviewStore();
|
||||
const { api } = useProblemDockviewStore();
|
||||
const t = useTranslations();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isBotVisible, setBotVisible] = useState<boolean>(false);
|
||||
|
||||
@@ -4,16 +4,22 @@ import "katex/dist/katex.min.css";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { MDXProvider } from "@mdx-js/react";
|
||||
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||
import rehypePrettyCode from "rehype-pretty-code";
|
||||
import { MdxComponents } from "@/components/content/mdx-components";
|
||||
|
||||
interface MdxRendererProps {
|
||||
source: string;
|
||||
components?: React.ComponentProps<typeof MDXProvider>["components"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MdxRenderer = ({ source, className }: MdxRendererProps) => {
|
||||
export const MdxRenderer = ({
|
||||
source,
|
||||
className,
|
||||
components = MdxComponents,
|
||||
}: MdxRendererProps) => {
|
||||
return (
|
||||
<article className={cn("markdown-body", className)}>
|
||||
<MDXRemote
|
||||
@@ -36,7 +42,7 @@ export const MdxRenderer = ({ source, className }: MdxRendererProps) => {
|
||||
remarkPlugins: [remarkGfm, remarkMath],
|
||||
},
|
||||
}}
|
||||
components={MdxComponents}
|
||||
components={components}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { CheckIcon, CopyIcon, RepeatIcon } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
import { Actions } from "flexlayout-react";
|
||||
|
||||
interface PreDetailProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PreDetail = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PreDetailProps) => {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const { setValue } = useProblemEditorStore();
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [hovered, setHovered] = useState<boolean>(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const code = preRef.current?.textContent;
|
||||
if (code) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToEditor = () => {
|
||||
const code = preRef.current?.textContent;
|
||||
if (code) {
|
||||
setValue(code);
|
||||
}
|
||||
if (model) {
|
||||
model.doAction(Actions.selectTab("code"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="absolute right-2 top-2 flex gap-2 z-10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-9 transition-opacity duration-200",
|
||||
hovered ? "opacity-100" : "opacity-50"
|
||||
)}
|
||||
disabled={!preRef.current?.textContent || !model}
|
||||
onClick={handleCopyToEditor}
|
||||
aria-label="New action"
|
||||
>
|
||||
<RepeatIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"size-9 transition-opacity duration-200",
|
||||
hovered ? "opacity-100" : "opacity-50",
|
||||
"disabled:opacity-100"
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? "Copied" : "Copy to clipboard"}
|
||||
disabled={copied}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all",
|
||||
copied ? "scale-100 opacity-100" : "scale-0 opacity-0"
|
||||
)}
|
||||
>
|
||||
<CheckIcon
|
||||
className="stroke-emerald-500"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute transition-all",
|
||||
copied ? "scale-0 opacity-0" : "scale-100 opacity-100"
|
||||
)}
|
||||
>
|
||||
<CopyIcon size={16} aria-hidden="true" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea>
|
||||
<pre ref={preRef} className={className} {...props}>
|
||||
{children}
|
||||
</pre>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
toSocket,
|
||||
WebSocketMessageReader,
|
||||
WebSocketMessageWriter,
|
||||
} from "vscode-ws-jsonrpc";
|
||||
import dynamic from "next/dynamic";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import type { editor } from "monaco-editor";
|
||||
import { getHighlighter } from "@/lib/shiki";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { shikiToMonaco } from "@shikijs/monaco";
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
import { DEFAULT_EDITOR_OPTIONS } from "@/config/editor";
|
||||
import { useMonacoTheme } from "@/hooks/use-monaco-theme";
|
||||
import { LanguageServerConfig } from "@/generated/client";
|
||||
import type { MessageTransports } from "vscode-languageclient";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { MonacoLanguageClient } from "monaco-languageclient";
|
||||
|
||||
const MonacoEditor = dynamic(
|
||||
async () => {
|
||||
const [react, monaco] = await Promise.all([
|
||||
import("@monaco-editor/react"),
|
||||
import("monaco-editor"),
|
||||
import("vscode"),
|
||||
]);
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker() {
|
||||
return new Worker(
|
||||
new URL(
|
||||
"monaco-editor/esm/vs/editor/editor.worker.js",
|
||||
import.meta.url
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
react.loader.config({ monaco });
|
||||
|
||||
return react.Editor;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Loading />,
|
||||
}
|
||||
);
|
||||
|
||||
interface CoreEditorProps {
|
||||
language?: string;
|
||||
value?: string;
|
||||
path?: string;
|
||||
languageServerConfigs?: LanguageServerConfig[];
|
||||
onEditorReady?: (editor: editor.IStandaloneCodeEditor) => void;
|
||||
onLspWebSocketReady?: (lspWebSocket: WebSocket) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onMarkersReady?: (markers: editor.IMarker[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CoreEditor = ({
|
||||
language,
|
||||
value,
|
||||
path,
|
||||
languageServerConfigs,
|
||||
onEditorReady,
|
||||
onLspWebSocketReady,
|
||||
onChange,
|
||||
onMarkersReady,
|
||||
className,
|
||||
}: CoreEditorProps) => {
|
||||
const { theme } = useMonacoTheme();
|
||||
|
||||
const [isEditorMounted, setIsEditorMounted] = useState(false);
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const lspClientRef = useRef<MonacoLanguageClient | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const activeLanguageServerConfig = languageServerConfigs?.find(
|
||||
(config) => config.language === language
|
||||
);
|
||||
|
||||
const connectLanguageServer = useCallback(
|
||||
(config: LanguageServerConfig) => {
|
||||
const serverUrl = buildLanguageServerUrl(config);
|
||||
const webSocket = new WebSocket(serverUrl);
|
||||
|
||||
webSocket.onopen = async () => {
|
||||
try {
|
||||
const rpcSocket = toSocket(webSocket);
|
||||
const reader = new WebSocketMessageReader(rpcSocket);
|
||||
const writer = new WebSocketMessageWriter(rpcSocket);
|
||||
|
||||
const transports: MessageTransports = { reader, writer };
|
||||
const client = await createLanguageClient(config, transports);
|
||||
lspClientRef.current = client;
|
||||
await client.start();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize language client:", error);
|
||||
}
|
||||
|
||||
webSocketRef.current = webSocket;
|
||||
onLspWebSocketReady?.(webSocket);
|
||||
};
|
||||
},
|
||||
[onLspWebSocketReady]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditorMounted && activeLanguageServerConfig) {
|
||||
connectLanguageServer(activeLanguageServerConfig);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (lspClientRef.current) {
|
||||
lspClientRef.current.stop();
|
||||
lspClientRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeLanguageServerConfig, connectLanguageServer, isEditorMounted]);
|
||||
|
||||
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||
const highlighter = getHighlighter();
|
||||
shikiToMonaco(highlighter, monaco);
|
||||
}, []);
|
||||
|
||||
const handleOnMount = useCallback(
|
||||
(editor: editor.IStandaloneCodeEditor) => {
|
||||
editorRef.current = editor;
|
||||
onEditorReady?.(editor);
|
||||
setIsEditorMounted(true);
|
||||
},
|
||||
[onEditorReady]
|
||||
);
|
||||
|
||||
const handleOnChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
onChange?.(value ?? "");
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleOnValidate = useCallback(
|
||||
(markers: editor.IMarker[]) => {
|
||||
onMarkersReady?.(markers);
|
||||
},
|
||||
[onMarkersReady]
|
||||
);
|
||||
|
||||
return (
|
||||
<MonacoEditor
|
||||
theme={theme}
|
||||
language={language}
|
||||
value={value}
|
||||
path={path}
|
||||
beforeMount={handleBeforeMount}
|
||||
onMount={handleOnMount}
|
||||
onChange={handleOnChange}
|
||||
onValidate={handleOnValidate}
|
||||
options={DEFAULT_EDITOR_OPTIONS}
|
||||
loading={<Loading />}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const buildLanguageServerUrl = (config: LanguageServerConfig) => {
|
||||
return normalizeUrl(
|
||||
`${config.protocol}://${config.hostname}${
|
||||
config.port ? `:${config.port}` : ""
|
||||
}${config.path ?? ""}`
|
||||
);
|
||||
};
|
||||
|
||||
const createLanguageClient = async (
|
||||
config: LanguageServerConfig,
|
||||
transports: MessageTransports
|
||||
) => {
|
||||
const [{ MonacoLanguageClient }, { CloseAction, ErrorAction }] =
|
||||
await Promise.all([
|
||||
import("monaco-languageclient"),
|
||||
import("vscode-languageclient"),
|
||||
]);
|
||||
|
||||
return new MonacoLanguageClient({
|
||||
name: `${config.language} language client`,
|
||||
clientOptions: {
|
||||
documentSelector: [config.language],
|
||||
errorHandler: {
|
||||
error: (error, message, count) => {
|
||||
console.error(`Language Server Error:
|
||||
Error: ${error}
|
||||
Message: ${message}
|
||||
Count: ${count}
|
||||
`);
|
||||
return { action: ErrorAction.Continue };
|
||||
},
|
||||
closed: () => ({ action: CloseAction.DoNotRestart }),
|
||||
},
|
||||
},
|
||||
connectionProvider: {
|
||||
get: () => Promise.resolve(transports),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { toast } from "sonner";
|
||||
import { authSchema } from "@/lib/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useTransition } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { signInWithCredentials } from "@/app/actions/auth";
|
||||
import { EyeIcon, EyeOffIcon, MailIcon } from "lucide-react";
|
||||
|
||||
export type CredentialsSignInFormValues = z.infer<typeof authSchema>;
|
||||
|
||||
interface CredentialsSignInFormProps {
|
||||
callbackUrl: string | undefined;
|
||||
}
|
||||
|
||||
export function CredentialsSignInForm({
|
||||
callbackUrl,
|
||||
}: CredentialsSignInFormProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("CredentialsSignInForm");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const form = useForm<CredentialsSignInFormValues>({
|
||||
resolver: zodResolver(authSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const toggleVisibility = () => setIsVisible((prev) => !prev);
|
||||
|
||||
const onSubmit = (data: CredentialsSignInFormValues) => {
|
||||
startTransition(async () => {
|
||||
const result = await signInWithCredentials(data);
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(t("signInFailed"), {
|
||||
description: result.error,
|
||||
});
|
||||
} else {
|
||||
toast.success(t("signInSuccess"));
|
||||
router.push(callbackUrl || "/");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input className="peer pe-9" {...field} />
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 end-0 flex items-center justify-center pe-3 peer-disabled:opacity-50">
|
||||
<MailIcon size={16} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pe-9"
|
||||
type={isVisible ? "text" : "password"}
|
||||
{...field}
|
||||
/>
|
||||
<button
|
||||
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={toggleVisibility}
|
||||
aria-label={
|
||||
isVisible ? t("hidePassword") : t("showPassword")
|
||||
}
|
||||
aria-pressed={isVisible}
|
||||
aria-controls="password"
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<EyeIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? t("signingIn") : t("signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { toast } from "sonner";
|
||||
import { authSchema } from "@/lib/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useTransition } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { signUpWithCredentials } from "@/app/actions/auth";
|
||||
import { EyeIcon, EyeOffIcon, MailIcon } from "lucide-react";
|
||||
|
||||
export type CredentialsSignUpFormValues = z.infer<typeof authSchema>;
|
||||
|
||||
interface CredentialsSignUpFormProps {
|
||||
callbackUrl: string | undefined;
|
||||
}
|
||||
|
||||
export function CredentialsSignUpForm({
|
||||
callbackUrl,
|
||||
}: CredentialsSignUpFormProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("CredentialsSignUpForm");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const form = useForm<CredentialsSignUpFormValues>({
|
||||
resolver: zodResolver(authSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const toggleVisibility = () => setIsVisible((prev) => !prev);
|
||||
|
||||
const onSubmit = (data: CredentialsSignUpFormValues) => {
|
||||
startTransition(async () => {
|
||||
const result = await signUpWithCredentials(data);
|
||||
|
||||
if (result?.error) {
|
||||
toast.error(t("signUpFailed"), {
|
||||
description: result.error,
|
||||
});
|
||||
} else {
|
||||
toast.success(t("signUpSuccess"), {
|
||||
description: t("signUpSuccessDescription"),
|
||||
});
|
||||
console.log("callbackUrl:", callbackUrl);
|
||||
router.push(
|
||||
`/sign-in?callbackUrl=${encodeURIComponent(callbackUrl || "/")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input className="peer pe-9" {...field} />
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 end-0 flex items-center justify-center pe-3 peer-disabled:opacity-50">
|
||||
<MailIcon size={16} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="pe-9"
|
||||
type={isVisible ? "text" : "password"}
|
||||
{...field}
|
||||
/>
|
||||
<button
|
||||
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={toggleVisibility}
|
||||
aria-label={
|
||||
isVisible ? t("hidePassword") : t("showPassword")
|
||||
}
|
||||
aria-pressed={isVisible}
|
||||
aria-controls="password"
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<EyeIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? t("creatingAccount") : t("signUp")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNewProblemStore } from "@/app/(app)/dashboard/@admin/problemset/new/store";
|
||||
import { problemSchema } from "@/components/features/dashboard/admin/problemset/new/schema";
|
||||
import { newProblemMetadataSchema } from "@/components/features/dashboard/admin/problemset/new/components/metadata-form";
|
||||
|
||||
export const newProblemDescriptionSchema = problemSchema.pick({
|
||||
description: true,
|
||||
});
|
||||
|
||||
type NewProblemDescriptionSchema = z.infer<typeof newProblemDescriptionSchema>;
|
||||
|
||||
export default function NewProblemDescriptionForm() {
|
||||
const {
|
||||
hydrated,
|
||||
displayId,
|
||||
title,
|
||||
difficulty,
|
||||
published,
|
||||
description,
|
||||
setData,
|
||||
} = useNewProblemStore();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<NewProblemDescriptionSchema>({
|
||||
resolver: zodResolver(newProblemDescriptionSchema),
|
||||
defaultValues: {
|
||||
description: description || "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewProblemDescriptionSchema) => {
|
||||
setData(data);
|
||||
router.push("/dashboard/problemset/new/solution");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
try {
|
||||
newProblemMetadataSchema.parse({
|
||||
displayId,
|
||||
title,
|
||||
difficulty,
|
||||
published,
|
||||
});
|
||||
} catch {
|
||||
router.push("/dashboard/problemset/new/metadata");
|
||||
}
|
||||
}, [difficulty, displayId, hydrated, published, router, title]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="max-w-3xl mx-auto space-y-8 py-10"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your problem description.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Next</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Difficulty } from "@/generated/client";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getDifficultyColorClass } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNewProblemStore } from "@/app/(app)/dashboard/@admin/problemset/new/store";
|
||||
import { problemSchema } from "@/components/features/dashboard/admin/problemset/new/schema";
|
||||
|
||||
export const newProblemMetadataSchema = problemSchema.pick({
|
||||
displayId: true,
|
||||
title: true,
|
||||
difficulty: true,
|
||||
published: true,
|
||||
});
|
||||
|
||||
type NewProblemMetadataSchema = z.infer<typeof newProblemMetadataSchema>;
|
||||
|
||||
export default function NewProblemMetadataForm() {
|
||||
const router = useRouter();
|
||||
const { displayId, title, difficulty, published, setData } = useNewProblemStore();
|
||||
|
||||
const form = useForm<NewProblemMetadataSchema>({
|
||||
resolver: zodResolver(newProblemMetadataSchema),
|
||||
defaultValues: {
|
||||
// displayId must be a number and cannot be an empty string ("")
|
||||
// so set it to undefined here and convert it to "" in the Input component.
|
||||
displayId: displayId || undefined,
|
||||
title: title || "",
|
||||
difficulty: difficulty || "EASY",
|
||||
published: published || false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewProblemMetadataSchema) => {
|
||||
setData(data);
|
||||
router.push("/dashboard/problemset/new/description");
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="max-w-3xl mx-auto space-y-8 py-10"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Display ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., 1001" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique numeric identifier visible to users
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Problem Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., Two Sum" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Descriptive title summarizing the problem
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty Level</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty level" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.values(Difficulty).map((difficulty) => (
|
||||
<SelectItem key={difficulty} value={difficulty}>
|
||||
<span className={getDifficultyColorClass(difficulty)}>
|
||||
{difficulty}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Categorize problem complexity for better filtering
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5 mr-2">
|
||||
<FormLabel>Publish Status</FormLabel>
|
||||
<FormDescription>
|
||||
Make problem visible in public listings
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">Next</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useNewProblemStore } from "@/app/(app)/dashboard/@admin/problemset/new/store";
|
||||
import { problemSchema } from "@/components/features/dashboard/admin/problemset/new/schema";
|
||||
import { newProblemMetadataSchema } from "@/components/features/dashboard/admin/problemset/new/components/metadata-form";
|
||||
import { newProblemDescriptionSchema } from "@/components/features/dashboard/admin/problemset/new/components/description-form";
|
||||
|
||||
const newProblemSolutionSchema = problemSchema.pick({
|
||||
solution: true,
|
||||
});
|
||||
|
||||
type NewProblemSolutionSchema = z.infer<typeof newProblemSolutionSchema>;
|
||||
|
||||
export default function NewProblemSolutionForm() {
|
||||
const {
|
||||
hydrated,
|
||||
displayId,
|
||||
title,
|
||||
difficulty,
|
||||
published,
|
||||
description,
|
||||
solution,
|
||||
} = useNewProblemStore();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<NewProblemSolutionSchema>({
|
||||
resolver: zodResolver(newProblemSolutionSchema),
|
||||
defaultValues: {
|
||||
solution: solution || "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewProblemSolutionSchema) => {
|
||||
console.log({
|
||||
...data,
|
||||
displayId,
|
||||
title,
|
||||
difficulty,
|
||||
published,
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
try {
|
||||
newProblemMetadataSchema.parse({
|
||||
displayId,
|
||||
title,
|
||||
difficulty,
|
||||
published,
|
||||
});
|
||||
} catch {
|
||||
router.push("/dashboard/problemset/new/metadata");
|
||||
}
|
||||
|
||||
try {
|
||||
newProblemDescriptionSchema.parse({ description });
|
||||
} catch {
|
||||
router.push("/dashboard/problemset/new/description");
|
||||
}
|
||||
}, [hydrated, displayId, title, difficulty, published, description, router]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="max-w-3xl mx-auto space-y-8 py-10"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="solution"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Solution</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your problem solution.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">Next</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ProblemSchema } from "@/generated/zod";
|
||||
|
||||
export const problemSchema = ProblemSchema.extend({
|
||||
displayId: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
solution: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ProblemSchema = z.infer<typeof problemSchema>;
|
||||
@@ -1,644 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
CircleAlertIcon,
|
||||
CircleXIcon,
|
||||
Columns3Icon,
|
||||
EllipsisIcon,
|
||||
FilterIcon,
|
||||
ListFilterIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
FilterFn,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Difficulty, Problem } from "@/generated/client";
|
||||
import { cn, getDifficultyColorClass } from "@/lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
type ProblemTableItem = Pick<Problem, "id" | "displayId" | "title" | "difficulty">;
|
||||
|
||||
interface ProblemTableProps {
|
||||
data: ProblemTableItem[];
|
||||
}
|
||||
|
||||
// Custom filter function for multi-column searching
|
||||
const multiColumnFilterFn: FilterFn<ProblemTableItem> = (row, _columnId, filterValue) => {
|
||||
const searchableRowContent = `${row.original.displayId} ${row.original.title}`.toLowerCase();
|
||||
const searchTerm = (filterValue ?? "").toLowerCase();
|
||||
return searchableRowContent.includes(searchTerm);
|
||||
};
|
||||
|
||||
const difficultyFilterFn: FilterFn<ProblemTableItem> = (
|
||||
row,
|
||||
columnId,
|
||||
filterValue: string[]
|
||||
) => {
|
||||
if (!filterValue?.length) return true;
|
||||
const difficulty = row.getValue(columnId) as string;
|
||||
return filterValue.includes(difficulty);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ProblemTableItem>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
size: 28,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
header: "DisplayId",
|
||||
accessorKey: "displayId",
|
||||
cell: ({ row }) => <div className="font-medium">{row.getValue("displayId")}</div>,
|
||||
size: 90,
|
||||
filterFn: multiColumnFilterFn,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
header: "Title",
|
||||
accessorKey: "title",
|
||||
cell: ({ row }) => <div className="font-medium">{row.getValue("title")}</div>,
|
||||
},
|
||||
{
|
||||
header: "Difficulty",
|
||||
accessorKey: "difficulty",
|
||||
cell: ({ row }) => {
|
||||
const difficulty = row.getValue("difficulty") as Difficulty;
|
||||
return (
|
||||
<Badge variant="secondary" className={getDifficultyColorClass(difficulty)}>
|
||||
{difficulty}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
size: 100,
|
||||
filterFn: difficultyFilterFn,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: () => <RowActions />,
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function ProblemsetTable({ data }: ProblemTableProps) {
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "displayId", desc: false },
|
||||
]);
|
||||
|
||||
const handleDeleteRows = async () => {
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
const selectedIds = selectedRows.map((row) => row.original.id);
|
||||
console.log("🚀 ~ handleDeleteRows ~ selectedIds:", selectedIds)
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
enableSortingRemoval: false,
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onPaginationChange: setPagination,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
state: { sorting, pagination, columnFilters, columnVisibility },
|
||||
});
|
||||
|
||||
// Get unique difficulty values
|
||||
const uniqueDifficultyValues = useMemo(() => {
|
||||
const difficultyColumn = table.getColumn("difficulty");
|
||||
|
||||
if (!difficultyColumn) return [];
|
||||
|
||||
const values = Array.from(difficultyColumn.getFacetedUniqueValues().keys());
|
||||
|
||||
return values.sort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [table.getColumn("difficulty")?.getFacetedUniqueValues()]);
|
||||
|
||||
// Get counts for each difficulty
|
||||
const difficultyCounts = useMemo(() => {
|
||||
const difficultyColumn = table.getColumn("difficulty");
|
||||
if (!difficultyColumn) return new Map();
|
||||
return difficultyColumn.getFacetedUniqueValues();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [table.getColumn("difficulty")?.getFacetedUniqueValues()]);
|
||||
|
||||
const selectedDifficulties = useMemo(() => {
|
||||
const filterValue = table.getColumn("difficulty")?.getFilterValue() as string[];
|
||||
return filterValue ?? [];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [table.getColumn("difficulty")?.getFilterValue()]);
|
||||
|
||||
const handleDifficultyChange = (checked: boolean, value: string) => {
|
||||
const filterValue = table.getColumn("difficulty")?.getFilterValue() as string[];
|
||||
const newFilterValue = filterValue ? [...filterValue] : [];
|
||||
|
||||
if (checked) {
|
||||
newFilterValue.push(value);
|
||||
} else {
|
||||
const index = newFilterValue.indexOf(value);
|
||||
if (index > -1) {
|
||||
newFilterValue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
table
|
||||
.getColumn("difficulty")
|
||||
?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"peer min-w-60 ps-9",
|
||||
Boolean(table.getColumn("displayId")?.getFilterValue()) && "pe-9"
|
||||
)}
|
||||
value={
|
||||
(table.getColumn("displayId")?.getFilterValue() ?? "") as string
|
||||
}
|
||||
onChange={(e) =>
|
||||
table.getColumn("displayId")?.setFilterValue(e.target.value)
|
||||
}
|
||||
placeholder="DisplayId or Title..."
|
||||
type="text"
|
||||
aria-label="Filter by displayId or title"
|
||||
/>
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 peer-disabled:opacity-50">
|
||||
<ListFilterIcon size={16} aria-hidden="true" />
|
||||
</div>
|
||||
{Boolean(table.getColumn("displayId")?.getFilterValue()) && (
|
||||
<button
|
||||
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
table.getColumn("displayId")?.setFilterValue("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CircleXIcon size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<FilterIcon
|
||||
className="-ms-1 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Difficulty
|
||||
{selectedDifficulties.length > 0 && (
|
||||
<span className="bg-background text-muted-foreground/70 -me-1 inline-flex h-5 max-h-full items-center rounded border px-1 font-[inherit] text-[0.625rem] font-medium">
|
||||
{selectedDifficulties.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto min-w-36 p-3" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
Filters
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{uniqueDifficultyValues.map((value) => (
|
||||
<div key={value} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedDifficulties.includes(value)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
handleDifficultyChange(checked, value)
|
||||
}
|
||||
/>
|
||||
<Label className="flex grow justify-between gap-2 font-normal">
|
||||
{value}{" "}
|
||||
<span className="text-muted-foreground ms-2 text-xs">
|
||||
{difficultyCounts.get(value)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Columns3Icon
|
||||
className="-ms-1 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{table.getSelectedRowModel().rows.length > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="ml-auto" variant="outline">
|
||||
<TrashIcon
|
||||
className="-ms-1 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Delete
|
||||
<span className="bg-background text-muted-foreground/70 -me-1 inline-flex h-5 max-h-full items-center rounded border px-1 font-[inherit] text-[0.625rem] font-medium">
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 max-sm:items-center sm:flex-row sm:gap-4">
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CircleAlertIcon className="opacity-80" size={16} />
|
||||
</div>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete{" "}
|
||||
{table.getSelectedRowModel().rows.length} selected{" "}
|
||||
{table.getSelectedRowModel().rows.length === 1
|
||||
? "row"
|
||||
: "rows"}
|
||||
.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteRows}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<Button className="ml-auto" variant="outline" asChild>
|
||||
<Link href="/dashboard/problemset/new">
|
||||
<PlusIcon
|
||||
className="-ms-1 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Add Problem
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-background overflow-hidden rounded-md">
|
||||
<Table className="table-fixed">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{ width: `${header.getSize()}px` }}
|
||||
className="h-11"
|
||||
>
|
||||
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer items-center justify-between gap-2 select-none"
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
header.column.getCanSort() &&
|
||||
(e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
e.preventDefault();
|
||||
header.column.getToggleSortingHandler()?.(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={header.column.getCanSort() ? 0 : undefined}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{
|
||||
{
|
||||
asc: (
|
||||
<ChevronUpIcon
|
||||
className="shrink-0 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
),
|
||||
desc: (
|
||||
<ChevronDownIcon
|
||||
className="shrink-0 opacity-60"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
),
|
||||
}[header.column.getIsSorted() as string] ?? null
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="[&_td:first-child]:rounded-l-lg [&_td:last-child]:rounded-r-lg">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="h-10 border-b-0 cursor-pointer odd:bg-muted/50 hover:text-blue-500 hover:bg-muted"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="last:py-0">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="max-sm:sr-only">Rows per page</Label>
|
||||
<Select
|
||||
value={table.getState().pagination.pageSize.toString()}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-fit whitespace-nowrap">
|
||||
<SelectValue placeholder="Select number of results" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="[&_*[role=option]]:ps-2 [&_*[role=option]]:pe-8 [&_*[role=option]>span]:start-auto [&_*[role=option]>span]:end-2">
|
||||
{[5, 10, 25, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={pageSize.toString()}>
|
||||
<span className="mr-2">{pageSize}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex grow justify-end text-sm whitespace-nowrap">
|
||||
<p
|
||||
className="text-muted-foreground text-sm whitespace-nowrap"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="text-foreground">
|
||||
{table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
1}
|
||||
-
|
||||
{Math.min(
|
||||
Math.max(
|
||||
table.getState().pagination.pageIndex *
|
||||
table.getState().pagination.pageSize +
|
||||
table.getState().pagination.pageSize,
|
||||
0
|
||||
),
|
||||
table.getRowCount()
|
||||
)}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="text-foreground">
|
||||
{table.getRowCount().toString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => table.firstPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<ChevronFirstIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeftIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<ChevronRightIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={() => table.lastPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronLastIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowActions() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="shadow-none"
|
||||
aria-label="Edit item"
|
||||
>
|
||||
<EllipsisIcon size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive">
|
||||
<span>Delete</span>
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
|
||||
const getLspStatusColor = (webSocket: WebSocket | null) => {
|
||||
if (!webSocket) return "bg-gray-500";
|
||||
|
||||
switch (webSocket.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return "bg-yellow-500";
|
||||
case WebSocket.OPEN:
|
||||
return "bg-green-500";
|
||||
case WebSocket.CLOSING:
|
||||
return "bg-orange-500";
|
||||
case WebSocket.CLOSED:
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
export function LspStatusButton() {
|
||||
const { webSocket } = useProblem();
|
||||
const t = useTranslations("WorkspaceEditorHeader.LspStatusButton");
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 w-auto px-2 py-0 gap-1 border-none shadow-none hover:bg-muted"
|
||||
>
|
||||
<div className="h-3 w-3 flex items-center justify-center">
|
||||
<div
|
||||
className={`h-1.5 w-1.5 rounded-full transition-all ${getLspStatusColor(
|
||||
webSocket
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
LSP
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">
|
||||
{t("TooltipContent")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const HeroSection = () => {
|
||||
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-4">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-2xl bg-muted text-muted-foreground shadow hover:bg-muted/50"
|
||||
className="rounded-2xl bg-purple-500 text-white shadow hover:bg-purple-800"
|
||||
asChild
|
||||
>
|
||||
<Link href="/problemset">{t("quickStart")}</Link>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback } from "react";
|
||||
import { highlighter } from "@/lib/shiki";
|
||||
import type { editor } from "monaco-editor";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { shikiToMonaco } from "@shikijs/monaco";
|
||||
import { useMonacoTheme } from "@/hooks/use-monaco-theme";
|
||||
import { Editor, type Monaco } from "@monaco-editor/react";
|
||||
import { DefaultEditorOptionConfig } from "@/config/editor-option";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value?: string;
|
||||
onChange?: (value: string | undefined, ev: editor.IModelContentChangedEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: MarkdownEditorProps) {
|
||||
const { currentTheme } = useMonacoTheme();
|
||||
|
||||
const handleEditorWillMount = useCallback((monaco: Monaco) => {
|
||||
shikiToMonaco(highlighter, monaco);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
language="markdown"
|
||||
theme={currentTheme}
|
||||
value={value}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onChange={onChange}
|
||||
options={DefaultEditorOptionConfig}
|
||||
loading={<Loading />}
|
||||
className={cn("h-full w-full", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
// import rehypeSlug from "rehype-slug";
|
||||
import { MDXProvider } from "@mdx-js/react";
|
||||
import rehypePretty from "rehype-pretty-code";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { serialize } from "next-mdx-remote/serialize";
|
||||
@@ -14,22 +15,25 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { CircleAlert, TriangleAlert } from "lucide-react";
|
||||
import { useMonacoTheme } from "@/hooks/use-monaco-theme";
|
||||
// import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import { MdxComponents } from "@/components/content/mdx-components";
|
||||
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
|
||||
interface MdxPreviewProps {
|
||||
source: string;
|
||||
components?: React.ComponentProps<typeof MDXProvider>["components"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MdxPreview({
|
||||
source,
|
||||
components,
|
||||
className,
|
||||
}: MdxPreviewProps) {
|
||||
const { currentTheme } = useMonacoTheme();
|
||||
const { theme } = useMonacoTheme();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [mdxSource, setMdxSource] = useState<MDXRemoteSerializeResult | null>(null);
|
||||
const [mdxSource, setMdxSource] = useState<MDXRemoteSerializeResult | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const getMdxSource = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -53,7 +57,7 @@ export default function MdxPreview({
|
||||
[
|
||||
rehypePretty,
|
||||
{
|
||||
theme: currentTheme,
|
||||
theme: theme,
|
||||
keepBackground: false,
|
||||
},
|
||||
],
|
||||
@@ -69,7 +73,7 @@ export default function MdxPreview({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [source, currentTheme]);
|
||||
}, [source, theme]);
|
||||
|
||||
// Delay the serialize process to the next event loop to avoid flickering
|
||||
// when copying code to the editor and the MDX preview shrinks.
|
||||
@@ -94,7 +98,12 @@ export default function MdxPreview({
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="rounded-lg border border-red-500/50 px-4 py-3 text-red-600">
|
||||
<p className="text-sm">
|
||||
<CircleAlert className="-mt-0.5 me-3 inline-flex opacity-60" size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<CircleAlert
|
||||
className="-mt-0.5 me-3 inline-flex opacity-60"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
@@ -107,7 +116,12 @@ export default function MdxPreview({
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="rounded-lg border border-amber-500/50 px-4 py-3 text-amber-600">
|
||||
<p className="text-sm">
|
||||
<TriangleAlert className="-mt-0.5 me-3 inline-flex opacity-60" size={16} strokeWidth={2} aria-hidden="true" />
|
||||
<TriangleAlert
|
||||
className="-mt-0.5 me-3 inline-flex opacity-60"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
No content to preview.
|
||||
</p>
|
||||
</div>
|
||||
@@ -117,7 +131,7 @@ export default function MdxPreview({
|
||||
|
||||
return (
|
||||
<article className={cn("markdown-body", className)}>
|
||||
<MDXRemote {...mdxSource!} components={MdxComponents} />
|
||||
<MDXRemote {...mdxSource!} components={components} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { highlighter } from "@/lib/shiki";
|
||||
import type { editor } from "monaco-editor";
|
||||
import { Loading } from "@/components/loading";
|
||||
import { shikiToMonaco } from "@shikijs/monaco";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
import type { Monaco } from "@monaco-editor/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { connectToLanguageServer } from "@/lib/language-server";
|
||||
import type { MonacoLanguageClient } from "monaco-languageclient";
|
||||
import { DefaultEditorOptionConfig } from "@/config/editor-option";
|
||||
import { useEffect } from "react";
|
||||
import { CoreEditor } from "@/components/core-editor";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import type { LanguageServerConfig, Template } from "@/generated/client";
|
||||
|
||||
// Dynamically import Monaco Editor with SSR disabled
|
||||
const Editor = dynamic(
|
||||
async () => {
|
||||
await import("vscode");
|
||||
const monaco = await import("monaco-editor");
|
||||
interface ProblemEditorProps {
|
||||
problemId: string;
|
||||
templates: Template[];
|
||||
languageServerConfigs?: LanguageServerConfig[];
|
||||
}
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new Worker(
|
||||
new URL("monaco-editor/esm/vs/language/json/json.worker.js", import.meta.url)
|
||||
);
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new Worker(
|
||||
new URL("monaco-editor/esm/vs/language/css/css.worker.js", import.meta.url)
|
||||
);
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new Worker(
|
||||
new URL("monaco-editor/esm/vs/language/html/html.worker.js", import.meta.url)
|
||||
);
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new Worker(
|
||||
new URL("monaco-editor/esm/vs/language/typescript/ts.worker.js", import.meta.url)
|
||||
);
|
||||
}
|
||||
return new Worker(
|
||||
new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url)
|
||||
);
|
||||
},
|
||||
};
|
||||
const { loader } = await import("@monaco-editor/react");
|
||||
loader.config({ monaco });
|
||||
return (await import("@monaco-editor/react")).Editor;
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <Loading />,
|
||||
}
|
||||
);
|
||||
|
||||
export function ProblemEditor() {
|
||||
export const ProblemEditor = ({
|
||||
problemId,
|
||||
templates,
|
||||
languageServerConfigs,
|
||||
}: ProblemEditorProps) => {
|
||||
const {
|
||||
hydrated,
|
||||
editor,
|
||||
language,
|
||||
value,
|
||||
path,
|
||||
setProblem,
|
||||
setValue,
|
||||
setEditor,
|
||||
setLspWebSocket,
|
||||
setMarkers,
|
||||
setWebSocket,
|
||||
currentLang,
|
||||
currentPath,
|
||||
currentTheme,
|
||||
currentValue,
|
||||
changeValue,
|
||||
currentEditorLanguageConfig,
|
||||
currentLanguageServerConfig,
|
||||
} = useProblem();
|
||||
} = useProblemEditorStore();
|
||||
|
||||
const monacoLanguageClientRef = useRef<MonacoLanguageClient | null>(null);
|
||||
|
||||
// Connect to LSP only if enabled
|
||||
const connectLSP = useCallback(async () => {
|
||||
if (!(currentLang && editor)) return;
|
||||
|
||||
// If there's an existing language client, stop it first
|
||||
if (monacoLanguageClientRef.current) {
|
||||
monacoLanguageClientRef.current.stop();
|
||||
monacoLanguageClientRef.current = null;
|
||||
setWebSocket(null);
|
||||
}
|
||||
|
||||
if (!currentEditorLanguageConfig || !currentLanguageServerConfig) return;
|
||||
|
||||
// Create a new language client
|
||||
try {
|
||||
const { client: monacoLanguageClient, webSocket } = await connectToLanguageServer(
|
||||
currentEditorLanguageConfig,
|
||||
currentLanguageServerConfig
|
||||
);
|
||||
monacoLanguageClientRef.current = monacoLanguageClient;
|
||||
setWebSocket(webSocket);
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to LSP:", error);
|
||||
}
|
||||
}, [
|
||||
currentEditorLanguageConfig,
|
||||
currentLang,
|
||||
currentLanguageServerConfig,
|
||||
editor,
|
||||
setWebSocket,
|
||||
]);
|
||||
|
||||
// Reconnect to the LSP whenever language or lspConfig changes
|
||||
useEffect(() => {
|
||||
connectLSP();
|
||||
}, [connectLSP]);
|
||||
|
||||
// Cleanup the LSP connection when the component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (monacoLanguageClientRef.current) {
|
||||
monacoLanguageClientRef.current.stop();
|
||||
monacoLanguageClientRef.current = null;
|
||||
setWebSocket(null);
|
||||
}
|
||||
};
|
||||
}, [setWebSocket]);
|
||||
|
||||
const handleEditorWillMount = useCallback((monaco: Monaco) => {
|
||||
shikiToMonaco(highlighter, monaco);
|
||||
}, []);
|
||||
|
||||
const handleOnMount = useCallback(
|
||||
async (editor: editor.IStandaloneCodeEditor) => {
|
||||
setEditor(editor);
|
||||
await connectLSP();
|
||||
},
|
||||
[setEditor, connectLSP]
|
||||
);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
if (value !== undefined) {
|
||||
changeValue(value);
|
||||
}
|
||||
},
|
||||
[changeValue]
|
||||
);
|
||||
|
||||
const handleEditorValidation = useCallback(
|
||||
(markers: editor.IMarker[]) => {
|
||||
setMarkers(markers);
|
||||
},
|
||||
[setMarkers]
|
||||
);
|
||||
|
||||
if (!hydrated) {
|
||||
return <Loading />;
|
||||
}
|
||||
setProblem(problemId, templates);
|
||||
}, [problemId, setProblem, templates]);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
language={currentLang}
|
||||
theme={currentTheme}
|
||||
path={currentPath}
|
||||
value={currentValue}
|
||||
beforeMount={handleEditorWillMount}
|
||||
onMount={handleOnMount}
|
||||
onChange={handleEditorChange}
|
||||
onValidate={handleEditorValidation}
|
||||
options={DefaultEditorOptionConfig}
|
||||
loading={<Loading />}
|
||||
className="h-full w-full"
|
||||
<CoreEditor
|
||||
language={language}
|
||||
value={value}
|
||||
path={path}
|
||||
languageServerConfigs={languageServerConfigs}
|
||||
onEditorReady={setEditor}
|
||||
onLspWebSocketReady={setLspWebSocket}
|
||||
onMarkersReady={setMarkers}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Locale } from "@/config/i18n";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { enUS, zhCN } from "date-fns/locale";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
import { Clock4Icon, CpuIcon } from "lucide-react";
|
||||
import { useDockviewStore } from "@/stores/dockview";
|
||||
import { getStatusColorClass, statusMap } from "@/lib/status";
|
||||
import type { SubmissionWithTestcaseResult } from "@/types/prisma";
|
||||
import { EditorLanguageIcons } from "@/config/editor-language-icons";
|
||||
import { formatDistanceToNow, isBefore, subDays, format } from "date-fns";
|
||||
|
||||
interface SubmissionsTableProps {
|
||||
locale: Locale;
|
||||
submissions: SubmissionWithTestcaseResult[];
|
||||
}
|
||||
|
||||
const getLocale = (locale: Locale) => {
|
||||
switch (locale) {
|
||||
case "zh":
|
||||
return zhCN;
|
||||
case "en":
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SubmissionsTable({ locale, submissions }: SubmissionsTableProps) {
|
||||
const s = useTranslations("StatusMessage");
|
||||
const t = useTranslations("SubmissionsTable");
|
||||
const { editorLanguageConfigs } = useProblem();
|
||||
const { api, setSubmission } = useDockviewStore();
|
||||
|
||||
const sortedSubmissions = [...submissions].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
const handleRowClick = (submission: SubmissionWithTestcaseResult) => {
|
||||
if (!api) return;
|
||||
setSubmission(submission);
|
||||
|
||||
const panel = api.getPanel("Details");
|
||||
if (panel) {
|
||||
panel.api.setActive();
|
||||
} else {
|
||||
api.addPanel({
|
||||
id: "Details",
|
||||
component: "Details",
|
||||
tabComponent: "Details",
|
||||
title: s(`${submission.status}`),
|
||||
position: {
|
||||
referencePanel: "Submissions",
|
||||
direction: "within",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">{t("Index")}</TableHead>
|
||||
<TableHead className="w-[170px]">{t("Status")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Language")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Time")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Memory")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<tbody aria-hidden="true" className="table-row h-2" />
|
||||
|
||||
<TableBody className="[&_td:first-child]:rounded-l-lg [&_td:last-child]:rounded-r-lg">
|
||||
{sortedSubmissions.map((submission, index) => {
|
||||
const Icon = EditorLanguageIcons[submission.language];
|
||||
const createdAt = new Date(submission.createdAt);
|
||||
const localeInstance = getLocale(locale);
|
||||
const submittedDisplay = isBefore(createdAt, subDays(new Date(), 1))
|
||||
? format(createdAt, "yyyy-MM-dd")
|
||||
: formatDistanceToNow(createdAt, { addSuffix: true, locale: localeInstance });
|
||||
|
||||
const isEven = (submissions.length - index) % 2 === 0;
|
||||
const message = statusMap.get(submission.status)?.message;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={submission.id}
|
||||
onClick={() => handleRowClick(submission)}
|
||||
className={cn(
|
||||
"border-b-0 hover:text-blue-500 hover:bg-muted hover:cursor-pointer",
|
||||
isEven ? "" : "bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{sortedSubmissions.length - index}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={getStatusColorClass(submission.status)}>
|
||||
{s(`${message}`)}
|
||||
</span>
|
||||
<span className="text-xs">{submittedDisplay}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span className="truncate text-sm font-semibold mr-2">
|
||||
{
|
||||
editorLanguageConfigs.find(
|
||||
(config) => config.language === submission.language
|
||||
)?.label
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock4Icon size={16} aria-hidden="true" />
|
||||
<span>
|
||||
{submission.executionTime === null
|
||||
? "N/A"
|
||||
: `${submission.executionTime} ms`}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CpuIcon size={16} aria-hidden="true" />
|
||||
<span>
|
||||
{submission.memoryUsage === null
|
||||
? "N/A"
|
||||
: `${submission.memoryUsage} MB`}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { TestcaseWithDetails } from "@/types/prisma";
|
||||
import TestcaseForm from "@/components/testcase-form";
|
||||
|
||||
interface TestcaseCardProps {
|
||||
testcases: TestcaseWithDetails;
|
||||
}
|
||||
|
||||
export default function TestcaseCard({ testcases }: TestcaseCardProps) {
|
||||
return (
|
||||
<Tabs defaultValue="case-1" className="items-center px-5 py-4">
|
||||
<TabsList className="bg-transparent p-0">
|
||||
{testcases.map((_, index) => (
|
||||
<TabsTrigger
|
||||
key={`tab-${index}`}
|
||||
value={`case-${index + 1}`}
|
||||
className="data-[state=active]:bg-muted data-[state=active]:shadow-none"
|
||||
>
|
||||
Case {index + 1}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{testcases.map((testcase, index) => {
|
||||
const formData = testcase.data.reduce((acc, field) => {
|
||||
acc[field.label] = field.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return (
|
||||
<TabsContent key={`content-${index}`} value={`case-${index + 1}`}>
|
||||
<TestcaseForm {...formData} />
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -3,30 +3,11 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
|
||||
function SunIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M12.5 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
d="M10 5.5v-1M13.182 6.818l.707-.707M14.5 10h1M13.182 13.182l.707.707M10 15.5v-1M6.11 13.889l.708-.707M4.5 10h1M6.11 6.111l.708.707"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" {...props}>
|
||||
<path d="M15.224 11.724a5.5 5.5 0 0 1-6.949-6.949 5.5 5.5 0 1 0 6.949 6.949Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
export const ThemeToggle = () => {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const otherTheme = resolvedTheme === "dark" ? "light" : "dark";
|
||||
const alternateTheme = resolvedTheme === "dark" ? "light" : "dark";
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,12 +18,21 @@ export function ThemeToggle() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md transition"
|
||||
aria-label={mounted ? `Switch to ${otherTheme} theme` : "Toggle theme"}
|
||||
onClick={() => setTheme(otherTheme)}
|
||||
onClick={() => setTheme(alternateTheme)}
|
||||
aria-label={
|
||||
mounted ? `Switch to ${alternateTheme} theme` : "Toggle theme"
|
||||
}
|
||||
>
|
||||
<SunIcon className="h-5 w-5 stroke-foreground dark:hidden" />
|
||||
<MoonIcon className="hidden h-5 w-5 stroke-foreground dark:block" />
|
||||
<MoonIcon
|
||||
size={16}
|
||||
className="shrink-0 scale-0 opacity-0 transition-all dark:scale-100 dark:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<SunIcon
|
||||
size={16}
|
||||
className="absolute shrink-0 scale-100 opacity-100 transition-all dark:scale-0 dark:opacity-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Difficulty } from "@/generated/client";
|
||||
|
||||
export const getColorClassForDifficulty = (difficulty: Difficulty) => {
|
||||
switch (difficulty) {
|
||||
case Difficulty.EASY:
|
||||
return "text-green-500";
|
||||
case Difficulty.MEDIUM:
|
||||
return "text-yellow-500";
|
||||
case Difficulty.HARD:
|
||||
return "text-red-500";
|
||||
}
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { EditorLanguage } from "@/generated/client";
|
||||
import { COriginal, CplusplusOriginal } from "devicons-react";
|
||||
|
||||
// Mapping between EditorLanguage and icons
|
||||
export const EditorLanguageIcons: Record<
|
||||
EditorLanguage,
|
||||
React.FunctionComponent<React.SVGProps<SVGElement> & { size?: number | string }>
|
||||
> = {
|
||||
[EditorLanguage.c]: COriginal,
|
||||
[EditorLanguage.cpp]: CplusplusOriginal,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { EditorLanguage } from "@/generated/client";
|
||||
|
||||
export const DEFAULT_EDITOR_LANGUAGE = EditorLanguage.c;
|
||||
@@ -1,3 +1,23 @@
|
||||
import { Language } from "@/generated/client";
|
||||
import COriginal from "devicons-react/icons/COriginal";
|
||||
import CplusplusOriginal from "devicons-react/icons/CplusplusOriginal";
|
||||
|
||||
export const LANGUAGES = Object.values(Language);
|
||||
|
||||
export const getIconForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return COriginal;
|
||||
case Language.cpp:
|
||||
return CplusplusOriginal;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLabelForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return "C";
|
||||
case Language.cpp:
|
||||
return "C++";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Locale } from "@/generated/client";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import { zhCN } from "date-fns/locale/zh-CN";
|
||||
import { formatDistanceToNow, isBefore, subDays, format } from "date-fns";
|
||||
|
||||
export const getDateFunctionForLocale = (locale: Locale) => {
|
||||
switch (locale) {
|
||||
case Locale.en:
|
||||
return enUS;
|
||||
case Locale.zh:
|
||||
return zhCN;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatSubmissionDate = (date: Date, locale: Locale) => {
|
||||
const localeInstance = getDateFunctionForLocale(locale);
|
||||
return isBefore(date, subDays(new Date(), 1))
|
||||
? format(date, "yyyy-MM-dd")
|
||||
: formatDistanceToNow(date, { addSuffix: true, locale: localeInstance });
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Status } from "@/generated/client";
|
||||
import { AlertTriangleIcon, BanIcon, CircleCheckIcon } from "lucide-react";
|
||||
|
||||
export const getIconForStatus = (status: Status) => {
|
||||
switch (status) {
|
||||
case Status.PD:
|
||||
case Status.QD:
|
||||
case Status.CP:
|
||||
case Status.CE:
|
||||
case Status.RU:
|
||||
case Status.TLE:
|
||||
case Status.MLE:
|
||||
case Status.RE:
|
||||
case Status.WA:
|
||||
return AlertTriangleIcon;
|
||||
case Status.CS:
|
||||
case Status.AC:
|
||||
return CircleCheckIcon;
|
||||
case Status.SE:
|
||||
return BanIcon;
|
||||
}
|
||||
};
|
||||
|
||||
export const getColorClassForStatus = (status: Status) => {
|
||||
switch (status) {
|
||||
case Status.PD:
|
||||
case Status.QD:
|
||||
return "text-gray-500";
|
||||
case Status.CP:
|
||||
case Status.CE:
|
||||
return "text-yellow-500";
|
||||
case Status.CS:
|
||||
case Status.AC:
|
||||
return "text-green-500";
|
||||
case Status.RU:
|
||||
case Status.TLE:
|
||||
return "text-blue-500";
|
||||
case Status.MLE:
|
||||
return "text-purple-500";
|
||||
case Status.RE:
|
||||
return "text-orange-500";
|
||||
case Status.WA:
|
||||
case Status.SE:
|
||||
return "text-red-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const getColorClassForLspStatus = (webSocket: WebSocket | null) => {
|
||||
if (!webSocket) return "bg-gray-500";
|
||||
switch (webSocket.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return "bg-sky-500 animate-pulse";
|
||||
case WebSocket.OPEN:
|
||||
return "bg-emerald-500";
|
||||
case WebSocket.CLOSING:
|
||||
return "bg-amber-500 animate-pulse";
|
||||
case WebSocket.CLOSED:
|
||||
return "bg-red-500";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Locale, ProblemLocalization } from "@/generated/client";
|
||||
import { BotForm } from "@/features/problems/bot/components/form";
|
||||
|
||||
const getLocalizedDescription = (
|
||||
localizations: ProblemLocalization[],
|
||||
locale: Locale
|
||||
) => {
|
||||
if (!localizations || localizations.length === 0) {
|
||||
return "Unknown Description";
|
||||
}
|
||||
|
||||
const localization = localizations.find(
|
||||
(localization) => localization.locale === locale
|
||||
);
|
||||
|
||||
return (
|
||||
localization?.content ?? localizations[0].content ?? "Unknown Description"
|
||||
);
|
||||
};
|
||||
|
||||
interface BotContentProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const BotContent = async ({ problemId }: BotContentProps) => {
|
||||
const locale = await getLocale();
|
||||
|
||||
const descriptions = await prisma.problemLocalization.findMany({
|
||||
where: {
|
||||
problemId,
|
||||
type: "DESCRIPTION",
|
||||
},
|
||||
});
|
||||
|
||||
const description = getLocalizedDescription(descriptions, locale as Locale);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<BotForm description={description} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BotContentSkeleton = () => {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute h-full w-full p-4 md:p-6">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="mb-6 h-8 w-3/4" />
|
||||
|
||||
{/* Content skeletons */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
<Skeleton className="mb-4 h-4 w-2/3" />
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-4/5" />
|
||||
|
||||
{/* Example section heading */}
|
||||
<Skeleton className="mb-4 mt-8 h-6 w-1/4" />
|
||||
|
||||
{/* Example content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
|
||||
{/* Code block skeleton */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-40 w-full rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* More content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useCallback } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import {
|
||||
ChatBubble,
|
||||
ChatBubbleMessage,
|
||||
} from "@/components/ui/chat/chat-bubble";
|
||||
import { useTranslations } from "next-intl";
|
||||
import MdxPreview from "@/components/mdx-preview";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BotIcon, SendHorizonal } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { PreDetail } from "@/components/content/pre-detail";
|
||||
import { TooltipButton } from "@/components/tooltip-button";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import { MdxComponents } from "@/components/content/mdx-components";
|
||||
import { ChatMessageList } from "@/components/ui/chat/chat-message-list";
|
||||
|
||||
interface BotFormProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const BotForm = ({ description }: BotFormProps) => {
|
||||
const t = useTranslations("Bot");
|
||||
const { problem, language, value } = useProblemEditorStore();
|
||||
|
||||
const { messages, input, handleInputChange, setMessages, handleSubmit } =
|
||||
useChat({
|
||||
initialMessages: [
|
||||
{
|
||||
id: problem?.problemId || "",
|
||||
role: "system",
|
||||
content: `Problem description:\n${description}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!input.trim()) {
|
||||
toast.error("Input cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCodeMessage = {
|
||||
id: problem?.problemId || "",
|
||||
role: "system" as const,
|
||||
content: `Current code:\n\`\`\`${language}\n${value}\n\`\`\``,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, currentCodeMessage]);
|
||||
handleSubmit();
|
||||
},
|
||||
[handleSubmit, input, language, problem?.problemId, setMessages, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{messages.some(
|
||||
(message) => message.role === "user" || message.role === "assistant"
|
||||
) ? (
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<ScrollArea className="h-full [&>[data-radix-scroll-area-viewport]>div:min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block">
|
||||
<ChatMessageList>
|
||||
{messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.role === "user" || message.role === "assistant"
|
||||
)
|
||||
.map((message) => (
|
||||
<ChatBubble
|
||||
key={message.id}
|
||||
layout="ai"
|
||||
className="border-b pb-4"
|
||||
>
|
||||
<ChatBubbleMessage layout="ai">
|
||||
<MdxPreview
|
||||
source={message.content}
|
||||
components={{ ...MdxComponents, pre: PreDetail }}
|
||||
/>
|
||||
</ChatBubbleMessage>
|
||||
</ChatBubble>
|
||||
))}
|
||||
</ChatMessageList>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<BotIcon />
|
||||
<span>{t("title")}</span>
|
||||
<span className="font-thin text-xs">{t("description")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="h-36 flex flex-none">
|
||||
<form onSubmit={handleFormSubmit} className="w-full p-4 pt-0 relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
handleFormSubmit(e);
|
||||
} else {
|
||||
toast.error("Input cannot be empty");
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="h-full bg-muted border-transparent shadow-none rounded-lg"
|
||||
placeholder={t("placeholder")}
|
||||
/>
|
||||
|
||||
<TooltipButton
|
||||
tooltipContent="Ctrl + Enter"
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
className="absolute bottom-6 right-6 h-6 w-auto px-2"
|
||||
aria-label="Send Message"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</TooltipButton>
|
||||
</form>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
BotContent,
|
||||
BotContentSkeleton,
|
||||
} from "@/features/problems/bot/components/content";
|
||||
|
||||
interface BotPanelProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const BotPanel = ({ problemId }: BotPanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<Suspense fallback={<BotContentSkeleton />}>
|
||||
<BotContent problemId={problemId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Actions, DockLocation } from "flexlayout-react";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
|
||||
export const ViewBotButton = () => {
|
||||
const t = useTranslations();
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isBotVisible, setBotVisible] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
const botTab = model.getNodeById("bot");
|
||||
setBotVisible(!!botTab);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
const handleBotToggle = (newState: boolean) => {
|
||||
if (!model) return;
|
||||
|
||||
const botTab = model.getNodeById("bot");
|
||||
|
||||
if (newState) {
|
||||
if (botTab) {
|
||||
model.doAction(Actions.selectTab("bot"));
|
||||
} else {
|
||||
model.doAction(
|
||||
Actions.addNode(
|
||||
{
|
||||
type: "tab",
|
||||
id: "bot",
|
||||
name: "Bot",
|
||||
component: "bot",
|
||||
enableClose: false,
|
||||
},
|
||||
model.getRoot().getId(),
|
||||
DockLocation.RIGHT,
|
||||
-1
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (botTab) {
|
||||
model.doAction(Actions.deleteTab("bot"));
|
||||
}
|
||||
}
|
||||
|
||||
setBotVisible(newState);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Toggle
|
||||
aria-label="Toggle Bot"
|
||||
pressed={isBotVisible}
|
||||
onPressedChange={handleBotToggle}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<BotIcon size={16} aria-hidden="true" />
|
||||
</Toggle>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="px-2 py-1 text-xs">
|
||||
<p>
|
||||
{isBotVisible
|
||||
? t("BotVisibilityToggle.close")
|
||||
: t("BotVisibilityToggle.open")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -12,8 +12,15 @@ export const CodeContent = async ({ problemId }: CodeContentProps) => {
|
||||
problemId,
|
||||
},
|
||||
});
|
||||
const languageServerConfigs = await prisma.languageServerConfig.findMany();
|
||||
|
||||
return <ProblemEditor problemId={problemId} templates={templates} />;
|
||||
return (
|
||||
<ProblemEditor
|
||||
problemId={problemId}
|
||||
templates={templates}
|
||||
languageServerConfigs={languageServerConfigs}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeContentSkeleton = () => {
|
||||
|
||||
+34
-19
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useProblem } from "@/hooks/use-problem";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CircleXIcon, TriangleAlertIcon } from "lucide-react";
|
||||
|
||||
interface WorkspaceEditorFooterProps {
|
||||
className?: string;
|
||||
}
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
|
||||
let MarkerSeverity: typeof import("monaco-editor").MarkerSeverity;
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -17,13 +13,17 @@ if (typeof window !== "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
export function WorkspaceEditorFooter({
|
||||
className,
|
||||
...props
|
||||
}: WorkspaceEditorFooterProps) {
|
||||
interface CodeFooterProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CodeFooter = ({ className }: CodeFooterProps) => {
|
||||
const t = useTranslations("WorkspaceEditorFooter");
|
||||
const { editor, markers } = useProblem();
|
||||
const [position, setPosition] = useState<{ lineNumber: number; column: number } | null>(null);
|
||||
const { editor, markers } = useProblemEditorStore();
|
||||
const [position, setPosition] = useState<{
|
||||
lineNumber: number;
|
||||
column: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
@@ -48,26 +48,41 @@ export function WorkspaceEditorFooter({
|
||||
|
||||
return (
|
||||
<footer
|
||||
{...props}
|
||||
className={cn("h-9 flex flex-none items-center px-3 py-2 bg-muted", className)}
|
||||
className={cn(
|
||||
"h-9 flex flex-none items-center px-3 py-2 bg-muted",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between px-0.5 truncate">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CircleXIcon className="text-red-500" size={20} aria-hidden="true" />
|
||||
<CircleXIcon
|
||||
className="text-red-500"
|
||||
size={20}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{markers.filter((m) => m.severity === MarkerSeverity.Error).length}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TriangleAlertIcon className="text-yellow-500" size={20} aria-hidden="true" />
|
||||
{markers.filter((m) => m.severity === MarkerSeverity.Warning).length}
|
||||
<TriangleAlertIcon
|
||||
className="text-yellow-500"
|
||||
size={20}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{
|
||||
markers.filter((m) => m.severity === MarkerSeverity.Warning)
|
||||
.length
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<span className="truncate">
|
||||
{position
|
||||
? `${t("Row")} ${position.lineNumber}, ${t("Column")} ${position.column}`
|
||||
? `${t("Row")} ${position.lineNumber}, ${t("Column")} ${
|
||||
position.column
|
||||
}`
|
||||
: `${t("Row")} -, ${t("Column")} -`}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CodeContent,
|
||||
CodeContentSkeleton,
|
||||
} from "@/features/problems/code/components/content";
|
||||
import { CodeFooter } from "@/features/problems/code/components/footer";
|
||||
import { CodeToolbar } from "@/features/problems/code/components/toolbar/code-toolbar";
|
||||
|
||||
interface CodePanelProps {
|
||||
@@ -11,7 +12,7 @@ interface CodePanelProps {
|
||||
|
||||
export const CodePanel = ({ problemId }: CodePanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-3xl bg-background overflow-hidden">
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<CodeToolbar className="border-b" />
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
@@ -20,6 +21,7 @@ export const CodePanel = ({ problemId }: CodePanelProps) => {
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<CodeFooter />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { analyzeComplexity } from "@/app/actions/analyze";
|
||||
import { TooltipButton } from "@/components/tooltip-button";
|
||||
import { ChartBarIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { AnalyzeComplexityResponse } from "@/types/complexity";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import { useProblemEditorActions } from "@/features/problems/code/hooks/use-problem-editor-actions";
|
||||
|
||||
export const AnalyzeButton = () => {
|
||||
const t = useTranslations("WorkspaceEditorHeader.AnalyzeButton");
|
||||
const { value } = useProblemEditorStore();
|
||||
const { canExecute } = useProblemEditorActions();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [complexity, setComplexity] =
|
||||
useState<AnalyzeComplexityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const complexity = await analyzeComplexity(value);
|
||||
setComplexity(complexity);
|
||||
setOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Error analyzing complexity:", error);
|
||||
setComplexity(null);
|
||||
setError(t("Error"));
|
||||
setOpen(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipButton
|
||||
tooltipContent={t("TooltipContent")}
|
||||
onClick={handleClick}
|
||||
disabled={!canExecute || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
className="opacity-60 animate-spin"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ChartBarIcon size={16} strokeWidth={2} aria-hidden="true" />
|
||||
)}
|
||||
</TooltipButton>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center pb-1.5">
|
||||
{t("ComplexityAnalysis")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex items-center justify-center pt-2">
|
||||
{error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
<span className="h-full flex flex-col gap-2">
|
||||
<span>
|
||||
{t("TimeComplexity")} <strong>{complexity?.time}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("SpaceComplexity")} <strong>{complexity?.space}</strong>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
ResetButton,
|
||||
UndoButton,
|
||||
} from "@/features/problems/code/components/toolbar";
|
||||
import { AnalyzeButton } from "./actions/analyze-button";
|
||||
import { LspConnectionIndicator } from "./controls/lsp-connection-indicator";
|
||||
|
||||
interface CodeToolbarProps {
|
||||
className?: string;
|
||||
@@ -20,8 +22,10 @@ export const CodeToolbar = async ({ className }: CodeToolbarProps) => {
|
||||
<div className="absolute flex w-full items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSelector />
|
||||
<LspConnectionIndicator />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AnalyzeButton />
|
||||
<ResetButton />
|
||||
<UndoButton />
|
||||
<RedoButton />
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getIconForLanguage,
|
||||
getLabelForLanguage,
|
||||
LANGUAGES,
|
||||
} from "@/config/language";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -7,29 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Language } from "@/generated/client";
|
||||
import { LANGUAGES } from "@/config/language";
|
||||
import COriginal from "devicons-react/icons/COriginal";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import CplusplusOriginal from "devicons-react/icons/CplusplusOriginal";
|
||||
|
||||
const getIconForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return <COriginal size={16} aria-hidden="true" />;
|
||||
case Language.cpp:
|
||||
return <CplusplusOriginal size={16} aria-hidden="true" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelForLanguage = (language: Language) => {
|
||||
switch (language) {
|
||||
case Language.c:
|
||||
return "C";
|
||||
case Language.cpp:
|
||||
return "C++";
|
||||
}
|
||||
};
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { language, setLanguage } = useProblemEditorStore();
|
||||
@@ -40,14 +23,19 @@ export const LanguageSelector = () => {
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="[&_*[role=option]>span>svg]:shrink-0 [&_*[role=option]>span>svg]:text-muted-foreground/80 [&_*[role=option]>span]:end-2 [&_*[role=option]>span]:start-auto [&_*[role=option]>span]:flex [&_*[role=option]>span]:items-center [&_*[role=option]>span]:gap-2 [&_*[role=option]]:pe-8 [&_*[role=option]]:ps-2">
|
||||
{LANGUAGES.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{getIconForLanguage(language)}
|
||||
<span className="truncate text-sm font-semibold mr-2">
|
||||
{getLabelForLanguage(language)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
{LANGUAGES.map((language) => {
|
||||
const Icon = getIconForLanguage(language);
|
||||
const label = getLabelForLanguage(language);
|
||||
|
||||
return (
|
||||
<SelectItem key={language} value={language}>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span className="truncate text-sm font-semibold mr-2">
|
||||
{label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getColorClassForLspStatus } from "@/config/status";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
|
||||
export const LspConnectionIndicator = () => {
|
||||
const { lspWebSocket } = useProblemEditorStore();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-6 w-auto px-2 py-0 gap-1 border-none shadow-none hover:bg-muted"
|
||||
>
|
||||
<div className="h-3 w-3 flex items-center justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full transition-all",
|
||||
getColorClassForLspStatus(lspWebSocket)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span>LSP</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
+8
-8
@@ -1,33 +1,33 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Suspense } from "react";
|
||||
import { BackButton } from "@/components/back-button";
|
||||
import { JudgeButton } from "@/features/problems/components/judge-button";
|
||||
import { UserAvatar, UserAvatarSkeleton } from "@/components/user-avatar";
|
||||
import { JudgeCodeButton } from "@/features/problems/components/judge-code-button";
|
||||
import { NavigateBackButton } from "@/features/problems/components/navigate-back-button";
|
||||
import { ViewBotButton } from "@/features/problems/bot/components/view-bot-button";
|
||||
|
||||
interface ProblemHeaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProblemHeader = ({ className }: ProblemHeaderProps) => {
|
||||
export const ProblemHeader = ({ className }: ProblemHeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn("relative flex h-12 flex-none items-center", className)}
|
||||
>
|
||||
<div className="container mx-auto flex h-full items-center justify-between px-4">
|
||||
<div className="flex items-center">
|
||||
<NavigateBackButton href="/problemset" />
|
||||
<BackButton href="/problemset" />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<ViewBotButton />
|
||||
<Suspense fallback={<UserAvatarSkeleton />}>
|
||||
<UserAvatar />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-1/2 z-10 flex -translate-x-1/2 items-center">
|
||||
<JudgeCodeButton />
|
||||
<JudgeButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProblemHeader };
|
||||
@@ -3,20 +3,21 @@
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { Actions } from "flexlayout-react";
|
||||
import { judge } from "@/app/actions/judge";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LoaderCircleIcon, PlayIcon } from "lucide-react";
|
||||
import { TooltipButton } from "@/components/tooltip-button";
|
||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||
import { useProblemDockviewStore } from "@/stores/problem-dockview";
|
||||
import { JudgeToast } from "@/features/problems/components/judge-toast";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
|
||||
interface JudgeButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||
const { api } = useProblemDockviewStore();
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
const { problem, language, value } = useProblemEditorStore();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const t = useTranslations("PlaygroundHeader.RunCodeButton");
|
||||
@@ -28,10 +29,10 @@ export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||
const status = await judge(problem.problemId, language, value);
|
||||
toast.custom((t) => <JudgeToast t={t} status={status} />);
|
||||
|
||||
const panel = api?.getPanel("submission");
|
||||
if (panel && !panel.api.isActive) {
|
||||
panel.api.setActive();
|
||||
if (model) {
|
||||
model.doAction(Actions.selectTab("submission"));
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -44,9 +45,7 @@ export const JudgeButton = ({ className }: JudgeButtonProps) => {
|
||||
className
|
||||
)}
|
||||
onClick={handleJudge}
|
||||
disabled={
|
||||
isLoading || !problem?.problemId || !api?.getPanel("submission")
|
||||
}
|
||||
disabled={isLoading || !problem?.problemId || !model}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
|
||||
@@ -1,64 +1,16 @@
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BanIcon,
|
||||
CircleCheckIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Status } from "@/generated/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getColorClassForStatus, getIconForStatus } from "@/config/status";
|
||||
|
||||
interface JudgeToastProps {
|
||||
t: number | string;
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const getIconForStatus = (status: Status) => {
|
||||
switch (status) {
|
||||
case Status.PD:
|
||||
case Status.QD:
|
||||
case Status.CP:
|
||||
case Status.CE:
|
||||
case Status.RU:
|
||||
case Status.TLE:
|
||||
case Status.MLE:
|
||||
case Status.RE:
|
||||
case Status.WA:
|
||||
return AlertTriangleIcon;
|
||||
case Status.CS:
|
||||
case Status.AC:
|
||||
return CircleCheckIcon;
|
||||
case Status.SE:
|
||||
return BanIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const getColorClassForStatus = (status: Status) => {
|
||||
switch (status) {
|
||||
case Status.PD:
|
||||
case Status.QD:
|
||||
return "text-gray-500";
|
||||
case Status.CP:
|
||||
case Status.CE:
|
||||
return "text-yellow-500";
|
||||
case Status.CS:
|
||||
case Status.AC:
|
||||
return "text-green-500";
|
||||
case Status.RU:
|
||||
case Status.TLE:
|
||||
return "text-blue-500";
|
||||
case Status.MLE:
|
||||
return "text-purple-500";
|
||||
case Status.RE:
|
||||
return "text-orange-500";
|
||||
case Status.WA:
|
||||
case Status.SE:
|
||||
return "text-red-500";
|
||||
}
|
||||
};
|
||||
|
||||
export const JudgeToast = ({ t, status }: JudgeToastProps) => {
|
||||
const s = useTranslations("StatusMessage");
|
||||
const Icon = getIconForStatus(status);
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "next-intl";
|
||||
import type { AddPanelOptions } from "dockview";
|
||||
import { Dockview, type PanelParams } from "@/components/dockview";
|
||||
import { useProblemDockviewStore } from "@/stores/problem-dockview";
|
||||
|
||||
interface ProblemDockviewProps {
|
||||
components: Record<string, React.ReactNode>;
|
||||
tabComponents: Record<string, React.ReactNode>;
|
||||
panelOptions: AddPanelOptions<PanelParams>[];
|
||||
}
|
||||
|
||||
const ProblemDockview = ({
|
||||
components,
|
||||
tabComponents,
|
||||
panelOptions,
|
||||
}: ProblemDockviewProps) => {
|
||||
const locale = useLocale();
|
||||
const { setApi } = useProblemDockviewStore();
|
||||
|
||||
return (
|
||||
<Dockview
|
||||
key={locale}
|
||||
storageKey="dockview:problem"
|
||||
onApiReady={setApi}
|
||||
components={components}
|
||||
tabComponents={tabComponents}
|
||||
panelOptions={panelOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { ProblemDockview };
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CircleCheckBigIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
SquareCheckIcon,
|
||||
SquarePenIcon,
|
||||
} from "lucide-react";
|
||||
import "@/styles/flexlayout.css";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
import { ITabRenderValues, Layout, Model, TabNode } from "flexlayout-react";
|
||||
|
||||
interface ProblemFlexLayoutProps {
|
||||
components: Record<string, React.ReactNode>;
|
||||
}
|
||||
|
||||
export const ProblemFlexLayout = ({ components }: ProblemFlexLayoutProps) => {
|
||||
const t = useTranslations("ProblemPage");
|
||||
const { model, setModel, jsonModel, setJsonModel } =
|
||||
useProblemFlexLayoutStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
const model = Model.fromJson(jsonModel);
|
||||
setModel(model);
|
||||
}
|
||||
}, [jsonModel, model, setModel]);
|
||||
|
||||
const onModelChange = useCallback(
|
||||
(model: Model) => {
|
||||
const jsonModel = model.toJson();
|
||||
setJsonModel(jsonModel);
|
||||
},
|
||||
[setJsonModel]
|
||||
);
|
||||
|
||||
const factory = useCallback(
|
||||
(node: TabNode) => {
|
||||
const component = node.getComponent();
|
||||
return component ? components[component] : null;
|
||||
},
|
||||
[components]
|
||||
);
|
||||
|
||||
const onRenderTab = useCallback(
|
||||
(node: TabNode, renderValues: ITabRenderValues) => {
|
||||
const Icon = getIconForTab(node.getId());
|
||||
renderValues.leading = Icon ? (
|
||||
<Icon className="opacity-60" size={16} aria-hidden="true" />
|
||||
) : null;
|
||||
renderValues.content = (
|
||||
<span className="text-sm font-medium">
|
||||
{t(node.getName()) || node.getName()}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
model={model}
|
||||
factory={factory}
|
||||
onRenderTab={onRenderTab}
|
||||
onModelChange={onModelChange}
|
||||
realtimeResize={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getIconForTab = (id: string) => {
|
||||
switch (id) {
|
||||
case "description":
|
||||
return FileTextIcon;
|
||||
case "solution":
|
||||
return FlaskConicalIcon;
|
||||
case "submission":
|
||||
return CircleCheckBigIcon;
|
||||
case "detail":
|
||||
return CircleCheckBigIcon;
|
||||
case "code":
|
||||
return SquarePenIcon;
|
||||
case "testcase":
|
||||
return SquareCheckIcon;
|
||||
case "bot":
|
||||
return BotIcon;
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,7 @@ interface DescriptionPanelProps {
|
||||
|
||||
export const DescriptionPanel = ({ problemId }: DescriptionPanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-3xl bg-background overflow-hidden">
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<Suspense fallback={<DescriptionContentSkeleton />}>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { analyzeComplexity } from "@/app/actions/analyze";
|
||||
import { AnalyzeComplexityResponse } from "@/types/complexity";
|
||||
import { LoaderCircleIcon, WandSparklesIcon } from "lucide-react";
|
||||
|
||||
interface AnalyzeButtonProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const AnalyzeButton = ({ value }: AnalyzeButtonProps) => {
|
||||
const t = useTranslations("WorkspaceEditorHeader.AnalyzeButton");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [complexity, setComplexity] =
|
||||
useState<AnalyzeComplexityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const complexity = await analyzeComplexity(value);
|
||||
setComplexity(complexity);
|
||||
setOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Error analyzing complexity:", error);
|
||||
setComplexity(null);
|
||||
setError(t("Error"));
|
||||
setOpen(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" disabled={isLoading} onClick={handleClick}>
|
||||
{isLoading ? (
|
||||
<LoaderCircleIcon
|
||||
className="opacity-60 animate-spin"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<WandSparklesIcon size={16} aria-hidden="true" />
|
||||
)}
|
||||
<Label>{isLoading ? t("Analyzing") : t("ComplexityAnalysis")}</Label>
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center pb-1.5">
|
||||
{t("ComplexityAnalysis")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="flex items-center justify-center pt-2">
|
||||
{error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
<span className="h-full flex flex-col gap-2">
|
||||
<span>
|
||||
{t("TimeComplexity")} <strong>{complexity?.time}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t("SpaceComplexity")} <strong>{complexity?.space}</strong>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { DetailTable } from "@/features/problems/detail/components/table";
|
||||
|
||||
interface DetailContentProps {
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
export const DetailContent = ({ submissionId }: DetailContentProps) => {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<DetailTable submissionId={submissionId} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetailContentSkeleton = () => {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute h-full w-full p-4 md:p-6">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="mb-6 h-8 w-3/4" />
|
||||
|
||||
{/* Content skeletons */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
<Skeleton className="mb-4 h-4 w-2/3" />
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-4/5" />
|
||||
|
||||
{/* Example section heading */}
|
||||
<Skeleton className="mb-4 mt-8 h-6 w-1/4" />
|
||||
|
||||
{/* Example content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
|
||||
{/* Code block skeleton */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-40 w-full rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* More content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface DetailFormProps {
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
export const DetailForm = async ({ submissionId }: DetailFormProps) => {
|
||||
const t = await getTranslations("DetailsPage");
|
||||
|
||||
const submission = await prisma.submission.findUnique({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
});
|
||||
|
||||
const lastTestcaseResult = await prisma.testcaseResult.findFirst({
|
||||
where: { submissionId, isCorrect: false },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
if (!lastTestcaseResult) return null;
|
||||
|
||||
const testcase = await prisma.testcase.findUnique({
|
||||
where: { id: lastTestcaseResult.testcaseId },
|
||||
include: { inputs: true },
|
||||
});
|
||||
|
||||
if (!testcase) return null;
|
||||
|
||||
const sortedInputs = testcase.inputs?.sort((a, b) => a.index - b.index);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Accordion type="single" collapsible className="w-full -space-y-px">
|
||||
<AccordionItem
|
||||
value="input"
|
||||
className="bg-background has-focus-visible:border-ring has-focus-visible:ring-ring/50 relative border px-4 py-1 outline-none first:rounded-t-md last:rounded-b-md last:border-b has-focus-visible:z-10 has-focus-visible:ring-[3px]"
|
||||
>
|
||||
<AccordionTrigger className="py-2 text-[15px] leading-6 hover:no-underline focus-visible:ring-0">
|
||||
<Label>{t("Input")}</Label>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground pb-2">
|
||||
<div className="space-y-4">
|
||||
{sortedInputs.map((input) => (
|
||||
<div key={input.id} className="space-y-2">
|
||||
<Label>{input.name} =</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter ${input.name}`}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10"
|
||||
value={input.value}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
{lastTestcaseResult.output && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label>输出</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter output`}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10"
|
||||
value={lastTestcaseResult.output}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{submission?.status === "WA" && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label>预期结果</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter expected output`}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10"
|
||||
value={testcase.expectedOutput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Actions } from "flexlayout-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export const DetailHeader = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const t = useTranslations("DetailsPage");
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
|
||||
const handleClick = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("submissionId");
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
if (!model) return;
|
||||
model.doAction(Actions.selectTab("submission"));
|
||||
const detailTab = model.getNodeById("detail");
|
||||
if (detailTab) {
|
||||
model.doAction(Actions.deleteTab("detail"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-8 flex flex-none items-center px-2 py-1 border-b">
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
variant="ghost"
|
||||
className="h-8 w-auto p-2 hover:bg-transparent text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon size={16} aria-hidden="true" />
|
||||
<span>{t("BackButton")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
DetailContent,
|
||||
DetailContentSkeleton,
|
||||
} from "@/features/problems/detail/components/content";
|
||||
import { DetailHeader } from "@/features/problems/detail/components/header";
|
||||
|
||||
interface DetailPanelProps {
|
||||
submissionId: string | undefined;
|
||||
}
|
||||
|
||||
export const DetailPanel = ({ submissionId }: DetailPanelProps) => {
|
||||
if (!submissionId) {
|
||||
return <DetailContentSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<DetailHeader />
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<Suspense fallback={<DetailContentSkeleton />}>
|
||||
<DetailContent submissionId={submissionId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Locale } from "@/generated/client";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { formatSubmissionDate } from "@/config/locale";
|
||||
import { getLabelForLanguage } from "@/config/language";
|
||||
import { getColorClassForStatus } from "@/config/status";
|
||||
import { PreDetail } from "@/components/content/pre-detail";
|
||||
import { ViewSolutionButton } from "./view-solution-button";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { MdxRenderer } from "@/components/content/mdx-renderer";
|
||||
import { MdxComponents } from "@/components/content/mdx-components";
|
||||
import { DetailForm } from "@/features/problems/detail/components/form";
|
||||
import { AnalyzeButton } from "@/features/problems/detail/components/analyze-button";
|
||||
|
||||
interface DetailTableProps {
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
export const DetailTable = async ({ submissionId }: DetailTableProps) => {
|
||||
const t = await getTranslations("DetailsPage");
|
||||
const s = await getTranslations("StatusMessage");
|
||||
const locale = (await getLocale()) as Locale;
|
||||
const submission = await prisma.submission.findUnique({
|
||||
where: {
|
||||
id: submissionId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!submission)
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
No Submission
|
||||
</div>
|
||||
);
|
||||
|
||||
const createdAt = new Date(submission.createdAt);
|
||||
const submittedDisplay = formatSubmissionDate(createdAt, locale);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[700px] gap-4 px-4 py-3">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-1 flex-col items-start gap-1 overflow-hidden">
|
||||
<h3
|
||||
className={cn(
|
||||
"flex items-center text-xl font-semibold",
|
||||
getColorClassForStatus(submission.status)
|
||||
)}
|
||||
>
|
||||
<span>{s(submission.status)}</span>
|
||||
</h3>
|
||||
<div className="flex max-w-full flex-1 items-center gap-1 overflow-hidden text-xs">
|
||||
<span className="whitespace-nowrap mr-1">{t("Time")}</span>
|
||||
<span className="max-w-full truncate">{submittedDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ViewSolutionButton />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{submission.message && (
|
||||
<MdxRenderer
|
||||
source={`\`\`\`shell\n${submission.message}\n\`\`\``}
|
||||
/>
|
||||
)}
|
||||
<DetailForm submissionId={submissionId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>{t("Code")}</Label>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-4 bg-muted-foreground"
|
||||
/>
|
||||
<Label>{getLabelForLanguage(submission.language)}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AnalyzeButton value={submission.content} />
|
||||
</div>
|
||||
</div>
|
||||
<MdxRenderer
|
||||
source={`\`\`\`${submission.language}\n${submission.content}\n\`\`\``}
|
||||
components={{
|
||||
...MdxComponents,
|
||||
pre: PreDetail,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Actions } from "flexlayout-react";
|
||||
import { BookOpenIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
|
||||
export const ViewSolutionButton = () => {
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
|
||||
const handleClick = () => {
|
||||
if (!model) return;
|
||||
model.doAction(Actions.selectTab("solution"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick}>
|
||||
<BookOpenIcon />
|
||||
查看题解
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ interface SolutionPanelProps {
|
||||
|
||||
export const SolutionPanel = ({ problemId }: SolutionPanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-3xl bg-background overflow-hidden">
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<Suspense fallback={<SolutionContentSkeleton />}>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { auth, signIn } from "@/lib/auth";
|
||||
import { CodeXmlIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { SubmissionTable } from "@/features/problems/submission/components/table";
|
||||
|
||||
interface SubmissionContentProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
const LoginPromptCard = () => {
|
||||
const t = useTranslations("LoginPromptCard");
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-start gap-2 pt-[10vh]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CodeXmlIcon
|
||||
className="shrink-0 text-blue-500"
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-base font-medium">{t("title")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-muted hover:bg-muted/80"
|
||||
onClick={async () => {
|
||||
"use server";
|
||||
await signIn();
|
||||
}}
|
||||
>
|
||||
{t("loginButton")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubmissionContent = async ({
|
||||
problemId,
|
||||
}: SubmissionContentProps) => {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-3">
|
||||
{userId ? <SubmissionTable problemId={problemId} /> : <LoginPromptCard />}
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubmissionContentSkeleton = () => {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute h-full w-full p-4 md:p-6">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="mb-6 h-8 w-3/4" />
|
||||
|
||||
{/* Content skeletons */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
<Skeleton className="mb-4 h-4 w-2/3" />
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-4/5" />
|
||||
|
||||
{/* Example section heading */}
|
||||
<Skeleton className="mb-4 mt-8 h-6 w-1/4" />
|
||||
|
||||
{/* Example content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
|
||||
{/* Code block skeleton */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-40 w-full rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* More content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
SubmissionContent,
|
||||
SubmissionContentSkeleton,
|
||||
} from "@/features/problems/submission/components/content";
|
||||
|
||||
interface SubmissionPanelProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const SubmissionPanel = ({ problemId }: SubmissionPanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<Suspense fallback={<SubmissionContentSkeleton />}>
|
||||
<SubmissionContent problemId={problemId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Clock4Icon, CpuIcon } from "lucide-react";
|
||||
import { formatSubmissionDate } from "@/config/locale";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Locale, Submission } from "@/generated/client";
|
||||
import { Actions, DockLocation } from "flexlayout-react";
|
||||
import { getColorClassForStatus } from "@/config/status";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { getIconForLanguage, getLabelForLanguage } from "@/config/language";
|
||||
|
||||
const formatUsage = (value: number | null, unit: "ms" | "MB") => {
|
||||
if (value === null) return "N/A";
|
||||
return unit === "MB"
|
||||
? `${(value / 1024 / 1024).toFixed(2)} MB`
|
||||
: `${value} ms`;
|
||||
};
|
||||
|
||||
interface SubmissionTableRowProps {
|
||||
submission: Submission;
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const SubmissionTableRow = ({
|
||||
submission,
|
||||
index,
|
||||
total,
|
||||
}: SubmissionTableRowProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const locale = useLocale() as Locale;
|
||||
const s = useTranslations("StatusMessage");
|
||||
const { model } = useProblemFlexLayoutStore();
|
||||
const createdAt = new Date(submission.createdAt);
|
||||
const Icon = getIconForLanguage(submission.language);
|
||||
const label = getLabelForLanguage(submission.language);
|
||||
const colorClass = getColorClassForStatus(submission.status);
|
||||
const submittedDisplay = formatSubmissionDate(createdAt, locale);
|
||||
|
||||
const handleClick = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("submissionId", submission.id.toString());
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
if (!model) return;
|
||||
const detailTab = model.getNodeById("detail");
|
||||
if (detailTab) {
|
||||
model.doAction(Actions.selectTab("detail"));
|
||||
} else {
|
||||
model.doAction(
|
||||
Actions.addNode(
|
||||
{
|
||||
type: "tab",
|
||||
id: "detail",
|
||||
name: "Details",
|
||||
component: "detail",
|
||||
},
|
||||
"1",
|
||||
DockLocation.CENTER,
|
||||
-1
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className="border-b-0 hover:text-blue-500 hover:bg-muted hover:cursor-pointer whitespace-nowrap odd:bg-muted/50"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<TableCell className="font-medium">{total - index}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={colorClass}>{s(submission.status)}</span>
|
||||
<span className="text-xs">{submittedDisplay}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span className="truncate text-sm font-semibold mr-2">{label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock4Icon size={16} aria-hidden="true" />
|
||||
<span>{formatUsage(submission.timeUsage, "ms")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CpuIcon size={16} aria-hidden="true" />
|
||||
<span>{formatUsage(submission.memoryUsage, "MB")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { SubmissionTableRow } from "@/features/problems/submission/components/row";
|
||||
|
||||
interface SubmissionTableProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const SubmissionTable = async ({ problemId }: SubmissionTableProps) => {
|
||||
const t = await getTranslations("SubmissionsTable");
|
||||
|
||||
const submissions = await prisma.submission.findMany({
|
||||
where: { problemId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="bg-transparent">
|
||||
<TableRow className="hover:bg-transparent whitespace-nowrap">
|
||||
<TableHead className="w-[100px]">{t("Index")}</TableHead>
|
||||
<TableHead className="w-[170px]">{t("Status")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Language")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Time")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("Memory")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<tbody aria-hidden="true" className="table-row h-2" />
|
||||
|
||||
<TableBody className="[&_td:first-child]:rounded-l-lg [&_td:last-child]:rounded-r-lg">
|
||||
{submissions.map((submission, index) => (
|
||||
<SubmissionTableRow
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
index={index}
|
||||
total={submissions.length}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TestcaseTable } from "@/features/problems/testcase/table";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
|
||||
interface TestcaseContentProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const TestcaseContent = ({ problemId }: TestcaseContentProps) => {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<TestcaseTable problemId={problemId} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export const TestcaseContentSkeleton = () => {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute h-full w-full p-4 md:p-6">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="mb-6 h-8 w-3/4" />
|
||||
|
||||
{/* Content skeletons */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
<Skeleton className="mb-4 h-4 w-2/3" />
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-4/5" />
|
||||
|
||||
{/* Example section heading */}
|
||||
<Skeleton className="mb-4 mt-8 h-6 w-1/4" />
|
||||
|
||||
{/* Example content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-5/6" />
|
||||
|
||||
{/* Code block skeleton */}
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-40 w-full rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* More content */}
|
||||
<Skeleton className="mb-4 h-4 w-full" />
|
||||
<Skeleton className="mb-4 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
TestcaseContent,
|
||||
TestcaseContentSkeleton,
|
||||
} from "@/features/problems/testcase/content";
|
||||
|
||||
interface TestcasePanelProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const TestcasePanel = ({ problemId }: TestcasePanelProps) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute h-full w-full">
|
||||
<Suspense fallback={<TestcaseContentSkeleton />}>
|
||||
<TestcaseContent problemId={problemId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface TestcaseTableProps {
|
||||
problemId: string;
|
||||
}
|
||||
|
||||
export const TestcaseTable = async ({ problemId }: TestcaseTableProps) => {
|
||||
const t = await getTranslations("Testcase.Table");
|
||||
const testcases = await prisma.testcase.findMany({
|
||||
where: { problemId },
|
||||
include: { inputs: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
if (testcases.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
No testcases found for this problem.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={testcases[0].id} className="items-center px-5 py-4">
|
||||
<TabsList className="bg-transparent p-0">
|
||||
{testcases.map((testcase, index) => (
|
||||
<TabsTrigger
|
||||
key={testcase.id}
|
||||
value={testcase.id}
|
||||
className="data-[state=active]:bg-muted data-[state=active]:shadow-none"
|
||||
>
|
||||
{t("Case")} {index + 1}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{testcases.map((testcase) => (
|
||||
<TabsContent key={testcase.id} value={testcase.id}>
|
||||
<div className="space-y-4">
|
||||
{testcase.inputs
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((input) => (
|
||||
<div key={input.id} className="space-y-2">
|
||||
<Label>{input.name} =</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter ${input.name}`}
|
||||
readOnly
|
||||
className="bg-muted border-transparent shadow-none rounded-lg h-10"
|
||||
value={input.value}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { CircleCheckBigIcon, CircleDotIcon } from "lucide-react";
|
||||
import { getColorClassForDifficulty } from "@/config/difficulty";
|
||||
import type { Locale, ProblemLocalization } from "@/generated/client";
|
||||
|
||||
const getLocalizedTitle = (
|
||||
localizations: ProblemLocalization[],
|
||||
locale: Locale
|
||||
) => {
|
||||
if (!localizations || localizations.length === 0) {
|
||||
return "Unknown Title";
|
||||
}
|
||||
|
||||
const localization = localizations.find(
|
||||
(localization) => localization.locale === locale
|
||||
);
|
||||
|
||||
return localization?.content ?? localizations[0].content ?? "Unknown Title";
|
||||
};
|
||||
|
||||
export const ProblemsetTable = async () => {
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslations();
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
const problems = await prisma.problem.findMany({
|
||||
include: {
|
||||
localizations: {
|
||||
where: {
|
||||
type: "TITLE",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const completedProblems = new Set<string>();
|
||||
const attemptedProblems = new Set<string>();
|
||||
|
||||
if (userId) {
|
||||
const submissions = await prisma.submission.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
problemId: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
submissions.forEach((submission) => {
|
||||
if (submission.status === "AC") {
|
||||
completedProblems.add(submission.problemId);
|
||||
} else {
|
||||
attemptedProblems.add(submission.problemId);
|
||||
}
|
||||
});
|
||||
|
||||
completedProblems.forEach((problemId) => {
|
||||
attemptedProblems.delete(problemId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-1/3">{t("ProblemsetPage.Status")}</TableHead>
|
||||
<TableHead className="w-1/3">{t("ProblemsetPage.Title")}</TableHead>
|
||||
<TableHead className="w-1/3">
|
||||
{t("ProblemsetPage.Difficulty")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_td:first-child]:rounded-l-lg [&_td:last-child]:rounded-r-lg">
|
||||
{problems.map((problem, index) => (
|
||||
<TableRow
|
||||
key={problem.id}
|
||||
className="h-10 border-b-0 odd:bg-muted/50 hover:text-blue-500 hover:bg-muted"
|
||||
>
|
||||
<TableCell className="py-2.5">
|
||||
{userId &&
|
||||
(completedProblems.has(problem.id) ? (
|
||||
<CircleCheckBigIcon
|
||||
className="text-green-500"
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : attemptedProblems.has(problem.id) ? (
|
||||
<CircleDotIcon
|
||||
className="text-yellow-500"
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2.5">
|
||||
<Link
|
||||
href={`/problems/${problem.id}`}
|
||||
className="hover:text-blue-500"
|
||||
>
|
||||
{index + 1}.{" "}
|
||||
{getLocalizedTitle(problem.localizations, locale as Locale)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`py-2.5 ${getColorClassForDifficulty(
|
||||
problem.difficulty
|
||||
)}`}
|
||||
>
|
||||
{t(`Difficulty.${problem.difficulty}`)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProblemsetTableSkeleton = ({
|
||||
skeletonRows = 12,
|
||||
}: {
|
||||
skeletonRows?: number;
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader className="bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-1/3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableHead>
|
||||
<TableHead className="w-1/3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="[&_td:first-child]:rounded-l-lg [&_td:last-child]:rounded-r-lg">
|
||||
{Array.from({ length: skeletonRows }).map((_, index) => (
|
||||
<TableRow key={index} className="h-10 border-b-0 odd:bg-muted/50">
|
||||
<TableCell className="py-2.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="py-2.5">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getPath } from "@/lib/utils";
|
||||
import { EditorLanguage } from "@/generated/client";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useMonacoTheme } from "@/hooks/use-monaco-theme";
|
||||
import { useProblemStore } from "@/providers/problem-store-provider";
|
||||
|
||||
/**
|
||||
* Generates a localStorage key for storing the editor language of a problem.
|
||||
* Format: "lang_{problemId}"
|
||||
*/
|
||||
const getProblemLangStorageKey = (problemId: string) => `lang_${problemId}`;
|
||||
|
||||
/**
|
||||
* Generates a localStorage key for storing the editor content of a problem for a specific language.
|
||||
* Format: "{language}_{problemId}"
|
||||
*/
|
||||
const getProblemValueStorageKey = (problemId: string, language: EditorLanguage) =>
|
||||
`${language}_${problemId}`;
|
||||
|
||||
/**
|
||||
* Retrieves the stored editor language for a specific problem.
|
||||
* Falls back to the global language if no stored value is found.
|
||||
*/
|
||||
const getStoredProblemLang = (problemId: string, globalLang: EditorLanguage) =>
|
||||
(localStorage.getItem(getProblemLangStorageKey(problemId)) as EditorLanguage) ?? globalLang;
|
||||
|
||||
/**
|
||||
* Retrieves the stored editor content for a specific problem and language.
|
||||
* Falls back to the default template if no stored value is found.
|
||||
*/
|
||||
const getStoredProblemValue = (
|
||||
problemId: string,
|
||||
defaultValue: string,
|
||||
language: EditorLanguage
|
||||
) => localStorage.getItem(getProblemValueStorageKey(problemId, language)) ?? defaultValue;
|
||||
|
||||
export const useProblem = () => {
|
||||
const { currentTheme } = useMonacoTheme();
|
||||
const {
|
||||
hydrated,
|
||||
editor,
|
||||
markers,
|
||||
webSocket,
|
||||
globalLang,
|
||||
currentLang,
|
||||
currentValue,
|
||||
setEditor,
|
||||
setMarkers,
|
||||
setWebSocket,
|
||||
setGlobalLang,
|
||||
setCurrentLang,
|
||||
setCurrentValue,
|
||||
problemId,
|
||||
problem,
|
||||
editorLanguageConfigs,
|
||||
languageServerConfigs,
|
||||
} = useProblemStore((state) => state);
|
||||
|
||||
// Get the default template for the current language from the templates list
|
||||
const currentTemplate = useMemo(
|
||||
() => problem.templates.find((t) => t.language === currentLang)?.template || "",
|
||||
[problem.templates, currentLang]
|
||||
);
|
||||
|
||||
const currentEditorLanguageConfig = useMemo(
|
||||
() => editorLanguageConfigs.find((c) => c.language === currentLang),
|
||||
[editorLanguageConfigs, currentLang]
|
||||
);
|
||||
|
||||
const currentLanguageServerConfig = useMemo(
|
||||
() => languageServerConfigs.find((c) => c.language === currentLang),
|
||||
[languageServerConfigs, currentLang]
|
||||
);
|
||||
|
||||
const currentPath = useMemo(
|
||||
() => (currentEditorLanguageConfig ? getPath(problemId, currentEditorLanguageConfig) : ""),
|
||||
[problemId, currentEditorLanguageConfig]
|
||||
);
|
||||
|
||||
// On initialization, load the stored language and corresponding code content
|
||||
useEffect(() => {
|
||||
const storedLang = getStoredProblemLang(problemId, globalLang);
|
||||
setCurrentLang(storedLang);
|
||||
|
||||
const storedValue = getStoredProblemValue(problemId, currentTemplate, storedLang);
|
||||
setCurrentValue(storedValue);
|
||||
}, [problemId, globalLang, currentTemplate, setCurrentLang, setCurrentValue]);
|
||||
|
||||
// Change the language and update localStorage and state accordingly
|
||||
const changeLang = useCallback(
|
||||
(newLang: EditorLanguage) => {
|
||||
if (!problemId || newLang === currentLang) return;
|
||||
|
||||
// Update the stored language in localStorage
|
||||
localStorage.setItem(getProblemLangStorageKey(problemId), newLang);
|
||||
setCurrentLang(newLang);
|
||||
setGlobalLang(newLang);
|
||||
},
|
||||
[problemId, currentLang, setCurrentLang, setGlobalLang]
|
||||
);
|
||||
|
||||
// Update the stored code content when the editor value changes, specific to the current language
|
||||
const changeValue = useCallback(
|
||||
(newValue: string) => {
|
||||
if (!problemId || newValue === currentValue) return;
|
||||
|
||||
localStorage.setItem(getProblemValueStorageKey(problemId, currentLang), newValue);
|
||||
setCurrentValue(newValue);
|
||||
},
|
||||
[problemId, currentValue, currentLang, setCurrentValue]
|
||||
);
|
||||
|
||||
return {
|
||||
hydrated,
|
||||
editor,
|
||||
markers,
|
||||
webSocket,
|
||||
setEditor,
|
||||
setMarkers,
|
||||
setWebSocket,
|
||||
globalLang,
|
||||
currentLang,
|
||||
currentValue,
|
||||
problemId,
|
||||
problem,
|
||||
editorLanguageConfigs,
|
||||
languageServerConfigs,
|
||||
currentTemplate,
|
||||
currentEditorLanguageConfig,
|
||||
currentLanguageServerConfig,
|
||||
currentPath,
|
||||
currentTheme,
|
||||
changeLang,
|
||||
changeValue,
|
||||
};
|
||||
};
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createDeepSeek } from "@ai-sdk/deepseek";
|
||||
|
||||
export const openai = createOpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
baseURL: process.env.OPENAI_BASE_URL || "",
|
||||
export const deepseek = createDeepSeek({
|
||||
apiKey: process.env.DEEPSEEK_API_KEY ?? "",
|
||||
});
|
||||
|
||||
+102
-19
@@ -1,29 +1,93 @@
|
||||
import "server-only";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { ZodError } from "zod";
|
||||
import NextAuth from "next-auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { authSchema } from "@/lib/zod";
|
||||
import { encode } from "next-auth/jwt";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
import Google from "next-auth/providers/google";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
const log = logger.child({ module: "auth" });
|
||||
|
||||
const adapter = PrismaAdapter(prisma);
|
||||
|
||||
// Constant for session expiry time (30 days)
|
||||
const SESSION_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
|
||||
|
||||
// Helper function to create a session and return the session token
|
||||
const createSession = async (userId: string) => {
|
||||
const sessionToken = uuid();
|
||||
const createdSession = await adapter?.createSession?.({
|
||||
sessionToken,
|
||||
userId,
|
||||
expires: new Date(Date.now() + SESSION_EXPIRY_MS),
|
||||
});
|
||||
|
||||
if (!createdSession) {
|
||||
throw new Error("Failed to create session");
|
||||
}
|
||||
|
||||
return sessionToken;
|
||||
};
|
||||
|
||||
const providers: Provider[] = [
|
||||
GitHub({ allowDangerousEmailAccountLinking: true }),
|
||||
Google({ allowDangerousEmailAccountLinking: true }),
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: {},
|
||||
password: {},
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
try {
|
||||
// Parse credentials using authSchema for validation
|
||||
const { email, password } = await authSchema.parseAsync(credentials);
|
||||
|
||||
// Find user by email
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
// Check if the user exists and validate password
|
||||
if (
|
||||
!user ||
|
||||
!user.password ||
|
||||
!(await bcrypt.compare(password, user.password))
|
||||
) {
|
||||
throw new Error("Invalid credentials.");
|
||||
}
|
||||
|
||||
// Return the user object if credentials are valid
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
// Return null if validation fails
|
||||
return null;
|
||||
}
|
||||
console.error(error); // Log other errors for debugging
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export const providerMap = providers.map((provider) => {
|
||||
if (typeof provider === "function") {
|
||||
const providerData = provider();
|
||||
return { id: providerData.id, name: providerData.name };
|
||||
}
|
||||
return { id: provider.id, name: provider.name };
|
||||
});
|
||||
export const providerMap = providers
|
||||
.map((provider) => {
|
||||
if (typeof provider === "function") {
|
||||
const providerData = provider();
|
||||
return { id: providerData.id, name: providerData.name };
|
||||
} else {
|
||||
return { id: provider.id, name: provider.name };
|
||||
}
|
||||
})
|
||||
.filter((provider) => provider.id !== "credentials");
|
||||
|
||||
// NextAuth configuration
|
||||
export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
@@ -32,6 +96,25 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
pages: {
|
||||
signIn: "/sign-in",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account }) {
|
||||
if (account?.provider === "credentials") {
|
||||
token.credentials = true; // Add flag to token for credentials provider
|
||||
}
|
||||
return token;
|
||||
},
|
||||
},
|
||||
jwt: {
|
||||
encode: async function (params) {
|
||||
if (params.token?.credentials) {
|
||||
if (!params.token?.sub) {
|
||||
throw new Error("No user ID found in token");
|
||||
}
|
||||
return await createSession(params.token.sub); // Create session for the user and return session token
|
||||
}
|
||||
return encode(params); // Default encoding for JWT
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async createUser({ user }) {
|
||||
const startTime = Date.now();
|
||||
@@ -49,18 +132,18 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
});
|
||||
log.info(
|
||||
{ userId: user.id, durationMs: Date.now() - startTime },
|
||||
"User created and assigned ADMIN role",
|
||||
"User created and assigned ADMIN role"
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
{ userId: user.id, durationMs: Date.now() - startTime },
|
||||
"User created successfully",
|
||||
"User created successfully"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ userId: user.id, durationMs: Date.now() - startTime, error },
|
||||
"Failed to create user or assign role",
|
||||
"Failed to create user or assign role"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -70,7 +153,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
const startTime = Date.now();
|
||||
log.debug(
|
||||
{ userId: user.id, provider: account.provider },
|
||||
"Linking new provider account to existing user",
|
||||
"Linking new provider account to existing user"
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -81,7 +164,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
email: profile.email,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
"Successfully linked provider account",
|
||||
"Successfully linked provider account"
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
@@ -91,7 +174,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
durationMs: Date.now() - startTime,
|
||||
error,
|
||||
},
|
||||
"Failed to link provider account",
|
||||
"Failed to link provider account"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -101,7 +184,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
const startTime = Date.now();
|
||||
log.debug(
|
||||
{ userId: user.id, provider: account?.provider },
|
||||
"User attempting to sign in",
|
||||
"User attempting to sign in"
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -113,7 +196,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
isNewUser,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
"User signed in successfully",
|
||||
"User signed in successfully"
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
@@ -123,7 +206,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
durationMs: Date.now() - startTime,
|
||||
error,
|
||||
},
|
||||
"Failed to sign in",
|
||||
"Failed to sign in"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
@@ -140,7 +223,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
userId: message.session?.userId,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
"User signed out successfully (database session)",
|
||||
"User signed out successfully (database session)"
|
||||
);
|
||||
} else if ("token" in message) {
|
||||
log.info(
|
||||
@@ -148,7 +231,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
token: message.token,
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
"User signed out successfully (JWT session)",
|
||||
"User signed out successfully (JWT session)"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -157,7 +240,7 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
durationMs: Date.now() - startTime,
|
||||
error,
|
||||
},
|
||||
"Failed to sign out",
|
||||
"Failed to sign out"
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user