open-productive-stack/drupal/nextjs/components/main-nav-client.tsx
rnsrk f8b8f53d54 Add Drupal headless stack with Next.js frontend
- Add Next.js frontend service (nextjs) with Dockerfile and source
- Update docker-compose.yml: image names, Drupal 11.3.3, nextjs service
- Add docker-compose.override.yml.disabled for dev hot-reload
- Add install-headless-modules.sh for OAuth/JSON:API module setup
- Add README.md with full setup and configuration guide
- Update nginx/Dockerfile and nginx.conf.template for cms. subdomain
- Update drupal/Dockerfile PHP-FPM build args
- Gitignore **/.vscode/ to prevent IDE workspace files from being tracked
2026-03-30 11:14:17 +02:00

156 lines
5.9 KiB
TypeScript

"use client"
import type { DrupalMenuItem } from "next-drupal"
import { Home, FolderOpen, ChevronDown } from "lucide-react"
import { useState, useRef, useEffect } from "react"
const drupalBaseUrl = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL ?? ""
function getHref(url: string): string {
if (drupalBaseUrl && url.startsWith(drupalBaseUrl)) {
return url.slice(drupalBaseUrl.length) || "/"
}
return url
}
function NavLink({
item,
isDropdown = false,
onNavigate,
}: {
item: DrupalMenuItem
isDropdown?: boolean
onNavigate?: () => void
}) {
const children = item.items?.filter((child) => child.enabled !== false) ?? []
const hasChildren = children.length > 0
const linkClass = isDropdown
? "block rounded-sm px-4 py-2 text-emerald-500 outline-none transition-colors duration-200 ease-out hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-inset"
: "flex items-center gap-1.5 rounded-sm text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 hover:underline focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
if (hasChildren) {
return (
<div className="group relative">
<a
href={getHref(item.url)}
className={`${linkClass} flex items-center gap-1.5`}
onClick={onNavigate}
>
<FolderOpen className="size-4" aria-hidden />
{item.title}
<ChevronDown className="size-4 transition-transform duration-200 ease-out group-hover:rotate-180" aria-hidden />
</a>
<div className="absolute left-0 top-full pt-2 opacity-0 pointer-events-none transition-all duration-200 ease-out group-hover:opacity-100 group-hover:pointer-events-auto translate-y-[-4px] group-hover:translate-y-0">
<div className="rounded-md border border-slate-200 bg-white py-2 shadow-md">
{children.map((child) => (
<a
key={child.id}
href={getHref(child.url)}
className="block rounded-sm px-4 py-2 text-emerald-600 outline-none transition-colors duration-200 ease-out hover:bg-slate-50 hover:text-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-inset"
onClick={onNavigate}
>
{child.title}
</a>
))}
</div>
</div>
</div>
)
}
const href = getHref(item.url)
const isHome = href === "/"
return (
<a href={href} className={linkClass} onClick={onNavigate}>
{isHome && <Home className="size-4 shrink-0" aria-hidden />}
{item.title}
</a>
)
}
interface MainNavClientProps {
menuItems: DrupalMenuItem[]
}
export function MainNavClient({ menuItems }: MainNavClientProps) {
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const enabledItems = menuItems.filter((item) => item.enabled !== false)
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener("click", handleClickOutside)
}
return () => document.removeEventListener("click", handleClickOutside)
}, [isOpen])
const closeMenu = () => setIsOpen(false)
return (
<nav className="flex items-center gap-8 text-sm font-medium" aria-label="Main">
{/* Desktop: full nav. */}
<div className="hidden lg:flex lg:items-center lg:gap-8">
{enabledItems.map((item) => (
<NavLink key={item.id} item={item} />
))}
</div>
{/* Mobile: MORE trigger and dropdown. */}
<div className="relative lg:hidden" ref={menuRef}>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="flex items-center rounded-sm px-3 py-2 text-lg font-bold uppercase tracking-wide text-emerald-500 outline-none transition-colors duration-200 ease-out hover:text-emerald-400 focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-800"
aria-expanded={isOpen}
aria-haspopup="true"
aria-label="Open menu"
>
MORE
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-2 min-w-[12rem] rounded-md border border-slate-200 bg-white py-2 shadow-lg">
{enabledItems.map((item) => {
const children = item.items?.filter((c) => c.enabled !== false) ?? []
return (
<div key={item.id} className="border-b border-slate-100 last:border-b-0">
<a
href={getHref(item.url)}
className="flex items-center gap-1.5 rounded-sm px-4 py-2 text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
onClick={closeMenu}
>
{children.length > 0 ? (
<FolderOpen className="size-4" aria-hidden />
) : getHref(item.url) === "/" ? (
<Home className="size-4" aria-hidden />
) : null}
{item.title}
</a>
{children.length > 0 && (
<div className="pl-6 pb-1">
{children.map((child) => (
<a
key={child.id}
href={getHref(child.url)}
className="block rounded-sm px-2 py-1.5 text-sm text-emerald-500 hover:bg-slate-50 hover:text-emerald-400"
onClick={closeMenu}
>
{child.title}
</a>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</nav>
)
}