Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -75,6 +75,10 @@ export function getFileFromIndexedDB(

const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [svgSize, setSvgSize] = useState<{
width: number;
height: number;
} | null>(null);

useEffect(() => {
let url: string | null = null;
Expand Down Expand Up @@ -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 <img> 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 (
<div className="my-2 flex w-full justify-center">
<img
Expand All @@ -119,10 +150,15 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ 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
}
/>
</div>
);
Expand Down
168 changes: 168 additions & 0 deletions src/components/frq/editorFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"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";

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) => {
return (
<footer className="fixed bottom-0 left-0 z-50 grid w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-4 border-t-2 border-gray-300 bg-background px-4 py-2.5 text-foreground">
<div className="flex min-w-0 items-center gap-2">
<Input
aria-label="FRQ batch name"
value={batchName}
onChange={(event) => onBatchNameChange(event.target.value)}
className="h-9 max-w-48 font-medium"
/>

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" className="capitalize">
{batchVisibility}
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={batchVisibility}
onValueChange={(value) => {
if (value === "public" || value === "private") {
onBatchVisibilityChange(value);
}
}}
>
<DropdownMenuRadioItem value="public">
Public
</DropdownMenuRadioItem>

<DropdownMenuRadioItem value="private">
Private
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>

<div className="flex items-center justify-center gap-2">
<Button
type="button"
variant="outline"
disabled
title="Saved FRQs cannot be deleted"
>
<Trash2 className="mr-2 size-4" />
Delete FRQ
</Button>

<Popover>
<PopoverTrigger className="flex items-center gap-1 rounded-md bg-black py-1 pl-3 pr-1 text-sm font-bold tabular-nums text-white">
FRQ {currentFrqIndex + 1} of {frqs.length}
<ChevronUp />
</PopoverTrigger>

<PopoverContent side="top" sideOffset={12} className="w-72">
<p className="font-semibold">Navigate to an FRQ</p>

<p className="mt-1 text-sm text-muted-foreground">
Select an FRQ from this batch.
</p>

<div className="mt-6 grid grid-cols-6 gap-4">
{frqs.map((frq, index) => {
const isCurrent = index === currentFrqIndex;

return (
<button
key={frq.id}
type="button"
aria-label={`Go to ${frq.title}`}
onClick={() => onSelectFrq(index)}
className={`relative flex size-8 items-center justify-center border-2 font-medium ${
isCurrent
? "border-transparent bg-[#2a47bb] text-white"
: "border-dotted border-gray-400 text-[#2a47bb] hover:bg-blue-50"
}`}
>
{index + 1}

{isCurrent && (
<MapPin className="absolute -top-5 fill-white stroke-black" />
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>

<Button type="button" variant="outline">
<Plus className="mr-2 size-4" />
Create FRQ
</Button>
</div>

<div className="justify-self-end">
<button
type="button"
onClick={onPrevious}
disabled={currentFrqIndex === 0}
className="rounded-full bg-[#294ad1] px-6 py-2 font-bold text-white hover:bg-[#2a47bb] disabled:cursor-not-allowed disabled:bg-gray-300"
>
Back
</button>

<button
type="button"
onClick={onNext}
disabled={currentFrqIndex === frqs.length - 1}
className="ml-3 rounded-full bg-[#294ad1] px-6 py-2 font-bold text-white hover:bg-[#2a47bb] disabled:cursor-not-allowed disabled:bg-gray-300"
>
Next
</button>
</div>
</footer>
);
};

export default FRQEditorFooter;
Loading
Loading