Skip to content

Commit f8d9a6e

Browse files
committed
feat: integrate Monaco editor for file submission
1 parent 05f15a0 commit f8d9a6e

File tree

4 files changed

+186
-61
lines changed

4 files changed

+186
-61
lines changed

components/submissions/submission-upload-form.tsx

Lines changed: 134 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import { useState, useCallback, useRef } from 'react';
2+
import { useState, useCallback, useRef, useEffect } from 'react';
33
import { useDropzone, FileWithPath } from 'react-dropzone';
44
import { Button } from '@/components/ui/button';
55
import { useToast } from '@/hooks/use-toast';
@@ -9,12 +9,16 @@ import { UploadCloud, File as FileIcon, X, Info, FolderUp, FileUp } from 'lucide
99
import useSWR, { useSWRConfig } from 'swr';
1010
import { Attempts } from '@/lib/types';
1111
import { Skeleton } from '../ui/skeleton';
12+
import Editor from "@monaco-editor/react";
13+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
1214

1315
interface SubmissionUploadFormProps {
1416
problemId: string;
1517
uploadLimits: {
1618
max_num: number;
1719
max_size: number; // in MB
20+
editor?: boolean;
21+
editor_files?: string[];
1822
};
1923
}
2024

@@ -47,29 +51,80 @@ function AttemptsCounter({ problemId, onLimitReached }: { problemId: string, onL
4751
);
4852
}
4953

