diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index 473debc3..b5862257 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -4,7 +4,7 @@ import type { QuestionFile, QuestionInput } from "@/types/questions"; import "../../../styles/katexStyling.css"; import { decodeEntities, katexMacros } from "../Renderer"; -import { cn, isSvgFileName } from "@/lib/utils"; +import { cn, isSvgFileName, parseSvgIntrinsicSize } from "@/lib/utils"; interface Props { content: QuestionInput; @@ -75,6 +75,10 @@ export function getFileFromIndexedDB( const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { const [objectUrl, setObjectUrl] = useState(null); + const [svgSize, setSvgSize] = useState<{ + width: number; + height: number; + } | null>(null); useEffect(() => { let url: string | null = null; @@ -104,13 +108,40 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { }; }, [file]); + const isSvg = isSvgFileName(file.name) || file.key.startsWith("image-svg"); + + // SVGs typically only declare a viewBox (no width/height), so has no + // intrinsic size to fall back on and can collapse to the browser's 300x150 + // replaced-element default. Fetch the raw markup and derive the aspect + // ratio so SVGs scale the same way PNG/JPG do instead of a fixed width. + useEffect(() => { + setSvgSize(null); + if (!objectUrl || !isSvg) return; + + const controller = new AbortController(); + + fetch(objectUrl, { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.text(); + }) + .then((markup) => { + if (!controller.signal.aborted) + setSvgSize(parseSvgIntrinsicSize(markup)); + }) + .catch((error) => { + if (controller.signal.aborted) return; + console.error("Error reading SVG dimensions:", error); + }); + + return () => { + controller.abort(); + }; + }, [objectUrl, isSvg]); + if (!objectUrl) return null; if (isImageFileKey(file.key)) { - // SVGs frequently omit intrinsic dimensions (viewBox only), which makes them - // collapse or overflow under width/height auto. Give them a definite, - // responsive width instead. - const isSvg = isSvgFileName(file.name) || file.key.startsWith("image-svg"); return (
= ({ file }) => { loading="lazy" className={cn( "rounded-md object-contain shadow-sm transition-shadow duration-200 hover:shadow-md", - isSvg + isSvg && !svgSize ? "h-auto w-full max-w-[450px]" : "h-auto max-h-[450px] w-auto max-w-full", )} + style={ + isSvg && svgSize + ? { aspectRatio: `${svgSize.width} / ${svgSize.height}` } + : undefined + } />
); diff --git a/src/components/frq/editorFooter.tsx b/src/components/frq/editorFooter.tsx new file mode 100644 index 00000000..037e9a68 --- /dev/null +++ b/src/components/frq/editorFooter.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ChevronUp, MapPin, Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; + +type BatchVisibility = "public" | "private"; + +interface FRQNavigationItem { + id: string; + title: string; +} + +interface FRQEditorFooterProps { + frqs: FRQNavigationItem[]; + currentFrqIndex: number; + batchName: string; + batchVisibility: BatchVisibility; + onBatchNameChange: (name: string) => void; + onBatchVisibilityChange: (visibility: BatchVisibility) => void; + onSelectFrq: (index: number) => void; + onPrevious: () => void; + onNext: () => void; +} + +const FRQEditorFooter = ({ + frqs, + currentFrqIndex, + batchName, + batchVisibility, + onBatchNameChange, + onBatchVisibilityChange, + onSelectFrq, + onPrevious, + onNext, +}: FRQEditorFooterProps) => { + // Controlled so picking an FRQ closes the panel instead of leaving it parked + // over the footer, matching frq/FRQFooter.tsx on the other FRQ pages. + const [navigationOpen, setNavigationOpen] = useState(false); + + const selectFrq = (index: number) => { + onSelectFrq(index); + setNavigationOpen(false); + }; + + return ( + + ); +}; + +export default FRQEditorFooter; diff --git a/src/components/frq/editorRenderer.tsx b/src/components/frq/editorRenderer.tsx index 0b93bd48..4779df63 100644 --- a/src/components/frq/editorRenderer.tsx +++ b/src/components/frq/editorRenderer.tsx @@ -1,15 +1,718 @@ +"use client"; + +import AdvancedTextbox from "@/components/article-creator/custom_questions/AdvancedTextbox"; +import FRQEditorFooter from "@/components/frq/editorFooter"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { QuestionFormat, QuestionInput } from "@/types/questions"; +import { Clock3, Eye, Info, Plus, Save, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; + +type QuestionStatus = "public" | "legacy"; +type InputType = "text" | "equation"; +type BatchVisibility = "public" | "private"; + +interface GradingCriterion { + id: string; + description: string; + points: number; +} + +interface EditorQuestion { + id: string; + questionData: QuestionFormat; + status: QuestionStatus; + inputType: InputType; + criteria: GradingCriterion[]; +} + +interface EditorFRQ { + id: string; + title: string; + description: QuestionInput; + questions: EditorQuestion[]; +} + interface FRQEditorRendererProps { frqFound: boolean; } -const FRQEditorRenderer = ({ - frqFound, -}: FRQEditorRendererProps) => { +const createQuestionInput = (value = ""): QuestionInput => ({ + value, + files: [], +}); + +/** + * Unique, immutable ID built from the current time plus a short random suffix, + * per the FRQ system spec. The random half is what makes it collision-safe: + * a timestamp alone repeats when several IDs are minted in the same + * millisecond. + */ +const makeId = (prefix: string) => + `${prefix}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; + +/** + * A question's point value is the sum of its grading criteria. Criteria are the + * single source of truth so the editor can never disagree with what the grading + * page will actually award. + */ +const getQuestionPoints = (question: EditorQuestion) => + question.criteria.reduce((total, criterion) => total + criterion.points, 0); + +const formatPoints = (points: number) => + `${points} ${points === 1 ? "point" : "points"}`; + +const createDescriptionQuestion = ( + description: QuestionInput, +): QuestionFormat => ({ + question: { + value: description.value, + files: [...description.files], + }, + type: "frq", + options: [], + answers: [], + explanation: createQuestionInput(), + content: createQuestionInput(), + topic: "", +}); + +const createEditorQuestion = ( + id: string, + prompt: string, + points: number, +): EditorQuestion => ({ + id, + status: "public", + inputType: "text", + criteria: [ + { + id: `${id}-criterion-1`, + description: "", + points, + }, + ], + questionData: { + question: createQuestionInput(prompt), + type: "frq", + options: [], + answers: [], + explanation: createQuestionInput(), + content: createQuestionInput(), + topic: "", + }, +}); + +/** + * AP-style subquestion label: 1a, 1b, 1c. Past 26 questions it rolls over to + * two letters (1aa, 1ab) rather than walking off the end of the alphabet into + * punctuation, which is what a bare String.fromCharCode(97 + index) would do. + */ +const getSubquestionLabel = (frqIndex: number, questionIndex: number) => { + let label = ""; + let remaining = questionIndex; + + do { + label = String.fromCharCode(97 + (remaining % 26)) + label; + remaining = Math.floor(remaining / 26) - 1; + } while (remaining >= 0); + + return `${frqIndex + 1}${label}`; +}; + +const mockFRQs: EditorFRQ[] = [ + { + id: "frq-1", + title: "FRQ 1", + description: createQuestionInput( + "Read the source material carefully. Then answer all parts of the question using evidence from the source.", + ), + questions: [ + createEditorQuestion( + "frq-1-a", + "Identify one claim made in the source.", + 1, + ), + createEditorQuestion( + "frq-1-b", + "Explain how evidence from the source supports that claim.", + 2, + ), + createEditorQuestion( + "frq-1-c", + "Evaluate one limitation of the source's argument.", + 2, + ), + ], + }, + { + id: "frq-2", + title: "FRQ 2", + description: createQuestionInput( + "Examine the provided information and respond to each part of the question.", + ), + questions: [ + createEditorQuestion( + "frq-2-a", + "Describe the main process shown in the provided material.", + 1, + ), + createEditorQuestion( + "frq-2-b", + "Explain one relationship between two parts of the process.", + 2, + ), + createEditorQuestion( + "frq-2-c", + "Use evidence from the material to justify your response.", + 2, + ), + ], + }, + { + id: "frq-3", + title: "FRQ 3", + description: createQuestionInput( + "Use the information provided to develop and support a defensible response.", + ), + questions: [ + createEditorQuestion( + "frq-3-a", + "State a defensible conclusion based on the information provided.", + 1, + ), + createEditorQuestion( + "frq-3-b", + "Support your conclusion with two relevant pieces of evidence.", + 2, + ), + createEditorQuestion( + "frq-3-c", + "Explain how one piece of evidence could be interpreted differently.", + 2, + ), + ], + }, +]; + +const FRQEditorRenderer = ({ frqFound }: FRQEditorRendererProps) => { + const [frqs, setFrqs] = useState(mockFRQs); + const [currentFrqIndex, setCurrentFrqIndex] = useState(0); + const [batchName, setBatchName] = useState("FRQ Batch"); + const [batchVisibility, setBatchVisibility] = + useState("public"); + const [timeLimitMinutes, setTimeLimitMinutes] = useState(90); + + const goToPreviousFrq = () => { + setCurrentFrqIndex((index) => Math.max(index - 1, 0)); + }; + + const goToNextFrq = () => { + setCurrentFrqIndex((index) => Math.min(index + 1, frqs.length - 1)); + }; + + const updateCurrentFrq = (updater: (frq: EditorFRQ) => EditorFRQ) => { + setFrqs((currentFrqs) => + currentFrqs.map((frq, index) => + index === currentFrqIndex ? updater(frq) : frq, + ), + ); + }; + + const updateQuestion = ( + questionId: string, + updater: (question: EditorQuestion) => EditorQuestion, + ) => { + updateCurrentFrq((frq) => ({ + ...frq, + questions: frq.questions.map((question) => + question.id === questionId ? updater(question) : question, + ), + })); + }; + + const addCriterion = (questionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: [ + ...question.criteria, + { + id: makeId("criterion"), + description: "", + points: 1, + }, + ], + })); + }; + + const updateCriterion = ( + questionId: string, + criterionId: string, + changes: Partial>, + ) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.map((criterion) => + criterion.id === criterionId ? { ...criterion, ...changes } : criterion, + ), + })); + }; + + const deleteCriterion = (questionId: string, criterionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.filter( + (criterion) => criterion.id !== criterionId, + ), + })); + }; + + const currentFrq = frqs[currentFrqIndex]; + + // Read through to the fields the memos actually depend on. AdvancedTextbox + // treats its `questions[qIndex]` entry as a stable reference, so these must + // keep their identity across unrelated re-renders instead of being rebuilt + // fresh every time the parent renders. Both memos sit above the early returns + // so the hooks stay unconditional. + const currentDescription = currentFrq?.description; + const currentQuestions = currentFrq?.questions; + + const descriptionQuestions = useMemo( + () => + currentDescription ? [createDescriptionQuestion(currentDescription)] : [], + [currentDescription], + ); + + const questionFormats = useMemo( + () => currentQuestions?.map((question) => question.questionData) ?? [], + [currentQuestions], + ); + + if (!frqFound || !currentFrq) { + return
Failed to load FRQ.
; + } + return ( -
- {frqFound - ? "FRQ loaded successfully." - : "Failed to load FRQ."} +
+
+
+ +
+ +
+ + + + setTimeLimitMinutes(Math.max(1, Number(event.target.value) || 1)) + } + className="h-9 w-20" + /> + minutes +
+ +
+ +
+
+ +
+
+
+
+

+ FRQ {currentFrqIndex + 1} of {frqs.length} +

+ + + + updateCurrentFrq((frq) => ({ + ...frq, + title: event.target.value, + })) + } + className="mt-2 max-w-md text-base font-semibold" + /> +
+ +
+

FRQ Description

+

+ Add the source material and directions students need for this + FRQ. +

+ +
+ { + const updatedDescription = updatedQuestions[0]?.question; + + if (!updatedDescription) { + return; + } + + updateCurrentFrq((frq) => ({ + ...frq, + description: updatedDescription, + })); + }} + origin="question" + qIndex={0} + placeholder="Enter the FRQ description here." + /> +
+
+
+ +
+
+
+

Questions

+

+ {currentFrq.questions.length} questions in this FRQ +

+
+ + +
+ + question.id)} + className="space-y-4" + > + {currentFrq.questions.map((question, questionIndex) => ( + + +
+ + + {getSubquestionLabel(currentFrqIndex, questionIndex)} + + + {question.status === "legacy" && ( + + Legacy + + )} + + + {formatPoints(getQuestionPoints(question))} + +
+
+ + {/* ui/accordion.tsx hardcodes opacity-70 on the content root + and routes className to an inner div, so there is no + class-based override. Without this the editor's inputs all + render washed out. Remove once accordion.tsx exposes it. */} + +
+ +
+ + updateQuestion(question.id, (currentQuestion) => ({ + ...currentQuestion, + questionData: + updatedQuestions[questionIndex] ?? + currentQuestion.questionData, + })) + } + origin="question" + qIndex={questionIndex} + placeholder="Enter the question prompt here." + /> +
+
+ + {/* No question-level points field: the total is derived from + the grading criteria below, which is what the grader + actually awards against. */} +
+
+ + + + + + + { + if (value !== "text" && value !== "equation") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + inputType: value, + }), + ); + }} + > + + Text + + + Equation + + + + +
+ +
+ + + + + + + { + if (value !== "public" && value !== "legacy") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + status: value, + }), + ); + }} + > + + Public + + + Legacy + + + + +
+
+ +
+ + + + + +
+ +
+

+ Grading Criteria +

+ + {question.criteria.length === 0 ? ( +

+ No grading criteria have been added. +

+ ) : ( + question.criteria.map((criterion, criterionIndex) => ( +
+
+ +