From 84ec973ebdee1fb3c4d08cee241d6e55034cf124 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Mon, 8 Jun 2026 10:50:33 -0700 Subject: [PATCH 01/49] v1 --- .../article-creator/FetchArticleFunctions.tsx | 6 +- .../custom_questions/AdvancedTextbox.tsx | 461 +++++++++++++----- .../RenderAdvancedTextbox.tsx | 182 +++++-- .../questions/checkForUnderstanding.tsx | 4 +- .../digital-testing/QuestionPanel.tsx | 6 +- src/components/questions/quizRenderer.tsx | 25 +- src/components/questions/testRenderer.tsx | 1 + src/types/questions.ts | 4 + 8 files changed, 499 insertions(+), 190 deletions(-) diff --git a/src/components/article-creator/FetchArticleFunctions.tsx b/src/components/article-creator/FetchArticleFunctions.tsx index fb68455e..ba51a29e 100644 --- a/src/components/article-creator/FetchArticleFunctions.tsx +++ b/src/components/article-creator/FetchArticleFunctions.tsx @@ -37,7 +37,11 @@ export const processQuestions = async ( ...question.options.flatMap((option) => option.value.files || []), ]; - allFiles.forEach((file) => allFileKeys.add(file.key)); + allFiles.forEach((file) => { + if (!file.url?.startsWith("http")) { + allFileKeys.add(file.key); + } + }); }); // Read all files from IndexedDB diff --git a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx index 36beb74b..290883db 100644 --- a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx @@ -8,12 +8,11 @@ import { Textarea } from "@/components/ui/textarea"; import type { QuestionFile, QuestionFormat, - QuestionInput, } from "@/types/questions"; -import { QuestionsInput } from "./QuestionInstance"; -import { Paperclip, Trash } from "lucide-react"; -import { deleteObject, getStorage, ref } from "firebase/storage"; +import { Paperclip, Trash, ChevronLeft, ChevronRight } from "lucide-react"; +import { deleteObject, getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { getUser } from "@/components/hooks/users"; +import { getFileFromIndexedDB } from "./RenderAdvancedTextbox"; interface Props { questions: QuestionFormat[]; @@ -73,6 +72,61 @@ function deleteFileFromIndexedDB(name: string) { }); } +// Helper component to display visual thumbnails/previews of files in editing mode +const ThumbnailPreview: React.FC<{ file: QuestionFile }> = ({ file }) => { + const [src, setSrc] = useState(null); + + useEffect(() => { + let url: string | null = null; + if (file.url) { + setSrc(file.url); + return; + } + + const loadLocal = async () => { + try { + const stored = await getFileFromIndexedDB(file.key); + if (stored?.file) { + url = URL.createObjectURL(stored.file); + setSrc(url); + } + } catch (err) { + console.error("Error loading thumbnail:", err); + } + }; + void loadLocal(); + + return () => { + if (url) URL.revokeObjectURL(url); + }; + }, [file]); + + if (!src) { + return ( +
+ Loading... +
+ ); + } + + if (file.key.startsWith("image-") || file.key.startsWith("image/")) { + return ( + Thumbnail preview + ); + } + + return ( +
+ + {file.name} +
+ ); +}; + export default function AdvancedTextbox({ questions, qIndex, @@ -85,16 +139,19 @@ export default function AdvancedTextbox({ const questionInstance = questions[qIndex]; const [currentText, setCurrentText] = useState(""); const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadStatuses, setUploadStatuses] = useState< + Record + >({}); + const textareaRef = useRef(null); const fileInputRef = useRef(null); - // Initialize currentText and fileExists when question gets loaded from db if any + // Sync state when loaded useEffect(() => { if (origin === "option" && oIndex !== undefined) { if (questionInstance!.options[oIndex]?.value?.value) { setCurrentText(questionInstance!.options[oIndex].value.value); } - setUploadedFiles(questionInstance!.options[oIndex]?.value?.files ?? []); } else if ( origin === "question" || @@ -108,8 +165,8 @@ export default function AdvancedTextbox({ } }, [questionInstance, oIndex, origin]); + // Handle keys logic const handleKeyDown = (e: React.KeyboardEvent) => { - // Keys are being handled by EditorJS rather than default behavior, so we need to block the EditorJS behavior const key = e.key; if ( @@ -131,11 +188,9 @@ export default function AdvancedTextbox({ const textBeforeCursor = currentText.substring(0, cursorPosition); const textAfterCursor = currentText.substring(textarea.selectionEnd); - // Find the current line the cursor is on const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - // Match leading whitespace const match = currentLine.match(/^\s*/); const leadingWhitespace = match ? match[0] : ""; @@ -162,11 +217,9 @@ export default function AdvancedTextbox({ const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - // If the line up to the cursor is purely spaces, treat it as indentation if (currentLine.length > 0 && /^\s+$/.test(currentLine)) { e.preventDefault(); - // delete 2 spaces instead of 1 if possible const spacesToDelete = currentLine.length % 2 !== 0 ? 1 : 2; const newText = @@ -208,10 +261,37 @@ export default function AdvancedTextbox({ } }; + const updateQuestionsWithFiles = (newFiles: QuestionFile[]) => { + setUnsavedChanges?.(true); + const updatedQuestions = [...questions]; + const updatedQuestion = { ...questionInstance! }; + + if (origin === "question" || origin === "explanation" || origin === "content") { + updatedQuestion[origin] = { + ...updatedQuestion[origin], + files: newFiles, + }; + } else if (origin === "option" && oIndex !== undefined) { + updatedQuestion.options = [ + ...updatedQuestion.options.slice(0, oIndex), + { + value: { + ...updatedQuestion.options[oIndex]!.value, + files: newFiles, + }, + id: updatedQuestion.options[oIndex]!.id, + }, + ...updatedQuestion.options.slice(oIndex + 1), + ]; + } + + updatedQuestions[qIndex] = updatedQuestion; + setQuestions(updatedQuestions); + }; + const updateQuestionText = (newText: string) => { setUnsavedChanges?.(true); setCurrentText(newText); - // Clone the current question to avoid direct mutation const updatedQuestions = [...questions]; if ( origin === "question" || @@ -223,12 +303,11 @@ export default function AdvancedTextbox({ [origin]: { ...questionInstance![origin], value: newText, - files: questionInstance?.[origin]?.files ?? [], // Keep the files they exist - }, // Clone question + files: questionInstance?.[origin]?.files ?? [], + }, }; updatedQuestions[qIndex] = updatedQuestion; } else if (origin === "option" && oIndex !== undefined) { - // oIndex !== undefined because 0 is falsy const updatedQuestion: QuestionFormat = { ...questionInstance!, options: [ @@ -246,7 +325,7 @@ export default function AdvancedTextbox({ updatedQuestions[qIndex] = updatedQuestion; } - setQuestions(updatedQuestions); // Update state immutably + setQuestions(updatedQuestions); }; const handleTextChange = (e: React.ChangeEvent) => { @@ -257,6 +336,44 @@ export default function AdvancedTextbox({ fileInputRef.current?.click(); }; + // Immediate upload to Firebase Storage + const uploadSingleFile = async (fileKey: string, file: File) => { + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { status: "uploading" }, + })); + + try { + const storage = getStorage(); + const storageRef = ref(storage, fileKey); + const snapshot = await uploadBytes(storageRef, file); + const downloadURL = await getDownloadURL(snapshot.ref); + + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { status: "success" }, + })); + + // Update the file URL in the state and questions + setUploadedFiles((prevFiles) => { + const updated = prevFiles.map((f) => + f.key === fileKey ? { ...f, url: downloadURL } : f + ); + updateQuestionsWithFiles(updated); + return updated; + }); + } catch (err) { + console.error(`Failed to upload file ${file.name}:`, err); + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { + status: "error", + error: err instanceof Error ? err.message : "Upload failed", + }, + })); + } + }; + const handleFileUpload = (e: React.ChangeEvent) => { const fileList = e.target.files ?? []; let files = Array.from(fileList).filter( @@ -274,71 +391,40 @@ export default function AdvancedTextbox({ alert( "No valid file selected (photo or audio). Try uploading again or contact support.", ); - return; // Early return if file is not defined + return; } - const storeAndAppendIfNewKey = ( - questionInput: QuestionInput, - files: File[], - ) => { - const newFiles = files.flatMap((file) => { - const fileKey = `${file.type}-${file.lastModified}`; - if (questionInput.files.map((file) => file.key).includes(fileKey)) { - return []; - } else { - storeFileInIndexedDB(fileKey, file); - return [ - { - key: fileKey, - name: file.name, - }, - ]; - } - }); - // Recreate array for set state - questionInput.files = [...questionInput.files, ...newFiles]; - }; + const newFiles: QuestionFile[] = []; + const filesToUpload: { key: string; file: File }[] = []; - const updatedQuestions = [...questions]; - const updatedQuestion: QuestionFormat = { ...questionInstance! }; + files.forEach((file) => { + // Create a unique key that works cleanly in storage + const sanitizedType = file.type.replace(/\//g, "-"); + const fileKey = `${sanitizedType}-${Date.now()}-${file.name}`; + + storeFileInIndexedDB(fileKey, file); - if (origin === "question") { - const questionInput: QuestionInput = { ...updatedQuestion.question }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.question = questionInput; - setUploadedFiles(questionInput.files); - } else if (origin === "option" && oIndex !== undefined) { - // Update a specific option by oIndex - const optionInput: QuestionInput = { - ...updatedQuestion.options[oIndex]!.value, - }; - storeAndAppendIfNewKey(optionInput, files); - updatedQuestion.options[oIndex]!.value = optionInput; // Update only the specified option - setUploadedFiles(optionInput.files); - } else if (origin === "explanation") { - const questionInput: QuestionInput = { - ...updatedQuestion.explanation, - }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.explanation = questionInput; - setUploadedFiles(questionInput.files); - } else if (origin === "content") { - const questionInput: QuestionInput = { ...updatedQuestion.content }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.content = questionInput; - setUploadedFiles(questionInput.files); - } - updatedQuestions[qIndex] = updatedQuestion; + newFiles.push({ + key: fileKey, + name: file.name, + order: uploadedFiles.length + newFiles.length, + }); - setQuestions(updatedQuestions); - setUnsavedChanges?.(true); + filesToUpload.push({ key: fileKey, file }); + }); + + const nextFiles = [...uploadedFiles, ...newFiles]; + setUploadedFiles(nextFiles); + updateQuestionsWithFiles(nextFiles); - // Reset input value so duplicate files can be reuploaded in the case of deletion - // Code logic will catch actual duplicates e.target.value = ""; + + // Trigger immediate uploads + filesToUpload.forEach(({ key, file }) => { + void uploadSingleFile(key, file); + }); }; - // Function to delete a file from Firebase Storage async function deleteFileFromStorage(fileKey: string): Promise { const user = await getUser(); @@ -355,57 +441,78 @@ export default function AdvancedTextbox({ await deleteObject(storageRef); } catch (error) { console.error(`Error deleting file ${fileKey} from storage:`, error); - // You might want to handle specific error codes here return; } } - const handleDeleteFile = (e: React.MouseEvent) => { - const fileKey = e.currentTarget.dataset.fileKey; + const handleDeleteFile = (fileKey: string) => { + const nextFiles = uploadedFiles.filter((file) => file.key !== fileKey); + setUploadedFiles(nextFiles); + updateQuestionsWithFiles(nextFiles); - if (!fileKey) { - alert("Error deleting file, please try again"); - return; - } + setUploadStatuses((prev) => { + const next = { ...prev }; + delete next[fileKey]; + return next; + }); - const updatedQuestions = [...questions]; - const updatedQuestion: QuestionFormat = { ...questionInstance! }; + void deleteFileFromIndexedDB(fileKey); + void deleteFileFromStorage(fileKey); + }; - const deleteFile = (question: QuestionInput) => { - deleteFileFromIndexedDB(fileKey).catch((error) => { - console.error("Error deleting file from IndexedDB:", error); - }); - deleteFileFromStorage(fileKey).catch((error) => { - console.error("Error deleting file from Storage:", error); - }); + const handleRetryUpload = async (fileKey: string, fileName: string) => { + try { + const stored = await getFileFromIndexedDB(fileKey); + if (stored?.file) { + void uploadSingleFile(fileKey, stored.file); + } else { + alert(`Could not find local file data for "${fileName}". Please remove and re-upload.`); + } + } catch (err) { + console.error("Retry load from IndexedDB failed:", err); + alert("Failed to retry. Please try uploading the file again."); + } + }; - question.files = question.files.filter((file) => file.key !== fileKey); - setUploadedFiles(question.files); - }; + const updateFileAlt = (fileKey: string, newAlt: string) => { + const updated = uploadedFiles.map((f) => + f.key === fileKey ? { ...f, alt: newAlt } : f + ); + setUploadedFiles(updated); + updateQuestionsWithFiles(updated); + }; - if ( - origin === "question" || - origin === "explanation" || - origin === "content" - ) { - const questionInput: QuestionInput = { ...updatedQuestion[origin] }; + const moveFile = (index: number, direction: "left" | "right") => { + const targetIndex = index + (direction === "left" ? -1 : 1); + if (targetIndex < 0 || targetIndex >= uploadedFiles.length) return; - deleteFile(questionInput); + const nextFiles = [...uploadedFiles]; + const temp = nextFiles[index]!; + nextFiles[index] = nextFiles[targetIndex]!; + nextFiles[targetIndex] = temp; - updatedQuestion[origin] = questionInput; - } else if (origin === "option" && oIndex !== undefined) { - const optionInput: QuestionInput = { - ...updatedQuestion.options[oIndex]!.value, - }; + const orderedFiles = nextFiles.map((f, idx) => ({ ...f, order: idx })); + setUploadedFiles(orderedFiles); + updateQuestionsWithFiles(orderedFiles); + }; - deleteFile(optionInput); + const insertPlaceholder = (file: QuestionFile, index: number) => { + const textarea = textareaRef.current; + if (!textarea) return; - updatedQuestion.options[oIndex]!.value = optionInput; - } + const placeholderText = `[image:${index + 1}]`; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; - updatedQuestions[qIndex] = updatedQuestion; - setQuestions(updatedQuestions); - setUnsavedChanges?.(true); + const newText = + currentText.substring(0, start) + placeholderText + currentText.substring(end); + + updateQuestionText(newText); + + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start + placeholderText.length, start + placeholderText.length); + }, 0); }; return ( @@ -417,7 +524,7 @@ export default function AdvancedTextbox({ onKeyDown={handleKeyDown} placeholder={ placeholder ?? - "Type or drag and drop here (only 1 file allowed). Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code." + "Type or drag and drop here. Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code. References to images can be made via [image:1] placeholders." } /> @@ -430,28 +537,128 @@ export default function AdvancedTextbox({ multiple /> - {/* Section under the textarea for upload and delete buttons */} -
- {uploadedFiles.length > 0 && - uploadedFiles.map((file) => ( -
- -
{file.name}
-
- ))} +
+
+ + {statusInfo.status === "uploading" && ( +
+
+
+ )} +
+ +
+
+ {file.name} +
+ + {statusInfo.status === "error" && ( +
+ + Upload failed: {statusInfo.error ?? "Unknown error"} + + +
+ )} + + {statusInfo.status === "success" && ( + + Uploaded + + )} +
+ + +
+ +
+
+ + updateFileAlt(file.key, e.target.value)} + className="flex h-7 w-full rounded border border-gray-200 bg-background px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 placeholder:text-gray-400" + /> +
+ +
+
+ + +
+ + +
+
+
+ ); + })} +
+ )} + +
diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index 2cfda0c3..fe497aff 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -2,11 +2,13 @@ import React, { useState, useEffect } from "react"; import katex from "katex"; import type { QuestionFile, QuestionInput } from "@/types/questions"; import "../../../styles/katexStyling.css"; -import Image from "next/image"; import { decodeEntities, katexMacros } from "../Renderer"; +import { cn } from "@/lib/utils"; + interface Props { content: QuestionInput; + origin?: "question" | "explanation" | "option" | "content"; } interface FileWrapper { @@ -100,14 +102,12 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("image/")) { return ( -
- + Uploaded image
); @@ -116,7 +116,7 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("audio/")) { return (
-
diff --git a/src/types/questions.ts b/src/types/questions.ts index 1b88859d..83def309 100644 --- a/src/types/questions.ts +++ b/src/types/questions.ts @@ -2,8 +2,12 @@ export interface QuestionFile { key: string; url?: string; name: string; + id?: string; + alt?: string; + order?: number; } + export interface QuestionInput { value: string; files: QuestionFile[]; From bbcd7a4ccbf2d47ed95ac26df12a4aa04c5447c8 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Mon, 8 Jun 2026 18:54:38 -0700 Subject: [PATCH 02/49] feat: initialize Firebase SDKs, admin configuration, environment validation, and CORS middleware for feedback system --- src/lib/firebase-admin.ts | 42 ++++++++++++++++++++++++++++ src/types/feedback.ts | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/lib/firebase-admin.ts create mode 100644 src/types/feedback.ts diff --git a/src/lib/firebase-admin.ts b/src/lib/firebase-admin.ts new file mode 100644 index 00000000..0b47ec7b --- /dev/null +++ b/src/lib/firebase-admin.ts @@ -0,0 +1,42 @@ +import { initializeApp, getApps, cert } from "firebase-admin/app"; +import { getFirestore } from "firebase-admin/firestore"; +import { getAuth } from "firebase-admin/auth"; + +function getFirebaseAdmin() { + if (getApps().length > 0) { + return { + adminDb: getFirestore(), + adminAuth: getAuth(), + }; + } + + // In development with emulators, we can initialize without credentials + // In production, use service account credentials from env vars + if (process.env.FIREBASE_ADMIN_SERVICE_ACCOUNT) { + const serviceAccount = JSON.parse( + process.env.FIREBASE_ADMIN_SERVICE_ACCOUNT, + ); + initializeApp({ + credential: cert(serviceAccount), + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + }); + } else { + // Auto-discover credentials (works in emulator and GCP environments) + initializeApp({ + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + }); + } + + const adminDb = getFirestore(); + const adminAuth = getAuth(); + + // Connect to emulators in development + if (process.env.NODE_ENV === "development") { + process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080"; + process.env.FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"; + } + + return { adminDb, adminAuth }; +} + +export const { adminDb, adminAuth } = getFirebaseAdmin(); diff --git a/src/types/feedback.ts b/src/types/feedback.ts new file mode 100644 index 00000000..637d81bf --- /dev/null +++ b/src/types/feedback.ts @@ -0,0 +1,58 @@ +import type { Timestamp } from "firebase/firestore"; + +export type FeedbackType = "bug" | "feedback" | "question"; + +export type BugType = + | "article-errors" + | "unit-test-errors" + | "mock-exam-errors" + | "website-bugs" + | "other"; + +export type FeedbackStatus = "pending" | "approved" | "rejected"; + +export interface FeedbackSubmission { + id: string; + contact: string; + type: FeedbackType; + // Bug-specific fields + bugType?: BugType; + bugUrl?: string; + // Common fields + description: string; + imageUrls: string[]; + // Metadata + status: FeedbackStatus; + submittedAt: Timestamp; + submittedBy: string | null; + // Review metadata (set after admin action) + reviewedAt?: Timestamp; + reviewedBy?: string; + githubIssueUrl?: string; + githubIssueNumber?: number; +} + +/** Human-readable display labels for bug sub-types */ +export const BUG_TYPE_LABELS: Record = { + "article-errors": "Website Article Errors", + "unit-test-errors": "Unit Test Errors", + "mock-exam-errors": "Mock Exam Errors", + "website-bugs": "Website Bugs", + other: "Other", +}; + +/** GitHub label mapping for bug sub-types */ +export const BUG_TYPE_GITHUB_LABELS: Record = { + "article-errors": "article-errors", + "unit-test-errors": "unit-tests", + "mock-exam-errors": "mock-exam-errors", + "website-bugs": "frontend/bug", + other: null, +}; + +/** GitHub label mapping for feedback types */ +export const FEEDBACK_TYPE_GITHUB_LABELS: Record = { + bug: "Bug", + feedback: "Feedback", + question: "Question", +}; From fe3f7a777d542f57fe45c650ce27f950e4ff8601 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Wed, 10 Jun 2026 11:54:25 -0700 Subject: [PATCH 03/49] fix issue --- src/app/admin/page.tsx | 3 - .../[slug]/[unit]/chapter/[id]/page.tsx | 10 +-- src/components/article-creator/Editor.tsx | 78 ++++++++++++++++++- .../custom_questions/AdvancedTextbox.tsx | 13 ++++ src/components/hooks/UserContext.tsx | 13 +++- 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 43ecd0cd..e7773a5b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,7 +10,6 @@ import { useUser } from "../../components/hooks/UserContext"; import Link from "next/link"; import { cn, formatSlug } from "@/lib/utils"; import { Ban, ClipboardPen, PencilRuler, ShieldUser, X } from "lucide-react"; -import AdminImport from "@/components/admin/AdminImport"; import { doc, updateDoc } from "firebase/firestore"; import { db } from "@/lib/firebase"; import { Button } from "@/components/ui/button"; @@ -44,8 +43,6 @@ const Page = () => { {user.access === "admin" && ( <> -

Validate Editor JSON

- )} diff --git a/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx b/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx index fc3fd585..76e25b08 100644 --- a/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx +++ b/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx @@ -1,9 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; -const ArticleCreator = dynamic( - () => import("@/components/article-creator/ArticleCreator"), - { ssr: false }, -); +import ArticleCreator from "@/components/article-creator/ArticleCreator"; import { useUser } from "@/components/hooks/UserContext"; import { buttonVariants } from "@/components/ui/button"; import { ArrowLeft, ExternalLink, UserRoundCog } from "lucide-react"; @@ -19,7 +15,7 @@ const Page = () => { const pathname = usePathname(); const searchParams = useSearchParams(); - const pathParts = pathname.split("/").slice(-4); + const pathParts = (pathname ?? "").split("/").slice(-4); useEffect(() => { if ((!user || user?.access === "user") && !loading) { @@ -28,7 +24,7 @@ const Page = () => { }, [user, loading, router]); return ( -
+
(); + +function isStorageObjectNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "storage/object-not-found" + ); +} + // https://github.com/editor-js/image/issues/54#issuecomment-1546833098 // https://github.com/editor-js/image/issues/27 class CustomImage extends Image { + renderSettings(): MenuConfig { + const typedTool = this as unknown as CustomImageTool; + const baseImageTool = Image as unknown as { + prototype: { + renderSettings(this: CustomImageTool): MenuConfig; + }; + }; + // The EditorJS image package ships loose inherited typings here, so we + // constrain the call at the boundary instead of widening the rest of the file. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access + const settings = baseImageTool.prototype.renderSettings.call(typedTool); + const settingsArray = (Array.isArray(settings) ? settings : [settings]) as unknown[]; + + return [ + { + name: "deleteImage", + icon: ``, + title: "Delete image", + onActivate: () => { + const { file } = typedTool._data; + const blockIndex = typedTool.api.blocks.getBlockIndex(typedTool.block.id); + + if (blockIndex === -1) { + return; + } + + if (file.storageRefFullPath) { + pendingStorageDeletes.add(file.storageRefFullPath); + } + + typedTool.api.blocks.delete(blockIndex); + }, + }, + ...settingsArray, + ] as unknown as MenuConfig; + } + removed() { const { file } = this._data as EditorImageData; if (!file.storageRefFullPath) return; + if (!pendingStorageDeletes.has(file.storageRefFullPath)) return; + + pendingStorageDeletes.delete(file.storageRefFullPath); const storage = getStorage(); const storageRef = ref(storage, file.storageRefFullPath); @@ -55,10 +121,14 @@ class CustomImage extends Image { console.log("Deleted " + file.storageRefFullPath); }) .catch((error) => { - console.log(error); - alert( - "Failed to delete image from Firebase Storage: notify FiveHive Website Team.\n" + - String(error), + if (isStorageObjectNotFoundError(error)) { + return; + } + + console.error( + "Failed to delete image from Firebase Storage:", + file.storageRefFullPath, + error, ); }); } diff --git a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx index 290883db..3abcd557 100644 --- a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx @@ -24,6 +24,15 @@ interface Props { setUnsavedChanges?: (unchangedChanges: boolean) => void; } +function isStorageObjectNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "storage/object-not-found" + ); +} + // Utility to store a file in IndexedDB with a unique key for each instance function storeFileInIndexedDB(name: string, file: File) { const dbRequest = indexedDB.open("mediaFilesDB", 3); @@ -440,6 +449,10 @@ export default function AdvancedTextbox({ if (!storageRef) return; await deleteObject(storageRef); } catch (error) { + if (isStorageObjectNotFoundError(error)) { + return; + } + console.error(`Error deleting file ${fileKey} from storage:`, error); return; } diff --git a/src/components/hooks/UserContext.tsx b/src/components/hooks/UserContext.tsx index 112112fa..ea5c8077 100644 --- a/src/components/hooks/UserContext.tsx +++ b/src/components/hooks/UserContext.tsx @@ -1,6 +1,8 @@ import React, { createContext, useState, useEffect, useContext } from "react"; import { getUser } from "./users"; import type { User } from "@/types/user"; +import { auth } from "@/lib/firebase"; +import { onAuthStateChanged } from "firebase/auth"; interface UserContextType { user: User | null; @@ -26,20 +28,25 @@ export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ setLoading(true); setError(null); const fetchedUser = await getUser(); - if (fetchedUser) { - setUser(fetchedUser); - } + setUser(fetchedUser); setLoading(false); } catch (err) { + setUser(null); setLoading(false); } }; useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, () => { + void fetchUser(); + }); + fetchUser().catch((error) => { console.error("Error fetching user:", error); setLoading(false); }); + + return () => unsubscribe(); }, []); const updateUser = async () => { From 3af692243749ffcddc15a62ec6a1ecea60745e34 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Wed, 10 Jun 2026 12:02:03 -0700 Subject: [PATCH 04/49] undo accidental commits from different feature --- src/lib/firebase-admin.ts | 42 ---------------------------- src/types/feedback.ts | 58 --------------------------------------- 2 files changed, 100 deletions(-) delete mode 100644 src/lib/firebase-admin.ts delete mode 100644 src/types/feedback.ts diff --git a/src/lib/firebase-admin.ts b/src/lib/firebase-admin.ts deleted file mode 100644 index 0b47ec7b..00000000 --- a/src/lib/firebase-admin.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { initializeApp, getApps, cert } from "firebase-admin/app"; -import { getFirestore } from "firebase-admin/firestore"; -import { getAuth } from "firebase-admin/auth"; - -function getFirebaseAdmin() { - if (getApps().length > 0) { - return { - adminDb: getFirestore(), - adminAuth: getAuth(), - }; - } - - // In development with emulators, we can initialize without credentials - // In production, use service account credentials from env vars - if (process.env.FIREBASE_ADMIN_SERVICE_ACCOUNT) { - const serviceAccount = JSON.parse( - process.env.FIREBASE_ADMIN_SERVICE_ACCOUNT, - ); - initializeApp({ - credential: cert(serviceAccount), - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - }); - } else { - // Auto-discover credentials (works in emulator and GCP environments) - initializeApp({ - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - }); - } - - const adminDb = getFirestore(); - const adminAuth = getAuth(); - - // Connect to emulators in development - if (process.env.NODE_ENV === "development") { - process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080"; - process.env.FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"; - } - - return { adminDb, adminAuth }; -} - -export const { adminDb, adminAuth } = getFirebaseAdmin(); diff --git a/src/types/feedback.ts b/src/types/feedback.ts deleted file mode 100644 index 637d81bf..00000000 --- a/src/types/feedback.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Timestamp } from "firebase/firestore"; - -export type FeedbackType = "bug" | "feedback" | "question"; - -export type BugType = - | "article-errors" - | "unit-test-errors" - | "mock-exam-errors" - | "website-bugs" - | "other"; - -export type FeedbackStatus = "pending" | "approved" | "rejected"; - -export interface FeedbackSubmission { - id: string; - contact: string; - type: FeedbackType; - // Bug-specific fields - bugType?: BugType; - bugUrl?: string; - // Common fields - description: string; - imageUrls: string[]; - // Metadata - status: FeedbackStatus; - submittedAt: Timestamp; - submittedBy: string | null; - // Review metadata (set after admin action) - reviewedAt?: Timestamp; - reviewedBy?: string; - githubIssueUrl?: string; - githubIssueNumber?: number; -} - -/** Human-readable display labels for bug sub-types */ -export const BUG_TYPE_LABELS: Record = { - "article-errors": "Website Article Errors", - "unit-test-errors": "Unit Test Errors", - "mock-exam-errors": "Mock Exam Errors", - "website-bugs": "Website Bugs", - other: "Other", -}; - -/** GitHub label mapping for bug sub-types */ -export const BUG_TYPE_GITHUB_LABELS: Record = { - "article-errors": "article-errors", - "unit-test-errors": "unit-tests", - "mock-exam-errors": "mock-exam-errors", - "website-bugs": "frontend/bug", - other: null, -}; - -/** GitHub label mapping for feedback types */ -export const FEEDBACK_TYPE_GITHUB_LABELS: Record = { - bug: "Bug", - feedback: "Feedback", - question: "Question", -}; From d90700bf87cff9078749b143948ae70120c13362 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Wed, 10 Jun 2026 12:04:06 -0700 Subject: [PATCH 05/49] undo changes from diff feature --- .../article-creator/FetchArticleFunctions.tsx | 6 +- .../custom_questions/AdvancedTextbox.tsx | 461 +++++------------- .../RenderAdvancedTextbox.tsx | 182 ++----- .../questions/checkForUnderstanding.tsx | 4 +- .../digital-testing/QuestionPanel.tsx | 6 +- src/components/questions/quizRenderer.tsx | 25 +- src/components/questions/testRenderer.tsx | 1 - src/types/questions.ts | 4 - 8 files changed, 190 insertions(+), 499 deletions(-) diff --git a/src/components/article-creator/FetchArticleFunctions.tsx b/src/components/article-creator/FetchArticleFunctions.tsx index ba51a29e..fb68455e 100644 --- a/src/components/article-creator/FetchArticleFunctions.tsx +++ b/src/components/article-creator/FetchArticleFunctions.tsx @@ -37,11 +37,7 @@ export const processQuestions = async ( ...question.options.flatMap((option) => option.value.files || []), ]; - allFiles.forEach((file) => { - if (!file.url?.startsWith("http")) { - allFileKeys.add(file.key); - } - }); + allFiles.forEach((file) => allFileKeys.add(file.key)); }); // Read all files from IndexedDB diff --git a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx index 3abcd557..17666f21 100644 --- a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx @@ -8,11 +8,12 @@ import { Textarea } from "@/components/ui/textarea"; import type { QuestionFile, QuestionFormat, + QuestionInput, } from "@/types/questions"; -import { Paperclip, Trash, ChevronLeft, ChevronRight } from "lucide-react"; -import { deleteObject, getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage"; +import { QuestionsInput } from "./QuestionInstance"; +import { Paperclip, Trash } from "lucide-react"; +import { deleteObject, getStorage, ref } from "firebase/storage"; import { getUser } from "@/components/hooks/users"; -import { getFileFromIndexedDB } from "./RenderAdvancedTextbox"; interface Props { questions: QuestionFormat[]; @@ -81,61 +82,6 @@ function deleteFileFromIndexedDB(name: string) { }); } -// Helper component to display visual thumbnails/previews of files in editing mode -const ThumbnailPreview: React.FC<{ file: QuestionFile }> = ({ file }) => { - const [src, setSrc] = useState(null); - - useEffect(() => { - let url: string | null = null; - if (file.url) { - setSrc(file.url); - return; - } - - const loadLocal = async () => { - try { - const stored = await getFileFromIndexedDB(file.key); - if (stored?.file) { - url = URL.createObjectURL(stored.file); - setSrc(url); - } - } catch (err) { - console.error("Error loading thumbnail:", err); - } - }; - void loadLocal(); - - return () => { - if (url) URL.revokeObjectURL(url); - }; - }, [file]); - - if (!src) { - return ( -
- Loading... -
- ); - } - - if (file.key.startsWith("image-") || file.key.startsWith("image/")) { - return ( - Thumbnail preview - ); - } - - return ( -
- - {file.name} -
- ); -}; - export default function AdvancedTextbox({ questions, qIndex, @@ -148,19 +94,16 @@ export default function AdvancedTextbox({ const questionInstance = questions[qIndex]; const [currentText, setCurrentText] = useState(""); const [uploadedFiles, setUploadedFiles] = useState([]); - const [uploadStatuses, setUploadStatuses] = useState< - Record - >({}); - const textareaRef = useRef(null); const fileInputRef = useRef(null); - // Sync state when loaded + // Initialize currentText and fileExists when question gets loaded from db if any useEffect(() => { if (origin === "option" && oIndex !== undefined) { if (questionInstance!.options[oIndex]?.value?.value) { setCurrentText(questionInstance!.options[oIndex].value.value); } + setUploadedFiles(questionInstance!.options[oIndex]?.value?.files ?? []); } else if ( origin === "question" || @@ -174,8 +117,8 @@ export default function AdvancedTextbox({ } }, [questionInstance, oIndex, origin]); - // Handle keys logic const handleKeyDown = (e: React.KeyboardEvent) => { + // Keys are being handled by EditorJS rather than default behavior, so we need to block the EditorJS behavior const key = e.key; if ( @@ -197,9 +140,11 @@ export default function AdvancedTextbox({ const textBeforeCursor = currentText.substring(0, cursorPosition); const textAfterCursor = currentText.substring(textarea.selectionEnd); + // Find the current line the cursor is on const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); + // Match leading whitespace const match = currentLine.match(/^\s*/); const leadingWhitespace = match ? match[0] : ""; @@ -226,9 +171,11 @@ export default function AdvancedTextbox({ const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); + // If the line up to the cursor is purely spaces, treat it as indentation if (currentLine.length > 0 && /^\s+$/.test(currentLine)) { e.preventDefault(); + // delete 2 spaces instead of 1 if possible const spacesToDelete = currentLine.length % 2 !== 0 ? 1 : 2; const newText = @@ -270,37 +217,10 @@ export default function AdvancedTextbox({ } }; - const updateQuestionsWithFiles = (newFiles: QuestionFile[]) => { - setUnsavedChanges?.(true); - const updatedQuestions = [...questions]; - const updatedQuestion = { ...questionInstance! }; - - if (origin === "question" || origin === "explanation" || origin === "content") { - updatedQuestion[origin] = { - ...updatedQuestion[origin], - files: newFiles, - }; - } else if (origin === "option" && oIndex !== undefined) { - updatedQuestion.options = [ - ...updatedQuestion.options.slice(0, oIndex), - { - value: { - ...updatedQuestion.options[oIndex]!.value, - files: newFiles, - }, - id: updatedQuestion.options[oIndex]!.id, - }, - ...updatedQuestion.options.slice(oIndex + 1), - ]; - } - - updatedQuestions[qIndex] = updatedQuestion; - setQuestions(updatedQuestions); - }; - const updateQuestionText = (newText: string) => { setUnsavedChanges?.(true); setCurrentText(newText); + // Clone the current question to avoid direct mutation const updatedQuestions = [...questions]; if ( origin === "question" || @@ -312,11 +232,12 @@ export default function AdvancedTextbox({ [origin]: { ...questionInstance![origin], value: newText, - files: questionInstance?.[origin]?.files ?? [], - }, + files: questionInstance?.[origin]?.files ?? [], // Keep the files they exist + }, // Clone question }; updatedQuestions[qIndex] = updatedQuestion; } else if (origin === "option" && oIndex !== undefined) { + // oIndex !== undefined because 0 is falsy const updatedQuestion: QuestionFormat = { ...questionInstance!, options: [ @@ -334,7 +255,7 @@ export default function AdvancedTextbox({ updatedQuestions[qIndex] = updatedQuestion; } - setQuestions(updatedQuestions); + setQuestions(updatedQuestions); // Update state immutably }; const handleTextChange = (e: React.ChangeEvent) => { @@ -345,44 +266,6 @@ export default function AdvancedTextbox({ fileInputRef.current?.click(); }; - // Immediate upload to Firebase Storage - const uploadSingleFile = async (fileKey: string, file: File) => { - setUploadStatuses((prev) => ({ - ...prev, - [fileKey]: { status: "uploading" }, - })); - - try { - const storage = getStorage(); - const storageRef = ref(storage, fileKey); - const snapshot = await uploadBytes(storageRef, file); - const downloadURL = await getDownloadURL(snapshot.ref); - - setUploadStatuses((prev) => ({ - ...prev, - [fileKey]: { status: "success" }, - })); - - // Update the file URL in the state and questions - setUploadedFiles((prevFiles) => { - const updated = prevFiles.map((f) => - f.key === fileKey ? { ...f, url: downloadURL } : f - ); - updateQuestionsWithFiles(updated); - return updated; - }); - } catch (err) { - console.error(`Failed to upload file ${file.name}:`, err); - setUploadStatuses((prev) => ({ - ...prev, - [fileKey]: { - status: "error", - error: err instanceof Error ? err.message : "Upload failed", - }, - })); - } - }; - const handleFileUpload = (e: React.ChangeEvent) => { const fileList = e.target.files ?? []; let files = Array.from(fileList).filter( @@ -400,40 +283,71 @@ export default function AdvancedTextbox({ alert( "No valid file selected (photo or audio). Try uploading again or contact support.", ); - return; + return; // Early return if file is not defined } - const newFiles: QuestionFile[] = []; - const filesToUpload: { key: string; file: File }[] = []; - - files.forEach((file) => { - // Create a unique key that works cleanly in storage - const sanitizedType = file.type.replace(/\//g, "-"); - const fileKey = `${sanitizedType}-${Date.now()}-${file.name}`; - - storeFileInIndexedDB(fileKey, file); - - newFiles.push({ - key: fileKey, - name: file.name, - order: uploadedFiles.length + newFiles.length, + const storeAndAppendIfNewKey = ( + questionInput: QuestionInput, + files: File[], + ) => { + const newFiles = files.flatMap((file) => { + const fileKey = `${file.type}-${file.lastModified}`; + if (questionInput.files.map((file) => file.key).includes(fileKey)) { + return []; + } else { + storeFileInIndexedDB(fileKey, file); + return [ + { + key: fileKey, + name: file.name, + }, + ]; + } }); + // Recreate array for set state + questionInput.files = [...questionInput.files, ...newFiles]; + }; + + const updatedQuestions = [...questions]; + const updatedQuestion: QuestionFormat = { ...questionInstance! }; - filesToUpload.push({ key: fileKey, file }); - }); + if (origin === "question") { + const questionInput: QuestionInput = { ...updatedQuestion.question }; + storeAndAppendIfNewKey(questionInput, files); + updatedQuestion.question = questionInput; + setUploadedFiles(questionInput.files); + } else if (origin === "option" && oIndex !== undefined) { + // Update a specific option by oIndex + const optionInput: QuestionInput = { + ...updatedQuestion.options[oIndex]!.value, + }; + storeAndAppendIfNewKey(optionInput, files); + updatedQuestion.options[oIndex]!.value = optionInput; // Update only the specified option + setUploadedFiles(optionInput.files); + } else if (origin === "explanation") { + const questionInput: QuestionInput = { + ...updatedQuestion.explanation, + }; + storeAndAppendIfNewKey(questionInput, files); + updatedQuestion.explanation = questionInput; + setUploadedFiles(questionInput.files); + } else if (origin === "content") { + const questionInput: QuestionInput = { ...updatedQuestion.content }; + storeAndAppendIfNewKey(questionInput, files); + updatedQuestion.content = questionInput; + setUploadedFiles(questionInput.files); + } + updatedQuestions[qIndex] = updatedQuestion; - const nextFiles = [...uploadedFiles, ...newFiles]; - setUploadedFiles(nextFiles); - updateQuestionsWithFiles(nextFiles); + setQuestions(updatedQuestions); + setUnsavedChanges?.(true); + // Reset input value so duplicate files can be reuploaded in the case of deletion + // Code logic will catch actual duplicates e.target.value = ""; - - // Trigger immediate uploads - filesToUpload.forEach(({ key, file }) => { - void uploadSingleFile(key, file); - }); }; + // Function to delete a file from Firebase Storage async function deleteFileFromStorage(fileKey: string): Promise { const user = await getUser(); @@ -454,78 +368,57 @@ export default function AdvancedTextbox({ } console.error(`Error deleting file ${fileKey} from storage:`, error); + // You might want to handle specific error codes here return; } } - const handleDeleteFile = (fileKey: string) => { - const nextFiles = uploadedFiles.filter((file) => file.key !== fileKey); - setUploadedFiles(nextFiles); - updateQuestionsWithFiles(nextFiles); + const handleDeleteFile = (e: React.MouseEvent) => { + const fileKey = e.currentTarget.dataset.fileKey; - setUploadStatuses((prev) => { - const next = { ...prev }; - delete next[fileKey]; - return next; - }); - - void deleteFileFromIndexedDB(fileKey); - void deleteFileFromStorage(fileKey); - }; - - const handleRetryUpload = async (fileKey: string, fileName: string) => { - try { - const stored = await getFileFromIndexedDB(fileKey); - if (stored?.file) { - void uploadSingleFile(fileKey, stored.file); - } else { - alert(`Could not find local file data for "${fileName}". Please remove and re-upload.`); - } - } catch (err) { - console.error("Retry load from IndexedDB failed:", err); - alert("Failed to retry. Please try uploading the file again."); + if (!fileKey) { + alert("Error deleting file, please try again"); + return; } - }; - const updateFileAlt = (fileKey: string, newAlt: string) => { - const updated = uploadedFiles.map((f) => - f.key === fileKey ? { ...f, alt: newAlt } : f - ); - setUploadedFiles(updated); - updateQuestionsWithFiles(updated); - }; + const updatedQuestions = [...questions]; + const updatedQuestion: QuestionFormat = { ...questionInstance! }; - const moveFile = (index: number, direction: "left" | "right") => { - const targetIndex = index + (direction === "left" ? -1 : 1); - if (targetIndex < 0 || targetIndex >= uploadedFiles.length) return; + const deleteFile = (question: QuestionInput) => { + deleteFileFromIndexedDB(fileKey).catch((error) => { + console.error("Error deleting file from IndexedDB:", error); + }); + deleteFileFromStorage(fileKey).catch((error) => { + console.error("Error deleting file from Storage:", error); + }); - const nextFiles = [...uploadedFiles]; - const temp = nextFiles[index]!; - nextFiles[index] = nextFiles[targetIndex]!; - nextFiles[targetIndex] = temp; + question.files = question.files.filter((file) => file.key !== fileKey); + setUploadedFiles(question.files); + }; - const orderedFiles = nextFiles.map((f, idx) => ({ ...f, order: idx })); - setUploadedFiles(orderedFiles); - updateQuestionsWithFiles(orderedFiles); - }; + if ( + origin === "question" || + origin === "explanation" || + origin === "content" + ) { + const questionInput: QuestionInput = { ...updatedQuestion[origin] }; - const insertPlaceholder = (file: QuestionFile, index: number) => { - const textarea = textareaRef.current; - if (!textarea) return; + deleteFile(questionInput); + + updatedQuestion[origin] = questionInput; + } else if (origin === "option" && oIndex !== undefined) { + const optionInput: QuestionInput = { + ...updatedQuestion.options[oIndex]!.value, + }; - const placeholderText = `[image:${index + 1}]`; - const start = textarea.selectionStart; - const end = textarea.selectionEnd; + deleteFile(optionInput); - const newText = - currentText.substring(0, start) + placeholderText + currentText.substring(end); - - updateQuestionText(newText); + updatedQuestion.options[oIndex]!.value = optionInput; + } - setTimeout(() => { - textarea.focus(); - textarea.setSelectionRange(start + placeholderText.length, start + placeholderText.length); - }, 0); + updatedQuestions[qIndex] = updatedQuestion; + setQuestions(updatedQuestions); + setUnsavedChanges?.(true); }; return ( @@ -537,7 +430,7 @@ export default function AdvancedTextbox({ onKeyDown={handleKeyDown} placeholder={ placeholder ?? - "Type or drag and drop here. Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code. References to images can be made via [image:1] placeholders." + "Type or drag and drop here (only 1 file allowed). Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code." } /> @@ -550,128 +443,28 @@ export default function AdvancedTextbox({ multiple /> - {/* Grid of uploaded file cards with visual preview, load states, and alt tag fields */} - {uploadedFiles.length > 0 && ( -
- {uploadedFiles.map((file, index) => { - const statusInfo = uploadStatuses[file.key] ?? { - status: file.url ? "success" : "uploading", - }; - return ( -
+ {uploadedFiles.length > 0 && + uploadedFiles.map((file) => ( +
+ -
- )} - - {statusInfo.status === "success" && ( - - Uploaded - - )} -
- - -
- -
-
- - updateFileAlt(file.key, e.target.value)} - className="flex h-7 w-full rounded border border-gray-200 bg-background px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 placeholder:text-gray-400" - /> -
- -
-
- - -
- - -
-
-
- ); - })} -
- )} - -
+ Delete file + +
{file.name}
+
+ ))}
diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index fe497aff..2cfda0c3 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -2,13 +2,11 @@ import React, { useState, useEffect } from "react"; import katex from "katex"; import type { QuestionFile, QuestionInput } from "@/types/questions"; import "../../../styles/katexStyling.css"; +import Image from "next/image"; import { decodeEntities, katexMacros } from "../Renderer"; -import { cn } from "@/lib/utils"; - interface Props { content: QuestionInput; - origin?: "question" | "explanation" | "option" | "content"; } interface FileWrapper { @@ -102,12 +100,14 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("image/")) { return ( -
- + {file.alt
); @@ -116,7 +116,7 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("audio/")) { return (
-
diff --git a/src/types/questions.ts b/src/types/questions.ts index 83def309..1b88859d 100644 --- a/src/types/questions.ts +++ b/src/types/questions.ts @@ -2,12 +2,8 @@ export interface QuestionFile { key: string; url?: string; name: string; - id?: string; - alt?: string; - order?: number; } - export interface QuestionInput { value: string; files: QuestionFile[]; From 57460bd227846690c4e473239d8d1eea4aeba7ae Mon Sep 17 00:00:00 2001 From: Venkata_Beast_King <87732284+Famousmaster206@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:38:01 -0700 Subject: [PATCH 06/49] remove type declarations to prevent build error --- src/components/article-creator/Editor.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/article-creator/Editor.tsx b/src/components/article-creator/Editor.tsx index 5fddd6cc..f8b93506 100644 --- a/src/components/article-creator/Editor.tsx +++ b/src/components/article-creator/Editor.tsx @@ -1,10 +1,5 @@ import React, { memo, useEffect, useRef, useState } from "react"; -import { - type EditorConfig, - type MenuConfig, - type ToolConstructable, - type OutputData, -} from "@editorjs/editorjs"; +import {EditorConfig, MenuConfig, ToolConstructable, OutputData} from "@editorjs/editorjs"; import useEditor from "hooks/useEditor"; import Header from "@editorjs/header"; From 1e9f0c82b099f9a354143a208532f6f18d09e383 Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Wed, 10 Jun 2026 12:56:31 -0700 Subject: [PATCH 07/49] change thing to avoid build error --- src/components/article-creator/Editor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/article-creator/Editor.tsx b/src/components/article-creator/Editor.tsx index 5fddd6cc..2985b76c 100644 --- a/src/components/article-creator/Editor.tsx +++ b/src/components/article-creator/Editor.tsx @@ -1,10 +1,9 @@ import React, { memo, useEffect, useRef, useState } from "react"; import { type EditorConfig, - type MenuConfig, - type ToolConstructable, type OutputData, } from "@editorjs/editorjs"; +import type { MenuConfig, ToolConstructable } from "@editorjs/editorjs"; import useEditor from "hooks/useEditor"; import Header from "@editorjs/header"; From d8226d91c73a4c2a39b2e5e2fbe051b78bf204ed Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Wed, 10 Jun 2026 13:35:20 -0700 Subject: [PATCH 08/49] fix build issue --- src/components/article-creator/Editor.tsx | 13 ++++++------- src/components/global/SearchBar.tsx | 2 +- src/components/questions/digital-testing/Footer.tsx | 2 +- src/components/questions/digital-testing/Header.tsx | 2 +- .../questions/digital-testing/ReviewPage.tsx | 2 +- src/components/subject/table-of-contents.tsx | 4 ++-- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/article-creator/Editor.tsx b/src/components/article-creator/Editor.tsx index 657e9c21..e6c6bcb9 100644 --- a/src/components/article-creator/Editor.tsx +++ b/src/components/article-creator/Editor.tsx @@ -2,6 +2,7 @@ import React, { memo, useEffect, useRef, useState } from "react"; import { type EditorConfig, type OutputData, + type ToolConstructable, } from "@editorjs/editorjs"; import useEditor from "hooks/useEditor"; @@ -39,7 +40,7 @@ interface EditorImageData { stretched?: boolean; } -type MenuConfig = Array<{ +type MenuConfigItemList = Array<{ name: string; icon: string; title: string; @@ -47,8 +48,6 @@ type MenuConfig = Array<{ toggle?: boolean; }>; -type ToolConstructable = any; - type CustomImageTool = { _data: EditorImageData; api: { @@ -60,7 +59,7 @@ type CustomImageTool = { block: { id: string; }; - renderSettings(): MenuConfig; + renderSettings(): MenuConfigItemList; }; const pendingStorageDeletes = new Set(); @@ -77,11 +76,11 @@ function isStorageObjectNotFoundError(error: unknown): boolean { // https://github.com/editor-js/image/issues/54#issuecomment-1546833098 // https://github.com/editor-js/image/issues/27 class CustomImage extends Image { - renderSettings(): MenuConfig { + renderSettings(): MenuConfigItemList { const typedTool = this as unknown as CustomImageTool; const baseImageTool = Image as unknown as { prototype: { - renderSettings(this: CustomImageTool): MenuConfig; + renderSettings(this: CustomImageTool): MenuConfigItemList; }; }; // The EditorJS image package ships loose inherited typings here, so we @@ -111,7 +110,7 @@ class CustomImage extends Image { }, }, ...settingsArray, - ] as unknown as MenuConfig; + ] as unknown as MenuConfigItemList; } removed() { diff --git a/src/components/global/SearchBar.tsx b/src/components/global/SearchBar.tsx index 329a242a..965ef12f 100644 --- a/src/components/global/SearchBar.tsx +++ b/src/components/global/SearchBar.tsx @@ -37,7 +37,7 @@ const SearchBar = ({ const containerRef = useRef(null); const startedRef = useRef(false); - const pathname = usePathname(); + const pathname = usePathname() ?? ""; const listboxId = useId(); const canPreview = isPreviewUser(user?.access); diff --git a/src/components/questions/digital-testing/Footer.tsx b/src/components/questions/digital-testing/Footer.tsx index 1cbb8069..dd30ede5 100644 --- a/src/components/questions/digital-testing/Footer.tsx +++ b/src/components/questions/digital-testing/Footer.tsx @@ -36,7 +36,7 @@ export default function Footer({ testName, }: FooterProps) { const router = useRouter(); - const pathname = usePathname(); + const pathname = usePathname() ?? ""; const handleNext = () => { if (showReviewPage) { diff --git a/src/components/questions/digital-testing/Header.tsx b/src/components/questions/digital-testing/Header.tsx index b428b8fe..6c0fe6a3 100644 --- a/src/components/questions/digital-testing/Header.tsx +++ b/src/components/questions/digital-testing/Header.tsx @@ -21,7 +21,7 @@ const Header: React.FC = ({ submitted, directions, }) => { - const pathname = usePathname(); + const pathname = usePathname() ?? ""; const [showTimer, setShowTimer] = useState(true); const [remainingTime, setRemainingTime] = useState(timeRemaining); const [showDirections, setShowDirections] = useState(true); diff --git a/src/components/questions/digital-testing/ReviewPage.tsx b/src/components/questions/digital-testing/ReviewPage.tsx index ca9929de..80c473ca 100644 --- a/src/components/questions/digital-testing/ReviewPage.tsx +++ b/src/components/questions/digital-testing/ReviewPage.tsx @@ -37,7 +37,7 @@ export default function QuestionNavigation({ setShowReviewPage, submitted, }: FooterProps) { - const pathname = usePathname(); + const pathname = usePathname() ?? ""; return ( <> diff --git a/src/components/subject/table-of-contents.tsx b/src/components/subject/table-of-contents.tsx index 05f5e180..d546a4a3 100644 --- a/src/components/subject/table-of-contents.tsx +++ b/src/components/subject/table-of-contents.tsx @@ -14,7 +14,7 @@ type Props = { }; const TableOfContents = ({ title, subject }: Props) => { - const router = usePathname(); + const pathname = usePathname() ?? ""; const [collapsed, setCollapsed] = useState(false); return ( @@ -51,7 +51,7 @@ const TableOfContents = ({ title, subject }: Props) => { {subject.units.map((unit, unitIndex) => (
Date: Mon, 8 Jun 2026 10:50:33 -0700 Subject: [PATCH 09/49] v1 --- .../article-creator/FetchArticleFunctions.tsx | 6 +- .../custom_questions/AdvancedTextbox.tsx | 461 +++++++++++++----- .../RenderAdvancedTextbox.tsx | 182 +++++-- .../questions/checkForUnderstanding.tsx | 4 +- .../digital-testing/QuestionPanel.tsx | 6 +- src/components/questions/quizRenderer.tsx | 25 +- src/components/questions/testRenderer.tsx | 1 + src/types/questions.ts | 4 + 8 files changed, 499 insertions(+), 190 deletions(-) diff --git a/src/components/article-creator/FetchArticleFunctions.tsx b/src/components/article-creator/FetchArticleFunctions.tsx index fb68455e..ba51a29e 100644 --- a/src/components/article-creator/FetchArticleFunctions.tsx +++ b/src/components/article-creator/FetchArticleFunctions.tsx @@ -37,7 +37,11 @@ export const processQuestions = async ( ...question.options.flatMap((option) => option.value.files || []), ]; - allFiles.forEach((file) => allFileKeys.add(file.key)); + allFiles.forEach((file) => { + if (!file.url?.startsWith("http")) { + allFileKeys.add(file.key); + } + }); }); // Read all files from IndexedDB diff --git a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx index 17666f21..3abcd557 100644 --- a/src/components/article-creator/custom_questions/AdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/AdvancedTextbox.tsx @@ -8,12 +8,11 @@ import { Textarea } from "@/components/ui/textarea"; import type { QuestionFile, QuestionFormat, - QuestionInput, } from "@/types/questions"; -import { QuestionsInput } from "./QuestionInstance"; -import { Paperclip, Trash } from "lucide-react"; -import { deleteObject, getStorage, ref } from "firebase/storage"; +import { Paperclip, Trash, ChevronLeft, ChevronRight } from "lucide-react"; +import { deleteObject, getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage"; import { getUser } from "@/components/hooks/users"; +import { getFileFromIndexedDB } from "./RenderAdvancedTextbox"; interface Props { questions: QuestionFormat[]; @@ -82,6 +81,61 @@ function deleteFileFromIndexedDB(name: string) { }); } +// Helper component to display visual thumbnails/previews of files in editing mode +const ThumbnailPreview: React.FC<{ file: QuestionFile }> = ({ file }) => { + const [src, setSrc] = useState(null); + + useEffect(() => { + let url: string | null = null; + if (file.url) { + setSrc(file.url); + return; + } + + const loadLocal = async () => { + try { + const stored = await getFileFromIndexedDB(file.key); + if (stored?.file) { + url = URL.createObjectURL(stored.file); + setSrc(url); + } + } catch (err) { + console.error("Error loading thumbnail:", err); + } + }; + void loadLocal(); + + return () => { + if (url) URL.revokeObjectURL(url); + }; + }, [file]); + + if (!src) { + return ( +
+ Loading... +
+ ); + } + + if (file.key.startsWith("image-") || file.key.startsWith("image/")) { + return ( + Thumbnail preview + ); + } + + return ( +
+ + {file.name} +
+ ); +}; + export default function AdvancedTextbox({ questions, qIndex, @@ -94,16 +148,19 @@ export default function AdvancedTextbox({ const questionInstance = questions[qIndex]; const [currentText, setCurrentText] = useState(""); const [uploadedFiles, setUploadedFiles] = useState([]); + const [uploadStatuses, setUploadStatuses] = useState< + Record + >({}); + const textareaRef = useRef(null); const fileInputRef = useRef(null); - // Initialize currentText and fileExists when question gets loaded from db if any + // Sync state when loaded useEffect(() => { if (origin === "option" && oIndex !== undefined) { if (questionInstance!.options[oIndex]?.value?.value) { setCurrentText(questionInstance!.options[oIndex].value.value); } - setUploadedFiles(questionInstance!.options[oIndex]?.value?.files ?? []); } else if ( origin === "question" || @@ -117,8 +174,8 @@ export default function AdvancedTextbox({ } }, [questionInstance, oIndex, origin]); + // Handle keys logic const handleKeyDown = (e: React.KeyboardEvent) => { - // Keys are being handled by EditorJS rather than default behavior, so we need to block the EditorJS behavior const key = e.key; if ( @@ -140,11 +197,9 @@ export default function AdvancedTextbox({ const textBeforeCursor = currentText.substring(0, cursorPosition); const textAfterCursor = currentText.substring(textarea.selectionEnd); - // Find the current line the cursor is on const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - // Match leading whitespace const match = currentLine.match(/^\s*/); const leadingWhitespace = match ? match[0] : ""; @@ -171,11 +226,9 @@ export default function AdvancedTextbox({ const lastNewLineIndex = textBeforeCursor.lastIndexOf("\n"); const currentLine = textBeforeCursor.substring(lastNewLineIndex + 1); - // If the line up to the cursor is purely spaces, treat it as indentation if (currentLine.length > 0 && /^\s+$/.test(currentLine)) { e.preventDefault(); - // delete 2 spaces instead of 1 if possible const spacesToDelete = currentLine.length % 2 !== 0 ? 1 : 2; const newText = @@ -217,10 +270,37 @@ export default function AdvancedTextbox({ } }; + const updateQuestionsWithFiles = (newFiles: QuestionFile[]) => { + setUnsavedChanges?.(true); + const updatedQuestions = [...questions]; + const updatedQuestion = { ...questionInstance! }; + + if (origin === "question" || origin === "explanation" || origin === "content") { + updatedQuestion[origin] = { + ...updatedQuestion[origin], + files: newFiles, + }; + } else if (origin === "option" && oIndex !== undefined) { + updatedQuestion.options = [ + ...updatedQuestion.options.slice(0, oIndex), + { + value: { + ...updatedQuestion.options[oIndex]!.value, + files: newFiles, + }, + id: updatedQuestion.options[oIndex]!.id, + }, + ...updatedQuestion.options.slice(oIndex + 1), + ]; + } + + updatedQuestions[qIndex] = updatedQuestion; + setQuestions(updatedQuestions); + }; + const updateQuestionText = (newText: string) => { setUnsavedChanges?.(true); setCurrentText(newText); - // Clone the current question to avoid direct mutation const updatedQuestions = [...questions]; if ( origin === "question" || @@ -232,12 +312,11 @@ export default function AdvancedTextbox({ [origin]: { ...questionInstance![origin], value: newText, - files: questionInstance?.[origin]?.files ?? [], // Keep the files they exist - }, // Clone question + files: questionInstance?.[origin]?.files ?? [], + }, }; updatedQuestions[qIndex] = updatedQuestion; } else if (origin === "option" && oIndex !== undefined) { - // oIndex !== undefined because 0 is falsy const updatedQuestion: QuestionFormat = { ...questionInstance!, options: [ @@ -255,7 +334,7 @@ export default function AdvancedTextbox({ updatedQuestions[qIndex] = updatedQuestion; } - setQuestions(updatedQuestions); // Update state immutably + setQuestions(updatedQuestions); }; const handleTextChange = (e: React.ChangeEvent) => { @@ -266,6 +345,44 @@ export default function AdvancedTextbox({ fileInputRef.current?.click(); }; + // Immediate upload to Firebase Storage + const uploadSingleFile = async (fileKey: string, file: File) => { + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { status: "uploading" }, + })); + + try { + const storage = getStorage(); + const storageRef = ref(storage, fileKey); + const snapshot = await uploadBytes(storageRef, file); + const downloadURL = await getDownloadURL(snapshot.ref); + + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { status: "success" }, + })); + + // Update the file URL in the state and questions + setUploadedFiles((prevFiles) => { + const updated = prevFiles.map((f) => + f.key === fileKey ? { ...f, url: downloadURL } : f + ); + updateQuestionsWithFiles(updated); + return updated; + }); + } catch (err) { + console.error(`Failed to upload file ${file.name}:`, err); + setUploadStatuses((prev) => ({ + ...prev, + [fileKey]: { + status: "error", + error: err instanceof Error ? err.message : "Upload failed", + }, + })); + } + }; + const handleFileUpload = (e: React.ChangeEvent) => { const fileList = e.target.files ?? []; let files = Array.from(fileList).filter( @@ -283,71 +400,40 @@ export default function AdvancedTextbox({ alert( "No valid file selected (photo or audio). Try uploading again or contact support.", ); - return; // Early return if file is not defined + return; } - const storeAndAppendIfNewKey = ( - questionInput: QuestionInput, - files: File[], - ) => { - const newFiles = files.flatMap((file) => { - const fileKey = `${file.type}-${file.lastModified}`; - if (questionInput.files.map((file) => file.key).includes(fileKey)) { - return []; - } else { - storeFileInIndexedDB(fileKey, file); - return [ - { - key: fileKey, - name: file.name, - }, - ]; - } - }); - // Recreate array for set state - questionInput.files = [...questionInput.files, ...newFiles]; - }; + const newFiles: QuestionFile[] = []; + const filesToUpload: { key: string; file: File }[] = []; - const updatedQuestions = [...questions]; - const updatedQuestion: QuestionFormat = { ...questionInstance! }; + files.forEach((file) => { + // Create a unique key that works cleanly in storage + const sanitizedType = file.type.replace(/\//g, "-"); + const fileKey = `${sanitizedType}-${Date.now()}-${file.name}`; + + storeFileInIndexedDB(fileKey, file); - if (origin === "question") { - const questionInput: QuestionInput = { ...updatedQuestion.question }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.question = questionInput; - setUploadedFiles(questionInput.files); - } else if (origin === "option" && oIndex !== undefined) { - // Update a specific option by oIndex - const optionInput: QuestionInput = { - ...updatedQuestion.options[oIndex]!.value, - }; - storeAndAppendIfNewKey(optionInput, files); - updatedQuestion.options[oIndex]!.value = optionInput; // Update only the specified option - setUploadedFiles(optionInput.files); - } else if (origin === "explanation") { - const questionInput: QuestionInput = { - ...updatedQuestion.explanation, - }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.explanation = questionInput; - setUploadedFiles(questionInput.files); - } else if (origin === "content") { - const questionInput: QuestionInput = { ...updatedQuestion.content }; - storeAndAppendIfNewKey(questionInput, files); - updatedQuestion.content = questionInput; - setUploadedFiles(questionInput.files); - } - updatedQuestions[qIndex] = updatedQuestion; + newFiles.push({ + key: fileKey, + name: file.name, + order: uploadedFiles.length + newFiles.length, + }); - setQuestions(updatedQuestions); - setUnsavedChanges?.(true); + filesToUpload.push({ key: fileKey, file }); + }); + + const nextFiles = [...uploadedFiles, ...newFiles]; + setUploadedFiles(nextFiles); + updateQuestionsWithFiles(nextFiles); - // Reset input value so duplicate files can be reuploaded in the case of deletion - // Code logic will catch actual duplicates e.target.value = ""; + + // Trigger immediate uploads + filesToUpload.forEach(({ key, file }) => { + void uploadSingleFile(key, file); + }); }; - // Function to delete a file from Firebase Storage async function deleteFileFromStorage(fileKey: string): Promise { const user = await getUser(); @@ -368,57 +454,78 @@ export default function AdvancedTextbox({ } console.error(`Error deleting file ${fileKey} from storage:`, error); - // You might want to handle specific error codes here return; } } - const handleDeleteFile = (e: React.MouseEvent) => { - const fileKey = e.currentTarget.dataset.fileKey; + const handleDeleteFile = (fileKey: string) => { + const nextFiles = uploadedFiles.filter((file) => file.key !== fileKey); + setUploadedFiles(nextFiles); + updateQuestionsWithFiles(nextFiles); - if (!fileKey) { - alert("Error deleting file, please try again"); - return; - } + setUploadStatuses((prev) => { + const next = { ...prev }; + delete next[fileKey]; + return next; + }); - const updatedQuestions = [...questions]; - const updatedQuestion: QuestionFormat = { ...questionInstance! }; + void deleteFileFromIndexedDB(fileKey); + void deleteFileFromStorage(fileKey); + }; - const deleteFile = (question: QuestionInput) => { - deleteFileFromIndexedDB(fileKey).catch((error) => { - console.error("Error deleting file from IndexedDB:", error); - }); - deleteFileFromStorage(fileKey).catch((error) => { - console.error("Error deleting file from Storage:", error); - }); + const handleRetryUpload = async (fileKey: string, fileName: string) => { + try { + const stored = await getFileFromIndexedDB(fileKey); + if (stored?.file) { + void uploadSingleFile(fileKey, stored.file); + } else { + alert(`Could not find local file data for "${fileName}". Please remove and re-upload.`); + } + } catch (err) { + console.error("Retry load from IndexedDB failed:", err); + alert("Failed to retry. Please try uploading the file again."); + } + }; - question.files = question.files.filter((file) => file.key !== fileKey); - setUploadedFiles(question.files); - }; + const updateFileAlt = (fileKey: string, newAlt: string) => { + const updated = uploadedFiles.map((f) => + f.key === fileKey ? { ...f, alt: newAlt } : f + ); + setUploadedFiles(updated); + updateQuestionsWithFiles(updated); + }; - if ( - origin === "question" || - origin === "explanation" || - origin === "content" - ) { - const questionInput: QuestionInput = { ...updatedQuestion[origin] }; + const moveFile = (index: number, direction: "left" | "right") => { + const targetIndex = index + (direction === "left" ? -1 : 1); + if (targetIndex < 0 || targetIndex >= uploadedFiles.length) return; - deleteFile(questionInput); + const nextFiles = [...uploadedFiles]; + const temp = nextFiles[index]!; + nextFiles[index] = nextFiles[targetIndex]!; + nextFiles[targetIndex] = temp; - updatedQuestion[origin] = questionInput; - } else if (origin === "option" && oIndex !== undefined) { - const optionInput: QuestionInput = { - ...updatedQuestion.options[oIndex]!.value, - }; + const orderedFiles = nextFiles.map((f, idx) => ({ ...f, order: idx })); + setUploadedFiles(orderedFiles); + updateQuestionsWithFiles(orderedFiles); + }; - deleteFile(optionInput); + const insertPlaceholder = (file: QuestionFile, index: number) => { + const textarea = textareaRef.current; + if (!textarea) return; - updatedQuestion.options[oIndex]!.value = optionInput; - } + const placeholderText = `[image:${index + 1}]`; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; - updatedQuestions[qIndex] = updatedQuestion; - setQuestions(updatedQuestions); - setUnsavedChanges?.(true); + const newText = + currentText.substring(0, start) + placeholderText + currentText.substring(end); + + updateQuestionText(newText); + + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start + placeholderText.length, start + placeholderText.length); + }, 0); }; return ( @@ -430,7 +537,7 @@ export default function AdvancedTextbox({ onKeyDown={handleKeyDown} placeholder={ placeholder ?? - "Type or drag and drop here (only 1 file allowed). Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code." + "Type or drag and drop here. Latex syntax starts with $@ and ends with $ (eg: $@e^{ipi} + 1 = 0$). Code blocks use ``` around the code. References to images can be made via [image:1] placeholders." } /> @@ -443,28 +550,128 @@ export default function AdvancedTextbox({ multiple /> - {/* Section under the textarea for upload and delete buttons */} -
- {uploadedFiles.length > 0 && - uploadedFiles.map((file) => ( -
- -
{file.name}
-
- ))} +
+
+ + {statusInfo.status === "uploading" && ( +
+
+
+ )} +
+ +
+
+ {file.name} +
+ + {statusInfo.status === "error" && ( +
+ + Upload failed: {statusInfo.error ?? "Unknown error"} + + +
+ )} + + {statusInfo.status === "success" && ( + + Uploaded + + )} +
+ + +
+ +
+
+ + updateFileAlt(file.key, e.target.value)} + className="flex h-7 w-full rounded border border-gray-200 bg-background px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-300 placeholder:text-gray-400" + /> +
+ +
+
+ + +
+ + +
+
+
+ ); + })} +
+ )} + +
diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index 2cfda0c3..fe497aff 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -2,11 +2,13 @@ import React, { useState, useEffect } from "react"; import katex from "katex"; import type { QuestionFile, QuestionInput } from "@/types/questions"; import "../../../styles/katexStyling.css"; -import Image from "next/image"; import { decodeEntities, katexMacros } from "../Renderer"; +import { cn } from "@/lib/utils"; + interface Props { content: QuestionInput; + origin?: "question" | "explanation" | "option" | "content"; } interface FileWrapper { @@ -100,14 +102,12 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("image/")) { return ( -
- + Uploaded image
); @@ -116,7 +116,7 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (file.key.startsWith("audio/")) { return (
-
diff --git a/src/types/questions.ts b/src/types/questions.ts index 1b88859d..83def309 100644 --- a/src/types/questions.ts +++ b/src/types/questions.ts @@ -2,8 +2,12 @@ export interface QuestionFile { key: string; url?: string; name: string; + id?: string; + alt?: string; + order?: number; } + export interface QuestionInput { value: string; files: QuestionFile[]; From 238c32360a8ca970d24b624e0aa5295365f7db8c Mon Sep 17 00:00:00 2001 From: venkata_beast_king Date: Thu, 11 Jun 2026 09:14:29 -0700 Subject: [PATCH 10/49] fix rendering on student-side --- .../custom_questions/RenderAdvancedTextbox.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index fe497aff..5b7ef019 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -15,6 +15,12 @@ interface FileWrapper { file: File; } +const isImageFileKey = (key: string) => + key.startsWith("image/") || key.startsWith("image-"); + +const isAudioFileKey = (key: string) => + key.startsWith("audio/") || key.startsWith("audio-"); + // Utility to retrieve a file from IndexedDB based on unique ID export function getFileFromIndexedDB( name: string, @@ -100,7 +106,7 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { if (!objectUrl) return null; - if (file.key.startsWith("image/")) { + if (isImageFileKey(file.key)) { return (
= ({ file }) => { ); } - if (file.key.startsWith("audio/")) { + if (isAudioFileKey(file.key)) { return (