Repository navigation
Expand file tree
/
Copy pathSeasonalBanner.tsx
More file actions
149 lines (131 loc) · 5.58 KB
/
Copy pathSeasonalBanner.tsx
File metadata and controls
149 lines (131 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import { useEffect, useRef, useState } from 'react';
import { getCurrentSeasonalConfig, type SeasonalConfig } from '../lib/themeUtils';
import '../styles/seasonal-banner.css';
const DISMISS_STORAGE_KEY = 'seasonalBannerDismissed';
const COLLAPSE_MS = 500;
// IPython REPL palette, tuned for the dark terminal surface.
const C = {
prompt: '#56d364', // In[]: green
promptNum: '#7ee787', // [n] light green
out: '#ff7b72', // Out[]: red
fn: '#79c0ff', // function name blue
str: '#ffa657', // string literal amber
punc: '#8b949e', // parens / punctuation slate
};
/** Minimal tokenizer to syntax-highlight the faux command, e.g. celebrate('pride'). */
function highlightCommand(cmd: string) {
const tokens: { text: string; color: string }[] = [];
const re = /('[^']*'|"[^"]*")|([A-Za-z_]\w*)|(\s+)|([^\sA-Za-z_'"]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(cmd)) !== null) {
if (m[1]) tokens.push({ text: m[1], color: C.str });
else if (m[2]) tokens.push({ text: m[2], color: C.fn });
else if (m[3]) tokens.push({ text: m[3], color: C.punc });
else tokens.push({ text: m[4], color: C.punc });
}
return tokens;
}
export default function SeasonalBanner() {
const [config, setConfig] = useState<SeasonalConfig | null>(null);
const [open, setOpen] = useState(false);
const dismissKey = useRef<string>('');
const collapseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const current = getCurrentSeasonalConfig();
if (!current || !current.banner) return;
// Re-show the banner each year by scoping dismissal to season id + year.
const key = `${current.id}-${new Date().getFullYear()}`;
dismissKey.current = key;
let dismissed = '';
try {
dismissed = localStorage.getItem(DISMISS_STORAGE_KEY) ?? '';
} catch {
dismissed = '';
}
if (dismissed === key) return;
setConfig(current);
// Next frame: flip to open so the height/opacity transition plays.
const raf = requestAnimationFrame(() => setOpen(true));
return () => cancelAnimationFrame(raf);
}, []);
const handleDismiss = () => {
try {
localStorage.setItem(DISMISS_STORAGE_KEY, dismissKey.current);
} catch {
// Ignore storage failures; the banner will simply reappear next load.
}
setOpen(false); // play the collapse animation, then unmount
collapseTimer.current = setTimeout(() => setConfig(null), COLLAPSE_MS);
};
// Escape-to-dismiss, matching the visible `esc` keycap affordance.
useEffect(() => {
if (!config) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleDismiss();
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
if (collapseTimer.current) clearTimeout(collapseTimer.current);
};
}, [config]);
if (!config) return null;
const month = new Date().getMonth() + 1; // 1-12; the cell number, e.g. In [6]
const strip =
config.accentGradient ??
'linear-gradient(to right, var(--theme-primary), var(--theme-secondary), var(--theme-accent))';
return (
<div className={`sb-shell ${open ? 'is-open' : ''}`}>
<div className="sb-clip">
<div
className="sb-bar font-mono text-[13px] leading-none text-white"
role="region"
aria-label="Seasonal announcement"
>
<div className="sb-strip" style={{ background: strip }} aria-hidden="true" />
<div className="mx-auto flex max-w-7xl items-center gap-x-2.5 gap-y-1 px-4 py-2.5 pr-14 sm:px-6 flex-wrap">
{/* In [n]: prompt — the cell number is the month */}
<span className="sb-fade sb-d1 whitespace-nowrap font-semibold tracking-tight">
<span style={{ color: C.prompt }}>In </span>
<span style={{ color: C.punc }}>[</span>
<span style={{ color: C.promptNum }}>{month}</span>
<span style={{ color: C.punc }}>]</span>
<span style={{ color: C.prompt }}>:</span>
</span>
{/* faux command, syntax-highlighted; hidden on the smallest screens */}
{config.command && (
<code className="sb-fade sb-d2 hidden whitespace-nowrap sm:inline">
{highlightCommand(config.command).map((t, i) => (
<span key={i} style={{ color: t.color }}>
{t.text}
</span>
))}
</code>
)}
{/* Out [n]: result prompt, IPython red */}
<span
className="sb-fade sb-d3 hidden whitespace-nowrap font-semibold tracking-tight sm:inline"
style={{ color: C.out }}
aria-hidden="true"
>
Out[{month}]:
</span>
{/* the message itself — the cell's output, rainbow-clipped text */}
<span className="sb-fade sb-d3 font-medium">
<span className="sb-msg">{config.banner}</span>
<span className="sb-cursor ml-1.5" aria-hidden="true" />
</span>
</div>
<button
onClick={handleDismiss}
aria-label="Dismiss announcement"
title="Dismiss (Esc)"
className="absolute right-3 top-1/2 inline-flex -translate-y-1/2 items-center rounded-md border border-white/25 bg-white/5 px-2 py-1 text-[11px] font-medium leading-none text-white/70 transition-colors hover:border-white/50 hover:bg-white/10 hover:text-white focus:outline-none focus:ring-2 focus:ring-white/50"
>
esc
</button>
</div>
</div>
</div>
);
}