Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
mastodon
GitHub Repository: mastodon/joinmastodon
Path: blob/main/components/Header.tsx
1006 views
1
import Link from "next/link"
2
import { FormattedMessage, useIntl } from "react-intl"
3
4
import mastodonLogo from "../public/logos/wordmark-white-text.svg"
5
import merch from "../public/merch.jpg"
6
import Image from "next/legacy/image"
7
import NewImage from "next/image"
8
import { useState, useEffect, useRef, useId } from "react"
9
import classNames from "classnames"
10
import { locales } from "../data/locales"
11
import MenuToggle from "./MenuToggle"
12
import DisclosureArrow from "../public/ui/disclosure-arrow.svg?inline"
13
import { useRouter } from "next/router"
14
15
type HeaderProps = {
16
/** determines whether the header is transparent on load, before scrolling down */
17
transparent?: boolean
18
}
19
20
/** Sitewide header and navigation */
21
const Header = ({ transparent = true }: HeaderProps) => {
22
const intl = useIntl()
23
const router = useRouter()
24
const [pageScrolled, setPageScrolled] = useState(false)
25
26
const navigationItems = [
27
{
28
value: "/apps",
29
label: <FormattedMessage id="nav.apps.title" defaultMessage="Apps" />,
30
},
31
{
32
value: "/hosting",
33
label: (
34
<FormattedMessage
35
id="nav.hosting.title"
36
defaultMessage="For Institutions"
37
/>
38
),
39
},
40
{
41
value: "/sponsors",
42
label: (
43
<FormattedMessage id="nav.sponsors.title" defaultMessage="Donate" />
44
),
45
},
46
{
47
key: "resources",
48
label: (
49
<FormattedMessage id="nav.resources.title" defaultMessage="Resources" />
50
),
51
childItems: [
52
{
53
value: "/about",
54
label: (
55
<FormattedMessage
56
id="nav.about_us.title"
57
defaultMessage="About us"
58
/>
59
),
60
description: (
61
<FormattedMessage
62
id="nav.about_us.description"
63
defaultMessage="Learn about the small team behind Mastodon."
64
/>
65
),
66
},
67
{
68
value: "/servers",
69
label: (
70
<FormattedMessage id="nav.servers.title" defaultMessage="Servers" />
71
),
72
description: (
73
<FormattedMessage
74
id="nav.servers.description"
75
defaultMessage="Browse the directory of other Mastodon servers."
76
/>
77
),
78
},
79
{
80
value: "https://blog.joinmastodon.org/",
81
label: <FormattedMessage id="nav.blog.title" defaultMessage="Blog" />,
82
description: (
83
<FormattedMessage
84
id="nav.blog.description"
85
defaultMessage="Get the latest news about the platform."
86
/>
87
),
88
},
89
{
90
value: "https://docs.joinmastodon.org",
91
label: (
92
<FormattedMessage
93
id="nav.docs.title"
94
defaultMessage="Documentation"
95
/>
96
),
97
description: (
98
<FormattedMessage
99
id="nav.docs.description"
100
defaultMessage="Learn how Mastodon works in-depth."
101
/>
102
),
103
},
104
{
105
value: "https://github.com/mastodon/mastodon/discussions",
106
label: (
107
<FormattedMessage id="nav.support.title" defaultMessage="Support" />
108
),
109
description: (
110
<FormattedMessage
111
id="nav.support.description"
112
defaultMessage="Get help or suggest a feature on GitHub."
113
/>
114
),
115
},
116
{
117
value: "/verification",
118
label: (
119
<FormattedMessage
120
id="nav.verification.title"
121
defaultMessage="Verification"
122
/>
123
),
124
description: (
125
<FormattedMessage
126
id="nav.verification.description"
127
defaultMessage="Learn about verified profile links on Mastodon."
128
/>
129
),
130
},
131
{
132
value: "/branding",
133
label: (
134
<FormattedMessage
135
id="nav.branding.title"
136
defaultMessage="Branding"
137
/>
138
),
139
description: (
140
<FormattedMessage
141
id="nav.branding.description"
142
defaultMessage="Our logos, colours, and promo materials."
143
/>
144
),
145
},
146
{
147
value: "https://share.joinmastodon.org/",
148
label: (
149
<FormattedMessage id="nav.share.title" defaultMessage="Share button" />
150
),
151
description: (
152
<FormattedMessage
153
id="nav.share.description"
154
defaultMessage="Add a social sharing button to your website."
155
/>
156
),
157
},
158
],
159
banner: <div className="px-3">
160
<a href="https://shop.joinmastodon.org/" className="flex relative overflow-hidden md:rounded-md group ring-blurple-500 md:hover:ring-2">
161
<NewImage src={merch} fill={true} className='hidden md:block absolute z-0 object-cover' alt='' />
162
163
<div className="relative flex flex-col min-w-0 md:rounded-md md:m-2 md:mt-24 text-white py-2 px-2 md:py-3 md:px-4 md:bg-nightshade-900/[0.7] md:backdrop-blur">
164
<span className="min-w-0 block font-extrabold"><FormattedMessage id="nav.merch.title" defaultMessage="Merch" /></span>
165
166
<span className="min-w-0 mt-1 block font-extranormal text-gray-1 md:text-white"><FormattedMessage
167
id="nav.merch.description"
168
defaultMessage="Support our mission in a fun way."
169
/></span>
170
</div>
171
</a>
172
</div>,
173
footer: {
174
value: "https://github.com/mastodon/mastodon",
175
label: (
176
<FormattedMessage id="nav.code.action" defaultMessage="Browse code" />
177
),
178
title: (
179
<FormattedMessage id="nav.code.title" defaultMessage="Source code" />
180
),
181
description: (
182
<FormattedMessage
183
id="nav.code.description"
184
defaultMessage="Mastodon is free and open-source software."
185
/>
186
),
187
},
188
},
189
{
190
key: "locale",
191
label: (
192
<span
193
aria-label={intl.formatMessage({
194
id: "translate_site",
195
defaultMessage: "文A, Translate site",
196
})}
197
>
198
文A
199
</span>
200
),
201
compact: true,
202
childItems: locales.map((locale) => ({
203
key: locale.code,
204
locale: locale.code,
205
scroll: false,
206
small: true,
207
value: "", // current page
208
label: locale.language,
209
active: router.locale === locale.code,
210
})),
211
},
212
]
213
// set active status on links
214
.map((item) => ({ ...item, active: router.asPath === item.value }))
215
216
const {
217
mobileMenuOpen,
218
openMenuIndex,
219
bindToggle,
220
bindPrimaryMenu,
221
bindPrimaryMenuItem,
222
bindSecondaryMenuItem,
223
} = useMenu({ navigationItems })
224
225
const checkPageScroll = () => {
226
setPageScrolled(window.scrollY > 0)
227
}
228
useEffect(() => {
229
window.addEventListener("scroll", checkPageScroll)
230
checkPageScroll()
231
return () => {
232
window.removeEventListener("scroll", checkPageScroll)
233
}
234
}, [])
235
236
return (
237
<header
238
// background needs to be on the ::before for now to get around nested compositing bug in chrome
239
className={classNames(
240
'full-width-bg sticky -top-[var(--header-offset)] z-20 -mb-[var(--header-area)] pt-[var(--header-offset)] text-white before:absolute before:inset-0 before:bg-nightshade-900/[0.9] before:backdrop-blur before:transition-opacity before:content-[""]',
241
pageScrolled || !transparent ? "before:opacity-100" : "before:opacity-0"
242
)}
243
>
244
<div className="full-width-bg__inner flex h-[var(--header-height)] items-center justify-between">
245
<div>
246
<Link
247
href="/"
248
className="relative z-10 flex max-w-[11.375rem] pt-[6%] md:max-w-[12.625rem]"
249
>
250
<Image src={mastodonLogo} alt="Mastodon" />
251
</Link>
252
</div>
253
254
<nav>
255
<MenuToggle {...bindToggle()} />
256
<ul
257
{...bindPrimaryMenu()}
258
className={classNames(
259
"md:ms-0 md:-me-1 fixed inset-0 w-screen flex-col overflow-auto bg-black px-1 pt-[calc(var(--header-area)_+_1rem)] pb-8 md:relative md:w-auto md:flex-row md:gap-1 md:overflow-visible md:rounded-md md:bg-[transparent] md:p-1",
260
mobileMenuOpen ? "flex" : "hidden md:flex"
261
)}
262
>
263
{navigationItems.map((item, itemIndex) => (
264
<li className="relative" key={item.key || item.value}>
265
{"childItems" in item ? (
266
// Top-level Dropdown
267
<>
268
<button
269
{...bindPrimaryMenuItem(itemIndex, { hasPopup: true })}
270
className="flex items-center gap-[0.125rem] whitespace-nowrap rounded-md p-3 px-5 text-h5 focus:outline-2 md:text-b2 md:font-medium"
271
>
272
{item.label}
273
<DisclosureArrow
274
className={classNames({
275
"rotate-180": openMenuIndex === itemIndex,
276
})}
277
/>
278
</button>
279
<div
280
className={classNames(
281
"end-0 top-full rounded-md md:absolute md:max-h-[calc(100vh_-_var(--header-height))] md:bg-white md:text-black md:shadow-lg",
282
openMenuIndex === itemIndex ? "overflow-auto" : "hidden"
283
)}
284
>
285
<ul
286
role="menu"
287
className={classNames(
288
item.compact
289
? "py-2 md:px-2"
290
: "w-screen max-w-md py-2 md:grid md:max-w-lg md:grid-cols-2 md:gap-1 md:px-3 md:py-4"
291
)}
292
>
293
{item.childItems.map((child, childIndex) => (
294
// Child Items
295
<li key={child.key || child.value} role="menu">
296
<Link
297
href={child.value}
298
locale={child.locale || undefined}
299
scroll={child.scroll ?? true}
300
{...bindSecondaryMenuItem(child)}
301
className={classNames(
302
"block rounded-md hover:md:bg-nightshade-50",
303
item.compact
304
? "py-2 px-5 md:px-4"
305
: "py-3 px-5 md:px-4",
306
item.compact && child.active && "font-extrabold"
307
)}
308
aria-current={child.active ? "page" : undefined}
309
>
310
<span
311
className={classNames(
312
"block",
313
!item.compact && "font-extrabold"
314
)}
315
>
316
{child.label}
317
</span>
318
<span className="mt-1 block font-extranormal text-gray-1">
319
{child.description}
320
</span>
321
</Link>
322
</li>
323
))}
324
</ul>
325
326
{item.banner}
327
328
{item.footer && (
329
<div className="md:bg-gray-4 md:p-4">
330
<a
331
href={item.footer.value}
332
className="group flex items-center justify-between rounded-md px-5 py-3 md:p-2"
333
>
334
<span>
335
<span className="font-extrabold">
336
{item.footer.title}
337
</span>
338
<span className="mt-1 block font-extranormal text-gray-1">
339
{item.footer.description}
340
</span>
341
</span>
342
343
<span className="b3 hidden h-12 items-center justify-center rounded-md border-2 border-blurple-500 bg-blurple-500 p-4 !font-semibold text-white transition-colors group-hover:border-blurple-600 group-hover:bg-blurple-600 md:flex">
344
{item.footer.label}
345
</span>
346
</a>
347
</div>
348
)}
349
</div>
350
</>
351
) : (
352
// Top-level Link
353
<Link
354
href={item.value}
355
className={classNames(
356
"block whitespace-nowrap rounded-md p-3 px-5 text-h5 font-medium md:text-b2",
357
item.active && "font-extrabold"
358
)}
359
aria-current={item.active ? "page" : undefined}
360
{...bindPrimaryMenuItem(itemIndex)}
361
>
362
{item.label}
363
</Link>
364
)}
365
</li>
366
))}
367
</ul>
368
</nav>
369
</div>
370
</header>
371
)
372
}
373
374
/**
375
* `useMenu` provides a React Hook for managing menu state and attributes for accessibility.
376
*/
377
const useMenu = ({ navigationItems }) => {
378
const menuId = useId()
379
const rootElement = useRef<HTMLUListElement>(null)
380
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
381
/** `null` means the secondary menu is closed */
382
const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null)
383
const secondaryMenuOpen = openMenuIndex !== null
384
385
// check for clicks outside of the menu
386
useEffect(() => {
387
const handleClickOutside = (e: MouseEvent) => {
388
if (!rootElement.current.contains(e.target as Node)) {
389
setOpenMenuIndex(null)
390
}
391
}
392
if (rootElement.current) {
393
document.addEventListener("click", handleClickOutside, false)
394
}
395
return () => {
396
document.removeEventListener("click", handleClickOutside, false)
397
}
398
}, [])
399
400
// Element attributes / listeners
401
const bindToggle = () => ({
402
open: mobileMenuOpen,
403
attributes: {
404
"aria-expanded": mobileMenuOpen,
405
"aria-controls": menuId,
406
},
407
onClick: () => setMobileMenuOpen(!mobileMenuOpen),
408
})
409
const bindPrimaryMenu = () => {
410
return {
411
ref: rootElement,
412
id: menuId,
413
onBlur: (e) => {
414
const focusLeftMenu = !rootElement.current.contains(e.relatedTarget)
415
/*if (focusLeftMenu) {
416
setOpenMenuIndex(null)
417
setMobileMenuOpen(false)
418
}*/
419
},
420
onKeyDown: (e) => {
421
if (e.key === "Escape") {
422
if (openMenuIndex) {
423
setOpenMenuIndex(null)
424
} else {
425
setMobileMenuOpen(false)
426
}
427
}
428
},
429
}
430
}
431
const bindPrimaryMenuItem = (
432
itemIndex: number,
433
{ hasPopup } = { hasPopup: false }
434
) => {
435
const isDropdownOpen = openMenuIndex === itemIndex
436
const isExpanded = hasPopup && isDropdownOpen
437
return {
438
"aria-haspopup": hasPopup,
439
"aria-expanded": hasPopup ? isExpanded : undefined,
440
onKeyDown: (e: React.KeyboardEvent) => {
441
if (e.key === "Enter" || e.key === " ") {
442
if (hasPopup) {
443
e.preventDefault()
444
}
445
setOpenMenuIndex(itemIndex)
446
}
447
},
448
onClick: () => {
449
if (!hasPopup) {
450
setMobileMenuOpen(false)
451
}
452
},
453
onMouseDown: () => {
454
if (hasPopup) {
455
setOpenMenuIndex(isDropdownOpen ? null : itemIndex)
456
} else {
457
setOpenMenuIndex(null)
458
}
459
},
460
}
461
}
462
const bindSecondaryMenuItem = (child) => {
463
return {
464
onKeyDown: (e) => {
465
if (e.key === "Escape") {
466
setOpenMenuIndex(null)
467
}
468
},
469
onClick: () => {
470
setMobileMenuOpen(false)
471
},
472
hrefLang: child.locale || undefined,
473
lang: child.locale || undefined,
474
role: "menuitem",
475
}
476
}
477
478
return {
479
mobileMenuOpen,
480
openMenuIndex,
481
bindToggle,
482
bindPrimaryMenu,
483
bindPrimaryMenuItem,
484
bindSecondaryMenuItem,
485
secondaryMenuOpen,
486
}
487
}
488
489
export default Header
490
491