Skip to content

Commit 14a4890

Browse files
authored
Add support for binary files in submissions, preview markdown files (#612)
1 parent 43f7d7a commit 14a4890

File tree

12 files changed

+2040
-283
lines changed

12 files changed

+2040
-283
lines changed

app/course/[course_id]/assignments/[assignment_id]/submissions/[submissions_id]/files/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import CodeFile, {
1010
import DownloadLink from "@/components/ui/download-link";
1111
import Link from "@/components/ui/link";
1212
import Markdown from "@/components/ui/markdown";
13+
import MarkdownFilePreview, { isMarkdownFile } from "@/components/ui/markdown-file-preview";
14+
import BinaryFilePreview from "@/components/ui/binary-file-preview";
1315
import MessageInput from "@/components/ui/message-input";
1416
import NotFound from "@/components/ui/not-found";
1517
import PersonAvatar from "@/components/ui/person-avatar";
@@ -1267,7 +1269,18 @@ export default function FilesView() {
12671269
</Box>
12681270
) : selectedFile ? (
12691271
<Box data-file-id={selectedFile.id} scrollMarginTop="80px">
1270-
<CodeFile key={selectedFile.id} file={selectedFile} />
1272+
{isMarkdownFile(selectedFile.name) && !selectedFile.is_binary ? (
1273+
<MarkdownFilePreview
1274+
key={selectedFile.id}
1275+
file={selectedFile}
1276+
allFiles={submission.submission_files}
1277+
onNavigateToFile={handleSelectFile}
1278+
/>
1279+
) : selectedFile.is_binary ? (
1280+
<BinaryFilePreview key={selectedFile.id} file={selectedFile} />
1281+
) : (
1282+
<CodeFile key={selectedFile.id} file={selectedFile} />
1283+
)}
12711284
</Box>
12721285
) : (
12731286
<Text>Select a file or artifact to view.</Text>

app/course/[course_id]/assignments/[assignment_id]/submissions/[submissions_id]/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,15 @@ function SubmissionReviewScoreTweak() {
260260
}
261261
// Select query for full submission data with grader results, test outputs, and files
262262
const FULL_SUBMISSION_SELECT =
263-
"*, grader_results!grader_results_submission_id_fkey(*, grader_result_tests(*, grader_result_test_output(*)), grader_result_output(*)), submission_reviews!submissions_grading_review_id_fkey(*), repository_check_runs!submissions_repository_check_run_id_fkey(commit_message), submission_files(name, contents)";
263+
"*, grader_results!grader_results_submission_id_fkey(*, grader_result_tests(*, grader_result_test_output(*)), grader_result_output(*)), submission_reviews!submissions_grading_review_id_fkey(*), repository_check_runs!submissions_repository_check_run_id_fkey(commit_message), submission_files(name, contents, is_binary, file_size, mime_type, storage_key)";
264264

265265
// Type that matches the FULL_SUBMISSION_SELECT query result
266266
type FullSubmissionQueryResult = GetResult<
267267
Database["public"],
268268
Database["public"]["Tables"]["submissions"]["Row"],
269269
"submissions",
270270
Database["public"]["Tables"]["submissions"]["Relationships"],
271-
"*, grader_results!grader_results_submission_id_fkey(*, grader_result_tests(*, grader_result_test_output(*)), grader_result_output(*)), submission_reviews!submissions_grading_review_id_fkey(*), repository_check_runs!submissions_repository_check_run_id_fkey(commit_message), submission_files(name, contents)"
271+
"*, grader_results!grader_results_submission_id_fkey(*, grader_result_tests(*, grader_result_test_output(*)), grader_result_output(*)), submission_reviews!submissions_grading_review_id_fkey(*), repository_check_runs!submissions_repository_check_run_id_fkey(commit_message), submission_files(name, contents, is_binary, file_size, mime_type, storage_key)"
272272
>;
273273

274274
// Use Omit to avoid implying assignments/workflow_run_error are populated (they aren't in our query)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"use client";
2+
3+
import { SubmissionFile } from "@/utils/supabase/DatabaseTypes";
4+
import { createClient } from "@/utils/supabase/client";
5+
import { Box, Flex, HStack, Icon, Spinner, Text } from "@chakra-ui/react";
6+
import { useEffect, useState } from "react";
7+
import { FaDownload, FaFile } from "react-icons/fa";
8+
import DownloadLink from "./download-link";
9+
10+
function isImageMime(mime: string | null): boolean {
11+
return mime !== null && mime.startsWith("image/");
12+
}
13+
14+
function formatFileSize(bytes: number | null): string {
15+
if (bytes === null) return "Unknown size";
16+
if (bytes < 1024) return `${bytes} B`;
17+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19+
}
20+
21+
export default function BinaryFilePreview({ file }: { file: SubmissionFile }) {
22+
const [objectUrl, setObjectUrl] = useState<string | null>(null);
23+
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
24+
const [loading, setLoading] = useState(true);
25+
const [error, setError] = useState<string | null>(null);
26+
27+
useEffect(() => {
28+
let isMounted = true;
29+
let currentObjectUrl: string | null = null;
30+
31+
async function loadFile() {
32+
if (!file.storage_key) {
33+
if (isMounted) {
34+
setError("No storage key for this binary file");
35+
setLoading(false);
36+
}
37+
return;
38+
}
39+
40+
const client = createClient();
41+
42+
// Create signed URL for download
43+
const { data: signedUrlData, error: signedUrlError } = await client.storage
44+
.from("submission-files")
45+
.createSignedUrl(file.storage_key, 60 * 60 * 24); // 24 hour expiry
46+
47+
if (isMounted && signedUrlData) {
48+
setDownloadUrl(signedUrlData.signedUrl);
49+
}
50+
if (signedUrlError && isMounted) {
51+
setError(`Failed to create download link: ${signedUrlError.message}`);
52+
setLoading(false);
53+
return;
54+
}
55+
56+
// For images, also create an object URL for inline display
57+
if (isImageMime(file.mime_type)) {
58+
const { data: blob, error: downloadError } = await client.storage
59+
.from("submission-files")
60+
.download(file.storage_key);
61+
62+
if (downloadError && isMounted) {
63+
setError(`Failed to load image: ${downloadError.message}`);
64+
setLoading(false);
65+
return;
66+
}
67+
68+
if (blob && isMounted) {
69+
currentObjectUrl = URL.createObjectURL(blob);
70+
setObjectUrl(currentObjectUrl);
71+
}
72+
}
73+
74+
if (isMounted) {
75+
setLoading(false);
76+
}
77+
}
78+
79+
loadFile();
80+
81+
return () => {
82+
isMounted = false;
83+
if (currentObjectUrl) {
84+
URL.revokeObjectURL(currentObjectUrl);
85+
}
86+
};
87+
}, [file.storage_key, file.mime_type]);
88+
89+
return (
90+
<Box border="1px solid" borderColor="border.emphasized" borderRadius="md" m={2} w="100%">
91+
<Flex
92+
w="100%"
93+
bg="bg.subtle"
94+
p={2}
95+
borderBottom="1px solid"
96+
borderColor="border.emphasized"
97+
alignItems="center"
98+
justifyContent="space-between"
99+
>
100+
<HStack>
101+
<Icon as={FaFile} color="fg.muted" />
102+
<Text fontSize="xs" color="text.subtle">
103+
{file.name}
104+
</Text>
105+
{file.file_size !== null && (
106+
<Text fontSize="xs" color="fg.muted">
107+
({formatFileSize(file.file_size)})
108+
</Text>
109+
)}
110+
{file.mime_type && (
111+
<Box bg="blue.subtle" px={2} py={0.5} borderRadius="full">
112+
<Text fontSize="xs" color="blue.fg" fontWeight="medium">
113+
{file.mime_type}
114+
</Text>
115+
</Box>
116+
)}
117+
</HStack>
118+
{downloadUrl && (
119+
<DownloadLink href={downloadUrl} filename={file.name}>
120+
<HStack gap={1}>
121+
<Icon as={FaDownload} />
122+
<Text fontSize="xs">Download</Text>
123+
</HStack>
124+
</DownloadLink>
125+
)}
126+
</Flex>
127+
128+
<Box p={4}>
129+
{loading ? (
130+
<Flex justify="center" align="center" py={8}>
131+
<Spinner size="md" />
132+
<Text ml={3} color="fg.muted">
133+
Loading file...
134+
</Text>
135+
</Flex>
136+
) : error ? (
137+
<Box p={4} bg="bg.error" borderRadius="md">
138+
<Text color="fg.error">{error}</Text>
139+
</Box>
140+
) : isImageMime(file.mime_type) && objectUrl ? (
141+
<Flex justify="center">
142+
{/* eslint-disable-next-line @next/next/no-img-element */}
143+
<img
144+
src={objectUrl}
145+
alt={file.name}
146+
style={{
147+
maxWidth: "100%",
148+
height: "auto",
149+
display: "block",
150+
borderRadius: "0.375rem"
151+
}}
152+
/>
153+
</Flex>
154+
) : file.mime_type === "application/pdf" && downloadUrl ? (
155+
<Box w="100%" h="600px">
156+
<iframe
157+
src={downloadUrl}
158+
style={{ width: "100%", height: "100%", border: "none", borderRadius: "0.375rem" }}
159+
title={file.name}
160+
/>
161+
</Box>
162+
) : (
163+
<Flex direction="column" align="center" py={8} gap={3}>
164+
<Icon as={FaFile} boxSize={12} color="fg.muted" />
165+
<Text color="fg.muted">No preview available for this file type</Text>
166+
{downloadUrl && (
167+
<DownloadLink href={downloadUrl} filename={file.name}>
168+
<HStack gap={1}>
169+
<Icon as={FaDownload} />
170+
<Text>Download {file.name}</Text>
171+
</HStack>
172+
</DownloadLink>
173+
)}
174+
</Flex>
175+
)}
176+
</Box>
177+
</Box>
178+
);
179+
}

0 commit comments

Comments
 (0)