- 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
156 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|