Spaces:
Running
Running
Commit Β·
0fc01b4
1
Parent(s): 8887fa5
mobile optimized UI
Browse files- frontend/index.html +3 -1
- frontend/src/components/shared/Layout.jsx +2 -2
- frontend/src/components/shared/Navbar.jsx +64 -35
- frontend/src/components/test/Calculator.jsx +2 -2
- frontend/src/index.css +46 -0
- frontend/src/pages/AdminTests.jsx +2 -2
- frontend/src/pages/AdminUsers.jsx +60 -1
- frontend/src/pages/Bookmarks.jsx +4 -4
- frontend/src/pages/Checklist.jsx +1 -1
- frontend/src/pages/Dashboard.jsx +1 -1
- frontend/src/pages/Leaderboard.jsx +15 -15
- frontend/src/pages/Result.jsx +8 -8
- frontend/src/pages/TestEngine.jsx +48 -18
frontend/index.html
CHANGED
|
@@ -3,7 +3,9 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
|
|
|
|
| 7 |
<title>GATE Prep Platform</title>
|
| 8 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
| 7 |
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 8 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
| 9 |
<title>GATE Prep Platform</title>
|
| 10 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 11 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
frontend/src/components/shared/Layout.jsx
CHANGED
|
@@ -4,8 +4,8 @@ export default function Layout({ children }) {
|
|
| 4 |
return (
|
| 5 |
<div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
| 6 |
<Navbar />
|
| 7 |
-
<main className="pt-14">
|
| 8 |
-
<div className="max-w-7xl mx-auto px-4 py-6">
|
| 9 |
{children}
|
| 10 |
</div>
|
| 11 |
</main>
|
|
|
|
| 4 |
return (
|
| 5 |
<div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
| 6 |
<Navbar />
|
| 7 |
+
<main className="pt-14 pb-20 sm:pb-0">
|
| 8 |
+
<div className="max-w-7xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
| 9 |
{children}
|
| 10 |
</div>
|
| 11 |
</main>
|
frontend/src/components/shared/Navbar.jsx
CHANGED
|
@@ -27,47 +27,76 @@ export default function Navbar() {
|
|
| 27 |
|
| 28 |
const links = isAdmin ? adminLinks : aspirantLinks
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
return (
|
| 31 |
-
<
|
| 32 |
-
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
</div>
|
| 37 |
-
|
| 38 |
-
</Link>
|
| 39 |
|
| 40 |
-
|
|
|
|
| 41 |
{links.map(({ to, label, icon: Icon }) => (
|
| 42 |
-
<Link
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</Link>
|
| 50 |
))}
|
| 51 |
</div>
|
| 52 |
-
|
| 53 |
-
<div className="flex items-center gap-2 flex-shrink-0">
|
| 54 |
-
<button onClick={toggle}
|
| 55 |
-
title="Toggle theme"
|
| 56 |
-
aria-label="Toggle theme"
|
| 57 |
-
className="p-1.5 rounded text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:bg-slate-800 transition-colors">
|
| 58 |
-
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
| 59 |
-
</button>
|
| 60 |
-
<div className="text-right hidden sm:block">
|
| 61 |
-
<p className="text-xs font-medium text-slate-900 dark:text-white leading-none">{user?.full_name}</p>
|
| 62 |
-
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{user?.role}</p>
|
| 63 |
-
</div>
|
| 64 |
-
<button onClick={logout}
|
| 65 |
-
title="Log out"
|
| 66 |
-
aria-label="Log out"
|
| 67 |
-
className="p-1.5 rounded text-slate-500 dark:text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-colors">
|
| 68 |
-
<LogOut size={14} />
|
| 69 |
-
</button>
|
| 70 |
-
</div>
|
| 71 |
-
</nav>
|
| 72 |
)
|
| 73 |
}
|
|
|
|
| 27 |
|
| 28 |
const links = isAdmin ? adminLinks : aspirantLinks
|
| 29 |
|
| 30 |
+
const isActive = (to) =>
|
| 31 |
+
location.pathname === to || (to !== '/admin' && to !== '/dashboard' && location.pathname.startsWith(to))
|
| 32 |
+
|
| 33 |
return (
|
| 34 |
+
<>
|
| 35 |
+
{/* ββ Desktop/Tablet Top Bar ββββββββββββββββββββββββββββββββββ */}
|
| 36 |
+
<nav className="app-header fixed top-0 left-0 right-0 z-50 h-14 flex items-center px-3 sm:px-4 gap-2 sm:gap-4 border-b"
|
| 37 |
+
style={{ background: 'var(--header-bg)', borderColor: 'var(--border)', backdropFilter: 'blur(8px)' }}>
|
| 38 |
+
<Link to={isAdmin ? '/admin' : '/dashboard'} className="flex items-center gap-2 mr-2 sm:mr-4 flex-shrink-0">
|
| 39 |
+
<div className="w-7 h-7 rounded bg-sky-600 flex items-center justify-center">
|
| 40 |
+
<span className="font-bold text-slate-900 dark:text-white text-sm">G</span>
|
| 41 |
+
</div>
|
| 42 |
+
<span className="font-bold text-slate-900 dark:text-white text-sm">GATEPrep</span>
|
| 43 |
+
</Link>
|
| 44 |
+
|
| 45 |
+
{/* Desktop nav links β hidden on mobile (shown in bottom nav instead) */}
|
| 46 |
+
<div className="hidden sm:flex items-center gap-0.5 flex-1 overflow-x-auto">
|
| 47 |
+
{links.map(({ to, label, icon: Icon }) => (
|
| 48 |
+
<Link key={to} to={to} className={clsx(
|
| 49 |
+
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors',
|
| 50 |
+
isActive(to)
|
| 51 |
+
? 'bg-sky-600/20 text-sky-600 dark:text-sky-400'
|
| 52 |
+
: 'text-slate-500 hover:text-slate-800 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/50'
|
| 53 |
+
)}>
|
| 54 |
+
<Icon size={16} /> <span>{label}</span>
|
| 55 |
+
</Link>
|
| 56 |
+
))}
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* Mobile spacer */}
|
| 60 |
+
<div className="flex-1 sm:hidden" />
|
| 61 |
+
|
| 62 |
+
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
|
| 63 |
+
<button onClick={toggle}
|
| 64 |
+
title="Toggle theme"
|
| 65 |
+
aria-label="Toggle theme"
|
| 66 |
+
className="p-2 sm:p-1.5 rounded text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
| 67 |
+
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
| 68 |
+
</button>
|
| 69 |
+
<div className="text-right hidden sm:block">
|
| 70 |
+
<p className="text-xs font-medium text-slate-900 dark:text-white leading-none">{user?.full_name}</p>
|
| 71 |
+
<p className="text-xs text-slate-500 dark:text-slate-400 capitalize">{user?.role}</p>
|
| 72 |
+
</div>
|
| 73 |
+
<button onClick={logout}
|
| 74 |
+
title="Log out"
|
| 75 |
+
aria-label="Log out"
|
| 76 |
+
className="p-2 sm:p-1.5 rounded text-slate-500 dark:text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-colors">
|
| 77 |
+
<LogOut size={14} />
|
| 78 |
+
</button>
|
| 79 |
</div>
|
| 80 |
+
</nav>
|
|
|
|
| 81 |
|
| 82 |
+
{/* ββ Mobile Bottom Navigation ββββββββββββββββββββββββββββββββ */}
|
| 83 |
+
<div className="mobile-bottom-nav sm:hidden flex items-center justify-around px-1">
|
| 84 |
{links.map(({ to, label, icon: Icon }) => (
|
| 85 |
+
<Link
|
| 86 |
+
key={to}
|
| 87 |
+
to={to}
|
| 88 |
+
className={clsx(
|
| 89 |
+
'flex flex-col items-center justify-center gap-0.5 py-1.5 px-2 min-w-[52px] rounded-lg transition-colors',
|
| 90 |
+
isActive(to)
|
| 91 |
+
? 'text-sky-500'
|
| 92 |
+
: 'text-slate-500 dark:text-slate-400'
|
| 93 |
+
)}
|
| 94 |
+
>
|
| 95 |
+
<Icon size={20} strokeWidth={isActive(to) ? 2.5 : 1.8} />
|
| 96 |
+
<span className="text-[10px] font-medium leading-none">{label}</span>
|
| 97 |
</Link>
|
| 98 |
))}
|
| 99 |
</div>
|
| 100 |
+
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
)
|
| 102 |
}
|
frontend/src/components/test/Calculator.jsx
CHANGED
|
@@ -104,8 +104,8 @@ export default function Calculator({ onClose }) {
|
|
| 104 |
)
|
| 105 |
|
| 106 |
return (
|
| 107 |
-
<div className="fixed z-50 shadow-2xl rounded-lg overflow-hidden select-none"
|
| 108 |
-
style={{ top: '70px',
|
| 109 |
{/* Header */}
|
| 110 |
<div className="flex items-center justify-between px-3 py-2 border-b"
|
| 111 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)' }}>
|
|
|
|
| 104 |
)
|
| 105 |
|
| 106 |
return (
|
| 107 |
+
<div className="fixed z-50 shadow-2xl rounded-lg overflow-hidden select-none sm:w-[320px] left-4 right-4 sm:left-auto sm:right-4"
|
| 108 |
+
style={{ top: '70px', background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
|
| 109 |
{/* Header */}
|
| 110 |
<div className="flex items-center justify-between px-3 py-2 border-b"
|
| 111 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)' }}>
|
frontend/src/index.css
CHANGED
|
@@ -160,7 +160,9 @@
|
|
| 160 |
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 161 |
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
| 162 |
@keyframes timerPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
|
|
| 163 |
.timer-critical { animation: timerPulse 1s ease-in-out infinite; }
|
|
|
|
| 164 |
|
| 165 |
.theme-text { color: var(--text); }
|
| 166 |
.theme-muted { color: var(--text-muted); }
|
|
@@ -213,3 +215,47 @@
|
|
| 213 |
.calc-btn-eq { background: #15803d; color: white; @apply text-lg; }
|
| 214 |
.calc-btn-clear { background: #dc2626; color: white; }
|
| 215 |
.calc-btn-backspace { background: #92400e; color: white; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
| 161 |
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
| 162 |
@keyframes timerPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
| 163 |
+
@keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
| 164 |
.timer-critical { animation: timerPulse 1s ease-in-out infinite; }
|
| 165 |
+
.animate-slide-in-right { animation: slideInRight 0.25s ease-out forwards; }
|
| 166 |
|
| 167 |
.theme-text { color: var(--text); }
|
| 168 |
.theme-muted { color: var(--text-muted); }
|
|
|
|
| 215 |
.calc-btn-eq { background: #15803d; color: white; @apply text-lg; }
|
| 216 |
.calc-btn-clear { background: #dc2626; color: white; }
|
| 217 |
.calc-btn-backspace { background: #92400e; color: white; }
|
| 218 |
+
|
| 219 |
+
/* ββ Mobile Utilities ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 220 |
+
|
| 221 |
+
/* Safe area padding for iOS notch / home indicator */
|
| 222 |
+
.safe-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
| 223 |
+
.safe-top { padding-top: env(safe-area-inset-top, 0px); }
|
| 224 |
+
|
| 225 |
+
/* Bottom nav spacing β 56px nav + safe area */
|
| 226 |
+
.pb-mobile-nav {
|
| 227 |
+
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* Scrollbar hide for horizontal scrollers */
|
| 231 |
+
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
| 232 |
+
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
| 233 |
+
|
| 234 |
+
/* Touch target enforcement β 44x44px per WCAG */
|
| 235 |
+
@media (max-width: 640px) {
|
| 236 |
+
.btn-primary,
|
| 237 |
+
.btn-ghost,
|
| 238 |
+
.btn-danger,
|
| 239 |
+
.btn-success {
|
| 240 |
+
min-height: 44px;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.q-option {
|
| 244 |
+
@apply py-3.5;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/* Mobile bottom navigation */
|
| 249 |
+
.mobile-bottom-nav {
|
| 250 |
+
position: fixed;
|
| 251 |
+
bottom: 0;
|
| 252 |
+
left: 0;
|
| 253 |
+
right: 0;
|
| 254 |
+
z-index: 40;
|
| 255 |
+
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
| 256 |
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
| 257 |
+
background: var(--header-bg);
|
| 258 |
+
border-top: 1px solid var(--border);
|
| 259 |
+
backdrop-filter: blur(12px);
|
| 260 |
+
}
|
| 261 |
+
|
frontend/src/pages/AdminTests.jsx
CHANGED
|
@@ -242,7 +242,7 @@ export default function AdminTests() {
|
|
| 242 |
return (
|
| 243 |
<Layout>
|
| 244 |
<div className="space-y-6 animate-fade-in">
|
| 245 |
-
<div className="flex items-center justify-between">
|
| 246 |
<div>
|
| 247 |
<h1 className="text-2xl font-bold" style={{ color: 'var(--text)' }}>Tests</h1>
|
| 248 |
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{tests.length} total</p>
|
|
@@ -253,7 +253,7 @@ export default function AdminTests() {
|
|
| 253 |
</div>
|
| 254 |
|
| 255 |
{/* Filter tabs */}
|
| 256 |
-
<div className="flex gap-1 p-1 rounded-lg w-fit" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
|
| 257 |
{[['all','All'], ['weekly_quiz','Weekly Quiz'], ['test_series','Test Series']].map(([v, l]) => (
|
| 258 |
<button key={v} onClick={() => setFilter(v)}
|
| 259 |
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
|
|
| 242 |
return (
|
| 243 |
<Layout>
|
| 244 |
<div className="space-y-6 animate-fade-in">
|
| 245 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
| 246 |
<div>
|
| 247 |
<h1 className="text-2xl font-bold" style={{ color: 'var(--text)' }}>Tests</h1>
|
| 248 |
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{tests.length} total</p>
|
|
|
|
| 253 |
</div>
|
| 254 |
|
| 255 |
{/* Filter tabs */}
|
| 256 |
+
<div className="flex gap-1 p-1 rounded-lg w-fit overflow-x-auto" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
|
| 257 |
{[['all','All'], ['weekly_quiz','Weekly Quiz'], ['test_series','Test Series']].map(([v, l]) => (
|
| 258 |
<button key={v} onClick={() => setFilter(v)}
|
| 259 |
className={`px-4 py-1.5 rounded text-sm font-medium transition-colors ${
|
frontend/src/pages/AdminUsers.jsx
CHANGED
|
@@ -102,7 +102,8 @@ export default function AdminUsers() {
|
|
| 102 |
{loading ? (
|
| 103 |
<div className="flex justify-center py-16"><Spinner size={28} className="text-sky-500" /></div>
|
| 104 |
) : (
|
| 105 |
-
<
|
|
|
|
| 106 |
<table className="w-full text-sm">
|
| 107 |
<thead>
|
| 108 |
<tr className="border-b border-slate-200 dark:border-slate-800">
|
|
@@ -181,6 +182,64 @@ export default function AdminUsers() {
|
|
| 181 |
</tbody>
|
| 182 |
</table>
|
| 183 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
)}
|
| 185 |
|
| 186 |
{resetLink && (
|
|
|
|
| 102 |
{loading ? (
|
| 103 |
<div className="flex justify-center py-16"><Spinner size={28} className="text-sky-500" /></div>
|
| 104 |
) : (
|
| 105 |
+
<>
|
| 106 |
+
<div className="gate-card overflow-hidden hidden md:block">
|
| 107 |
<table className="w-full text-sm">
|
| 108 |
<thead>
|
| 109 |
<tr className="border-b border-slate-200 dark:border-slate-800">
|
|
|
|
| 182 |
</tbody>
|
| 183 |
</table>
|
| 184 |
</div>
|
| 185 |
+
|
| 186 |
+
{/* Mobile card view */}
|
| 187 |
+
<div className="space-y-3 md:hidden">
|
| 188 |
+
{filtered.map(u => (
|
| 189 |
+
<div key={u.id} className="gate-card p-4 space-y-3">
|
| 190 |
+
<div className="flex items-center gap-3">
|
| 191 |
+
<div className="w-10 h-10 rounded-full bg-sky-500/20 border border-brand-500/20 flex items-center justify-center flex-shrink-0">
|
| 192 |
+
<span className="text-sky-400 text-sm font-semibold">{u.full_name[0]?.toUpperCase()}</span>
|
| 193 |
+
</div>
|
| 194 |
+
<div className="flex-1 min-w-0">
|
| 195 |
+
<p className="font-medium text-sm truncate" style={{ color: 'var(--text)' }}>{u.full_name}</p>
|
| 196 |
+
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 200 |
+
<span className={`badge ${roleStyle[u.role] || 'badge-slate'}`}>{u.role}</span>
|
| 201 |
+
<span className={`badge ${u.is_active ? 'badge-green' : 'badge-red'}`}>
|
| 202 |
+
{u.is_active ? 'Active' : 'Disabled'}
|
| 203 |
+
</span>
|
| 204 |
+
<span className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
| 205 |
+
Joined {new Date(u.created_at).toLocaleDateString('en-IN', { day:'2-digit', month:'short', year:'numeric' })}
|
| 206 |
+
</span>
|
| 207 |
+
</div>
|
| 208 |
+
<div className="flex items-center gap-2 flex-wrap pt-2 border-t" style={{ borderColor: 'var(--border)' }}>
|
| 209 |
+
{updating[u.id] ? <Spinner size={14} className="text-sky-400" /> : (
|
| 210 |
+
<>
|
| 211 |
+
{u.role === 'user' && (
|
| 212 |
+
<button onClick={() => changeRole(u.id, 'aspirant')}
|
| 213 |
+
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-green-500/10 border border-green-500/20 text-green-400 text-xs font-medium hover:bg-green-500/20 transition-colors">
|
| 214 |
+
<UserCheck size={13} /> Approve
|
| 215 |
+
</button>
|
| 216 |
+
)}
|
| 217 |
+
{u.role === 'aspirant' && (
|
| 218 |
+
<button onClick={() => changeRole(u.id, 'user')}
|
| 219 |
+
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors">
|
| 220 |
+
<UserX size={13} /> Revoke
|
| 221 |
+
</button>
|
| 222 |
+
)}
|
| 223 |
+
{u.role !== 'admin' && (
|
| 224 |
+
<button onClick={() => toggleStatus(u.id)}
|
| 225 |
+
className="px-3 py-2 rounded-lg bg-slate-200 dark:bg-slate-700/50 border border-slate-300 dark:border-slate-600/50 text-slate-500 dark:text-slate-400 text-xs font-medium">
|
| 226 |
+
{u.is_active ? 'Disable' : 'Enable'}
|
| 227 |
+
</button>
|
| 228 |
+
)}
|
| 229 |
+
<button onClick={() => createResetLink(u.id)} disabled={updating[`reset-${u.id}`]}
|
| 230 |
+
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-sky-500/10 border border-sky-500/20 text-sky-400 text-xs font-medium hover:bg-sky-500/20 transition-colors disabled:opacity-50">
|
| 231 |
+
{updating[`reset-${u.id}`] ? <Spinner size={13} /> : <KeyRound size={13} />} Reset
|
| 232 |
+
</button>
|
| 233 |
+
</>
|
| 234 |
+
)}
|
| 235 |
+
</div>
|
| 236 |
+
</div>
|
| 237 |
+
))}
|
| 238 |
+
{filtered.length === 0 && (
|
| 239 |
+
<p className="text-center py-12" style={{ color: 'var(--text-muted)' }}>No users found</p>
|
| 240 |
+
)}
|
| 241 |
+
</div>
|
| 242 |
+
</>
|
| 243 |
)}
|
| 244 |
|
| 245 |
{resetLink && (
|
frontend/src/pages/Bookmarks.jsx
CHANGED
|
@@ -70,7 +70,7 @@ function BookmarkCard({ bm, onRemove }) {
|
|
| 70 |
<div className="px-4 pb-4 border-t border-slate-200 dark:border-slate-800 pt-3 space-y-3">
|
| 71 |
{/* Options */}
|
| 72 |
{bm.options?.length > 0 && (
|
| 73 |
-
<div className="grid grid-cols-2 gap-1.5">
|
| 74 |
{bm.options.map((o, i) => {
|
| 75 |
const letter = 'ABCD'[i]
|
| 76 |
const isCorrect = bm.correct_answer?.includes(letter)
|
|
@@ -156,20 +156,20 @@ export default function BookmarksPage() {
|
|
| 156 |
<Layout>
|
| 157 |
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
| 158 |
<div>
|
| 159 |
-
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
| 160 |
<Bookmark size={26} className="text-sky-400" /> Bookmarks
|
| 161 |
</h1>
|
| 162 |
<p className="text-slate-500 dark:text-slate-400 mt-1">{bookmarks.length} saved question{bookmarks.length !== 1 ? 's' : ''}</p>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
{/* Filters */}
|
| 166 |
-
<div className="flex gap-3">
|
| 167 |
<div className="relative flex-1">
|
| 168 |
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 dark:text-slate-400" />
|
| 169 |
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search questions or notes..." className="input pl-10 text-sm" />
|
| 170 |
</div>
|
| 171 |
{subjects.length > 1 && (
|
| 172 |
-
<select value={filterSubject} onChange={e => setFilterSubject(e.target.value)} className="input text-sm w-40">
|
| 173 |
{subjects.map(s => <option key={s} value={s}>{s === 'all' ? 'All subjects' : s}</option>)}
|
| 174 |
</select>
|
| 175 |
)}
|
|
|
|
| 70 |
<div className="px-4 pb-4 border-t border-slate-200 dark:border-slate-800 pt-3 space-y-3">
|
| 71 |
{/* Options */}
|
| 72 |
{bm.options?.length > 0 && (
|
| 73 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
| 74 |
{bm.options.map((o, i) => {
|
| 75 |
const letter = 'ABCD'[i]
|
| 76 |
const isCorrect = bm.correct_answer?.includes(letter)
|
|
|
|
| 156 |
<Layout>
|
| 157 |
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
| 158 |
<div>
|
| 159 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
| 160 |
<Bookmark size={26} className="text-sky-400" /> Bookmarks
|
| 161 |
</h1>
|
| 162 |
<p className="text-slate-500 dark:text-slate-400 mt-1">{bookmarks.length} saved question{bookmarks.length !== 1 ? 's' : ''}</p>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
{/* Filters */}
|
| 166 |
+
<div className="flex flex-col sm:flex-row gap-3">
|
| 167 |
<div className="relative flex-1">
|
| 168 |
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-500 dark:text-slate-400" />
|
| 169 |
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search questions or notes..." className="input pl-10 text-sm" />
|
| 170 |
</div>
|
| 171 |
{subjects.length > 1 && (
|
| 172 |
+
<select value={filterSubject} onChange={e => setFilterSubject(e.target.value)} className="input text-sm w-full sm:w-40">
|
| 173 |
{subjects.map(s => <option key={s} value={s}>{s === 'all' ? 'All subjects' : s}</option>)}
|
| 174 |
</select>
|
| 175 |
)}
|
frontend/src/pages/Checklist.jsx
CHANGED
|
@@ -160,7 +160,7 @@ export default function ChecklistPage() {
|
|
| 160 |
onClick={() => toggle(topic.id, item, done)}
|
| 161 |
disabled={updating[key]}
|
| 162 |
className={clsx(
|
| 163 |
-
'flex items-center gap-1.5 px-2.5 py-1.5 rounded border text-xs font-medium transition-all',
|
| 164 |
done
|
| 165 |
? 'border-green-500/30 text-green-400'
|
| 166 |
: 'text-slate-500 dark:text-slate-400 hover:border-slate-500'
|
|
|
|
| 160 |
onClick={() => toggle(topic.id, item, done)}
|
| 161 |
disabled={updating[key]}
|
| 162 |
className={clsx(
|
| 163 |
+
'flex items-center gap-1.5 px-2.5 py-2 sm:py-1.5 rounded border text-xs font-medium transition-all',
|
| 164 |
done
|
| 165 |
? 'border-green-500/30 text-green-400'
|
| 166 |
: 'text-slate-500 dark:text-slate-400 hover:border-slate-500'
|
frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -32,7 +32,7 @@ export default function Dashboard() {
|
|
| 32 |
<Layout>
|
| 33 |
<div className="space-y-8 animate-fade-in">
|
| 34 |
<div>
|
| 35 |
-
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">
|
| 36 |
Hello, {user?.full_name?.split(' ')[0]}
|
| 37 |
</h1>
|
| 38 |
<p className="text-slate-500 dark:text-slate-400 mt-1">Ready to practice? Pick a test below.</p>
|
|
|
|
| 32 |
<Layout>
|
| 33 |
<div className="space-y-8 animate-fade-in">
|
| 34 |
<div>
|
| 35 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white">
|
| 36 |
Hello, {user?.full_name?.split(' ')[0]}
|
| 37 |
</h1>
|
| 38 |
<p className="text-slate-500 dark:text-slate-400 mt-1">Ready to practice? Pick a test below.</p>
|
frontend/src/pages/Leaderboard.jsx
CHANGED
|
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
|
|
| 47 |
<div className="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
| 48 |
<Trophy size={26} className="text-amber-400"/>
|
| 49 |
</div>
|
| 50 |
-
<h1 className="text-3xl font-bold text-slate-900 dark:text-white">{data.test_title}</h1>
|
| 51 |
<p className="text-slate-500 dark:text-slate-400 mt-1">{data.total_participants} participant{data.total_participants !== 1 ? 's' : ''} Β· First attempt only</p>
|
| 52 |
{data.current_user_rank && (
|
| 53 |
<div className="inline-flex items-center gap-2 mt-3 px-4 py-2 rounded-full bg-sky-500/10 border border-brand-500/20">
|
|
@@ -67,16 +67,16 @@ export default function LeaderboardPage() {
|
|
| 67 |
<>
|
| 68 |
{/* Top 3 podium */}
|
| 69 |
{top3.length > 0 && (
|
| 70 |
-
<div className="grid grid-cols-3 gap-3">
|
| 71 |
{/* Reorder: 2nd, 1st, 3rd for podium effect */}
|
| 72 |
{[top3[1], top3[0], top3[2]].map((entry, podiumIdx) => {
|
| 73 |
if (!entry) return <div key={podiumIdx}/>
|
| 74 |
const actualRank = entry.rank
|
| 75 |
const m = MEDAL_COLORS[actualRank] || {}
|
| 76 |
-
const heights = ['h-28', 'h-36', 'h-24']
|
| 77 |
return (
|
| 78 |
<div key={entry.user_id} className={clsx(
|
| 79 |
-
'gate-card border p-4 flex flex-col items-center justify-end text-center',
|
| 80 |
m.bg, heights[podiumIdx],
|
| 81 |
entry.is_current_user && 'ring-2 ring-brand-500/50'
|
| 82 |
)}>
|
|
@@ -94,15 +94,15 @@ export default function LeaderboardPage() {
|
|
| 94 |
)}
|
| 95 |
|
| 96 |
{/* Full table */}
|
| 97 |
-
<div className="gate-card overflow-hidden">
|
| 98 |
<table className="w-full text-sm">
|
| 99 |
<thead>
|
| 100 |
<tr className="border-b border-slate-200 dark:border-slate-800">
|
| 101 |
-
<th className="text-left px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider w-12">Rank</th>
|
| 102 |
-
<th className="text-left px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">Name</th>
|
| 103 |
-
<th className="text-right px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">Score</th>
|
| 104 |
-
<th className="text-right px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">%</th>
|
| 105 |
-
<th className="text-right px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider hidden sm:table-cell">Date</th>
|
| 106 |
</tr>
|
| 107 |
</thead>
|
| 108 |
<tbody className="divide-y divide-slate-800/60">
|
|
@@ -113,12 +113,12 @@ export default function LeaderboardPage() {
|
|
| 113 |
'transition-colors',
|
| 114 |
entry.is_current_user ? 'bg-sky-500/5' : 'hover:bg-slate-100 dark:bg-slate-800/30'
|
| 115 |
)} style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 53px' }}>
|
| 116 |
-
<td className="px-5 py-3.5">
|
| 117 |
<div className="flex items-center justify-center w-7 h-7">
|
| 118 |
{m ? m.icon : <span className="text-slate-500 dark:text-slate-400 font-mono text-sm">#{entry.rank}</span>}
|
| 119 |
</div>
|
| 120 |
</td>
|
| 121 |
-
<td className="px-5 py-3.5">
|
| 122 |
<div className="flex items-center gap-2">
|
| 123 |
<div className="w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
|
| 124 |
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">{entry.full_name[0]?.toUpperCase()}</span>
|
|
@@ -129,15 +129,15 @@ export default function LeaderboardPage() {
|
|
| 129 |
</span>
|
| 130 |
</div>
|
| 131 |
</td>
|
| 132 |
-
<td className="px-5 py-3.5 text-right font-mono text-slate-600 dark:text-slate-300">
|
| 133 |
{entry.score}/{entry.total_marks}
|
| 134 |
</td>
|
| 135 |
-
<td className="px-5 py-3.5 text-right">
|
| 136 |
<span className={clsx('font-semibold', entry.percentage >= 75 ? 'text-green-400' : entry.percentage >= 50 ? 'text-amber-400' : 'text-red-400')}>
|
| 137 |
{entry.percentage}%
|
| 138 |
</span>
|
| 139 |
</td>
|
| 140 |
-
<td className="px-5 py-3.5 text-right text-slate-500 dark:text-slate-400 text-xs hidden sm:table-cell">
|
| 141 |
{entry.submitted_at ? new Date(entry.submitted_at).toLocaleDateString('en-IN') : '-'}
|
| 142 |
</td>
|
| 143 |
</tr>
|
|
|
|
| 47 |
<div className="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-4">
|
| 48 |
<Trophy size={26} className="text-amber-400"/>
|
| 49 |
</div>
|
| 50 |
+
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white">{data.test_title}</h1>
|
| 51 |
<p className="text-slate-500 dark:text-slate-400 mt-1">{data.total_participants} participant{data.total_participants !== 1 ? 's' : ''} Β· First attempt only</p>
|
| 52 |
{data.current_user_rank && (
|
| 53 |
<div className="inline-flex items-center gap-2 mt-3 px-4 py-2 rounded-full bg-sky-500/10 border border-brand-500/20">
|
|
|
|
| 67 |
<>
|
| 68 |
{/* Top 3 podium */}
|
| 69 |
{top3.length > 0 && (
|
| 70 |
+
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
| 71 |
{/* Reorder: 2nd, 1st, 3rd for podium effect */}
|
| 72 |
{[top3[1], top3[0], top3[2]].map((entry, podiumIdx) => {
|
| 73 |
if (!entry) return <div key={podiumIdx}/>
|
| 74 |
const actualRank = entry.rank
|
| 75 |
const m = MEDAL_COLORS[actualRank] || {}
|
| 76 |
+
const heights = ['h-24 sm:h-28', 'h-28 sm:h-36', 'h-20 sm:h-24']
|
| 77 |
return (
|
| 78 |
<div key={entry.user_id} className={clsx(
|
| 79 |
+
'gate-card border p-2.5 sm:p-4 flex flex-col items-center justify-end text-center',
|
| 80 |
m.bg, heights[podiumIdx],
|
| 81 |
entry.is_current_user && 'ring-2 ring-brand-500/50'
|
| 82 |
)}>
|
|
|
|
| 94 |
)}
|
| 95 |
|
| 96 |
{/* Full table */}
|
| 97 |
+
<div className="gate-card overflow-hidden overflow-x-auto">
|
| 98 |
<table className="w-full text-sm">
|
| 99 |
<thead>
|
| 100 |
<tr className="border-b border-slate-200 dark:border-slate-800">
|
| 101 |
+
<th className="text-left px-3 sm:px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider w-12">Rank</th>
|
| 102 |
+
<th className="text-left px-3 sm:px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">Name</th>
|
| 103 |
+
<th className="text-right px-3 sm:px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">Score</th>
|
| 104 |
+
<th className="text-right px-3 sm:px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider">%</th>
|
| 105 |
+
<th className="text-right px-3 sm:px-5 py-3 text-slate-500 dark:text-slate-400 font-medium text-xs uppercase tracking-wider hidden sm:table-cell">Date</th>
|
| 106 |
</tr>
|
| 107 |
</thead>
|
| 108 |
<tbody className="divide-y divide-slate-800/60">
|
|
|
|
| 113 |
'transition-colors',
|
| 114 |
entry.is_current_user ? 'bg-sky-500/5' : 'hover:bg-slate-100 dark:bg-slate-800/30'
|
| 115 |
)} style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 53px' }}>
|
| 116 |
+
<td className="px-3 sm:px-5 py-3.5">
|
| 117 |
<div className="flex items-center justify-center w-7 h-7">
|
| 118 |
{m ? m.icon : <span className="text-slate-500 dark:text-slate-400 font-mono text-sm">#{entry.rank}</span>}
|
| 119 |
</div>
|
| 120 |
</td>
|
| 121 |
+
<td className="px-3 sm:px-5 py-3.5">
|
| 122 |
<div className="flex items-center gap-2">
|
| 123 |
<div className="w-7 h-7 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
|
| 124 |
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">{entry.full_name[0]?.toUpperCase()}</span>
|
|
|
|
| 129 |
</span>
|
| 130 |
</div>
|
| 131 |
</td>
|
| 132 |
+
<td className="px-3 sm:px-5 py-3.5 text-right font-mono text-slate-600 dark:text-slate-300">
|
| 133 |
{entry.score}/{entry.total_marks}
|
| 134 |
</td>
|
| 135 |
+
<td className="px-3 sm:px-5 py-3.5 text-right">
|
| 136 |
<span className={clsx('font-semibold', entry.percentage >= 75 ? 'text-green-400' : entry.percentage >= 50 ? 'text-amber-400' : 'text-red-400')}>
|
| 137 |
{entry.percentage}%
|
| 138 |
</span>
|
| 139 |
</td>
|
| 140 |
+
<td className="px-3 sm:px-5 py-3.5 text-right text-slate-500 dark:text-slate-400 text-xs hidden sm:table-cell">
|
| 141 |
{entry.submitted_at ? new Date(entry.submitted_at).toLocaleDateString('en-IN') : '-'}
|
| 142 |
</td>
|
| 143 |
</tr>
|
frontend/src/pages/Result.jsx
CHANGED
|
@@ -137,7 +137,7 @@ const QuestionReview = memo(function QuestionReview({ qa, idx, isBookmarked, onT
|
|
| 137 |
<Clock size={10} /> {qa.time_spent_seconds}s
|
| 138 |
</span>
|
| 139 |
<button onClick={e => { e.stopPropagation(); onToggleBookmark(qa.question_id) }}
|
| 140 |
-
className={clsx('p-
|
| 141 |
{isBookmarked ? <BookmarkCheck size={13} /> : <Bookmark size={13} />}
|
| 142 |
</button>
|
| 143 |
{open ? <ChevronUp size={13} style={{ color: 'var(--text-muted)' }} /> : <ChevronDown size={13} style={{ color: 'var(--text-muted)' }} />}
|
|
@@ -148,7 +148,7 @@ const QuestionReview = memo(function QuestionReview({ qa, idx, isBookmarked, onT
|
|
| 148 |
<div className="px-4 pb-3 border-t space-y-2" style={{ borderColor: 'var(--border)' }}>
|
| 149 |
{/* Options */}
|
| 150 |
{qa.options?.length > 0 && (
|
| 151 |
-
<div className="grid grid-cols-2 gap-1.5 mt-2">
|
| 152 |
{qa.options.map((o, i) => {
|
| 153 |
const l = 'ABCD'[i]
|
| 154 |
const isCorrect = qa.correct_answer?.includes(l)
|
|
@@ -169,7 +169,7 @@ const QuestionReview = memo(function QuestionReview({ qa, idx, isBookmarked, onT
|
|
| 169 |
</div>
|
| 170 |
)}
|
| 171 |
{qa.question_type === 'nat' && (
|
| 172 |
-
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
|
| 173 |
<div className="px-3 py-2 rounded bg-green-500/10 border border-green-500/20 text-green-300">
|
| 174 |
β Correct: <span className="font-mono">{qa.correct_answer}</span>
|
| 175 |
</div>
|
|
@@ -300,7 +300,7 @@ export default function ResultPage() {
|
|
| 300 |
return (
|
| 301 |
<Layout>
|
| 302 |
<div className="max-w-2xl mx-auto space-y-5 animate-fade-in">
|
| 303 |
-
<div className="flex items-center justify-between">
|
| 304 |
<Link to="/dashboard" className="flex items-center gap-1.5 text-sm hover:opacity-80" style={{ color: 'var(--text-muted)' }}>
|
| 305 |
<ArrowLeft size={14} /> Dashboard
|
| 306 |
</Link>
|
|
@@ -317,7 +317,7 @@ export default function ResultPage() {
|
|
| 317 |
</div>
|
| 318 |
|
| 319 |
{/* Attempt badge */}
|
| 320 |
-
<div className={clsx('flex items-center gap-2 px-4 py-2
|
| 321 |
result.counts_for_leaderboard ? 'border-sky-500/20 text-sky-300' : 'border-amber-500/20 text-amber-300')}
|
| 322 |
style={{ background: result.counts_for_leaderboard ? 'rgba(14,165,233,0.08)' : 'rgba(245,158,11,0.08)' }}>
|
| 323 |
<Medal size={14} />
|
|
@@ -383,7 +383,7 @@ export default function ResultPage() {
|
|
| 383 |
<h3 className="font-semibold mb-3" style={{ color: 'var(--text)' }}>Your Attempts</h3>
|
| 384 |
<div className="space-y-2">
|
| 385 |
{attempts.map(a => (
|
| 386 |
-
<div key={a.attempt_id} className={clsx('flex items-center justify-between px-3 py-2 rounded border',
|
| 387 |
a.attempt_id === parseInt(attemptId) ? 'border-sky-500/30' : '')}
|
| 388 |
style={{ borderColor: a.attempt_id === parseInt(attemptId) ? undefined : 'var(--border)', background: a.attempt_id === parseInt(attemptId) ? 'rgba(14,165,233,0.06)' : 'transparent' }}>
|
| 389 |
<div className="flex items-center gap-2">
|
|
@@ -415,12 +415,12 @@ export default function ResultPage() {
|
|
| 415 |
|
| 416 |
{/* Question review */}
|
| 417 |
<div>
|
| 418 |
-
<div className="flex items-center justify-between mb-3">
|
| 419 |
<h3 className="font-semibold" style={{ color: 'var(--text)' }}>
|
| 420 |
Question Review
|
| 421 |
<span className="text-xs ml-2" style={{ color: 'var(--text-muted)' }}>(click bookmark icon)</span>
|
| 422 |
</h3>
|
| 423 |
-
<div className="flex gap-1 p-1 rounded" style={{ background: 'var(--bg-card)' }}>
|
| 424 |
{['all', 'correct', 'incorrect', 'skipped'].map(f => (
|
| 425 |
<button key={f} onClick={() => setFilter(f)}
|
| 426 |
className={clsx('px-2.5 py-1 rounded text-xs font-medium capitalize transition-colors',
|
|
|
|
| 137 |
<Clock size={10} /> {qa.time_spent_seconds}s
|
| 138 |
</span>
|
| 139 |
<button onClick={e => { e.stopPropagation(); onToggleBookmark(qa.question_id) }}
|
| 140 |
+
className={clsx('p-2 rounded transition-colors', isBookmarked ? 'text-sky-400' : 'text-slate-600 hover:text-sky-400')}>
|
| 141 |
{isBookmarked ? <BookmarkCheck size={13} /> : <Bookmark size={13} />}
|
| 142 |
</button>
|
| 143 |
{open ? <ChevronUp size={13} style={{ color: 'var(--text-muted)' }} /> : <ChevronDown size={13} style={{ color: 'var(--text-muted)' }} />}
|
|
|
|
| 148 |
<div className="px-4 pb-3 border-t space-y-2" style={{ borderColor: 'var(--border)' }}>
|
| 149 |
{/* Options */}
|
| 150 |
{qa.options?.length > 0 && (
|
| 151 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mt-2">
|
| 152 |
{qa.options.map((o, i) => {
|
| 153 |
const l = 'ABCD'[i]
|
| 154 |
const isCorrect = qa.correct_answer?.includes(l)
|
|
|
|
| 169 |
</div>
|
| 170 |
)}
|
| 171 |
{qa.question_type === 'nat' && (
|
| 172 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2 text-xs">
|
| 173 |
<div className="px-3 py-2 rounded bg-green-500/10 border border-green-500/20 text-green-300">
|
| 174 |
β Correct: <span className="font-mono">{qa.correct_answer}</span>
|
| 175 |
</div>
|
|
|
|
| 300 |
return (
|
| 301 |
<Layout>
|
| 302 |
<div className="max-w-2xl mx-auto space-y-5 animate-fade-in">
|
| 303 |
+
<div className="flex items-center justify-between flex-wrap gap-2">
|
| 304 |
<Link to="/dashboard" className="flex items-center gap-1.5 text-sm hover:opacity-80" style={{ color: 'var(--text-muted)' }}>
|
| 305 |
<ArrowLeft size={14} /> Dashboard
|
| 306 |
</Link>
|
|
|
|
| 317 |
</div>
|
| 318 |
|
| 319 |
{/* Attempt badge */}
|
| 320 |
+
<div className={clsx('flex items-center gap-2 px-3 sm:px-4 py-2 text-xs sm:text-sm rounded border',
|
| 321 |
result.counts_for_leaderboard ? 'border-sky-500/20 text-sky-300' : 'border-amber-500/20 text-amber-300')}
|
| 322 |
style={{ background: result.counts_for_leaderboard ? 'rgba(14,165,233,0.08)' : 'rgba(245,158,11,0.08)' }}>
|
| 323 |
<Medal size={14} />
|
|
|
|
| 383 |
<h3 className="font-semibold mb-3" style={{ color: 'var(--text)' }}>Your Attempts</h3>
|
| 384 |
<div className="space-y-2">
|
| 385 |
{attempts.map(a => (
|
| 386 |
+
<div key={a.attempt_id} className={clsx('flex flex-col sm:flex-row sm:items-center justify-between gap-2 px-3 py-2 rounded border',
|
| 387 |
a.attempt_id === parseInt(attemptId) ? 'border-sky-500/30' : '')}
|
| 388 |
style={{ borderColor: a.attempt_id === parseInt(attemptId) ? undefined : 'var(--border)', background: a.attempt_id === parseInt(attemptId) ? 'rgba(14,165,233,0.06)' : 'transparent' }}>
|
| 389 |
<div className="flex items-center gap-2">
|
|
|
|
| 415 |
|
| 416 |
{/* Question review */}
|
| 417 |
<div>
|
| 418 |
+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
|
| 419 |
<h3 className="font-semibold" style={{ color: 'var(--text)' }}>
|
| 420 |
Question Review
|
| 421 |
<span className="text-xs ml-2" style={{ color: 'var(--text-muted)' }}>(click bookmark icon)</span>
|
| 422 |
</h3>
|
| 423 |
+
<div className="flex flex-wrap gap-1 p-1 rounded" style={{ background: 'var(--bg-card)' }}>
|
| 424 |
{['all', 'correct', 'incorrect', 'skipped'].map(f => (
|
| 425 |
<button key={f} onClick={() => setFilter(f)}
|
| 426 |
className={clsx('px-2.5 py-1 rounded text-xs font-medium capitalize transition-colors',
|
frontend/src/pages/TestEngine.jsx
CHANGED
|
@@ -154,6 +154,7 @@ export default function TestEngine() {
|
|
| 154 |
const [started, setStarted] = useState(false)
|
| 155 |
const [starting, setStarting] = useState(false)
|
| 156 |
const [accepted, setAccepted] = useState(false)
|
|
|
|
| 157 |
|
| 158 |
// ββ Refs (stable, never cause re-renders) βββββββββββββββββββββ
|
| 159 |
const submitLockRef = useRef(false)
|
|
@@ -499,10 +500,9 @@ export default function TestEngine() {
|
|
| 499 |
[attempt?.started_at, test?.duration_minutes]
|
| 500 |
)
|
| 501 |
|
| 502 |
-
const
|
| 503 |
return (
|
| 504 |
-
<
|
| 505 |
-
style={{ background: 'var(--sidebar-bg)', borderColor: 'var(--border)', contain: 'layout style paint' }}>
|
| 506 |
<div className="p-3 border-b text-center" style={{ borderColor: 'var(--border)' }}>
|
| 507 |
<div className="w-9 h-9 rounded-full bg-sky-700 flex items-center justify-center mx-auto mb-1">
|
| 508 |
<span className="text-slate-900 dark:text-white font-bold text-sm">U</span>
|
|
@@ -538,7 +538,7 @@ export default function TestEngine() {
|
|
| 538 |
: !visited.has(q2.id) ? 'not-visited'
|
| 539 |
: getQStatus(q2.id, q?.id, answers, marked)
|
| 540 |
return (
|
| 541 |
-
<button key={q2.id} onClick={() => setCurrent(qIdx)}
|
| 542 |
className={STATUS_CLASS[status] || STATUS_CLASS['not-visited']}>
|
| 543 |
{qIdx + 1}
|
| 544 |
</button>
|
|
@@ -555,7 +555,7 @@ export default function TestEngine() {
|
|
| 555 |
{submitting ? <Spinner size={12} /> : <Send size={12} />} Submit Test
|
| 556 |
</button>
|
| 557 |
</div>
|
| 558 |
-
</
|
| 559 |
)
|
| 560 |
}, [answered, answers, marked, notAnswered, q?.id, questions, subjects, submitting, visited])
|
| 561 |
|
|
@@ -581,7 +581,7 @@ export default function TestEngine() {
|
|
| 581 |
</button>
|
| 582 |
</div>
|
| 583 |
|
| 584 |
-
<div className="grid grid-cols-3 gap-3 mb-6">
|
| 585 |
{[
|
| 586 |
['Duration', `${test?.duration_minutes || 0} min`],
|
| 587 |
['Questions', test?.question_count || 0],
|
|
@@ -686,7 +686,7 @@ export default function TestEngine() {
|
|
| 686 |
)}
|
| 687 |
|
| 688 |
{/* ββ Top bar ββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 689 |
-
<div className="app-header flex items-center px-4 h-12 border-b flex-shrink-0"
|
| 690 |
style={{ background: 'var(--header-bg)', borderColor: 'var(--border)' }}>
|
| 691 |
<div className="flex items-center gap-2 mr-3 flex-shrink-0">
|
| 692 |
<div className="w-6 h-6 rounded bg-sky-600 flex items-center justify-center">
|
|
@@ -750,7 +750,7 @@ export default function TestEngine() {
|
|
| 750 |
</div>
|
| 751 |
|
| 752 |
{/* ββ Question type info bar βββββββββββββββββββββββββββββββ */}
|
| 753 |
-
<div className="px-4 py-1.5 text-xs border-b flex items-center gap-4 flex-shrink-0"
|
| 754 |
style={{ background: 'var(--bg-card)', borderColor: 'var(--border)' }}>
|
| 755 |
<span style={{ color: 'var(--text-muted)' }}>
|
| 756 |
Question Type:{' '}
|
|
@@ -872,35 +872,65 @@ export default function TestEngine() {
|
|
| 872 |
</div>
|
| 873 |
|
| 874 |
{/* Bottom action bar */}
|
| 875 |
-
<div className="flex items-center justify-between px-4 py-2.5 border-t flex-shrink-0"
|
| 876 |
style={{ background: 'var(--bg-card)', borderColor: 'var(--border)' }}>
|
| 877 |
-
<div className="flex items-center gap-2">
|
| 878 |
<button onClick={markAndNext}
|
| 879 |
-
className="flex items-center gap-1.5 px-3 py-2 rounded border text-xs font-medium"
|
| 880 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 881 |
-
<Flag size={
|
| 882 |
</button>
|
| 883 |
<button onClick={clearResponse}
|
| 884 |
-
className="px-3 py-2 rounded border text-xs font-medium"
|
| 885 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 886 |
Clear
|
| 887 |
</button>
|
| 888 |
</div>
|
| 889 |
-
<div className="flex items-center gap-2">
|
| 890 |
<button onClick={() => current > 0 && setCurrent(c => c - 1)} disabled={current === 0}
|
| 891 |
-
className="flex items-center gap-1 px-3 py-2 rounded border text-xs font-medium disabled:opacity-40"
|
| 892 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 893 |
-
<ChevronLeft size={13} /> Prev
|
| 894 |
</button>
|
| 895 |
<button onClick={saveAndNext}
|
| 896 |
-
className="flex items-center gap-1 px-4 py-2 rounded text-xs font-semibold bg-sky-700 hover:bg-sky-600 text-white transition-colors">
|
| 897 |
Save & Next <ChevronRight size={13} />
|
| 898 |
</button>
|
| 899 |
</div>
|
| 900 |
</div>
|
| 901 |
</div>
|
| 902 |
|
| 903 |
-
{sidebar}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
</div>
|
| 905 |
|
| 906 |
{/* Calculator popup */}
|
|
|
|
| 154 |
const [started, setStarted] = useState(false)
|
| 155 |
const [starting, setStarting] = useState(false)
|
| 156 |
const [accepted, setAccepted] = useState(false)
|
| 157 |
+
const [showPalette, setShowPalette] = useState(false)
|
| 158 |
|
| 159 |
// ββ Refs (stable, never cause re-renders) βββββββββββββββββββββ
|
| 160 |
const submitLockRef = useRef(false)
|
|
|
|
| 500 |
[attempt?.started_at, test?.duration_minutes]
|
| 501 |
)
|
| 502 |
|
| 503 |
+
const sidebarContent = useMemo(() => {
|
| 504 |
return (
|
| 505 |
+
<>
|
|
|
|
| 506 |
<div className="p-3 border-b text-center" style={{ borderColor: 'var(--border)' }}>
|
| 507 |
<div className="w-9 h-9 rounded-full bg-sky-700 flex items-center justify-center mx-auto mb-1">
|
| 508 |
<span className="text-slate-900 dark:text-white font-bold text-sm">U</span>
|
|
|
|
| 538 |
: !visited.has(q2.id) ? 'not-visited'
|
| 539 |
: getQStatus(q2.id, q?.id, answers, marked)
|
| 540 |
return (
|
| 541 |
+
<button key={q2.id} onClick={() => { setCurrent(qIdx); setShowPalette(false) }}
|
| 542 |
className={STATUS_CLASS[status] || STATUS_CLASS['not-visited']}>
|
| 543 |
{qIdx + 1}
|
| 544 |
</button>
|
|
|
|
| 555 |
{submitting ? <Spinner size={12} /> : <Send size={12} />} Submit Test
|
| 556 |
</button>
|
| 557 |
</div>
|
| 558 |
+
</>
|
| 559 |
)
|
| 560 |
}, [answered, answers, marked, notAnswered, q?.id, questions, subjects, submitting, visited])
|
| 561 |
|
|
|
|
| 581 |
</button>
|
| 582 |
</div>
|
| 583 |
|
| 584 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
|
| 585 |
{[
|
| 586 |
['Duration', `${test?.duration_minutes || 0} min`],
|
| 587 |
['Questions', test?.question_count || 0],
|
|
|
|
| 686 |
)}
|
| 687 |
|
| 688 |
{/* ββ Top bar ββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 689 |
+
<div className="app-header flex items-center px-2 sm:px-4 h-12 border-b flex-shrink-0"
|
| 690 |
style={{ background: 'var(--header-bg)', borderColor: 'var(--border)' }}>
|
| 691 |
<div className="flex items-center gap-2 mr-3 flex-shrink-0">
|
| 692 |
<div className="w-6 h-6 rounded bg-sky-600 flex items-center justify-center">
|
|
|
|
| 750 |
</div>
|
| 751 |
|
| 752 |
{/* ββ Question type info bar βββββββββββββββββββββββββββββββ */}
|
| 753 |
+
<div className="px-3 sm:px-4 py-1.5 text-xs border-b flex items-center flex-wrap gap-2 sm:gap-4 flex-shrink-0"
|
| 754 |
style={{ background: 'var(--bg-card)', borderColor: 'var(--border)' }}>
|
| 755 |
<span style={{ color: 'var(--text-muted)' }}>
|
| 756 |
Question Type:{' '}
|
|
|
|
| 872 |
</div>
|
| 873 |
|
| 874 |
{/* Bottom action bar */}
|
| 875 |
+
<div className="flex items-center justify-between px-2 sm:px-4 py-2 sm:py-2.5 border-t flex-shrink-0 gap-1"
|
| 876 |
style={{ background: 'var(--bg-card)', borderColor: 'var(--border)' }}>
|
| 877 |
+
<div className="flex items-center gap-1 sm:gap-2">
|
| 878 |
<button onClick={markAndNext}
|
| 879 |
+
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-2.5 sm:py-2 rounded border text-xs font-medium"
|
| 880 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 881 |
+
<Flag size={14} className="text-purple-400" /> <span className="hidden sm:inline">Mark & Next</span>
|
| 882 |
</button>
|
| 883 |
<button onClick={clearResponse}
|
| 884 |
+
className="px-2 sm:px-3 py-2.5 sm:py-2 rounded border text-xs font-medium"
|
| 885 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 886 |
Clear
|
| 887 |
</button>
|
| 888 |
</div>
|
| 889 |
+
<div className="flex items-center gap-1 sm:gap-2">
|
| 890 |
<button onClick={() => current > 0 && setCurrent(c => c - 1)} disabled={current === 0}
|
| 891 |
+
className="flex items-center gap-1 px-2 sm:px-3 py-2.5 sm:py-2 rounded border text-xs font-medium disabled:opacity-40"
|
| 892 |
style={{ background: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--text)' }}>
|
| 893 |
+
<ChevronLeft size={13} /> <span className="hidden sm:inline">Prev</span>
|
| 894 |
</button>
|
| 895 |
<button onClick={saveAndNext}
|
| 896 |
+
className="flex items-center gap-1 px-3 sm:px-4 py-2.5 sm:py-2 rounded text-xs font-semibold bg-sky-700 hover:bg-sky-600 text-white transition-colors">
|
| 897 |
Save & Next <ChevronRight size={13} />
|
| 898 |
</button>
|
| 899 |
</div>
|
| 900 |
</div>
|
| 901 |
</div>
|
| 902 |
|
| 903 |
+
{/* Desktop sidebar */}
|
| 904 |
+
<div className="hidden md:flex w-52 border-l flex-col flex-shrink-0 overflow-y-auto"
|
| 905 |
+
style={{ background: 'var(--sidebar-bg)', borderColor: 'var(--border)', contain: 'layout style paint' }}>
|
| 906 |
+
{sidebarContent}
|
| 907 |
+
</div>
|
| 908 |
+
|
| 909 |
+
{/* Mobile floating palette toggle */}
|
| 910 |
+
<button
|
| 911 |
+
onClick={() => setShowPalette(true)}
|
| 912 |
+
className="md:hidden fixed bottom-16 right-4 z-30 flex items-center gap-1.5 px-3 py-2.5 rounded-full shadow-lg text-xs font-semibold text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
| 913 |
+
>
|
| 914 |
+
Q{current + 1}/{questions.length}
|
| 915 |
+
</button>
|
| 916 |
+
|
| 917 |
+
{/* Mobile palette drawer */}
|
| 918 |
+
{showPalette && (
|
| 919 |
+
<div className="md:hidden fixed inset-0 z-40 flex">
|
| 920 |
+
{/* Backdrop */}
|
| 921 |
+
<div className="flex-1" onClick={() => setShowPalette(false)}
|
| 922 |
+
style={{ background: 'rgba(0,0,0,0.6)' }} />
|
| 923 |
+
{/* Drawer */}
|
| 924 |
+
<div className="w-64 flex flex-col overflow-y-auto animate-slide-in-right"
|
| 925 |
+
style={{ background: 'var(--sidebar-bg)' }}>
|
| 926 |
+
<div className="flex items-center justify-between p-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
| 927 |
+
<span className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Question Palette</span>
|
| 928 |
+
<button onClick={() => setShowPalette(false)} className="p-1 rounded" style={{ color: 'var(--text-muted)' }}>β</button>
|
| 929 |
+
</div>
|
| 930 |
+
{sidebarContent}
|
| 931 |
+
</div>
|
| 932 |
+
</div>
|
| 933 |
+
)}
|
| 934 |
</div>
|
| 935 |
|
| 936 |
{/* Calculator popup */}
|