mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-04 07:40:51 +00:00
feat(admin): Implement admin problem editing and protected routing
This commit is contained in:
parent
845a26239a
commit
7d4e4ae2e4
@ -1,121 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ProblemFlexLayout } from '@/features/problems/components/problem-flexlayout';
|
|
||||||
import EditDescriptionPanel from '@/components/creater/edit-description-panel';
|
|
||||||
import EditSolutionPanel from '@/components/creater/edit-solution-panel';
|
|
||||||
import EditTestcasePanel from '@/components/creater/edit-testcase-panel';
|
|
||||||
import EditDetailPanel from '@/components/creater/edit-detail-panel';
|
|
||||||
import EditCodePanel from '@/components/creater/edit-code-panel';
|
|
||||||
import { updateProblem } from '@/app/actions/updateProblem';
|
|
||||||
|
|
||||||
interface ProblemEditorPageProps {
|
|
||||||
params: Promise<{ problemId: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateData {
|
|
||||||
content: string;
|
|
||||||
language?: 'c' | 'cpp';
|
|
||||||
inputs?: Array<{ index: number; name: string; value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = async (
|
|
||||||
updateFn: (data: UpdateData) => Promise<{ success: boolean }>,
|
|
||||||
data: UpdateData
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const result = await updateFn(data);
|
|
||||||
if (!result.success) {
|
|
||||||
// 这里可以添加更具体的错误处理
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('更新失败:', error);
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProblemEditorPage({
|
|
||||||
params,
|
|
||||||
}: ProblemEditorPageProps) {
|
|
||||||
const { problemId } = await params;
|
|
||||||
|
|
||||||
const components: Record<string, React.ReactNode> = {
|
|
||||||
description: <EditDescriptionPanel
|
|
||||||
problemId={problemId}
|
|
||||||
onUpdate={async (data) => {
|
|
||||||
await handleUpdate(
|
|
||||||
(descriptionData) => updateProblem({
|
|
||||||
problemId,
|
|
||||||
displayId: 0,
|
|
||||||
description: descriptionData.content
|
|
||||||
}),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
solution: <EditSolutionPanel
|
|
||||||
problemId={problemId}
|
|
||||||
onUpdate={async (data) => {
|
|
||||||
await handleUpdate(
|
|
||||||
(solutionData) => updateProblem({
|
|
||||||
problemId,
|
|
||||||
displayId: 0,
|
|
||||||
solution: solutionData.content
|
|
||||||
}),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
detail: <EditDetailPanel
|
|
||||||
problemId={problemId}
|
|
||||||
onUpdate={async (data) => {
|
|
||||||
await handleUpdate(
|
|
||||||
(detailData) => updateProblem({
|
|
||||||
problemId,
|
|
||||||
displayId: 0,
|
|
||||||
detail: detailData.content
|
|
||||||
}),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
code: <EditCodePanel
|
|
||||||
problemId={problemId}
|
|
||||||
onUpdate={async (data) => {
|
|
||||||
await handleUpdate(
|
|
||||||
(codeData) => updateProblem({
|
|
||||||
problemId,
|
|
||||||
displayId: 0,
|
|
||||||
templates: [{
|
|
||||||
language: codeData.language || 'c', // 添加默认值
|
|
||||||
content: codeData.content
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
testcase: <EditTestcasePanel
|
|
||||||
problemId={problemId}
|
|
||||||
onUpdate={async (data) => {
|
|
||||||
await handleUpdate(
|
|
||||||
(testcaseData) => updateProblem({
|
|
||||||
problemId,
|
|
||||||
displayId: 0,
|
|
||||||
testcases: [{
|
|
||||||
expectedOutput: testcaseData.content,
|
|
||||||
inputs: testcaseData.inputs || [] // 添加默认空数组
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex h-full w-full">
|
|
||||||
<ProblemFlexLayout components={components} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,15 +1,12 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ProblemHeader } from "@/features/problems/components/header";
|
import { ProblemHeader } from "@/features/problems/components/header";
|
||||||
|
|
||||||
interface ProblemLayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ problemId: string }>;
|
params: Promise<{ problemId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProblemLayout({
|
const Layout = async ({ children, params }: LayoutProps) => {
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: ProblemLayoutProps) {
|
|
||||||
const { problemId } = await params;
|
const { problemId } = await params;
|
||||||
|
|
||||||
if (!problemId) {
|
if (!problemId) {
|
||||||
@ -24,4 +21,6 @@ export default async function ProblemLayout({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
|
@ -1,39 +1,17 @@
|
|||||||
import { TestcasePanel } from "@/features/problems/testcase/panel";
|
import { ProblemView } from "@/features/problems/ui/views/problem-view";
|
||||||
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 {
|
interface PageProps {
|
||||||
params: Promise<{ problemId: string }>;
|
params: Promise<{ problemId: string }>;
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
submissionId: string | undefined;
|
submissionId: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProblemPage({
|
const Page = async ({ params, searchParams }: PageProps) => {
|
||||||
params,
|
|
||||||
searchParams,
|
|
||||||
}: ProblemPageProps) {
|
|
||||||
const { problemId } = await params;
|
const { problemId } = await params;
|
||||||
const { submissionId } = await searchParams;
|
const { submissionId } = await searchParams;
|
||||||
|
|
||||||
const components: Record<string, React.ReactNode> = {
|
return <ProblemView problemId={problemId} submissionId={submissionId} />;
|
||||||
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 (
|
export default Page;
|
||||||
<div className="relative flex h-full w-full">
|
|
||||||
<ProblemFlexLayout components={components} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ProblemEditLayout } from "@/features/admin/ui/layouts/problem-edit-layout";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ problemId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = async ({ children, params }: LayoutProps) => {
|
||||||
|
const { problemId } = await params;
|
||||||
|
|
||||||
|
if (!problemId) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProblemEditLayout>{children}</ProblemEditLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
13
src/app/(protected)/admin/problems/[problemId]/edit/page.tsx
Normal file
13
src/app/(protected)/admin/problems/[problemId]/edit/page.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ problemId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async ({ params }: PageProps) => {
|
||||||
|
const { problemId } = await params;
|
||||||
|
|
||||||
|
return <ProblemEditView problemId={problemId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
11
src/app/(protected)/layout.tsx
Normal file
11
src/app/(protected)/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { AdminProtectedLayout } from "@/features/admin/ui/layouts/admin-protected-layout";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = ({ children }: LayoutProps) => {
|
||||||
|
return <AdminProtectedLayout>{children}</AdminProtectedLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Actions } from "flexlayout-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ReactNode, useRef, useState } from "react";
|
import { ReactNode, useRef, useState } from "react";
|
||||||
import { CheckIcon, CopyIcon, RepeatIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, RepeatIcon } from "lucide-react";
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
|
||||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
import { Actions } from "flexlayout-react";
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
interface PreDetailProps {
|
interface PreDetailProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
26
src/features/admin/ui/components/problem-edit-flexlayout.tsx
Normal file
26
src/features/admin/ui/components/problem-edit-flexlayout.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useProblemEditFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
|
import { FlexLayout } from "@/features/problems/components/flexlayout";
|
||||||
|
|
||||||
|
interface ProblemEditFlexLayoutProps {
|
||||||
|
components: Record<string, React.ReactNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProblemEditFlexLayout = ({
|
||||||
|
components,
|
||||||
|
}: ProblemEditFlexLayoutProps) => {
|
||||||
|
const { hasHydrated, model, jsonModel, setModel, setJsonModel } =
|
||||||
|
useProblemEditFlexLayoutStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlexLayout
|
||||||
|
components={components}
|
||||||
|
hasHydrated={hasHydrated}
|
||||||
|
model={model}
|
||||||
|
jsonModel={jsonModel}
|
||||||
|
setModel={setModel}
|
||||||
|
setJsonModel={setJsonModel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
30
src/features/admin/ui/layouts/admin-protected-layout.tsx
Normal file
30
src/features/admin/ui/layouts/admin-protected-layout.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth, signIn } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
interface AdminProtectedLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminProtectedLayout = async ({
|
||||||
|
children,
|
||||||
|
}: AdminProtectedLayoutProps) => {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
await signIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.role !== "ADMIN") redirect("/unauthorized");
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
29
src/features/admin/ui/layouts/problem-edit-layout.tsx
Normal file
29
src/features/admin/ui/layouts/problem-edit-layout.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { BackButton } from "@/components/back-button";
|
||||||
|
import { UserAvatar, UserAvatarSkeleton } from "@/components/user-avatar";
|
||||||
|
|
||||||
|
interface ProblemEditLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProblemEditLayout = ({ children }: ProblemEditLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<header className="relative flex h-12 flex-none items-center">
|
||||||
|
<div className="container mx-auto flex h-full items-center justify-between px-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<BackButton href="/problem" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Suspense fallback={<UserAvatarSkeleton />}>
|
||||||
|
<UserAvatar />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex w-full flex-grow overflow-y-hidden p-2.5 pt-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
26
src/features/admin/ui/views/problem-edit-view.tsx
Normal file
26
src/features/admin/ui/views/problem-edit-view.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import EditCodePanel from "@/components/creater/edit-code-panel";
|
||||||
|
import EditDetailPanel from "@/components/creater/edit-detail-panel";
|
||||||
|
import EditSolutionPanel from "@/components/creater/edit-solution-panel";
|
||||||
|
import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
|
||||||
|
import EditDescriptionPanel from "@/components/creater/edit-description-panel";
|
||||||
|
import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
|
||||||
|
|
||||||
|
interface ProblemEditViewProps {
|
||||||
|
problemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
|
||||||
|
const components: Record<string, React.ReactNode> = {
|
||||||
|
description: <EditDescriptionPanel problemId={problemId} />,
|
||||||
|
solution: <EditSolutionPanel problemId={problemId} />,
|
||||||
|
detail: <EditDetailPanel problemId={problemId} />,
|
||||||
|
code: <EditCodePanel problemId={problemId} />,
|
||||||
|
testcase: <EditTestcasePanel problemId={problemId} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-full">
|
||||||
|
<ProblemEditFlexLayout components={components} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -11,7 +11,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
import { Actions, DockLocation } from "flexlayout-react";
|
import { Actions, DockLocation } from "flexlayout-react";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
|
|
||||||
export const ViewBotButton = () => {
|
export const ViewBotButton = () => {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
111
src/features/problems/components/flexlayout.tsx
Normal file
111
src/features/problems/components/flexlayout.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BotIcon,
|
||||||
|
CircleCheckBigIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
FlaskConicalIcon,
|
||||||
|
SquareCheckIcon,
|
||||||
|
SquarePenIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
IJsonModel,
|
||||||
|
ITabRenderValues,
|
||||||
|
Layout,
|
||||||
|
Model,
|
||||||
|
TabNode,
|
||||||
|
} from "flexlayout-react";
|
||||||
|
import "@/styles/flexlayout.css";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
interface FlexLayoutProps {
|
||||||
|
components: Record<string, React.ReactNode>;
|
||||||
|
hasHydrated: boolean;
|
||||||
|
model: Model | null;
|
||||||
|
jsonModel: IJsonModel;
|
||||||
|
setModel: (model: Model) => void;
|
||||||
|
setJsonModel: (jsonModel: IJsonModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlexLayout = ({
|
||||||
|
components,
|
||||||
|
hasHydrated,
|
||||||
|
model,
|
||||||
|
jsonModel,
|
||||||
|
setModel,
|
||||||
|
setJsonModel,
|
||||||
|
}: FlexLayoutProps) => {
|
||||||
|
const t = useTranslations("ProblemPage");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasHydrated && !model) {
|
||||||
|
const model = Model.fromJson(jsonModel);
|
||||||
|
setModel(model);
|
||||||
|
}
|
||||||
|
}, [hasHydrated, 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 || !hasHydrated)
|
||||||
|
return <Skeleton className="h-full w-full rounded-2xl" />;
|
||||||
|
|
||||||
|
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 @@ import { LoaderCircleIcon, PlayIcon } from "lucide-react";
|
|||||||
import { TooltipButton } from "@/components/tooltip-button";
|
import { TooltipButton } from "@/components/tooltip-button";
|
||||||
import { useProblemEditorStore } from "@/stores/problem-editor";
|
import { useProblemEditorStore } from "@/stores/problem-editor";
|
||||||
import { JudgeToast } from "@/features/problems/components/judge-toast";
|
import { JudgeToast } from "@/features/problems/components/judge-toast";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
|
|
||||||
interface JudgeButtonProps {
|
interface JudgeButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -1,94 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { FlexLayout } from "./flexlayout";
|
||||||
BotIcon,
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
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 {
|
interface ProblemFlexLayoutProps {
|
||||||
components: Record<string, React.ReactNode>;
|
components: Record<string, React.ReactNode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProblemFlexLayout = ({ components }: ProblemFlexLayoutProps) => {
|
export const ProblemFlexLayout = ({ components }: ProblemFlexLayoutProps) => {
|
||||||
const t = useTranslations("ProblemPage");
|
const { hasHydrated, model, jsonModel, setModel, setJsonModel } =
|
||||||
const { model, setModel, jsonModel, setJsonModel } =
|
|
||||||
useProblemFlexLayoutStore();
|
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 (
|
return (
|
||||||
<Layout
|
<FlexLayout
|
||||||
|
components={components}
|
||||||
|
hasHydrated={hasHydrated}
|
||||||
model={model}
|
model={model}
|
||||||
factory={factory}
|
jsonModel={jsonModel}
|
||||||
onRenderTab={onRenderTab}
|
setModel={setModel}
|
||||||
onModelChange={onModelChange}
|
setJsonModel={setJsonModel}
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -4,7 +4,7 @@ import { Actions } from "flexlayout-react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { ArrowLeftIcon } from "lucide-react";
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
export const DetailHeader = () => {
|
export const DetailHeader = () => {
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { Actions } from "flexlayout-react";
|
import { Actions } from "flexlayout-react";
|
||||||
import { BookOpenIcon } from "lucide-react";
|
import { BookOpenIcon } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
|
|
||||||
export const ViewSolutionButton = () => {
|
export const ViewSolutionButton = () => {
|
||||||
const { model } = useProblemFlexLayoutStore();
|
const { model } = useProblemFlexLayoutStore();
|
||||||
|
@ -7,7 +7,7 @@ import { Locale, Submission } from "@/generated/client";
|
|||||||
import { Actions, DockLocation } from "flexlayout-react";
|
import { Actions, DockLocation } from "flexlayout-react";
|
||||||
import { getColorClassForStatus } from "@/config/status";
|
import { getColorClassForStatus } from "@/config/status";
|
||||||
import { TableCell, TableRow } from "@/components/ui/table";
|
import { TableCell, TableRow } from "@/components/ui/table";
|
||||||
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
|
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { getIconForLanguage, getLabelForLanguage } from "@/config/language";
|
import { getIconForLanguage, getLabelForLanguage } from "@/config/language";
|
||||||
|
|
||||||
|
31
src/features/problems/ui/views/problem-view.tsx
Normal file
31
src/features/problems/ui/views/problem-view.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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 ProblemViewProps {
|
||||||
|
problemId: string;
|
||||||
|
submissionId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProblemView = ({ problemId, submissionId }: ProblemViewProps) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
211
src/stores/flexlayout.ts
Normal file
211
src/stores/flexlayout.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { IJsonModel, Model } from "flexlayout-react";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
type FlexLayoutState = {
|
||||||
|
hasHydrated: boolean;
|
||||||
|
model: Model | null;
|
||||||
|
jsonModel: IJsonModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlexLayoutAction = {
|
||||||
|
setHasHydrated: (hasHydrated: boolean) => void;
|
||||||
|
setModel: (model: Model) => void;
|
||||||
|
setJsonModel: (jsonModel: IJsonModel) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlexLayoutStore = FlexLayoutState & FlexLayoutAction;
|
||||||
|
|
||||||
|
const createFlexLayoutStore = (storageKey: string, jsonModel: IJsonModel) =>
|
||||||
|
create<FlexLayoutStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
hasHydrated: false,
|
||||||
|
model: null,
|
||||||
|
jsonModel: jsonModel,
|
||||||
|
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
|
||||||
|
setModel: (model) => set({ model }),
|
||||||
|
setJsonModel: (jsonModel) => set({ jsonModel }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: storageKey,
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
jsonModel: state.jsonModel,
|
||||||
|
}),
|
||||||
|
onRehydrateStorage: () => {
|
||||||
|
return (state) => {
|
||||||
|
state?.setHasHydrated(true);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialProblemFlexLayoutJsonModel: IJsonModel = {
|
||||||
|
global: {
|
||||||
|
// tabEnableClose: false,
|
||||||
|
tabEnableRename: false,
|
||||||
|
},
|
||||||
|
borders: [],
|
||||||
|
layout: {
|
||||||
|
type: "row",
|
||||||
|
weight: 100,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "1",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "description",
|
||||||
|
name: "Description",
|
||||||
|
component: "description",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "solution",
|
||||||
|
name: "Solutions",
|
||||||
|
component: "solution",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "submission",
|
||||||
|
name: "Submissions",
|
||||||
|
component: "submission",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// type: "tab",
|
||||||
|
// id: "detail",
|
||||||
|
// name: "Details",
|
||||||
|
// component: "detail",
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "row",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "2",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "code",
|
||||||
|
name: "Code",
|
||||||
|
component: "code",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "3",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "testcase",
|
||||||
|
name: "Testcase",
|
||||||
|
component: "testcase",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProblemFlexLayoutStore = createFlexLayoutStore(
|
||||||
|
"problem-flexlayout",
|
||||||
|
initialProblemFlexLayoutJsonModel
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialProblemEditFlexLayoutJsonModel: IJsonModel = {
|
||||||
|
global: {
|
||||||
|
// tabEnableClose: false,
|
||||||
|
tabEnableRename: false,
|
||||||
|
},
|
||||||
|
borders: [],
|
||||||
|
layout: {
|
||||||
|
type: "row",
|
||||||
|
weight: 100,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "1",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "description",
|
||||||
|
name: "Description",
|
||||||
|
component: "description",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "solution",
|
||||||
|
name: "Solutions",
|
||||||
|
component: "solution",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "detail",
|
||||||
|
name: "Details",
|
||||||
|
component: "detail",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "row",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "2",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "code",
|
||||||
|
name: "Code",
|
||||||
|
component: "code",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tabset",
|
||||||
|
id: "3",
|
||||||
|
weight: 50,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "testcase",
|
||||||
|
name: "Testcase",
|
||||||
|
component: "testcase",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProblemEditFlexLayoutStore = createFlexLayoutStore(
|
||||||
|
"problem-edit-flexlayout",
|
||||||
|
initialProblemEditFlexLayoutJsonModel
|
||||||
|
);
|
@ -1,115 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { IJsonModel, Model } from "flexlayout-react";
|
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
|
||||||
|
|
||||||
const initialJsonModel: IJsonModel = {
|
|
||||||
global: {
|
|
||||||
// tabEnableClose: false,
|
|
||||||
tabEnableRename: false,
|
|
||||||
},
|
|
||||||
borders: [],
|
|
||||||
layout: {
|
|
||||||
type: "row",
|
|
||||||
weight: 100,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "tabset",
|
|
||||||
id: "1",
|
|
||||||
weight: 50,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "description",
|
|
||||||
name: "Description",
|
|
||||||
component: "description",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "solution",
|
|
||||||
name: "Solutions",
|
|
||||||
component: "solution",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "submission",
|
|
||||||
name: "Submissions",
|
|
||||||
component: "submission",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// type: "tab",
|
|
||||||
// id: "detail",
|
|
||||||
// name: "Details",
|
|
||||||
// component: "detail",
|
|
||||||
// },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "row",
|
|
||||||
weight: 50,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "tabset",
|
|
||||||
id: "2",
|
|
||||||
weight: 50,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "code",
|
|
||||||
name: "Code",
|
|
||||||
component: "code",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "tabset",
|
|
||||||
id: "3",
|
|
||||||
weight: 50,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "testcase",
|
|
||||||
name: "Testcase",
|
|
||||||
component: "testcase",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProblemFlexLayoutState = {
|
|
||||||
model: Model | null;
|
|
||||||
jsonModel: IJsonModel;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProblemFlexLayoutAction = {
|
|
||||||
setModel: (model: Model) => void;
|
|
||||||
setJsonModel: (jsonModel: IJsonModel) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProblemFlexLayoutStore = ProblemFlexLayoutState & ProblemFlexLayoutAction;
|
|
||||||
|
|
||||||
export const useProblemFlexLayoutStore = create<ProblemFlexLayoutStore>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
model: null,
|
|
||||||
jsonModel: initialJsonModel,
|
|
||||||
setModel: (model) => set({ model }),
|
|
||||||
setJsonModel: (jsonModel) => set({ jsonModel }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "problem-flexlayout",
|
|
||||||
storage: createJSONStorage(() => localStorage),
|
|
||||||
partialize: (state) => ({
|
|
||||||
jsonModel: state.jsonModel,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
Loading…
Reference in New Issue
Block a user