feat: responsive for web with sidebar and different styling for test ui on web
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { BlockMath, InlineMath } from "react-katex";
|
||||
import {
|
||||
Binary,
|
||||
Calculator,
|
||||
@ -16,6 +17,9 @@ import {
|
||||
BookOpen,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Highlighter,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "../../../utils/api";
|
||||
@ -85,6 +89,8 @@ const DOTS = [
|
||||
const GLOBAL_STYLES = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
||||
|
||||
:root { --content-max: 1100px; }
|
||||
|
||||
.test-screen {
|
||||
min-height: 100vh;
|
||||
background: ${COLORS.bg};
|
||||
@ -245,6 +251,115 @@ const GLOBAL_STYLES = `
|
||||
/* Incorrect flash */
|
||||
@keyframes tFlashRed { 0%,100%{background:transparent;}50%{background:rgba(239,68,68,0.15);} }
|
||||
.t-flash-red { animation:tFlashRed 0.6s ease; }
|
||||
|
||||
/* Highlighting */
|
||||
.t-highlight {
|
||||
background: rgba(251, 191, 36, 0.42);
|
||||
border-radius: 6px;
|
||||
padding: 0 2px;
|
||||
box-shadow: inset 0 -1px 0 rgba(217, 119, 6, 0.25);
|
||||
}
|
||||
|
||||
/* Desktop / wide tweaks */
|
||||
@media (min-width: 900px) {
|
||||
.t-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
|
||||
.t-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
|
||||
.t-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
|
||||
.t-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
|
||||
|
||||
/* Desktop split layout */
|
||||
.t-desktop-shell {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.t-split-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.t-split-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.t-split-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.t-split-divider {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.t-split-divider-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.t-split-divider-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(15,23,42,0.12);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.t-split-divider-dots {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to bottom, #c4b5fd, #a855f7);
|
||||
}
|
||||
.t-split-divider:hover .t-split-divider-bar {
|
||||
background: #c4b5fd;
|
||||
}
|
||||
.t-split-divider:hover .t-split-divider-handle {
|
||||
border-color: #c4b5fd;
|
||||
box-shadow: 0 6px 18px rgba(129,140,248,0.3);
|
||||
}
|
||||
|
||||
/* Directions button (desktop only) */
|
||||
.t-directions-btn {
|
||||
font-weight: 900;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 2.5px solid ${COLORS.border};
|
||||
background: white;
|
||||
color: ${COLORS.text};
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.06);
|
||||
}
|
||||
.t-directions-btn:hover {
|
||||
border-color: ${COLORS.borderPurple};
|
||||
background: #fdf4ff;
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.t-directions-btn.active {
|
||||
background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark});
|
||||
border-color: ${COLORS.primaryDark};
|
||||
color: white;
|
||||
box-shadow: 0 4px 0 ${COLORS.primaryDark}, 0 8px 18px rgba(168,85,247,0.28);
|
||||
}
|
||||
.t-directions-btn.active:hover {
|
||||
box-shadow: 0 6px 0 ${COLORS.primaryDark}, 0 12px 22px rgba(168,85,247,0.32);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Confetti ─────────────────────────────────────────────────────────────────
|
||||
@ -269,6 +384,9 @@ interface IncorrectEntry {
|
||||
originalIndex: number;
|
||||
}
|
||||
|
||||
type HighlightRange = { start: number; end: number };
|
||||
type HighlightsByField = Record<string, HighlightRange[]>;
|
||||
|
||||
const Confetti = ({ active }: { active: boolean }) => {
|
||||
const [particles, setParticles] = useState<ConfettiParticle[]>([]);
|
||||
useEffect(() => {
|
||||
@ -627,6 +745,19 @@ export const Test = () => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [showDirections, setShowDirections] = useState(false);
|
||||
const [timerVisible, setTimerVisible] = useState(true);
|
||||
const [leftColumnWidth, setLeftColumnWidth] = useState(350); // desktop only (re-centered on mount)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
const [isDraggingDivider, setIsDraggingDivider] = useState(false);
|
||||
const userAdjustedSplitRef = useRef(false);
|
||||
const desktopShellRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isMdUp, setIsMdUp] = useState(false);
|
||||
const HIGHLIGHTS_STORAGE_KEY = "edbridge_highlights_v1";
|
||||
const [highlightMode, setHighlightMode] = useState(false);
|
||||
const [highlightsByField, setHighlightsByField] = useState<HighlightsByField>(
|
||||
{},
|
||||
);
|
||||
const [showIncorrectFlash, setShowIncorrectFlash] = useState(false);
|
||||
|
||||
// ── Retry / targeted state ──────────────────────────────────────────────────
|
||||
@ -655,6 +786,111 @@ export const Test = () => {
|
||||
? currentModule?.questions[retryQueue[retryIndex]?.originalIndex]
|
||||
: currentModule?.questions[questionIndex];
|
||||
|
||||
// close directions when question changes
|
||||
useEffect(() => {
|
||||
setShowDirections(false);
|
||||
}, [currentQuestion?.id]);
|
||||
|
||||
// Handle separator drag (desktop only)
|
||||
const handleSeparatorMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); // prevent text selection kick-off
|
||||
userAdjustedSplitRef.current = true;
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startWidth: leftColumnWidth,
|
||||
};
|
||||
setIsDraggingDivider(true);
|
||||
};
|
||||
|
||||
// Center the splitter on initial desktop load (and on resize until user drags)
|
||||
useEffect(() => {
|
||||
const centerSplit = () => {
|
||||
if (userAdjustedSplitRef.current) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth < 768) return; // md breakpoint
|
||||
const shell = desktopShellRef.current;
|
||||
if (!shell) return;
|
||||
const w = shell.getBoundingClientRect().width;
|
||||
// Account for divider+margin so the handle lands near visual center.
|
||||
const target = Math.round((w - 40) / 2);
|
||||
setLeftColumnWidth(Math.max(300, Math.min(600, target)));
|
||||
};
|
||||
|
||||
centerSplit();
|
||||
window.addEventListener("resize", centerSplit);
|
||||
return () => window.removeEventListener("resize", centerSplit);
|
||||
}, []);
|
||||
|
||||
// True responsive check (portals ignore parent display:none)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia("(min-width: 768px)");
|
||||
const update = () => setIsMdUp(mq.matches);
|
||||
update();
|
||||
mq.addEventListener("change", update);
|
||||
return () => mq.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
// Highlights persistence (desktop/web feature; safe on mobile too)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIGHLIGHTS_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as HighlightsByField;
|
||||
if (parsed && typeof parsed === "object") setHighlightsByField(parsed);
|
||||
} catch {
|
||||
// ignore corrupted storage
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
HIGHLIGHTS_STORAGE_KEY,
|
||||
JSON.stringify(highlightsByField),
|
||||
);
|
||||
} catch {
|
||||
// ignore quota / private mode issues
|
||||
}
|
||||
}, [HIGHLIGHTS_STORAGE_KEY, highlightsByField]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const delta = e.clientX - dragRef.current.startX;
|
||||
const newWidth = Math.max(
|
||||
300,
|
||||
Math.min(600, dragRef.current.startWidth + delta),
|
||||
);
|
||||
setLeftColumnWidth(newWidth);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
dragRef.current = null;
|
||||
setIsDraggingDivider(false);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prevent text selection while dragging divider
|
||||
useEffect(() => {
|
||||
if (!isDraggingDivider) return;
|
||||
const prevUserSelect = document.body.style.userSelect;
|
||||
const prevCursor = document.body.style.cursor;
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
return () => {
|
||||
document.body.style.userSelect = prevUserSelect;
|
||||
document.body.style.cursor = prevCursor;
|
||||
};
|
||||
}, [isDraggingDivider]);
|
||||
|
||||
const currentAnswer = currentQuestion
|
||||
? (answers[currentQuestion.id] ?? "")
|
||||
: "";
|
||||
@ -941,6 +1177,163 @@ export const Test = () => {
|
||||
const formatTime = (s: number) =>
|
||||
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
|
||||
const mergeHighlightRanges = (ranges: HighlightRange[]) => {
|
||||
const sorted = ranges
|
||||
.filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end))
|
||||
.map((r) => ({ start: Math.max(0, r.start), end: Math.max(0, r.end) }))
|
||||
.filter((r) => r.end > r.start)
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
const merged: HighlightRange[] = [];
|
||||
for (const r of sorted) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (!last || r.start > last.end) merged.push(r);
|
||||
else last.end = Math.max(last.end, r.end);
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
const addHighlight = (fieldKey: string, start: number, end: number) => {
|
||||
const s = Math.min(start, end);
|
||||
const e = Math.max(start, end);
|
||||
if (!fieldKey || s === e) return;
|
||||
|
||||
setHighlightsByField((prev) => {
|
||||
const cur = prev[fieldKey] ?? [];
|
||||
const next = mergeHighlightRanges([...cur, { start: s, end: e }]);
|
||||
return { ...prev, [fieldKey]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const renderQuestionTextWithHighlights = (
|
||||
text: string,
|
||||
highlights: HighlightRange[],
|
||||
) => {
|
||||
const merged = mergeHighlightRanges(highlights);
|
||||
const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||
let pos = 0;
|
||||
let pieceIdx = 0;
|
||||
|
||||
const renderPlain = (plain: string, basePos: number) => {
|
||||
const segStart = basePos;
|
||||
const segEnd = basePos + plain.length;
|
||||
const overlapping = merged.filter(
|
||||
(h) => h.end > segStart && h.start < segEnd,
|
||||
);
|
||||
if (!overlapping.length) {
|
||||
const key = `p-${pieceIdx++}`;
|
||||
return <span key={key}>{plain}</span>;
|
||||
}
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
for (const h of overlapping) {
|
||||
const localStart = Math.max(0, h.start - segStart);
|
||||
const localEnd = Math.min(plain.length, h.end - segStart);
|
||||
if (localStart > cursor) {
|
||||
nodes.push(
|
||||
<span key={`t-${pieceIdx++}`}>
|
||||
{plain.slice(cursor, localStart)}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
nodes.push(
|
||||
<mark key={`m-${pieceIdx++}`} className="t-highlight">
|
||||
{plain.slice(localStart, localEnd)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = Math.max(cursor, localEnd);
|
||||
}
|
||||
if (cursor < plain.length) {
|
||||
nodes.push(<span key={`t-${pieceIdx++}`}>{plain.slice(cursor)}</span>);
|
||||
}
|
||||
return <span key={`w-${pieceIdx++}`}>{nodes}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => {
|
||||
if (part.startsWith("$$")) {
|
||||
const inner = part.slice(2, -2);
|
||||
const len = inner.length;
|
||||
const hasOverlap = merged.some(
|
||||
(h) => h.end > pos && h.start < pos + len,
|
||||
);
|
||||
const node = <BlockMath key={index}>{inner}</BlockMath>;
|
||||
pos += len;
|
||||
if (!hasOverlap) return node;
|
||||
return (
|
||||
<span key={index} className="t-highlight">
|
||||
{node}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (part.startsWith("$")) {
|
||||
const inner = part.slice(1, -1);
|
||||
const len = inner.length;
|
||||
const hasOverlap = merged.some(
|
||||
(h) => h.end > pos && h.start < pos + len,
|
||||
);
|
||||
const node = <InlineMath key={index}>{inner}</InlineMath>;
|
||||
pos += len;
|
||||
if (!hasOverlap) return node;
|
||||
return (
|
||||
<span key={index} className="t-highlight">
|
||||
{node}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const base = pos;
|
||||
pos += part.length;
|
||||
return <span key={index}>{renderPlain(part, base)}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const HighlightableRichText = ({
|
||||
fieldKey,
|
||||
text,
|
||||
className,
|
||||
}: {
|
||||
fieldKey: string;
|
||||
text: string;
|
||||
className?: string;
|
||||
}) => {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const highlights = highlightsByField[fieldKey] ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={className}
|
||||
onMouseUp={() => {
|
||||
if (!highlightMode) return;
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (
|
||||
!root.contains(range.startContainer) ||
|
||||
!root.contains(range.endContainer)
|
||||
)
|
||||
return;
|
||||
const selected = range.toString();
|
||||
if (!selected.trim()) return;
|
||||
|
||||
const pre = document.createRange();
|
||||
pre.selectNodeContents(root);
|
||||
pre.setEnd(range.startContainer, range.startOffset);
|
||||
const start = pre.toString().length;
|
||||
addHighlight(fieldKey, start, start + selected.length);
|
||||
}}
|
||||
>
|
||||
{renderQuestionTextWithHighlights(text, highlights)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────────
|
||||
const renderOptions = (question?: Question) => {
|
||||
if (!question?.options?.length) return null;
|
||||
@ -1098,14 +1491,14 @@ export const Test = () => {
|
||||
key={i}
|
||||
className="flex items-center gap-4 p-4 bg-gray-50 rounded-2xl border-2 border-gray-100"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center shrink-0">
|
||||
<Check size={20} className="text-purple-600" />
|
||||
</div>
|
||||
<span className="font-bold text-gray-700">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
{isTargeted && (
|
||||
<div className="flex items-center gap-4 p-4 bg-gradient-to-r from-purple-50 to-orange-50 rounded-2xl border-2 border-purple-200">
|
||||
<div className="flex items-center gap-4 p-4 bg-linear-to-r from-purple-50 to-orange-50 rounded-2xl border-2 border-purple-200">
|
||||
<span className="text-3xl">🎯</span>
|
||||
<div>
|
||||
<p className="font-bold text-purple-800">Targeted Mode</p>
|
||||
@ -1161,26 +1554,104 @@ export const Test = () => {
|
||||
<header
|
||||
className={`fixed left-0 right-0 bg-white/95 backdrop-blur-sm border-b-2 border-purple-100 px-4 pt-6 pb-4 z-20 ${retryMode ? "top-10" : "top-0"}`}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`t-q-badge ${isCurrentMarked ? "reviewing" : ""}`}
|
||||
>
|
||||
{isCurrentMarked ? "🔖 " : ""}Q
|
||||
{retryMode ? retryIndex + 1 : questionIndex + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-400">
|
||||
of {currentModule?.questions.length}
|
||||
</span>
|
||||
<div className="mx-auto max-w-2xl md:max-w-(--content-max)">
|
||||
{/* Mobile header (unchanged) */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`t-q-badge ${isCurrentMarked ? "reviewing" : ""}`}
|
||||
>
|
||||
{isCurrentMarked ? "🔖 " : ""}Q
|
||||
{retryMode ? retryIndex + 1 : questionIndex + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-400">
|
||||
of {currentModule?.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="t-timer">
|
||||
{timerVisible
|
||||
? `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`
|
||||
: "--:--"}
|
||||
</div>
|
||||
<button
|
||||
className="mt-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setTimerVisible((v) => !v)}
|
||||
>
|
||||
{timerVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="t-timer">
|
||||
{Math.floor(time / 60)}:{String(time % 60).padStart(2, "0")}
|
||||
<h1 className="text-center font-bold">
|
||||
{currentModule?.module_title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Desktop header (web only) */}
|
||||
<div className="hidden md:flex items-start justify-between gap-6 relative">
|
||||
{/* Left: section/module + directions */}
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm font-semibold text-gray-700">
|
||||
{currentQuestion?.section || ""}
|
||||
</div>
|
||||
<div className="text-base font-bold text-gray-700">
|
||||
{currentModule?.module_title}
|
||||
</div>
|
||||
<button
|
||||
className={`mt-2 t-btn-3d t-directions-btn ${showDirections ? "active" : ""}`}
|
||||
onClick={() => setShowDirections((v) => !v)}
|
||||
>
|
||||
Directions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Middle: time + hide + question number (perfectly centered) */}
|
||||
<div className="flex flex-col items-center absolute left-1/2 -translate-x-1/2 top-0">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="t-timer">
|
||||
{timerVisible
|
||||
? `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`
|
||||
: "--:--"}
|
||||
</div>
|
||||
<button
|
||||
className="mt-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setTimerVisible((v) => !v)}
|
||||
>
|
||||
{timerVisible ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setShowNavigator(true)}
|
||||
>
|
||||
<span
|
||||
className={`t-q-badge ${isCurrentMarked ? "reviewing" : ""}`}
|
||||
>
|
||||
{isCurrentMarked ? "🔖 " : ""}Q
|
||||
{retryMode ? retryIndex + 1 : questionIndex + 1}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-400">
|
||||
of {currentModule?.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: highlights */}
|
||||
<div className="flex items-start">
|
||||
<button
|
||||
onClick={() => setHighlightMode((v) => !v)}
|
||||
className={`t-btn-3d px-5 py-3 flex items-center gap-2 ${
|
||||
highlightMode ? "t-btn-primary" : "t-btn-outline"
|
||||
}`}
|
||||
title="Highlights & Notes"
|
||||
>
|
||||
<Highlighter size={18} />
|
||||
<span className="font-black">Highlights & Notes</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-center font-bold text-gray-700">
|
||||
{currentModule?.module_title}
|
||||
</h1>
|
||||
|
||||
{isTargeted && retryMode && (
|
||||
<p className="text-center text-orange-500 font-bold text-sm mt-2">
|
||||
🔥 Reviewing incorrect answers — get them right to finish!
|
||||
@ -1191,23 +1662,108 @@ export const Test = () => {
|
||||
|
||||
{/* Question content */}
|
||||
<section
|
||||
className={`flex-1 px-4 ${isMCQ ? "pb-110" : "pb-32"} ${retryMode ? "pt-36" : "pt-28"}`}
|
||||
className={`flex-1 px-4 ${isMCQ ? "pb-110" : "pb-32"} ${retryMode ? "pt-36 md:pt-52" : "pt-28 md:pt-44"}`}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto space-y-6 pt-4">
|
||||
{currentQuestion?.context && (
|
||||
<div className="t-card p-6">
|
||||
<p className="font-semibold text-gray-700 leading-relaxed">
|
||||
{renderQuestionText(currentQuestion.context)}
|
||||
</p>
|
||||
{/* Mobile layout (unchanged) */}
|
||||
<div className="block md:hidden">
|
||||
<div className="max-w-2xl mx-auto pt-4">
|
||||
<div className="space-y-6">
|
||||
{showDirections ? (
|
||||
<div className="t-card p-6 flex-1">
|
||||
<p className="font-semibold text-gray-700 leading-relaxed">
|
||||
{renderQuestionText(currentQuestion?.explanation || "")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentQuestion?.context && (
|
||||
<div className="t-card p-6">
|
||||
<p className="font-semibold text-gray-700 leading-relaxed">
|
||||
{renderQuestionText(currentQuestion.context)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="t-card t-card-purple p-6">
|
||||
<p className="font-bold text-lg text-[#1e1b4b] leading-relaxed">
|
||||
{currentQuestion?.text &&
|
||||
renderQuestionText(currentQuestion.text)}
|
||||
</p>
|
||||
</div>
|
||||
{!isMCQ && renderShortAnswer(currentQuestion)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop layout with draggable separator */}
|
||||
<div className="hidden md:block">
|
||||
<div
|
||||
ref={desktopShellRef}
|
||||
className="t-desktop-shell pt-8"
|
||||
style={{ paddingBottom: 160 }}
|
||||
>
|
||||
<div className="t-split-layout">
|
||||
{/* Left column: passage / directions */}
|
||||
<div
|
||||
className="t-split-left"
|
||||
style={{
|
||||
width: leftColumnWidth,
|
||||
minWidth: 300,
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
{showDirections ? (
|
||||
<div className="t-card p-6 flex-1">
|
||||
<HighlightableRichText
|
||||
fieldKey={`${currentQuestion?.id ?? "unknown"}:explanation`}
|
||||
text={currentQuestion?.explanation || ""}
|
||||
className="font-semibold text-gray-700 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{currentQuestion?.context && (
|
||||
<div className="t-card p-6">
|
||||
<HighlightableRichText
|
||||
fieldKey={`${currentQuestion?.id ?? "unknown"}:context`}
|
||||
text={currentQuestion.context}
|
||||
className="font-semibold text-gray-700 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Draggable divider */}
|
||||
<div
|
||||
className="t-split-divider mx-3"
|
||||
onMouseDown={handleSeparatorMouseDown}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="t-split-divider-bar" />
|
||||
<div className="t-split-divider-handle">
|
||||
<div className="t-split-divider-dots" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column: question + options/answer */}
|
||||
<div className="t-split-right flex-1">
|
||||
<div className="t-card t-card-purple p-6 mt-4">
|
||||
{currentQuestion?.text && (
|
||||
<HighlightableRichText
|
||||
fieldKey={`${currentQuestion.id}:text`}
|
||||
text={currentQuestion.text}
|
||||
className="font-bold text-lg text-[#1e1b4b] leading-relaxed"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isMCQ && renderShortAnswer(currentQuestion)}
|
||||
{isMCQ && renderOptions(currentQuestion)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="t-card t-card-purple p-6">
|
||||
<p className="font-bold text-lg text-[#1e1b4b] leading-relaxed">
|
||||
{currentQuestion?.text &&
|
||||
renderQuestionText(currentQuestion.text)}
|
||||
</p>
|
||||
</div>
|
||||
{!isMCQ && renderShortAnswer(currentQuestion)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@ -1217,23 +1773,25 @@ export const Test = () => {
|
||||
className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-100 py-4 px-4"
|
||||
style={{ zIndex: 20 }}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto flex items-center justify-between gap-3">
|
||||
<div className="mx-auto max-w-2xl md:max-w-(--content-max) flex items-center justify-between gap-3">
|
||||
{/* Back button (non-targeted) */}
|
||||
{!isTargeted && (
|
||||
<button
|
||||
disabled={isFirstQuestion}
|
||||
onClick={prevQuestion}
|
||||
className="t-btn-3d t-btn-outline px-5 py-3 disabled:opacity-40"
|
||||
className="t-btn-3d t-btn-outline px-5 py-3 disabled:opacity-40 flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
<span className="hidden md:inline font-black">Previous</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Menu */}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="t-btn-3d t-btn-outline px-5 py-3">
|
||||
<button className="t-btn-3d t-btn-outline px-5 py-3 flex items-center gap-2">
|
||||
<Menu size={18} />
|
||||
<span className="hidden md:inline font-black">Menu</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="rounded-2xl border-2 border-gray-100 p-2">
|
||||
@ -1314,19 +1872,46 @@ export const Test = () => {
|
||||
|
||||
{/* ── Mark for Review button (non-targeted) ── */}
|
||||
{!isTargeted && (
|
||||
<button
|
||||
className={`t-bookmark-btn ${isCurrentMarked ? "marked" : ""}`}
|
||||
onClick={toggleMark}
|
||||
title={
|
||||
isCurrentMarked ? "Remove review mark" : "Mark for review"
|
||||
}
|
||||
>
|
||||
{isCurrentMarked ? (
|
||||
<BookMarked size={18} color="white" />
|
||||
<>
|
||||
{/* Mobile: keep existing icon-only button */}
|
||||
{!isMdUp ? (
|
||||
<button
|
||||
className={`t-bookmark-btn ${isCurrentMarked ? "marked" : ""}`}
|
||||
onClick={toggleMark}
|
||||
title={
|
||||
isCurrentMarked ? "Remove review mark" : "Mark for review"
|
||||
}
|
||||
>
|
||||
{isCurrentMarked ? (
|
||||
<BookMarked size={18} color="white" />
|
||||
) : (
|
||||
<Bookmark size={18} color="#9ca3af" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<Bookmark size={18} color="#9ca3af" />
|
||||
/* Desktop/web: icon + text */
|
||||
<button
|
||||
onClick={toggleMark}
|
||||
className={`t-btn-3d px-5 py-3 flex items-center gap-2 ${
|
||||
isCurrentMarked ? "t-btn-primary" : "t-btn-outline"
|
||||
}`}
|
||||
title={
|
||||
isCurrentMarked ? "Remove review mark" : "Mark for review"
|
||||
}
|
||||
>
|
||||
{isCurrentMarked ? (
|
||||
<BookMarked size={18} color="white" />
|
||||
) : (
|
||||
<Bookmark size={18} color="#9ca3af" />
|
||||
)}
|
||||
<span className="font-black">
|
||||
{isCurrentMarked
|
||||
? "Marked for review"
|
||||
: "Mark for review"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Question navigator dialog */}
|
||||
@ -1484,8 +2069,8 @@ export const Test = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* MCQ Options Drawer */}
|
||||
{isMCQ && (
|
||||
{/* MCQ Options Drawer (mobile only) */}
|
||||
{isMCQ && !isMdUp && (
|
||||
<Drawer.Root
|
||||
modal={false}
|
||||
snapPoints={[0.35, 0.6, 0.95]}
|
||||
|
||||
Reference in New Issue
Block a user