54+
const getLanguageForFile = (filename: string = '') => {
55+
const extension = filename.split('.').pop()?.toLowerCase();
56+
switch (extension) {
57+
case 'cpp':
58+
case 'cxx':
59+
case 'h':
60+
case 'hpp':
61+
return 'cpp';
62+
case 'c':
63+
return 'c';
64+
case 'py':
65+
return 'python';
66+
case 'java':
67+
return 'java';
68+
case 'js':
69+
return 'javascript';
70+
case 'ts':
71+
return 'typescript';
72+
case 'json':
73+
return 'json';
74+
case 'xml':
75+
return 'xml';
76+
case 'html':
77+
return 'html';
78+
case 'css':
79+
return 'css';
80+
case 'md':
81+
return 'markdown';
82+
default:
83+
return 'plaintext';
84+
}
85+
};
86+
5087
export default function SubmissionUploadForm({ problemId, uploadLimits }: SubmissionUploadFormProps) {
51-
const [files, setFiles] = useState<File[]>([]);
5288
const [isSubmitting, setIsSubmitting] = useState(false);
5389
const [isLimitReached, setIsLimitReached] = useState(false);
5490
const { toast } = useToast();
5591
const router = useRouter();
5692
const { mutate } = useSWRConfig();
93+
94+
// State for file upload mode
95+
const [files, setFiles] = useState<File[]>([]);
5796
const fileInputRef = useRef<HTMLInputElement>(null);
5897
const folderInputRef = useRef<HTMLInputElement>(null);
98+
99+
// State for editor mode
100+
const initialContents = Object.fromEntries((uploadLimits.editor_files || []).map(file => [file, '']));
101+
const [fileContents, setFileContents] = useState<Record<string, string>>(initialContents);
102+
const [activeFile, setActiveFile] = useState<string>((uploadLimits.editor_files || [])[0] || '');
103+
const [monacoTheme, setMonacoTheme] = useState('light');
59104

60-
const addFiles = useCallback((newFiles: File[]) => {
61-
const allFiles = [...files, ...newFiles];
105+
useEffect(() => {
106+
// Detect theme for Monaco editor on component mount
107+
if (typeof window !== "undefined" && window.document.documentElement.classList.contains('dark')) {
108+
setMonacoTheme('vs-dark');
109+
}
110+
}, []);
62111

63-
if (uploadLimits.max_num > 0 && allFiles.length > uploadLimits.max_num) {
64-
toast({
65-
variant: 'destructive',
66-
title: 'Too many files',
67-
description: `You can upload a maximum of ${uploadLimits.max_num} files.`,
68-
});
112+
const handleSubmit = async () => {
113+
let filesToSubmit: File[];
114+
115+
if (uploadLimits.editor) {
116+
filesToSubmit = Object.entries(fileContents)
117+
.map(([name, content]) => new File([content], name, { type: 'text/plain' }));
118+
} else {
119+
filesToSubmit = files;
120+
}
121+
122+
if (filesToSubmit.length === 0) {
123+
toast({ variant: 'destructive', title: 'No files selected or content is empty' });
69124
return;
70125
}
71126

72-
const totalSize = allFiles.reduce((acc, file) => acc + file.size, 0);
127+
const totalSize = filesToSubmit.reduce((acc, file) => acc + file.size, 0);
73128
if (uploadLimits.max_size > 0 && totalSize > uploadLimits.max_size * 1024 * 1024) {
74129
toast({
75130
variant: 'destructive',
@@ -78,43 +133,11 @@ export default function SubmissionUploadForm({ problemId, uploadLimits }: Submis
78133
});
79134
return;
80135
}
81-
82-
setFiles(prev => [...prev, ...newFiles]);
83-
}, [files, uploadLimits, toast]);
84-
85-
const onDrop = useCallback((acceptedFiles: FileWithPath[]) => {
86-
addFiles(acceptedFiles);
87-
}, [addFiles]);
88-
89-
const handleManualFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
90-
if (event.target.files) {
91-
addFiles(Array.from(event.target.files));
92-
}
93-
// Reset the input value to allow selecting the same file/folder again
94-
event.target.value = '';
95-
};
96-
97-
const { getRootProps, isDragActive } = useDropzone({
98-
onDrop,
99-
noClick: true,
100-
noKeyboard: true
101-
});
102136

103-
const removeFile = (fileToRemove: File) => {
104-
setFiles(files.filter(file => file !== fileToRemove));
105-
};
106-
107-
const handleSubmit = async () => {
108-
if (files.length === 0) {
109-
toast({ variant: 'destructive', title: 'No files selected' });
110-
return;
111-
}
112137
setIsSubmitting(true);
113138
const formData = new FormData();
114-
files.forEach(file => {
115-
// Dynamically get the full path for the file.
116-
// `path` comes from react-dropzone. `webkitRelativePath` comes from folder selection input. `name` is the fallback.
117-
const filePath = (file as FileWithPath).path || (file as any).webkitRelativePath || file.name;
139+
filesToSubmit.forEach(file => {
140+
const filePath = uploadLimits.editor ? file.name : (file as FileWithPath).path || (file as any).webkitRelativePath || file.name;
118141
formData.append('files', file, filePath);
119142
});
120143

@@ -137,27 +160,80 @@ export default function SubmissionUploadForm({ problemId, uploadLimits }: Submis
137160
setIsSubmitting(false);
138161
}
139162
};
163+
164+
// --- File Upload Logic ---
165+
const addFiles = useCallback((newFiles: File[]) => {
166+
const allFiles = [...files, ...newFiles];
167+
if (uploadLimits.max_num > 0 && allFiles.length > uploadLimits.max_num) {
168+
toast({ variant: 'destructive', title: 'Too many files', description: `You can upload a maximum of ${uploadLimits.max_num} files.` });
169+
return;
170+
}
171+
setFiles(prev => [...prev, ...newFiles]);
172+
}, [files, uploadLimits.max_num, toast]);
173+
174+
const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { addFiles(acceptedFiles); }, [addFiles]);
140175

176+
const handleManualFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
177+
if (event.target.files) { addFiles(Array.from(event.target.files)); }
178+
event.target.value = '';
179+
};
180+
181+
const { getRootProps, isDragActive } = useDropzone({ onDrop, noClick: true, noKeyboard: true });
182+
183+
const removeFile = (fileToRemove: File) => { setFiles(files.filter(file => file !== fileToRemove)); };
184+
185+
186+
// --- Render Logic ---
187+
if (uploadLimits.editor) {
188+
return (
189+
<div className="space-y-4">
190+
<AttemptsCounter problemId={problemId} onLimitReached={setIsLimitReached} />
191+
<Tabs value={activeFile} onValueChange={setActiveFile} className="w-full">
192+
<TabsList className="grid w-full" style={{gridTemplateColumns: `repeat(${uploadLimits.editor_files?.length || 1}, minmax(0, 1fr))`}}>
193+
{(uploadLimits.editor_files || []).map(filename => (
194+
<TabsTrigger key={filename} value={filename}>{filename}</TabsTrigger>
195+
))}
196+
</TabsList>
197+
<div className="mt-4 rounded-md border overflow-hidden">
198+
<Editor
199+
height="40vh"
200+
path={activeFile}
201+
language={getLanguageForFile(activeFile)}
202+
value={fileContents[activeFile]}
203+
onChange={(value) => setFileContents(prev => ({ ...prev, [activeFile]: value || '' }))}
204+
theme={monacoTheme}
205+
options={{ minimap: { enabled: false }, scrollBeyondLastLine: false, automaticLayout: true }}
206+
/>
207+
</div>
208+
</Tabs>
209+
<Button onClick={handleSubmit} disabled={isSubmitting || isLimitReached} className="w-full">
210+
{isSubmitting ? 'Submitting...' : 'Submit'}
211+
</Button>
212+
</div>
213+
);
214+
}
215+
216+
// Default to file upload form
141217
return (
142218
<div className="space-y-4">
143219
<AttemptsCounter problemId={problemId} onLimitReached={setIsLimitReached} />
144220
<div {...getRootProps()}
145-
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-md transition-colors
146-
${isDragActive ? 'border-primary bg-primary/10' : 'border-border'}
147-
${isLimitReached ? 'bg-muted opacity-50' : ''}`}
221+
className={`relative flex flex-col items-center justify-center p-6 border-2 border-dashed rounded-md transition-colors
222+
${isDragActive ? 'border-primary bg-primary/10' : 'border-border'}
223+
${isLimitReached ? 'bg-muted opacity-50' : ''}`}
148224
>
149225
<input type="file" multiple ref={fileInputRef} onChange={handleManualFileSelect} style={{ display: 'none' }} disabled={isLimitReached} />
150226
{/* @ts-ignore */}
151227
<input type="file" webkitdirectory="true" ref={folderInputRef} onChange={handleManualFileSelect} style={{ display: 'none' }} disabled={isLimitReached}/>
152228

153229
<UploadCloud className="h-10 w-10 text-muted-foreground" />
154230
{isLimitReached ? (
155-
<p className="mt-2 text-sm text-destructive">You have reached the submission limit.</p>
156-
) : (
157-
<p className="mt-2 text-sm text-muted-foreground text-center">
158-
{isDragActive ? 'Drop files or a folder here...' : 'Drag & drop files or a folder'}
159-
</p>
160-
)}
231+
<p className="mt-2 text-sm text-destructive">You have reached the submission limit.</p>
232+
) : (
233+
<p className="mt-2 text-sm text-muted-foreground text-center">
234+
{isDragActive ? 'Drop files or a folder here...' : 'Drag & drop files or a folder'}
235+
</p>
236+
)}
161237
<div className='mt-4 flex flex-col sm:flex-row gap-2 w-full'>
162238
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isLimitReached} className='w-full'>
163239
<FileUp className="mr-2 h-4 w-4" /> Select Files
@@ -166,7 +242,6 @@ export default function SubmissionUploadForm({ problemId, uploadLimits }: Submis
166242
<FolderUp className="mr-2 h-4 w-4" /> Select Folder
167243
</Button>
168244
</div>
169-
170245
<p className="text-xs text-muted-foreground mt-4">
171246
Max {uploadLimits.max_num > 0 ? `${uploadLimits.max_num} files` : 'unlimited files'}, up to {uploadLimits.max_size > 0 ? `${uploadLimits.max_size}MB total` : 'unlimited size'}.
172247
</p>
@@ -177,8 +252,8 @@ export default function SubmissionUploadForm({ problemId, uploadLimits }: Submis
177252
<h4 className="font-semibold">Selected files:</h4>
178253
<ul className="space-y-1 bg-muted p-3 rounded-md max-h-48 overflow-y-auto">
179254
{files.map((file) => {
180-
const displayPath = (file as FileWithPath).path || (file as any).webkitRelativePath || file.name;
181-
return (
255+
const displayPath = (file as FileWithPath).path || (file as any).webkitRelativePath || file.name;
256+
return (
182257
<li key={`${displayPath}-${file.lastModified}`} className="flex items-center justify-between text-sm">
183258
<span className="flex items-center gap-2 truncate">
184259
<FileIcon className="h-4 w-4 shrink-0"/>
@@ -189,8 +264,8 @@ export default function SubmissionUploadForm({ problemId, uploadLimits }: Submis
189264
<X className="h-4 w-4" />
190265
</Button>
191266
</li>
192-
);
193-
})}
267+
);
268+
})}
194269
</ul>
195270
</div>
196271
)}

lib/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
// FILE: lib/types.ts
2-
31
export type Status = "Queued" | "Running" | "Success" | "Failed";
42

53
export interface User {
@@ -35,6 +33,8 @@ export interface Problem {
3533
upload: {
3634
max_num: number;
3735
max_size: number;
36+
editor?: boolean;
37+
editor_files?: string[];
3838
};
3939
workflow: WorkflowStep[];
4040
description: string;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@hookform/resolvers": "^3.10.0",
13+
"@monaco-editor/react": "^4.7.0",
1314
"@radix-ui/react-avatar": "^1.1.10",
1415
"@radix-ui/react-dialog": "^1.1.15",
1516
"@radix-ui/react-dropdown-menu": "^2.1.16",

0 commit comments

Comments
 (0)