Skip to content

Commit f82c911

Browse files
committed
Add 'FileDockQuestion'
1 parent 8d3fb9d commit f82c911

File tree

8 files changed

+375
-58
lines changed

8 files changed

+375
-58
lines changed

api/quiz.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,62 @@ export const postAnswer = async (
9696
...shownAnswer};
9797
};
9898

99+
export const postFileAnswer = async (
100+
{ accessToken, group, bookId, questionId, addFiles, removeFiles}: {
101+
accessToken: string;
102+
group: string | null;
103+
bookId: number;
104+
questionId: string;
105+
addFiles: string[];
106+
removeFiles: string[] | boolean;
107+
}): Promise<string | null> => {
108+
const userId = await getUserId(accessToken);
109+
const groupId = group ? (await db.get(
110+
`SELECT id FROM groups WHERE name = ?`,
111+
[group]
112+
))?.id : null;
113+
114+
const question = await db.get(`
115+
SELECT id, type FROM questions q
116+
JOIN books_chapters bc ON q.chapterId = bc.chapterId
117+
WHERE questionId = ? AND bc.bookId = ?
118+
`, [questionId, bookId]
119+
);
120+
if (!question) {
121+
return "Question id and book id don't match";
122+
}
123+
if (!question.type.startsWith("upload")) {
124+
return "Question is not of upload type";
125+
}
126+
const qId = question.id;
127+
try {
128+
await db.run("BEGIN IMMEDIATE");
129+
const prevFiles = removeFiles === true ? [] :
130+
((await db.get(`
131+
SELECT answer FROM answers
132+
WHERE userId = ? AND bookId = ? AND groupId IS ? AND questionId = ?
133+
ORDER BY createdAt DESC
134+
LIMIT 1
135+
`, [userId, bookId, groupId, qId]
136+
)) as { answer: string } | undefined)?.answer
137+
.split(":")
138+
.filter((f) => f && !addFiles.includes(f) && (!removeFiles || !removeFiles.includes(f)))
139+
|| [];
140+
const newFiles = [...prevFiles, ...addFiles].join(":");
141+
await db.run(
142+
`INSERT INTO answers (userId, bookId, groupId, questionId, answer)
143+
VALUES (?, ?, ?, ?, ?)`,
144+
[userId, bookId, groupId, qId, newFiles]
145+
);
146+
await db.run("COMMIT");
147+
}
148+
catch (e) {
149+
await db.run("ROLLBACK");
150+
return "Database error";
151+
}
152+
return null;
153+
};
154+
99155
export type CorrectAnswers = { questionId: string, answer: string }[];
100156

