|
8 | 8 |
|
9 | 9 | let { children }: { children: Snippet } = $props(); |
10 | 10 |
|
| 11 | + const PASSWORD_HASH = '97d57425ad02fefa1dc33a94af012f73e73eae76643ee5c5629896a333c16faf'; |
| 12 | + const STORAGE_KEY = 'tstprep-dashboard-unlocked'; |
| 13 | +
|
| 14 | + let mounted = $state(false); |
| 15 | + let unlocked = $state(false); |
| 16 | + let password = $state(''); |
| 17 | + let error = $state(''); |
| 18 | + let checking = $state(false); |
| 19 | +
|
11 | 20 | // Sidebar: open by default on first visit; persisted in localStorage thereafter |
12 | 21 | let sidebarOpen = $state(false); |
13 | 22 |
|
14 | 23 | $effect(() => { |
| 24 | + mounted = true; |
| 25 | + if (sessionStorage.getItem(STORAGE_KEY) === '1') { |
| 26 | + unlocked = true; |
| 27 | + } |
15 | 28 | const saved = localStorage.getItem('sidebarOpen'); |
16 | 29 | sidebarOpen = saved !== null ? saved === 'true' : window.matchMedia('(min-width: 1024px)').matches; |
17 | 30 | }); |
|
21 | 34 | localStorage.setItem('sidebarOpen', String(val)); |
22 | 35 | } |
23 | 36 |
|
| 37 | + async function submit(e: SubmitEvent) { |
| 38 | + e.preventDefault(); |
| 39 | + checking = true; |
| 40 | + error = ''; |
| 41 | + const buf = new TextEncoder().encode(password); |
| 42 | + const hashBuf = await crypto.subtle.digest('SHA-256', buf); |
| 43 | + const hash = Array.from(new Uint8Array(hashBuf)) |
| 44 | + .map((b) => b.toString(16).padStart(2, '0')) |
| 45 | + .join(''); |
| 46 | + checking = false; |
| 47 | + if (hash === PASSWORD_HASH) { |
| 48 | + sessionStorage.setItem(STORAGE_KEY, '1'); |
| 49 | + unlocked = true; |
| 50 | + } else { |
| 51 | + error = 'Incorrect password'; |
| 52 | + password = ''; |
| 53 | + } |
| 54 | + } |
| 55 | +
|
24 | 56 | const levelColors: Record<string, string> = { |
25 | 57 | Beginner: 'bg-gray-400', |
26 | 58 | Practitioner: 'bg-blue-500', |
|
30 | 62 | }; |
31 | 63 | </script> |
32 | 64 |
|
33 | | -<!-- ─── Sticky Header ──────────────────────────────────────────────────────── --> |
34 | | -<header class="fixed top-0 inset-x-0 h-14 bg-white border-b border-gray-100 z-50 flex items-center px-4 gap-3 shadow-sm"> |
35 | | - |
36 | | - <!-- Sidebar toggle — mobile only (desktop uses the in-sidebar button) --> |
37 | | - <button |
38 | | - onclick={() => setSidebar(!sidebarOpen)} |
39 | | - class="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 flex-shrink-0" |
40 | | - aria-label="Toggle menu" |
41 | | - > |
42 | | - <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> |
43 | | - <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /> |
44 | | - </svg> |
45 | | - </button> |
46 | | - |
47 | | - <!-- Logo --> |
48 | | - <a href="/" class="flex items-center gap-2 flex-shrink-0"> |
49 | | - <div class="w-7 h-7 bg-brand-green rounded-lg flex items-center justify-center"> |
50 | | - <svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> |
51 | | - <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> |
| 65 | +{#if !mounted} |
| 66 | + <div class="min-h-screen bg-gray-50"></div> |
| 67 | +{:else if !unlocked} |
| 68 | + <div class="flex min-h-screen items-center justify-center bg-gray-50 px-4"> |
| 69 | + <form |
| 70 | + onsubmit={submit} |
| 71 | + class="w-full max-w-sm rounded-lg border border-gray-200 bg-white p-8 shadow-sm" |
| 72 | + > |
| 73 | + <h1 class="mb-2 text-xl font-semibold text-gray-900">TST Prep Dashboard</h1> |
| 74 | + <p class="mb-6 text-sm text-gray-600">Design preview — enter access password.</p> |
| 75 | + <input |
| 76 | + type="password" |
| 77 | + bind:value={password} |
| 78 | + autocomplete="current-password" |
| 79 | + placeholder="Password" |
| 80 | + class="mb-3 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none" |
| 81 | + /> |
| 82 | + {#if error} |
| 83 | + <p class="mb-3 text-sm text-red-600">{error}</p> |
| 84 | + {/if} |
| 85 | + <button |
| 86 | + type="submit" |
| 87 | + disabled={checking || !password} |
| 88 | + class="w-full rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50" |
| 89 | + > |
| 90 | + {checking ? 'Checking…' : 'Unlock'} |
| 91 | + </button> |
| 92 | + </form> |
| 93 | + </div> |
| 94 | +{:else} |
| 95 | + <!-- ─── Sticky Header ──────────────────────────────────────────────────────── --> |
| 96 | + <header class="fixed top-0 inset-x-0 h-14 bg-white border-b border-gray-100 z-50 flex items-center px-4 gap-3 shadow-sm"> |
| 97 | + |
| 98 | + <!-- Sidebar toggle — mobile only (desktop uses the in-sidebar button) --> |
| 99 | + <button |
| 100 | + onclick={() => setSidebar(!sidebarOpen)} |
| 101 | + class="lg:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors text-gray-500 flex-shrink-0" |
| 102 | + aria-label="Toggle menu" |
| 103 | + > |
| 104 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> |
| 105 | + <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /> |
52 | 106 | </svg> |
53 | | - </div> |
54 | | - <span class="font-black text-gray-900 tracking-tight">TST<span class="text-brand-green">Prep</span></span> |
55 | | - </a> |
| 107 | + </button> |
56 | 108 |
|
57 | | - <!-- Spacer --> |
58 | | - <div class="flex-1"></div> |
| 109 | + <!-- Logo --> |
| 110 | + <a href="/" class="flex items-center gap-2 flex-shrink-0"> |
| 111 | + <div class="w-7 h-7 bg-brand-green rounded-lg flex items-center justify-center"> |
| 112 | + <svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> |
| 113 | + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> |
| 114 | + </svg> |
| 115 | + </div> |
| 116 | + <span class="font-black text-gray-900 tracking-tight">TST<span class="text-brand-green">Prep</span></span> |
| 117 | + </a> |
59 | 118 |
|
60 | | - <!-- Score chip — hidden on xs --> |
61 | | - <div class="hidden sm:flex items-center gap-1.5 bg-brand-green-light rounded-full px-3 py-1.5 flex-shrink-0"> |
62 | | - <span class="text-brand-green font-black text-sm leading-none">{mockUser.currentScore.toFixed(1)}</span> |
63 | | - <span class="text-gray-400 text-xs leading-none">/ 6.0</span> |
64 | | - <span class="hidden md:inline text-[10px] text-gray-400 leading-none ml-0.5">score</span> |
65 | | - </div> |
| 119 | + <!-- Spacer --> |
| 120 | + <div class="flex-1"></div> |
66 | 121 |
|
67 | | - <!-- Credits chip — hidden on xs --> |
68 | | - <div class="hidden sm:flex items-center gap-1.5 bg-amber-50 border border-amber-100 rounded-full px-3 py-1.5 flex-shrink-0"> |
69 | | - <svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> |
70 | | - <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1.41 16.09V20h-2.67v-1.93c-1.71-.36-3.16-1.46-3.27-3.4h1.96c.1 1.05.82 1.87 2.65 1.87 1.96 0 2.4-.98 2.4-1.59 0-.83-.44-1.61-2.67-2.14-2.48-.6-4.18-1.62-4.18-3.67 0-1.72 1.39-2.84 3.11-3.21V4h2.67v1.95c1.86.45 2.79 1.86 2.85 3.39H14.3c-.05-1.11-.64-1.87-2.22-1.87-1.5 0-2.4.68-2.4 1.64 0 .84.65 1.39 2.67 1.91s4.18 1.39 4.18 3.91c-.01 1.83-1.38 2.83-3.12 3.16z"/> |
71 | | - </svg> |
72 | | - <span class="text-amber-700 font-bold text-xs leading-none">{mockUser.credits}</span> |
73 | | - <span class="hidden md:inline text-amber-400 text-[10px] leading-none">credits</span> |
74 | | - </div> |
| 122 | + <!-- Score chip — hidden on xs --> |
| 123 | + <div class="hidden sm:flex items-center gap-1.5 bg-brand-green-light rounded-full px-3 py-1.5 flex-shrink-0"> |
| 124 | + <span class="text-brand-green font-black text-sm leading-none">{mockUser.currentScore.toFixed(1)}</span> |
| 125 | + <span class="text-gray-400 text-xs leading-none">/ 6.0</span> |
| 126 | + <span class="hidden md:inline text-[10px] text-gray-400 leading-none ml-0.5">score</span> |
| 127 | + </div> |
75 | 128 |
|
76 | | - <!-- User info --> |
77 | | - <div class="flex items-center gap-2 flex-shrink-0"> |
78 | | - <!-- Avatar --> |
79 | | - <div class="w-8 h-8 bg-brand-green rounded-full flex items-center justify-center flex-shrink-0"> |
80 | | - <span class="text-white font-bold text-sm">{mockUser.firstName[0]}</span> |
| 129 | + <!-- Credits chip — hidden on xs --> |
| 130 | + <div class="hidden sm:flex items-center gap-1.5 bg-amber-50 border border-amber-100 rounded-full px-3 py-1.5 flex-shrink-0"> |
| 131 | + <svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"> |
| 132 | + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1.41 16.09V20h-2.67v-1.93c-1.71-.36-3.16-1.46-3.27-3.4h1.96c.1 1.05.82 1.87 2.65 1.87 1.96 0 2.4-.98 2.4-1.59 0-.83-.44-1.61-2.67-2.14-2.48-.6-4.18-1.62-4.18-3.67 0-1.72 1.39-2.84 3.11-3.21V4h2.67v1.95c1.86.45 2.79 1.86 2.85 3.39H14.3c-.05-1.11-.64-1.87-2.22-1.87-1.5 0-2.4.68-2.4 1.64 0 .84.65 1.39 2.67 1.91s4.18 1.39 4.18 3.91c-.01 1.83-1.38 2.83-3.12 3.16z"/> |
| 133 | + </svg> |
| 134 | + <span class="text-amber-700 font-bold text-xs leading-none">{mockUser.credits}</span> |
| 135 | + <span class="hidden md:inline text-amber-400 text-[10px] leading-none">credits</span> |
81 | 136 | </div> |
82 | | - <!-- Name + level — hidden on mobile --> |
83 | | - <div class="hidden md:block leading-none"> |
84 | | - <p class="text-sm font-semibold text-gray-800">{mockUser.firstName}</p> |
85 | | - <div class="flex items-center gap-1 mt-0.5"> |
86 | | - <span class="w-1.5 h-1.5 rounded-full {levelColors[mockUser.level] ?? 'bg-gray-400'}"></span> |
87 | | - <span class="text-[10px] text-gray-400">{mockUser.level}</span> |
| 137 | + |
| 138 | + <!-- User info --> |
| 139 | + <div class="flex items-center gap-2 flex-shrink-0"> |
| 140 | + <!-- Avatar --> |
| 141 | + <div class="w-8 h-8 bg-brand-green rounded-full flex items-center justify-center flex-shrink-0"> |
| 142 | + <span class="text-white font-bold text-sm">{mockUser.firstName[0]}</span> |
88 | 143 | </div> |
| 144 | + <!-- Name + level — hidden on mobile --> |
| 145 | + <div class="hidden md:block leading-none"> |
| 146 | + <p class="text-sm font-semibold text-gray-800">{mockUser.firstName}</p> |
| 147 | + <div class="flex items-center gap-1 mt-0.5"> |
| 148 | + <span class="w-1.5 h-1.5 rounded-full {levelColors[mockUser.level] ?? 'bg-gray-400'}"></span> |
| 149 | + <span class="text-[10px] text-gray-400">{mockUser.level}</span> |
| 150 | + </div> |
| 151 | + </div> |
| 152 | + <!-- Plan dropdown --> |
| 153 | + <select |
| 154 | + class="hidden sm:inline text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full font-medium border-0 cursor-pointer hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-green/30" |
| 155 | + value={userState.plan} |
| 156 | + onchange={(e) => { userState.plan = (e.currentTarget as HTMLSelectElement).value as UserPlan; }} |
| 157 | + aria-label="Simulate user plan" |
| 158 | + > |
| 159 | + {#each Object.entries(planLabels) as [value, label]} |
| 160 | + <option value={value}>{label}</option> |
| 161 | + {/each} |
| 162 | + </select> |
89 | 163 | </div> |
90 | | - <!-- Plan dropdown --> |
91 | | - <select |
92 | | - class="hidden sm:inline text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full font-medium border-0 cursor-pointer hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-brand-green/30" |
93 | | - value={userState.plan} |
94 | | - onchange={(e) => { userState.plan = (e.currentTarget as HTMLSelectElement).value as UserPlan; }} |
95 | | - aria-label="Simulate user plan" |
96 | | - > |
97 | | - {#each Object.entries(planLabels) as [value, label]} |
98 | | - <option value={value}>{label}</option> |
99 | | - {/each} |
100 | | - </select> |
| 164 | + </header> |
| 165 | + |
| 166 | + <!-- ─── Backdrop (mobile only) ───────────────────────────────────────────── --> |
| 167 | + {#if sidebarOpen} |
| 168 | + <div |
| 169 | + class="lg:hidden fixed inset-0 z-40 bg-black/40" |
| 170 | + onclick={() => (sidebarOpen = false)} |
| 171 | + role="button" |
| 172 | + aria-label="Close menu" |
| 173 | + tabindex="-1" |
| 174 | + ></div> |
| 175 | + {/if} |
| 176 | + |
| 177 | + <!-- ─── Layout body ───────────────────────────────────────────────────────── --> |
| 178 | + <div class="flex min-h-screen bg-gray-50 pt-14"> |
| 179 | + <Sidebar isOpen={sidebarOpen} onClose={() => setSidebar(false)} onToggle={() => setSidebar(!sidebarOpen)} /> |
| 180 | + <main class="flex-1 min-h-screen transition-[margin] duration-300 {sidebarOpen ? 'lg:ml-60' : 'lg:ml-14'}"> |
| 181 | + {@render children()} |
| 182 | + </main> |
101 | 183 | </div> |
102 | | -</header> |
103 | | - |
104 | | -<!-- ─── Backdrop (mobile only) ───────────────────────────────────────────── --> |
105 | | -{#if sidebarOpen} |
106 | | - <div |
107 | | - class="lg:hidden fixed inset-0 z-40 bg-black/40" |
108 | | - onclick={() => (sidebarOpen = false)} |
109 | | - role="button" |
110 | | - aria-label="Close menu" |
111 | | - tabindex="-1" |
112 | | - ></div> |
113 | 184 | {/if} |
114 | | - |
115 | | -<!-- ─── Layout body ───────────────────────────────────────────────────────── --> |
116 | | -<div class="flex min-h-screen bg-gray-50 pt-14"> |
117 | | - <Sidebar isOpen={sidebarOpen} onClose={() => setSidebar(false)} onToggle={() => setSidebar(!sidebarOpen)} /> |
118 | | - <main class="flex-1 min-h-screen transition-[margin] duration-300 {sidebarOpen ? 'lg:ml-60' : 'lg:ml-14'}"> |
119 | | - {@render children()} |
120 | | - </main> |
121 | | -</div> |
|
0 commit comments