banu4prasad commited on
Commit
0fc01b4
Β·
1 Parent(s): 8887fa5

mobile optimized UI

Browse files
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
- <nav className="app-header fixed top-0 left-0 right-0 z-50 h-14 flex items-center px-4 gap-4 border-b"
32
- style={{ background: 'var(--header-bg)', borderColor: 'var(--border)', backdropFilter: 'blur(8px)' }}>
33
- <Link to={isAdmin ? '/admin' : '/dashboard'} className="flex items-center gap-2 mr-4 flex-shrink-0">
34
- <div className="w-7 h-7 rounded bg-sky-600 flex items-center justify-center">
35
- <span className="font-bold text-slate-900 dark:text-white text-sm">G</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  </div>
37
- <span className="font-bold text-slate-900 dark:text-white text-sm hidden sm:block">GATEPrep</span>
38
- </Link>
39
 
40
- <div className="flex items-center gap-0.5 flex-1 overflow-x-auto">
 
41
  {links.map(({ to, label, icon: Icon }) => (
42
- <Link key={to} to={to} className={clsx(
43
- 'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium whitespace-nowrap transition-colors',
44
- location.pathname === to || (to !== '/admin' && to !== '/dashboard' && location.pathname.startsWith(to))
45
- ? 'bg-sky-600/20 text-sky-600 dark:text-sky-400'
46
- : 'text-slate-500 hover:text-slate-800 hover:bg-slate-100 dark:text-slate-500 dark:text-slate-400 dark:hover:text-slate-700 dark:text-slate-200 dark:hover:bg-slate-100 dark:bg-slate-800/50'
47
- )}>
48
- <Icon size={16} /> <span className="hidden sm:inline">{label}</span>
 
 
 
 
 
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', right: '16px', width: '320px', 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)' }}>
 
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
- <div className="gate-card overflow-hidden">
 
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-1 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,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 rounded border text-sm',
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 sidebar = useMemo(() => {
503
  return (
504
- <div className="w-52 border-l flex flex-col flex-shrink-0 overflow-y-auto"
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
- </div>
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={12} className="text-purple-400" /> Mark & Next
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 */}