import Link from "next/link"
import { FormattedMessage, useIntl } from "react-intl"
import mastodonLogo from "../public/logos/wordmark-white-text.svg"
import merch from "../public/merch.jpg"
import Image from "next/legacy/image"
import NewImage from "next/image"
import { useState, useEffect, useRef, useId } from "react"
import classNames from "classnames"
import { locales } from "../data/locales"
import MenuToggle from "./MenuToggle"
import DisclosureArrow from "../public/ui/disclosure-arrow.svg?inline"
import { useRouter } from "next/router"
type HeaderProps = {
transparent?: boolean
}
const Header = ({ transparent = true }: HeaderProps) => {
const intl = useIntl()
const router = useRouter()
const [pageScrolled, setPageScrolled] = useState(false)
const navigationItems = [
{
value: "/apps",
label: <FormattedMessage id="nav.apps.title" defaultMessage="Apps" />,
},
{
value: "/hosting",
label: (
<FormattedMessage
id="nav.hosting.title"
defaultMessage="For Institutions"
/>
),
},
{
value: "/sponsors",
label: (
<FormattedMessage id="nav.sponsors.title" defaultMessage="Donate" />
),
},
{
key: "resources",
label: (
<FormattedMessage id="nav.resources.title" defaultMessage="Resources" />
),
childItems: [
{
value: "/about",
label: (
<FormattedMessage
id="nav.about_us.title"
defaultMessage="About us"
/>
),
description: (
<FormattedMessage
id="nav.about_us.description"
defaultMessage="Learn about the small team behind Mastodon."
/>
),
},
{
value: "/servers",
label: (
<FormattedMessage id="nav.servers.title" defaultMessage="Servers" />
),
description: (
<FormattedMessage
id="nav.servers.description"
defaultMessage="Browse the directory of other Mastodon servers."
/>
),
},
{
value: "https://blog.joinmastodon.org/",
label: <FormattedMessage id="nav.blog.title" defaultMessage="Blog" />,
description: (
<FormattedMessage
id="nav.blog.description"
defaultMessage="Get the latest news about the platform."
/>
),
},
{
value: "https://docs.joinmastodon.org",
label: (
<FormattedMessage
id="nav.docs.title"
defaultMessage="Documentation"
/>
),
description: (
<FormattedMessage
id="nav.docs.description"
defaultMessage="Learn how Mastodon works in-depth."
/>
),
},
{
value: "https://github.com/mastodon/mastodon/discussions",
label: (
<FormattedMessage id="nav.support.title" defaultMessage="Support" />
),
description: (
<FormattedMessage
id="nav.support.description"
defaultMessage="Get help or suggest a feature on GitHub."
/>
),
},
{
value: "/verification",
label: (
<FormattedMessage
id="nav.verification.title"
defaultMessage="Verification"
/>
),
description: (
<FormattedMessage
id="nav.verification.description"
defaultMessage="Learn about verified profile links on Mastodon."
/>
),
},
{
value: "/branding",
label: (
<FormattedMessage
id="nav.branding.title"
defaultMessage="Branding"
/>
),
description: (
<FormattedMessage
id="nav.branding.description"
defaultMessage="Our logos, colours, and promo materials."
/>
),
},
{
value: "https://share.joinmastodon.org/",
label: (
<FormattedMessage id="nav.share.title" defaultMessage="Share button" />
),
description: (
<FormattedMessage
id="nav.share.description"
defaultMessage="Add a social sharing button to your website."
/>
),
},
],
banner: <div className="px-3">
<a href="https://shop.joinmastodon.org/" className="flex relative overflow-hidden md:rounded-md group ring-blurple-500 md:hover:ring-2">
<NewImage src={merch} fill={true} className='hidden md:block absolute z-0 object-cover' alt='' />
<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">
<span className="min-w-0 block font-extrabold"><FormattedMessage id="nav.merch.title" defaultMessage="Merch" /></span>
<span className="min-w-0 mt-1 block font-extranormal text-gray-1 md:text-white"><FormattedMessage
id="nav.merch.description"
defaultMessage="Support our mission in a fun way."
/></span>
</div>
</a>
</div>,
footer: {
value: "https://github.com/mastodon/mastodon",
label: (
<FormattedMessage id="nav.code.action" defaultMessage="Browse code" />
),
title: (
<FormattedMessage id="nav.code.title" defaultMessage="Source code" />
),
description: (
<FormattedMessage
id="nav.code.description"
defaultMessage="Mastodon is free and open-source software."
/>
),
},
},
{
key: "locale",
label: (
<span
aria-label={intl.formatMessage({
id: "translate_site",
defaultMessage: "文A, Translate site",
})}
>
文A
</span>
),
compact: true,
childItems: locales.map((locale) => ({
key: locale.code,
locale: locale.code,
scroll: false,
small: true,
value: "",
label: locale.language,
active: router.locale === locale.code,
})),
},
]
.map((item) => ({ ...item, active: router.asPath === item.value }))
const {
mobileMenuOpen,
openMenuIndex,
bindToggle,
bindPrimaryMenu,
bindPrimaryMenuItem,
bindSecondaryMenuItem,
} = useMenu({ navigationItems })
const checkPageScroll = () => {
setPageScrolled(window.scrollY > 0)
}
useEffect(() => {
window.addEventListener("scroll", checkPageScroll)
checkPageScroll()
return () => {
window.removeEventListener("scroll", checkPageScroll)
}
}, [])
return (
<header
className={classNames(
'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-[""]',
pageScrolled || !transparent ? "before:opacity-100" : "before:opacity-0"
)}
>
<div className="full-width-bg__inner flex h-[var(--header-height)] items-center justify-between">
<div>
<Link
href="/"
className="relative z-10 flex max-w-[11.375rem] pt-[6%] md:max-w-[12.625rem]"
>
<Image src={mastodonLogo} alt="Mastodon" />
</Link>
</div>
<nav>
<MenuToggle {...bindToggle()} />
<ul
{...bindPrimaryMenu()}
className={classNames(
"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",
mobileMenuOpen ? "flex" : "hidden md:flex"
)}
>
{navigationItems.map((item, itemIndex) => (
<li className="relative" key={item.key || item.value}>
{"childItems" in item ? (
<>
<button
{...bindPrimaryMenuItem(itemIndex, { hasPopup: true })}
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"
>
{item.label}
<DisclosureArrow
className={classNames({
"rotate-180": openMenuIndex === itemIndex,
})}
/>
</button>
<div
className={classNames(
"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",
openMenuIndex === itemIndex ? "overflow-auto" : "hidden"
)}
>
<ul
role="menu"
className={classNames(
item.compact
? "py-2 md:px-2"
: "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"
)}
>
{item.childItems.map((child, childIndex) => (
<li key={child.key || child.value} role="menu">
<Link
href={child.value}
locale={child.locale || undefined}
scroll={child.scroll ?? true}
{...bindSecondaryMenuItem(child)}
className={classNames(
"block rounded-md hover:md:bg-nightshade-50",
item.compact
? "py-2 px-5 md:px-4"
: "py-3 px-5 md:px-4",
item.compact && child.active && "font-extrabold"
)}
aria-current={child.active ? "page" : undefined}
>
<span
className={classNames(
"block",
!item.compact && "font-extrabold"
)}
>
{child.label}
</span>
<span className="mt-1 block font-extranormal text-gray-1">
{child.description}
</span>
</Link>
</li>
))}
</ul>
{item.banner}
{item.footer && (
<div className="md:bg-gray-4 md:p-4">
<a
href={item.footer.value}
className="group flex items-center justify-between rounded-md px-5 py-3 md:p-2"
>
<span>
<span className="font-extrabold">
{item.footer.title}
</span>
<span className="mt-1 block font-extranormal text-gray-1">
{item.footer.description}
</span>
</span>
<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">
{item.footer.label}
</span>
</a>
</div>
)}
</div>
</>
) : (
<Link
href={item.value}
className={classNames(
"block whitespace-nowrap rounded-md p-3 px-5 text-h5 font-medium md:text-b2",
item.active && "font-extrabold"
)}
aria-current={item.active ? "page" : undefined}
{...bindPrimaryMenuItem(itemIndex)}
>
{item.label}
</Link>
)}
</li>
))}
</ul>
</nav>
</div>
</header>
)
}
const useMenu = ({ navigationItems }) => {
const menuId = useId()
const rootElement = useRef<HTMLUListElement>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null)
const secondaryMenuOpen = openMenuIndex !== null
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (!rootElement.current.contains(e.target as Node)) {
setOpenMenuIndex(null)
}
}
if (rootElement.current) {
document.addEventListener("click", handleClickOutside, false)
}
return () => {
document.removeEventListener("click", handleClickOutside, false)
}
}, [])
const bindToggle = () => ({
open: mobileMenuOpen,
attributes: {
"aria-expanded": mobileMenuOpen,
"aria-controls": menuId,
},
onClick: () => setMobileMenuOpen(!mobileMenuOpen),
})
const bindPrimaryMenu = () => {
return {
ref: rootElement,
id: menuId,
onBlur: (e) => {
const focusLeftMenu = !rootElement.current.contains(e.relatedTarget)
},
onKeyDown: (e) => {
if (e.key === "Escape") {
if (openMenuIndex) {
setOpenMenuIndex(null)
} else {
setMobileMenuOpen(false)
}
}
},
}
}
const bindPrimaryMenuItem = (
itemIndex: number,
{ hasPopup } = { hasPopup: false }
) => {
const isDropdownOpen = openMenuIndex === itemIndex
const isExpanded = hasPopup && isDropdownOpen
return {
"aria-haspopup": hasPopup,
"aria-expanded": hasPopup ? isExpanded : undefined,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
if (hasPopup) {
e.preventDefault()
}
setOpenMenuIndex(itemIndex)
}
},
onClick: () => {
if (!hasPopup) {
setMobileMenuOpen(false)
}
},
onMouseDown: () => {
if (hasPopup) {
setOpenMenuIndex(isDropdownOpen ? null : itemIndex)
} else {
setOpenMenuIndex(null)
}
},
}
}
const bindSecondaryMenuItem = (child) => {
return {
onKeyDown: (e) => {
if (e.key === "Escape") {
setOpenMenuIndex(null)
}
},
onClick: () => {
setMobileMenuOpen(false)
},
hrefLang: child.locale || undefined,
lang: child.locale || undefined,
role: "menuitem",
}
}
return {
mobileMenuOpen,
openMenuIndex,
bindToggle,
bindPrimaryMenu,
bindPrimaryMenuItem,
bindSecondaryMenuItem,
secondaryMenuOpen,
}
}
export default Header