Skip to content

Commit 7ca1000

Browse files
Merge remote-tracking branch 'origin/main'
# Conflicts: # src/style.css
2 parents 6f54de6 + a67b3a2 commit 7ca1000

4 files changed

Lines changed: 456 additions & 30 deletions

File tree

‎src/style.css‎

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@
1313
--coral: #f07070;
1414
--paper: #f3ede3;
1515
--paper-ink: #1a1f22;
16-
--line: #2a353b;
16+
--line: #2e3d4d;
1717

1818
/* shell aliases (learn-r structure uses these names) */
19-
--haze: var(--muted);
19+
--haze: #8fa0b2;
2020
--text: var(--ink);
21-
--panel-2: var(--raised);
22-
--dvc: var(--amber);
23-
--code: var(--amber);
21+
--panel-2: #22303c;
22+
--dvc: #3776ab;
23+
--code: #60a5fa;
2424
--ok: var(--teal);
2525
--warn: var(--amber);
2626
--err: var(--coral);
@@ -171,11 +171,7 @@ body {
171171
.nav-bars::after { top: 6px; }
172172

173173
.nav-drawer {
174-
display: contents !important;
175-
}
176-
177-
.nav-drawer button {
178-
min-height: 36px;
174+
display: contents;
179175
}
180176

181177
.help-btn {
@@ -225,10 +221,46 @@ body {
225221
text-decoration: none;
226222
}
227223

224+
.tb-link:focus-visible {
225+
outline: 2px solid var(--code);
226+
outline-offset: 2px;
227+
}
228+
229+
.tb-stat {
230+
display: inline-flex;
231+
align-items: center;
232+
gap: 6px;
233+
min-height: 36px;
234+
padding: 6px 10px;
235+
border-radius: 8px;
236+
border: 1px solid color-mix(in srgb, var(--dvc) 35%, var(--line));
237+
background: color-mix(in srgb, var(--panel-2) 65%, transparent);
238+
color: color-mix(in srgb, var(--text) 90%, var(--dvc));
239+
font-family: var(--mono);
240+
font-size: 13px;
241+
font-weight: 600;
242+
letter-spacing: 0.04em;
243+
box-sizing: border-box;
244+
user-select: none;
245+
}
246+
247+
.tb-stat .visitor-icon {
248+
color: var(--dvc);
249+
flex-shrink: 0;
250+
}
251+
252+
.tb-stat[hidden] {
253+
display: none !important;
254+
}
255+
228256
.tb-link.support {
229257
color: #ffe6b0;
230-
border-color: color-mix(in srgb, var(--amber) 55%, var(--line));
231-
background: color-mix(in srgb, var(--amber) 14%, var(--panel-2));
258+
border-color: color-mix(in srgb, var(--remote) 55%, var(--line));
259+
background: color-mix(in srgb, var(--remote) 14%, var(--panel-2));
260+
}
261+
262+
.tb-link.support:hover {
263+
background: color-mix(in srgb, var(--remote) 24%, var(--panel-2));
232264
}
233265

234266
.tb-link.gh {
@@ -249,7 +281,7 @@ button,
249281
.btn {
250282
appearance: none;
251283
border: 1px solid var(--line);
252-
background: var(--raised);
284+
background: var(--panel-2);
253285
color: var(--text);
254286
border-radius: 8px;
255287
padding: 7px 12px;
@@ -268,14 +300,20 @@ button:hover,
268300

269301
button:focus-visible,
270302
.btn:focus-visible {
271-
outline: 2px solid var(--amber);
303+
outline: 2px solid var(--code);
272304
outline-offset: 2px;
273305
}
274306

307+
button:disabled,
308+
.btn:disabled {
309+
opacity: 0.45;
310+
cursor: not-allowed;
311+
}
312+
275313
button.primary,
276314
.btn.primary {
277-
background: color-mix(in srgb, var(--amber) 28%, var(--panel-2));
278-
border-color: color-mix(in srgb, var(--amber) 55%, var(--line));
315+
background: color-mix(in srgb, var(--dvc) 35%, var(--panel-2));
316+
border-color: color-mix(in srgb, var(--dvc) 55%, var(--line));
279317
color: var(--text);
280318
}
281319

@@ -289,6 +327,12 @@ button.ghost,
289327
background: transparent;
290328
}
291329

330+
.btn-sm {
331+
min-height: 28px;
332+
padding: 4px 10px;
333+
font-size: 12px;
334+
}
335+
292336
/* —— Board area (Memory / Plot tabs) —— */
293337
.board-wrap {
294338
display: flex;
@@ -1291,7 +1335,7 @@ button.ghost,
12911335
}
12921336

12931337
.nav-drawer {
1294-
display: none !important;
1338+
display: none;
12951339
position: absolute;
12961340
top: calc(100% + 6px);
12971341
inset-inline-end: 0;
@@ -1308,11 +1352,30 @@ button.ghost,
13081352
}
13091353

13101354
.nav-drawer.is-open {
1355+
display: flex;
1356+
}
1357+
1358+
.nav-drawer[hidden] {
1359+
display: none !important;
1360+
}
1361+
1362+
.nav-drawer.is-open[hidden] {
13111363
display: flex !important;
13121364
}
13131365

1314-
.nav-drawer button {
1366+
.nav-drawer button,
1367+
.nav-drawer .tb-link {
13151368
width: 100%;
1369+
justify-content: flex-start;
1370+
min-height: 44px;
1371+
}
1372+
1373+
.modal-actions {
1374+
flex-wrap: wrap;
1375+
}
1376+
1377+
.modal-actions button {
1378+
flex: 1 1 auto;
13161379
min-height: 44px;
13171380
}
13181381

‎src/ui/app.ts‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { BoardView } from './board';
1515
import { TerminalView } from './terminal';
1616
import { ScriptEditorView } from './editor';
1717
import { escapeHtml, renderMarkdown, showModal, type ModalSpec } from './dialog';
18+
import { formatVisitorCount, startVisitorCounter } from './visitor-counter';
1819

1920
const REPO_URL = 'https://github.com/alisadeghiaghili/learn-python';
2021
const COFFEE_URL = 'https://www.buymeacoffee.com/alisadeghil';
@@ -117,6 +118,18 @@ export class App {
117118
this.session = createSession();
118119
this.mount();
119120
this.init();
121+
void startVisitorCounter((count) => this.renderVisitors(count));
122+
}
123+
124+
/** Unique-visitor stat in the toolbar; stays hidden until a number is known. */
125+
private renderVisitors(count: number): void {
126+
const stat = this.root.querySelector<HTMLElement>('#visitor-stat');
127+
const value = this.root.querySelector<HTMLElement>('#visitor-count');
128+
if (!stat || !value) return;
129+
const text = formatVisitorCount(count);
130+
value.textContent = text;
131+
stat.setAttribute('aria-label', `${text} unique visitors`);
132+
stat.hidden = false;
120133
}
121134

122135
/* —— Mounting & init —— */
@@ -149,7 +162,7 @@ export class App {
149162
<button type="button" class="nav-toggle" data-action="nav-toggle" aria-label="Menu" aria-expanded="false" aria-controls="nav-drawer">
150163
<span class="nav-bars" aria-hidden="true"></span>
151164
</button>
152-
<div class="nav-drawer" id="nav-drawer">
165+
<div class="nav-drawer" id="nav-drawer" hidden>
153166
<button type="button" data-action="levels">Levels</button>
154167
<button type="button" data-action="lesson">Lesson</button>
155168
<button type="button" data-action="goal">Guide</button>
@@ -160,6 +173,10 @@ export class App {
160173
<button type="button" data-action="sandbox" class="ghost">Sandbox</button>
161174
<button type="button" class="help-btn" data-action="help" title="Help" aria-label="Help">?</button>
162175
</div>
176+
<span class="tb-stat visitors" id="visitor-stat" role="img" title="Unique visitors" aria-label="Unique visitors" hidden>
177+
<svg class="visitor-icon" viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0-2.21-2.69-4-6-4s-6 1.79-6 4v1h12v-1zm-1.07 0H3.07C3.56 11.83 5.48 11 8 11s4.44.83 4.93 2z"/></svg>
178+
<span class="visitor-count" id="visitor-count"></span>
179+
</span>
163180
<a class="tb-link gh" href="${REPO_URL}" target="_blank" rel="noopener noreferrer" title="GitHub repository" aria-label="GitHub repository">
164181
<svg class="gh-mark" viewBox="0 0 16 16" width="18" height="18"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
165182
</a>
@@ -242,24 +259,17 @@ export class App {
242259
const drawer = this.root.querySelector<HTMLElement>('#nav-drawer');
243260
const btn = this.root.querySelector<HTMLButtonElement>('[data-action="nav-toggle"]');
244261
if (!drawer || !btn) return;
245-
const open = drawer.hasAttribute('hidden');
246-
if (open) {
247-
drawer.removeAttribute('hidden');
248-
drawer.classList.add('is-open');
249-
btn.setAttribute('aria-expanded', 'true');
250-
} else {
251-
drawer.setAttribute('hidden', '');
252-
drawer.classList.remove('is-open');
253-
btn.setAttribute('aria-expanded', 'false');
254-
}
262+
const open = drawer.classList.toggle('is-open');
263+
drawer.hidden = !open;
264+
btn.setAttribute('aria-expanded', String(open));
255265
}
256266

257267
private closeNav(): void {
258268
const drawer = this.root.querySelector<HTMLElement>('#nav-drawer');
259269
const btn = this.root.querySelector<HTMLButtonElement>('[data-action="nav-toggle"]');
260270
if (!drawer || !btn) return;
261-
drawer.setAttribute('hidden', '');
262271
drawer.classList.remove('is-open');
272+
drawer.hidden = true;
263273
btn.setAttribute('aria-expanded', 'false');
264274
}
265275

‎src/ui/visitor-counter.ts‎

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Unique-visitor counter shared by the course sites.
3+
*
4+
* The number lives only on the counter service; this module never adds an
5+
* offset, floor or fallback figure. A browser is counted once, ever: a
6+
* per-course localStorage flag decides between `hit` (count this browser) and
7+
* `get` (read only). The cached number is a first-paint placeholder and is
8+
* always replaced by the server value.
9+
*/
10+
11+
export const COUNTER_API = 'https://abacus.jasoncameron.dev';
12+
export const COUNTER_NAMESPACE = 'alisadeghiaghili.github.io';
13+
export const COURSE_SLUG = 'learn-python';
14+
export const COUNTER_KEY = `${COURSE_SLUG}-unique-visitors`;
15+
export const COUNTED_FLAG_KEY = `${COURSE_SLUG}:visitor-counted:v1`;
16+
export const COUNT_CACHE_KEY = `${COURSE_SLUG}:visitor-count:v2`;
17+
export const REQUEST_TIMEOUT_MS = 5000;
18+
19+
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
20+
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
21+
22+
export interface VisitorCounterDeps {
23+
fetch?: FetchLike;
24+
/** `null` means storage is unavailable; omit to use the browser's localStorage. */
25+
storage?: StorageLike | null;
26+
timeoutMs?: number;
27+
now?: () => Date;
28+
}
29+
30+
function defaultStorage(): StorageLike | null {
31+
try {
32+
return typeof localStorage === 'undefined' ? null : localStorage;
33+
} catch {
34+
return null;
35+
}
36+
}
37+
38+
function isCount(value: unknown): value is number {
39+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
40+
}
41+
42+
/** Call the counter service. Resolves to the count; a 404 on `get` means 0. */
43+
async function request(
44+
action: 'get' | 'hit',
45+
deps: VisitorCounterDeps,
46+
): Promise<number> {
47+
const doFetch = deps.fetch ?? ((input, init) => fetch(input, init));
48+
const url = `${COUNTER_API}/${action}/${COUNTER_NAMESPACE}/${COUNTER_KEY}`;
49+
const controller = new AbortController();
50+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
51+
try {
52+
const res = await doFetch(url, { cache: 'no-store', signal: controller.signal });
53+
if (action === 'get' && res.status === 404) return 0;
54+
if (!res.ok) throw new Error(`counter ${action} failed: ${res.status}`);
55+
const body: unknown = await res.json();
56+
const value = (body as { value?: unknown } | null)?.value;
57+
if (!isCount(value)) throw new Error('counter returned an invalid value');
58+
return value;
59+
} finally {
60+
clearTimeout(timer);
61+
}
62+
}
63+
64+
/**
65+
* Count this browser if it has not been counted yet, then return the server
66+
* value, or `null` when the service cannot be reached.
67+
*/
68+
export async function resolveVisitorCount(deps: VisitorCounterDeps = {}): Promise<number | null> {
69+
const storage = deps.storage === undefined ? defaultStorage() : deps.storage;
70+
const now = deps.now ?? (() => new Date());
71+
72+
let shouldHit = false;
73+
if (storage) {
74+
try {
75+
if (storage.getItem(COUNTED_FLAG_KEY) === null) {
76+
storage.setItem(COUNTED_FLAG_KEY, now().toISOString());
77+
shouldHit = true;
78+
}
79+
} catch {
80+
shouldHit = false;
81+
}
82+
}
83+
84+
if (shouldHit) {
85+
try {
86+
return await request('hit', deps);
87+
} catch {
88+
try {
89+
storage?.removeItem(COUNTED_FLAG_KEY);
90+
} catch {
91+
// storage became unavailable; nothing to undo
92+
}
93+
}
94+
}
95+
96+
try {
97+
return await request('get', deps);
98+
} catch {
99+
return null;
100+
}
101+
}
102+
103+
/** The last server value, for the first paint only. */
104+
export function readCachedCount(storage: StorageLike | null = defaultStorage()): number | null {
105+
if (!storage) return null;
106+
try {
107+
const raw = storage.getItem(COUNT_CACHE_KEY);
108+
if (!raw) return null;
109+
const count = (JSON.parse(raw) as { count?: unknown } | null)?.count;
110+
return isCount(count) ? count : null;
111+
} catch {
112+
return null;
113+
}
114+
}
115+
116+
function writeCachedCount(storage: StorageLike | null, count: number, now: Date): void {
117+
if (!storage) return;
118+
try {
119+
storage.setItem(COUNT_CACHE_KEY, JSON.stringify({ count, at: now.toISOString() }));
120+
} catch {
121+
// cache is optional
122+
}
123+
}
124+
125+
/**
126+
* Show the cached number immediately (if any), then replace it with the server
127+
* value whenever it arrives, even when that value is lower.
128+
*/
129+
export async function startVisitorCounter(
130+
render: (count: number) => void,
131+
deps: VisitorCounterDeps = {},
132+
): Promise<void> {
133+
const storage = deps.storage === undefined ? defaultStorage() : deps.storage;
134+
const cached = readCachedCount(storage);
135+
if (cached !== null) render(cached);
136+
137+
const count = await resolveVisitorCount({ ...deps, storage });
138+
if (count === null) return;
139+
writeCachedCount(storage, count, (deps.now ?? (() => new Date()))());
140+
render(count);
141+
}
142+
143+
export function formatVisitorCount(count: number, locale = 'en-US'): string {
144+
return count.toLocaleString(locale);
145+
}

0 commit comments

Comments
 (0)