101157
export const getAnswers = async ({ accessToken, bookId, group }: {

app/api/upload-answer/route.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "path";
2-
import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs";
2+
import fs, { mkdirSync, writeFileSync } from "fs";
33
import { NextResponse } from "next/server";
44
import { getUploadDir } from "@/utils/zip";
55

@@ -15,7 +15,7 @@ export async function POST(req: Request) {
1515
// This is checked on the front-end, but let us prevent jokers
1616
// from calling this manually and uploading huge files
1717
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
18-
if (totalSize > 50 * 1024 * 1024) {
18+
if (totalSize > 10 * 1024 * 1024) {
1919
return NextResponse.json(
2020
{ error: "Upload failed: total file size is too large" },
2121
{ status: 500 });
@@ -27,11 +27,18 @@ export async function POST(req: Request) {
2727
{ status: 500 });
2828
}
2929

30-
if (existsSync(dir)) {
31-
rmSync(dir, { force: true, recursive: true });
32-
}
3330
mkdirSync(dir, { recursive: true });
3431

32+
const existingSize = fs.readdirSync(dir).reduce((acc, file) => {
33+
const stats = fs.statSync(path.join(dir, file));
34+
return acc + stats.size;
35+
}, 0);
36+
if (existingSize + totalSize > 100 * 1024 * 1024) {
37+
return NextResponse.json(
38+
{ error: "Upload failed: total file size exceeds limit" },
39+
{ status: 500 });
40+
}
41+
3542
for (const file of files) {
3643
const bytes = await file.arrayBuffer();
3744
const buffer = Buffer.from(bytes);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React from "react";
2+
import { useIntl } from "@/i18n";
3+
import { useFileAnswer } from "@/context/QuizContextProvider";
4+
import { RiDeleteBin2Line } from "react-icons/ri";
5+
6+
export type FileDropFunction = (event: React.DragEvent<HTMLElement>) => void;
7+
8+
export const FileDockQuestion = ({id, accept, ref}: {
9+
id: string;
10+
accept?: string[];
11+
ref?: React.RefObject<FileDropFunction | null>;
12+
}) => {
13+
const {t} = useIntl();
14+
const {files, addFiles, removeFile} = useFileAnswer(id);
15+
16+
const onFilesAdd = React.useCallback(async (newFiles: File[]) => {
17+
const accepted = newFiles.filter(({name}) =>
18+
!accept?.length
19+
|| accept.includes("." + (name.toLocaleLowerCase().split('.').pop() || "")));
20+
await addFiles(accepted);
21+
}, [accept, addFiles]);
22+
23+
const onRemoveFile = React.useCallback(async (name: string) => {
24+
await removeFile(name);
25+
}, [removeFile]);
26+
27+
const onFileChange = React.useCallback(
28+
async (event: React.ChangeEvent<HTMLInputElement>) => {
29+
if (event.target.files) {
30+
await onFilesAdd([...event.target.files]);
31+
}
32+
},
33+
[onFilesAdd]
34+
);
35+
36+
const onFileDrop = React.useCallback(async (event: React.DragEvent<HTMLElement>) => {
37+
event.preventDefault();
38+
await onFilesAdd(
39+
[...event.dataTransfer.items]
40+
.map((item: DataTransferItem) => item.getAsFile())
41+
.filter((item) => item !== null));
42+
}, [onFilesAdd]);
43+
44+
React.useImperativeHandle(ref, () => onFileDrop, [onFileDrop]);
45+
46+
return <>
47+
<div className="flex flex-col gap-1 my-4 border-dashed border-1 rounded p-3"
48+
>
49+
<div className="flex flex-col gap-2 mb-4">
50+
{files.map((f) =>
51+
<div key={f} className="flex items-center gap-2">
52+
{f}
53+
<RiDeleteBin2Line
54+
onClick={() => onRemoveFile(f)}
55+
style={{cursor: "pointer"}}
56+
className="hover:text-red-700"
57+
/>
58+
</div>
59+
)}
60+
</div>
61+
<div className="flex items-center justify-between">
62+
<input
63+
id="file"
64+
type="file"
65+
accept={accept?.join(",")}
66+
multiple
67+
onChange={onFileChange}
68+
style={{display: 'none'}}/>
69+
<label
70+
htmlFor="file"
71+
className={`px-10 mr-4 submit-quiz-popup-button border border-black rounded cursor-pointer transition inline-block`}
72+
>
73+
{t("quiz.select-files")(files.length, true)}
74+
</label>
75+
76+
<small className="form-text text-muted" style={{lineHeight: "1.4"}}>
77+
{t(`quiz.upload-desc`)(true)}
78+
{accept && <>
79+
<br/>
80+
{t("quiz.upload-allowed-extensions")} {accept.join(", ")}
81+
</>}
82+
</small>
83+
</div>
84+
</div>
85+
</>
86+
}

components/Quiz/Quiz.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useLastAnswer } from "@/context/QuizContextProvider";
1010
import { FileDropFunction, FileQuestion } from "./UploadQuestion";
1111
import { LongTextQuestion, TextQuestion } from "./TextQuestions";
1212
import { SingleChoiceQuestion } from "./SingleChoiceQuestion";
13+
import {FileDockQuestion} from "@/components/Quiz/FileDockQuestion";
1314

1415

1516
export interface QuizPropsBase {
@@ -190,14 +191,23 @@ export default function Question({
190191
{ type === "long-text" && <LongTextQuestion {...textProps} /> }
191192
{ type === "singlechoice" && <SingleChoiceQuestion
192193
options={options} answer={usersAnswers ? "" : answer} onSubmit={onSubmit} /> }
193-
{ isUpload && <FileQuestion
194-
id={id}
195-
submitDisabled={submitDisabled} /* TODO: is this needed? */
196-
setSubmitted={setSubmitted}
197-
ref={onFileDropRef}
198-
accept={accept}
199-
multiple={type === "uploads"}
200-
/> }
194+
{ isUpload && (
195+
type === "uploads" && maxAttempts !== 1 ?
196+
<FileDockQuestion
197+
id={id}
198+
accept={accept}
199+
ref={onFileDropRef}
200+
/>
201+
:
202+
<FileQuestion
203+
id={id}
204+
submitDisabled={submitDisabled} /* TODO: is this needed? */
205+
setSubmitted={setSubmitted}
206+
ref={onFileDropRef}
207+
accept={accept}
208+
multiple={type === "uploads"}
209+
/>
210+
)}
201211
</fieldset>
202212

203213
{ !usersAnswers &&

0 commit comments

Comments
 (0)