Files
website-enchun-mgr/apps/frontend/src/components/Header.astro
pkupuk 9c2181f743 feat(frontend): update pages, components and branding
Refresh Astro frontend implementation including new pages (Portfolio, Teams, Services), components, and styling updates.
2026-02-11 11:50:42 +08:00

514 lines
20 KiB
Plaintext

---
import { Image } from "astro:assets";
// Header component with scroll-based background and enhanced mobile animations
// Use local backend in development, production URL from dev.vars/wrangler
const isDev = import.meta.env.DEV;
const PAYLOAD_CMS_URL = isDev
? "http://localhost:3000" // Local backend in dev (port may vary)
: import.meta.env.PAYLOAD_CMS_URL || "https://enchun-admin.anlstudio.cc";
// Fetch navigation data from Payload CMS server-side
let navItems: any[] = [];
try {
const response = await fetch(
`${PAYLOAD_CMS_URL}/api/globals/header?depth=2&draft=false&locale=undefined&trash=false`,
);
if (response.ok) {
const data = await response.json();
navItems = data?.navItems || data || [];
}
} catch (error) {
console.error("[Header SSR] Failed to fetch navigation:", error);
}
// Helper to get link URL
function getLinkUrl(link: any): string {
if (!link) return "#";
if (link.type === "custom" && link.url) {
return link.url;
}
if (link.type === "reference" && link.reference?.value) {
if (typeof link.reference.value === "string") {
// It's an ID, construct URL based on relationTo
return `/${link.reference.relationTo || "pages"}/${link.reference.value}`;
} else if (link.reference.value.slug) {
// It's a populated object with slug
return `/${link.reference.value.slug}`;
}
}
return "#";
}
// Check if label should have a badge
function getBadgeForLabel(label: string): string {
if (label.includes("行銷方案")) {
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm animate-pulse">Hot</span>`;
}
if (label.includes("行銷放大鏡")) {
return `<span class="absolute -top-2 -right-3 bg-red-500 text-white text-[6px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wider shadow-sm">New</span>`;
}
return "";
}
// Check if link is active
function isLinkActive(url: string): boolean {
const currentPath = Astro.url.pathname;
return currentPath === url || (url === "/" && currentPath === "/");
}
---
<header
class="fixed top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out"
id="main-header"
>
<nav
class="max-w-5xl mx-auto px-4 py-4 transition-all duration-300 ease-in-out"
id="main-nav"
>
<ul class="flex items-center justify-between list-none">
<li class="shrink-0">
<a href="/" class="block">
<!-- Uses Astro's optimized Image component for the site logo -->
<Image
src="/enchun-logo.svg"
alt="Enchun Digital Marketing"
class="w-32 h-auto transition-opacity duration-300 drop-shadow-md"
width={919}
height={201}
loading="eager"
decoding="async"
/>
</a>
</li>
<li class="hidden md:flex items-center space-x-6">
{
navItems.map((item) => {
const link = item.link;
const href = getLinkUrl(link);
const label = link.label || "未命名";
const badge = getBadgeForLabel(label);
const activeClass = isLinkActive(href)
? "nav-active"
: "";
const hasBadge = badge ? "relative inline-block" : "";
return (
<a
href={href}
class={`${hasBadge} text-base font-medium transition-all duration-200 px-3 py-2 rounded ${activeClass} hover:bg-white/10`}
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{label}
{badge && <Fragment set:html={badge} />}
</a>
);
})
}
</li>
<!-- Mobile menu button with animated hamburger/X icon -->
<li class="md:hidden">
<button
class="text-(--color-enchunblue) hover:text-(--color-enchunblue)/80 focus:outline-none relative w-10 h-10 flex items-center justify-center"
id="mobile-menu-button"
aria-label="Toggle mobile menu"
aria-expanded="false"
>
<span class="sr-only text-(--color-enchunblue-dark)"
>Toggle menu</span
>
<svg
width="28"
height="28"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-7 h-7 hamburger-icon transition-transform duration-300"
id="hamburger-icon"
>
<path
d="M6 27H22.5C23.325 27 24 26.325 24 25.5C24 24.675 23.325 24 22.5 24H6C5.175 24 4.5 24.675 4.5 25.5C4.5 26.325 5.175 27 6 27ZM6 19.5H18C18.825 19.5 19.5 18.825 19.5 18C19.5 17.175 18.825 16.5 18 16.5H6C5.175 16.5 4.5 17.175 4.5 18C4.5 18.825 5.175 19.5 6 19.5ZM4.5 10.5C4.5 11.325 5.175 12 6 12H22.5C23.325 12 24 11.325 24 10.5C24 9.675 23.325 9 22.5 9H6C5.175 9 4.5 9.675 4.5 10.5ZM30.45 22.32L26.13 18L30.45 13.68C30.5889 13.5411 30.699 13.3763 30.7742 13.1948C30.8493 13.0134 30.888 12.8189 30.888 12.6225C30.888 12.4261 30.8493 12.2316 30.7742 12.0502C30.699 11.8687 30.5889 11.7039 30.45 11.565C30.3111 11.4261 30.1463 11.316 29.9648 11.2408C29.7834 11.1657 29.5889 11.127 29.3925 11.127C29.1961 11.127 29.0016 11.1657 28.8202 11.2408C28.6387 11.316 28.4739 11.4261 28.335 11.565L22.95 16.95C22.8109 17.0888 22.7006 17.2536 22.6254 17.4351C22.5501 17.6165 22.5113 17.811 22.5113 18.0075C22.5113 18.204 22.5501 18.3985 22.6254 18.5799C22.7006 18.7614 22.8109 18.9262 22.95 19.065L28.335 24.45C28.92 25.035 29.865 25.035 30.45 24.45C31.02 23.865 31.035 22.905 30.45 22.32V22.32Z"
fill="currentColor"
class="transition-opacity duration-300"></path>
</svg>
<!-- X icon for close state -->
<svg
width="28"
height="28"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="w-7 h-7 close-icon absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity duration-300 pointer-events-none"
id="close-icon"
>
<path
d="M28.5 9.6L26.4 7.5L18 15.9L9.6 7.5L7.5 9.6L15.9 18L7.5 26.4L9.6 28.5L18 20.1L26.4 28.5L28.5 26.4L20.1 18L28.5 9.6Z"
fill="currentColor"></path>
</svg>
</button>
</li>
</ul>
<!-- Mobile menu with full-screen overlay -->
<div
class="md:hidden fixed left-0 right-0 top-20 h-[calc(100vh-80px)] h-[calc(100dvh-80px)] bg-white/20 backdrop-blur-lg opacity-0 invisible transition-all duration-300 ease-in-out"
id="mobile-menu"
>
<ul
class="flex flex-col items-center justify-center h-full space-y-6 px-4"
id="mobile-nav"
>
{
navItems.map((item, index) => {
const link = item.link;
const href = getLinkUrl(link);
const label = link.label || "未命名";
return (
<li
class={`opacity-0`}
style={`animation-delay: ${index * 50}ms`}
>
<a
href={href}
class="text-2xl text-grey-700 text-shadow-sm font-medium transition-all duration-200 transform hover:scale-105"
{...(link.newTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{label}
</a>
</li>
);
})
}
</ul>
</div>
</nav>
</header>
<script>
// Scroll-based header with smooth hide/show animation
let lastScrollY = 0;
let ticking = false;
let isHeaderHidden = false;
function initScrollEffect() {
const header = document.getElementById("main-header");
const nav = document.getElementById("main-nav");
const mobileMenu = document.getElementById("mobile-menu");
if (!header || !nav) return;
const handleScroll = () => {
const scrollY = window.scrollY;
const scrollDelta = scrollY - lastScrollY;
const scrollDirection = scrollDelta > 0 ? "down" : "up";
// Only update lastScrollY if there's meaningful movement
if (Math.abs(scrollDelta) > 1) {
lastScrollY = scrollY;
}
// Header shrinks and gets background on scroll
if (scrollY > 10) {
if (!header.classList.contains("header-scrolled")) {
header.classList.add(
"bg-white/15",
"backdrop-blur-xl",
"shadow-md",
"header-scrolled",
);
header.classList.remove("bg-transparent");
nav.classList.add("py-2");
nav.classList.remove("py-4");
// Adjust mobile menu top position and height when header shrinks
if (mobileMenu) {
mobileMenu.classList.remove(
"top-20",
"h-[calc(100vh-80px)]",
"h-[calc(100dvh-80px)]",
);
mobileMenu.classList.add(
"top-16",
"h-[calc(100vh-64px)]",
"h-[calc(100dvh-64px)]",
);
}
}
} else {
if (header.classList.contains("header-scrolled")) {
header.classList.remove(
"bg-white/15",
"bg-white/90",
"backdrop-blur-xl",
"shadow-md",
"border-b",
"border-gray-200/50",
"header-scrolled",
);
header.classList.add("bg-transparent");
nav.classList.remove("py-2");
nav.classList.add("py-4");
// Reset mobile menu top position and height when header expands
if (mobileMenu) {
mobileMenu.classList.remove(
"top-16",
"h-[calc(100vh-64px)]",
"h-[calc(100dvh-64px)]",
);
mobileMenu.classList.add(
"top-20",
"h-[calc(100vh-80px)]",
"h-[calc(100dvh-80px)]",
);
}
}
}
// Hide/show header based on scroll direction - with threshold
const hideThreshold = 150; // Higher threshold before hiding
const showThreshold = 50; // Lower threshold to show (makes it feel smoother)
if (
scrollY > hideThreshold &&
scrollDirection === "down" &&
!isHeaderHidden
) {
// Only hide if scrolling down consistently
if (scrollDelta > 5) {
// Must be scrolling down with some speed
header.classList.remove("translate-in");
header.classList.add("translate-out");
isHeaderHidden = true;
}
} else if (scrollDirection === "up" && isHeaderHidden) {
// Show immediately when scrolling up
header.classList.remove("translate-out");
header.classList.add("translate-in");
isHeaderHidden = false;
} else if (scrollY < showThreshold) {
// Always show when near top
header.classList.remove("translate-out");
header.classList.add("translate-in");
isHeaderHidden = false;
}
ticking = false;
};
window.addEventListener(
"scroll",
() => {
if (!ticking) {
window.requestAnimationFrame(() => {
handleScroll();
ticking = false;
});
ticking = true;
}
},
{ passive: true },
);
handleScroll();
// Animate mobile menu items on page load
setTimeout(() => {
const mobileItems = document.querySelectorAll("#mobile-nav li");
mobileItems.forEach((item) => {
item.classList.remove("opacity-0");
item.classList.add(
"opacity-100",
"translate-y-0",
"transition-all",
"duration-300",
"ease-out",
);
});
}, 100);
}
// Enhanced mobile menu toggle with animations
let mobileMenuInitialized = false;
function initMobileMenu() {
if (mobileMenuInitialized) return;
const button = document.getElementById("mobile-menu-button");
const menu = document.getElementById("mobile-menu");
const hamburgerIcon = document.getElementById("hamburger-icon");
const closeIcon = document.getElementById("close-icon");
const mobileNavItems = document
.getElementById("mobile-nav")
?.querySelectorAll("li");
if (!button || !menu || !hamburgerIcon || !closeIcon) {
// Retry after a delay if elements aren't ready
setTimeout(initMobileMenu, 100);
return;
}
button.addEventListener("click", () => {
const isMenuOpen = menu.classList.contains("opacity-100");
if (isMenuOpen) {
menu.classList.remove("opacity-100", "visible");
menu.classList.add("opacity-0", "invisible");
hamburgerIcon.classList.remove("opacity-0", "rotate-90");
hamburgerIcon.classList.add("opacity-100");
closeIcon.classList.remove("opacity-100");
closeIcon.classList.add("opacity-0");
button.setAttribute("aria-expanded", "false");
mobileNavItems?.forEach((item) => {
item.classList.remove("opacity-100", "translate-y-0");
item.classList.add("opacity-0", "translate-y-4");
});
document.body.style.overflow = "";
} else {
menu.classList.remove("opacity-0", "invisible");
menu.classList.add("opacity-100", "visible");
hamburgerIcon.classList.remove("opacity-100");
hamburgerIcon.classList.add("opacity-0", "rotate-90");
closeIcon.classList.remove("opacity-0");
closeIcon.classList.add("opacity-100");
button.setAttribute("aria-expanded", "true");
mobileNavItems?.forEach((item, index) => {
setTimeout(() => {
item.classList.remove("opacity-0", "translate-y-4");
item.classList.add(
"opacity-100",
"translate-y-0",
"transition-all",
"duration-300",
"ease-out",
);
}, index * 50);
});
document.body.style.overflow = "hidden";
}
});
menu.addEventListener("click", (e) => {
if (e.target === menu) {
button.click();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menu.classList.contains("opacity-100")) {
button.click();
}
});
mobileMenuInitialized = true;
}
document.addEventListener("DOMContentLoaded", () => {
initScrollEffect();
initMobileMenu();
});
</script>
<style>
#main-header {
transition:
background-color 0.3s ease-in-out,
backdrop-filter 0.3s ease-in-out,
box-shadow 0.3s ease-in-out,
transform 0.3s ease-in-out;
will-change: transform;
}
/* Header translate animation - hide completely above viewport */
#main-header.translate-out {
transform: translateY(-100%);
}
#main-header.translate-in {
transform: translateY(0);
}
/* Nav padding transition for shrink effect */
#main-nav {
transition: padding 0.3s ease-in-out;
}
/* Logo shrinks when header is scrolled */
#main-header.header-scrolled img {
transform: scale(0.85);
transition: transform 0.3s ease-in-out;
}
/* Navigation links */
#main-nav a {
color: var(--color-nav-link);
position: relative;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.3);
}
#main-nav a:hover {
color: var(--color-primary);
}
/* Active state */
.nav-active {
color: var(--color-enchunblue) !important;
font-weight: 600;
}
/* Badge positioning and styling */
#main-nav a[class*="relative"] .absolute {
top: -4px;
right: -8px;
}
/* Mobile menu styles */
#mobile-menu {
transition:
opacity 0.3s ease-in-out,
visibility 0.3s ease-in-out,
top 0.3s ease-in-out;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
@media (max-width: 767px) {
#mobile-menu {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#mobile-nav a {
padding: 0.75rem 1rem;
min-height: 48px;
display: flex;
align-items: center;
color: var(--color-nav-link);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
#mobile-nav a:hover {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
/* Smooth transition for all nav elements */
#main-nav a,
#mobile-nav a {
transition:
color 0.2s ease-in-out,
text-shadow 0.2s ease-in-out,
background-color 0.2s ease-in-out;
}
</style>