Skip to content

Commit 8d3c243

Browse files
committed
feat: password gate + Blacksmith runner for GH Pages deploy
- Add SHA-256 password gate in root layout (sessionStorage-persisted, design preview only) - Switch deploy workflow runner to blacksmith-2vcpu-ubuntu-2404 + useblacksmith/setup-node@v5 - Fix handleHttpError favicon check to match under any BASE_PATH
1 parent c6b8773 commit 8d3c243

3 files changed

Lines changed: 146 additions & 83 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ concurrency:
1717

1818
jobs:
1919
build:
20-
runs-on: ubuntu-latest
20+
runs-on: blacksmith-2vcpu-ubuntu-2404
2121
steps:
2222
- name: Checkout
2323
uses: actions/checkout@v4
2424

2525
- name: Setup Node
26-
uses: actions/setup-node@v4
26+
uses: useblacksmith/setup-node@v5
2727
with:
2828
node-version: 20
2929
cache: npm
@@ -43,7 +43,7 @@ jobs:
4343

4444
deploy:
4545
needs: build
46-
runs-on: ubuntu-latest
46+
runs-on: blacksmith-2vcpu-ubuntu-2404
4747
environment:
4848
name: github-pages
4949
url: ${{ steps.deployment.outputs.page_url }}

‎src/routes/+layout.svelte‎

Lines changed: 142 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,23 @@
88
99
let { children }: { children: Snippet } = $props();
1010
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+
1120
// Sidebar: open by default on first visit; persisted in localStorage thereafter
1221
let sidebarOpen = $state(false);
1322
1423
$effect(() => {
24+
mounted = true;
25+
if (sessionStorage.getItem(STORAGE_KEY) === '1') {
26+
unlocked = true;
27+
}
1528
const saved = localStorage.getItem('sidebarOpen');
1629
sidebarOpen = saved !== null ? saved === 'true' : window.matchMedia('(min-width: 1024px)').matches;
1730
});
@@ -21,6 +34,25 @@
2134
localStorage.setItem('sidebarOpen', String(val));
2235
}
2336
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+
2456
const levelColors: Record<string, string> = {
2557
Beginner: 'bg-gray-400',
2658
Practitioner: 'bg-blue-500',
@@ -30,92 +62,123 @@
3062
};
3163
</script>
3264

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" />
52106
</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>
56108

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>
59118

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>
66121

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>
75128

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>
81136
</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>
88143
</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>
89163
</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>
101183
</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>
113184
{/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>

‎svelte.config.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const config = {
1616
}),
1717
prerender: {
1818
handleHttpError: ({ path, referrer, message }) => {
19-
if (path === '/favicon.png') return;
19+
if (path.endsWith('/favicon.png')) return;
2020
throw new Error(message);
2121
}
2222
},

0 commit comments

Comments
 (0)