feat: responsive for web with sidebar and different styling for test ui on web

This commit is contained in:
2026-02-27 02:36:54 +06:00
parent 2a00c44157
commit 634c67b741
15 changed files with 8254 additions and 251 deletions

7170
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -16,201 +16,263 @@ import {
BookOpen, BookOpen,
Home, Home,
Video, Video,
User,
Target, Target,
Zap, Zap,
Trophy, Trophy,
LayoutGrid,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import logo from "../assets/ed_logo1.png"; import logo from "../assets/ed_logo1.png";
import { NavLink } from "react-router-dom"; import { NavLink, useNavigate } from "react-router-dom";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
export function AppSidebar() { export function AppSidebar() {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(false);
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const navigate = useNavigate();
return ( return (
<Sidebar className="border-r bg-black text-white"> <Sidebar
variant="floating"
className="pointer-events-none border-0 bg-transparent px-0 py-0"
>
<div className="pointer-events-auto fixed inset-y-2 left-2 flex w-64 flex-col overflow-hidden rounded-[1.75rem] bg-[rgba(249,240,255,0.82)] shadow-[0_18px_45px_rgba(15,23,42,0.16)] transition-colors duration-300">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(168,85,247,0.18),transparent_55%),radial-gradient(circle_at_bottom,rgba(249,115,22,0.1),transparent_55%)]" />
<div className="relative flex h-full flex-col">
{/* HEADER */} {/* HEADER */}
<SidebarHeader> <SidebarHeader className="px-3 pb-4 pt-1">
<div className="flex items-center justify-between px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <div className="flex items-center justify-start gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 rounded-2xl px-2 py-2">
<div className="flex rounded-md w-10 h-10 border overflow-hidden"> <div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-linear-to-br from-purple-400 to-purple-500 shadow-[0_6px_18px_rgba(168,85,247,0.55)]">
<img <img
src={logo} src={logo}
className="w-full h-full object-cover object-left" className="h-full w-full object-cover object-left"
alt="Logo" alt="Logo"
/> />
</div> </div>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-satoshi-medium text-black"> <span className="font-satoshi-medium text-slate-900">
Edbridge Scholars Edbridge Scholars
</span> </span>
<span className="text-xs text-gray-400 font-satoshi"> <span className="font-satoshi text-xs text-slate-400">
Student Student
</span> </span>
</div> </div>
</div> </div>
<ChevronDown size={16} />
</div> </div>
</SidebarHeader> </SidebarHeader>
{/* CONTENT */} {/* CONTENT */}
<SidebarContent> <SidebarContent className="px-1">
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="text-gray-400 font-satoshi"> <SidebarGroupLabel className="px-2 text-[0.7rem] font-satoshi tracking-[0.16em] text-slate-400">
Platform PLATFORM
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarMenu> <SidebarMenu className="mt-1 space-y-1.5">
{/* HOME */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton
asChild
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
>
<NavLink <NavLink
to="/student/home" to="/student/home"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<Home size={18} className="text-black" /> {({ isActive }) => (
<span className="font-satoshi text-black">Home</span> <>
<Home
size={18}
className={
isActive ? "text-orange-400" : "text-slate-400"
}
/>
<span>Home</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> {/* PRACTICE */}
<SidebarMenuButton <SidebarMenuItem
className="cursor-pointer" onMouseEnter={() => setOpen(true)}
asChild onMouseLeave={() => setOpen(false)}
onClick={() => setOpen(!open)}
> >
<div> <SidebarMenuButton
<BookOpen size={18} className="text-black" /> className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
<span className="font-satoshi text-black">Practice</span> asChild
>
<NavLink
to="/student/practice"
className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi ${
isActive
? "text-slate-900"
: "text-slate-500 group-hover:text-slate-900"
}`
}
>
{({ isActive }) => (
<>
<BookOpen
size={18}
className={
isActive ? "text-purple-500" : "text-slate-400"
}
/>
<span>Practice</span>
<ChevronDown <ChevronDown
size={16} size={16}
className={`ml-auto transition-transform ${ className={`ml-auto text-slate-400 transition-transform ${
open ? "rotate-180" : "" open ? "rotate-180" : ""
}`} }`}
/> />
</div> </>
)}
</NavLink>
</SidebarMenuButton> </SidebarMenuButton>
{open && ( {open && (
<SidebarMenuSub className="space-y-3 mt-2"> <SidebarMenuSub className="mt-2 space-y-1.5 pl-3">
<NavLink
to="/student/practice"
className="text-black text-sm flex items-center gap-3"
>
<LayoutGrid size={18} className="text-black" />
<span className="font-satoshi text-black">
Practice your way
</span>
</NavLink>
<NavLink <NavLink
to="/student/practice/targeted-practice" to="/student/practice/targeted-practice"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Target size={18} className="text-black" /> <Target size={18} className="text-slate-400" />
<span className="font-satoshi text-black"> <span>Targeted Practice</span>
Targeted Practice
</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/drills" to="/student/practice/drills"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Zap size={18} className="text-black" /> <Zap size={18} className="text-slate-400" />
<span className="font-satoshi text-black">Drills</span> <span>Drills</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/student/practice/hard-test-modules" to="/student/practice/hard-test-modules"
className="text-black text-sm flex items-center gap-3" className={({ isActive }) =>
`flex items-center gap-2.5 rounded-2xl px-2 py-2 text-sm font-satoshi transition-colors duration-200 ${
isActive
? "bg-white text-slate-900"
: "text-slate-500 hover:bg-white hover:text-slate-900"
}`
}
> >
<Trophy size={18} className="text-black" /> <Trophy size={18} className="text-slate-400" />
<span className="font-satoshi text-black"> <span>Hard Test Modules</span>
Hard Test Modules
</span>
</NavLink> </NavLink>
</SidebarMenuSub> </SidebarMenuSub>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
{/* DOCS */} {/* LESSONS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
>
<NavLink <NavLink
to={`/student/lessons`} to="/student/lessons"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<Video size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Lessons</span> <Video
</SidebarMenuButton> size={18}
className={
isActive ? "text-cyan-500" : "text-slate-400"
}
/>
<span>Lessons</span>
</>
)}
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
{/* SETTINGS */} {/* REWARDS */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton
asChild
className="group cursor-pointer rounded-2xl px-2 py-2.5 transition-colors duration-200 hover:bg-white"
>
<NavLink <NavLink
to={`/student/rewards`} to="/student/rewards"
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-2.5 text-sm font-satoshi ${
isActive isActive
? "bg-zinc-800 text-white" ? "text-slate-900"
: "text-zinc-400 hover:bg-zinc-800" : "text-slate-500 group-hover:text-slate-900"
}`
} }
> >
<SidebarMenuButton className="cursor-pointer"> {({ isActive }) => (
<Trophy size={18} className="text-black" /> <>
<span className="text-black font-satoshi">Rewards</span> <Trophy
</SidebarMenuButton> size={18}
</NavLink> className={
</SidebarMenuItem> isActive ? "text-emerald-500" : "text-slate-400"
<SidebarMenuItem>
<NavLink
to={`/student/profile`}
className={({ isActive }) =>
isActive
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:bg-zinc-800"
} }
> />
<SidebarMenuButton className="cursor-pointer"> <span>Rewards</span>
<User size={18} className="text-black" /> </>
<span className="text-black font-satoshi">Profile</span> )}
</SidebarMenuButton>
</NavLink> </NavLink>
</SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
{/* FOOTER */} {/* FOOTER links to profile */}
<SidebarFooter> <SidebarFooter className="mt-auto px-3 pb-3 pt-4">
<div className="flex items-center gap-3 px-2 py-2 rounded-lg hover:bg-white/10 cursor-pointer"> <button
type="button"
onClick={() => navigate("/student/profile")}
className="flex w-full items-center gap-3 rounded-2xl bg-white/60 px-3 py-2 text-left shadow-sm ring-1 ring-white/80 hover:bg-white"
>
<Avatar> <Avatar>
<AvatarImage src={user?.avatar_url} /> <AvatarImage src={user?.avatar_url} />
<AvatarFallback className="font-satoshi-bold bg-linear-to-br from-purple-400 to-purple-500 uppercase"> <AvatarFallback className="bg-linear-to-br from-purple-400 to-purple-500 font-satoshi-bold uppercase text-white">
{user?.name.slice(0, 1)} {user?.name.slice(0, 1)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex flex-col text-sm"> <div className="flex flex-col text-sm">
<span className="font-medium text-black">{user?.name}</span> <span className="font-medium text-slate-900">{user?.name}</span>
<span className="text-xs text-gray-400">{user?.email}</span> <span className="text-xs text-slate-400">{user?.email}</span>
</div>
<ChevronDown size={16} className="ml-auto" />
</div> </div>
<ChevronDown size={16} className="ml-auto text-slate-400" />
</button>
</SidebarFooter> </SidebarFooter>
</div>
</div>
</Sidebar> </Sidebar>
); );
} }

View File

@ -244,7 +244,14 @@ function Sidebar({
<div <div
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" className={cn(
"flex h-full w-full flex-col",
// For the custom floating pill sidebar we render our own card,
// so keep this container visually transparent.
variant === "floating"
? "bg-transparent"
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
)}
> >
{children} {children}
</div> </div>

View File

@ -22,6 +22,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.home-screen { .home-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -149,7 +151,7 @@ const STYLES = `
} }
.h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; } .h-tab-btn.active { color:#1e1b4b; border-bottom-color:#a855f7; }
/* ── Practice sheet card ── */ /* ── Practice sheet ── */
.h-sheet-grid { .h-sheet-grid {
display:grid; gap:0.85rem; display:grid; gap:0.85rem;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -241,6 +243,18 @@ const STYLES = `
.h-anim-3 { animation-delay:0.15s; } .h-anim-3 { animation-delay:0.15s; }
.h-anim-4 { animation-delay:0.2s; } .h-anim-4 { animation-delay:0.2s; }
.h-anim-5 { animation-delay:0.25s; } .h-anim-5 { animation-delay:0.25s; }
/* Desktop / wide tweaks */
@media (min-width: 900px) {
.home-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.h-sheet-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
/* nudge blobs so they align visually with the centered container */
.h-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.h-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.h-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.h-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
}
`; `;
// ─── Sheet card ─────────────────────────────────────────────────────────────── // ─── Sheet card ───────────────────────────────────────────────────────────────

View File

@ -33,6 +33,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.ls-screen { .ls-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;

View File

@ -24,6 +24,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.pr-screen { .pr-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -63,6 +65,20 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem;
} }
/* Desktop / wide layout */
@media (min-width: 900px) {
.pr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.pr-grid { grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.pr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.pr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.pr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.pr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
.pr-hero { padding: 2rem; }
.pr-hero-icon-bg { right: -60px; top: -40px; opacity: 0.12; }
}
/* ── Animations ── */ /* ── Animations ── */
@keyframes prPopIn { @keyframes prPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from { opacity:0; transform: scale(0.92) translateY(12px); }

View File

@ -19,6 +19,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.pf-screen { .pf-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -55,6 +57,34 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem;
} }
/* Desktop / web layout: wider container and two-column grid */
@media (min-width: 900px) {
.pf-inner {
max-width: var(--content-max);
padding: 2.5rem 2.5rem 4rem;
display: grid;
grid-template-columns: 1fr 420px;
grid-template-rows: auto;
gap: 1.5rem 2rem;
align-items: start;
}
/* Hero spans full width */
.pf-hero { grid-column: 1 / -1; display: flex; align-items: center; gap: 1.5rem; }
/* Keep page title centered across the full layout */
.pf-page-title { grid-column: 1 / -1; }
/* Place first section (Account) in left column */
.pf-inner > section:nth-of-type(1) { grid-column: 1 / 2; }
/* Right column wrapper (Legal + Support) */
.pf-right-col { grid-column: 2 / 3; display: flex; flex-direction: column; gap: 1.5rem; }
/* Make signout button centered and constrained width */
.pf-signout-btn { grid-column: 1 / -1; justify-self: center; width: 420px; }
}
@keyframes pfPopIn { @keyframes pfPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from { opacity:0; transform: scale(0.92) translateY(12px); }
to { opacity:1; transform: scale(1) translateY(0); } to { opacity:1; transform: scale(1) translateY(0); }
@ -307,17 +337,18 @@ export const Profile = () => {
<SettingsGroup rows={ACCOUNT_ROWS} /> <SettingsGroup rows={ACCOUNT_ROWS} />
</section> </section>
{/* Legal */} {/* Right column: Legal + Support (stacked) */}
<section className="pf-section pf-anim pf-anim-3"> <div className="pf-right-col pf-anim pf-anim-3">
<section className="pf-section">
<p className="pf-section-label">Legal</p> <p className="pf-section-label">Legal</p>
<SettingsGroup rows={LEGAL_ROWS} /> <SettingsGroup rows={LEGAL_ROWS} />
</section> </section>
{/* Support */} <section className="pf-section">
<section className="pf-section pf-anim pf-anim-4">
<p className="pf-section-label">Support</p> <p className="pf-section-label">Support</p>
<SettingsGroup rows={SUPPORT_ROWS} /> <SettingsGroup rows={SUPPORT_ROWS} />
</section> </section>
</div>
{/* Sign out */} {/* Sign out */}
<button <button

View File

@ -33,6 +33,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.rw-screen { .rw-screen {
height: 100vh; height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -66,7 +68,6 @@ const STYLES = `
.rw-sticky-top { .rw-sticky-top {
position:relative;z-index:2; position:relative;z-index:2;
background:#fffbf4;
flex-shrink:0; flex-shrink:0;
padding:2rem 1.25rem 0; padding:2rem 1.25rem 0;
} }
@ -85,6 +86,26 @@ const STYLES = `
} }
.rw-scroll-inner { max-width:580px;margin:0 auto; } .rw-scroll-inner { max-width:580px;margin:0 auto; }
/* Desktop: wider centered layout */
@media (min-width: 900px) {
.rw-sticky-top-inner { max-width: var(--content-max); padding: 2rem 2rem 1.25rem; }
.rw-scroll-inner { max-width: var(--content-max); }
.rw-scroll-area { padding: 1.5rem 2.5rem 10rem; }
/* Make empty state sit visually centered within larger canvas */
.rw-empty { padding: 5rem 1rem; }
/* Slightly larger island pill on wide screens and rebalance blobs */
.rw-island-wrap { max-width: 420px; left:auto; right:calc((100vw - 256px - var(--content-max)) / 2); top:240px; bottom:auto; transform:none; margin-left:150px; }
.rw-island-card { gap: 0.75rem; }
/* Rebalance decorative blobs on wide screens */
.rw-blob-1 { left: -120px; top: -100px; width:300px; height:300px; }
.rw-blob-2 { left: 6%; bottom: -40px; }
.rw-blob-3 { right: -100px; top: 8%; width:260px; height:260px; }
.rw-blob-4 { right: 2%; bottom: 6%; }
}
@keyframes rwPopIn { @keyframes rwPopIn {
from{opacity:0;transform:scale(0.92) translateY(12px);} from{opacity:0;transform:scale(0.92) translateY(12px);}
to{opacity:1;transform:scale(1) translateY(0);} to{opacity:1;transform:scale(1) translateY(0);}
@ -177,8 +198,9 @@ const STYLES = `
flex-direction:column; flex-direction:column;
align-items:center; align-items:center;
gap:0.5rem; gap:0.5rem;
width:calc(100% - 2rem); width:auto;
max-width:300px; max-width:300px;
top:auto;
} }
.rw-island-card { .rw-island-card {

View File

@ -1,6 +1,6 @@
import { Outlet, NavLink, useLocation } from "react-router-dom"; import { Outlet, NavLink, useLocation } from "react-router-dom";
import { Home, BookOpen, Award, User, Video, Map } from "lucide-react"; import { Home, BookOpen, Award, User, Video, Map } from "lucide-react";
import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar"; import { SidebarProvider } from "../../components/ui/sidebar";
import { AppSidebar } from "../../components/AppSidebar"; import { AppSidebar } from "../../components/AppSidebar";
const NAV_ITEMS = [ const NAV_ITEMS = [
@ -190,6 +190,11 @@ const STYLES = `
.quest-mode .sl-dock-item:not(.active):hover .sl-dock-icon { .quest-mode .sl-dock-item:not(.active):hover .sl-dock-icon {
opacity: 0.85; opacity: 0.85;
} }
/* Ensure the dock is hidden on desktop (md and up) */
@media (min-width: 768px) {
.sl-dock-wrap { display: none !important; }
}
`; `;
export function StudentLayout() { export function StudentLayout() {
@ -204,11 +209,13 @@ export function StudentLayout() {
<style>{STYLES}</style> <style>{STYLES}</style>
<div className="flex min-h-screen w-full overflow-x-hidden"> <div className="flex min-h-screen w-full overflow-x-hidden">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="hidden md:block">
<AppSidebar /> <AppSidebar />
</div>
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<SidebarTrigger className="hidden md:block" /> {/* Extra bottom padding so content clears the floating dock */}
<main className="flex-1 md:pb-0"> <main className="flex-1 pb-24 md:pb-0">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -24,6 +24,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.dr-screen { .dr-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -213,14 +215,31 @@ const STYLES = `
/* CTA bar */ /* CTA bar */
.dr-cta-bar { .dr-cta-bar {
position:fixed;bottom:96px;left:0;right:0;z-index:10; position: fixed;
padding:0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom)); bottom: 96px;
left: 0;
right: 0;
z-index: 5;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
} }
.dr-cta-inner { .dr-cta-inner {
max-width:560px;margin:0 auto; max-width: 560px;
display:flex;gap:0.75rem;align-items:center; margin: 0 auto;
display: flex;
gap: 0.75rem;
align-items: center;
}
@media (min-width: 900px) {
.dr-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.dr-topic-grid { grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.dr-cta-bar { left: var(--sidebar-width); right: 0; }
/* Align decorative blobs relative to the centered content container */
.dr-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.dr-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.dr-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.dr-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
} }
.dr-next-btn { .dr-next-btn {

View File

@ -26,6 +26,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.htm-screen { .htm-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -179,16 +181,35 @@ const STYLES = `
/* CTA bar */ /* CTA bar */
.htm-cta-bar { .htm-cta-bar {
position: fixed; bottom: 96px; left: 0; right: 0; z-index: 10; position: fixed;
bottom: 96px;
left: 0;
right: 0;
z-index: 10;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom)); padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease; transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), opacity 0.25s ease;
} }
.htm-cta-bar.hidden { .htm-cta-bar.hidden {
transform: translateY(100%); opacity: 0; pointer-events: none; transform: translateY(100%); opacity: 0; pointer-events: none;
} }
.htm-cta-inner { .htm-cta-inner {
max-width: 560px; margin: 0 auto; max-width: 560px;
margin: 0 auto;
}
@media (min-width: 900px) {
.htm-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.htm-cta-bar { left: var(--sidebar-width); right: 0; }
.htm-cta-inner { max-width: var(--content-max); margin: 0 auto; }
/* align blobs to centered content */
.htm-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.htm-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.htm-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.htm-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
/* make module cards slightly wider on desktop */
.htm-card { min-height: 220px; }
} }
.htm-start-btn { .htm-start-btn {

View File

@ -25,6 +25,8 @@ const DOTS = [
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.pt-screen { .pt-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -64,6 +66,17 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1.25rem; display: flex; flex-direction: column; gap: 1.25rem;
} }
/* Desktop / wide layout */
@media (min-width: 900px) {
.pt-inner { max-width: var(--content-max); padding: 3rem 1.5rem 6rem; }
.pt-stats-row { grid-template-columns: repeat(3, 1fr); }
.pt-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.pt-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.pt-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.pt-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
}
/* ── Pop-in animation ── */ /* ── Pop-in animation ── */
@keyframes ptPopIn { @keyframes ptPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from { opacity:0; transform: scale(0.92) translateY(12px); }

View File

@ -9,6 +9,8 @@ import { useExamConfigStore } from "../../../stores/useExamConfigStore";
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap');
:root { --content-max: 1100px; }
.results-screen { .results-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -72,6 +74,17 @@ const STYLES = `
display: flex; flex-direction: column; gap: 1rem; display: flex; flex-direction: column; gap: 1rem;
} }
/* Desktop / wide layout */
@media (min-width: 900px) {
.results-inner { max-width: var(--content-max); padding: 3rem 1.5rem 4rem; }
.stats-grid { grid-template-columns: repeat(4, 1fr); }
.r-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; }
.r-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; }
.r-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; }
.r-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; }
}
/* ── Header ── */ /* ── Header ── */
.results-header { .results-header {
display: flex; align-items: center; gap: 1rem; display: flex; align-items: center; gap: 1rem;

View File

@ -1,5 +1,6 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { BlockMath, InlineMath } from "react-katex";
import { import {
Binary, Binary,
Calculator, Calculator,
@ -16,6 +17,9 @@ import {
BookOpen, BookOpen,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
Eye,
EyeOff,
Highlighter,
} from "lucide-react"; } from "lucide-react";
import { api } from "../../../utils/api"; import { api } from "../../../utils/api";
@ -85,6 +89,8 @@ const DOTS = [
const GLOBAL_STYLES = ` 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'); @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 { .test-screen {
min-height: 100vh; min-height: 100vh;
background: ${COLORS.bg}; background: ${COLORS.bg};
@ -245,6 +251,115 @@ const GLOBAL_STYLES = `
/* Incorrect flash */ /* Incorrect flash */
@keyframes tFlashRed { 0%,100%{background:transparent;}50%{background:rgba(239,68,68,0.15);} } @keyframes tFlashRed { 0%,100%{background:transparent;}50%{background:rgba(239,68,68,0.15);} }
.t-flash-red { animation:tFlashRed 0.6s ease; } .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 ───────────────────────────────────────────────────────────────── // ─── Confetti ─────────────────────────────────────────────────────────────────
@ -269,6 +384,9 @@ interface IncorrectEntry {
originalIndex: number; originalIndex: number;
} }
type HighlightRange = { start: number; end: number };
type HighlightsByField = Record<string, HighlightRange[]>;
const Confetti = ({ active }: { active: boolean }) => { const Confetti = ({ active }: { active: boolean }) => {
const [particles, setParticles] = useState<ConfettiParticle[]>([]); const [particles, setParticles] = useState<ConfettiParticle[]>([]);
useEffect(() => { useEffect(() => {
@ -627,6 +745,19 @@ export const Test = () => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(null); const [feedback, setFeedback] = useState<FeedbackState>(null);
const [showConfetti, setShowConfetti] = useState(false); 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); const [showIncorrectFlash, setShowIncorrectFlash] = useState(false);
// ── Retry / targeted state ────────────────────────────────────────────────── // ── Retry / targeted state ──────────────────────────────────────────────────
@ -655,6 +786,111 @@ export const Test = () => {
? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex]
: currentModule?.questions[questionIndex]; : 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 const currentAnswer = currentQuestion
? (answers[currentQuestion.id] ?? "") ? (answers[currentQuestion.id] ?? "")
: ""; : "";
@ -941,6 +1177,163 @@ export const Test = () => {
const formatTime = (s: number) => const formatTime = (s: number) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; `${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 ─────────────────────────────────────────────────────────── // ── Render helpers ───────────────────────────────────────────────────────────
const renderOptions = (question?: Question) => { const renderOptions = (question?: Question) => {
if (!question?.options?.length) return null; if (!question?.options?.length) return null;
@ -1098,14 +1491,14 @@ export const Test = () => {
key={i} key={i}
className="flex items-center gap-4 p-4 bg-gray-50 rounded-2xl border-2 border-gray-100" 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" /> <Check size={20} className="text-purple-600" />
</div> </div>
<span className="font-bold text-gray-700">{text}</span> <span className="font-bold text-gray-700">{text}</span>
</div> </div>
))} ))}
{isTargeted && ( {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> <span className="text-3xl">🎯</span>
<div> <div>
<p className="font-bold text-purple-800">Targeted Mode</p> <p className="font-bold text-purple-800">Targeted Mode</p>
@ -1161,7 +1554,9 @@ export const Test = () => {
<header <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"}`} 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="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 justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
@ -1174,13 +1569,89 @@ export const Test = () => {
of {currentModule?.questions.length} of {currentModule?.questions.length}
</span> </span>
</div> </div>
<div className="flex flex-col items-center">
<div className="t-timer"> <div className="t-timer">
{Math.floor(time / 60)}:{String(time % 60).padStart(2, "0")} {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> </div>
<h1 className="text-center font-bold text-gray-700"> <h1 className="text-center font-bold">
{currentModule?.module_title} {currentModule?.module_title}
</h1> </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 &amp; Notes</span>
</button>
</div>
</div>
{isTargeted && retryMode && ( {isTargeted && retryMode && (
<p className="text-center text-orange-500 font-bold text-sm mt-2"> <p className="text-center text-orange-500 font-bold text-sm mt-2">
🔥 Reviewing incorrect answers — get them right to finish! 🔥 Reviewing incorrect answers — get them right to finish!
@ -1191,9 +1662,20 @@ export const Test = () => {
{/* Question content */} {/* Question content */}
<section <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"> {/* 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 && ( {currentQuestion?.context && (
<div className="t-card p-6"> <div className="t-card p-6">
<p className="font-semibold text-gray-700 leading-relaxed"> <p className="font-semibold text-gray-700 leading-relaxed">
@ -1208,6 +1690,80 @@ export const Test = () => {
</p> </p>
</div> </div>
{!isMCQ && renderShortAnswer(currentQuestion)} {!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>
</div> </div>
</section> </section>
</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" className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-100 py-4 px-4"
style={{ zIndex: 20 }} 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) */} {/* Back button (non-targeted) */}
{!isTargeted && ( {!isTargeted && (
<button <button
disabled={isFirstQuestion} disabled={isFirstQuestion}
onClick={prevQuestion} 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} /> <ChevronLeft size={18} />
<span className="hidden md:inline font-black">Previous</span>
</button> </button>
)} )}
{/* Menu */} {/* Menu */}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild> <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} /> <Menu size={18} />
<span className="hidden md:inline font-black">Menu</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="rounded-2xl border-2 border-gray-100 p-2"> <DropdownMenuContent className="rounded-2xl border-2 border-gray-100 p-2">
@ -1314,6 +1872,9 @@ export const Test = () => {
{/* ── Mark for Review button (non-targeted) ── */} {/* ── Mark for Review button (non-targeted) ── */}
{!isTargeted && ( {!isTargeted && (
<>
{/* Mobile: keep existing icon-only button */}
{!isMdUp ? (
<button <button
className={`t-bookmark-btn ${isCurrentMarked ? "marked" : ""}`} className={`t-bookmark-btn ${isCurrentMarked ? "marked" : ""}`}
onClick={toggleMark} onClick={toggleMark}
@ -1327,6 +1888,30 @@ export const Test = () => {
<Bookmark size={18} color="#9ca3af" /> <Bookmark size={18} color="#9ca3af" />
)} )}
</button> </button>
) : (
/* 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>
)}
</>
)} )}
{/* Question navigator dialog */} {/* Question navigator dialog */}
@ -1484,8 +2069,8 @@ export const Test = () => {
</div> </div>
</section> </section>
{/* MCQ Options Drawer */} {/* MCQ Options Drawer (mobile only) */}
{isMCQ && ( {isMCQ && !isMdUp && (
<Drawer.Root <Drawer.Root
modal={false} modal={false}
snapPoints={[0.35, 0.6, 0.95]} snapPoints={[0.35, 0.6, 0.95]}

View File

@ -68,6 +68,8 @@ const getSectionMeta = (section?: string) =>
const STYLES = ` const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
:root { --content-max: 1100px; }
.tp-screen { .tp-screen {
min-height: 100vh; min-height: 100vh;
background: #fffbf4; background: #fffbf4;
@ -351,16 +353,35 @@ const STYLES = `
/* ── Bottom CTA bar ── */ /* ── Bottom CTA bar ── */
.tp-cta-bar { .tp-cta-bar {
position: fixed; bottom: 96px; left: 0; right: 0; z-index: 10; position: fixed;
bottom: 96px;
left: 0;
right: 0;
z-index: 5;
padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom)); padding: 0.85rem 1.25rem calc(0.85rem + env(safe-area-inset-bottom));
background: rgba(255,251,244,0.9); background: rgba(255, 251, 244, 0.9);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-top: 2px solid #f3f4f6; border-top: 2px solid #f3f4f6;
} }
.tp-cta-inner { .tp-cta-inner {
max-width: 560px; margin: 0 auto; max-width: 560px;
display: flex; gap: 0.75rem; align-items: center; margin: 0 auto;
display: flex;
gap: 0.75rem;
align-items: center;
}
@media (min-width: 900px) {
.tp-inner { max-width: var(--content-max); padding: 3rem 1.5rem 10rem; }
.tp-topic-grid { grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.tp-cta-bar { left: var(--sidebar-width); right: 0; }
/* Align decorative blobs relative to the centered content container */
.tp-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 48px); }
.tp-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 56px); }
.tp-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 12px); }
.tp-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 12px); }
} }
/* Next / Start button */ /* Next / Start button */