feat (v1.0.0): initial refactor and redesign
This commit is contained in:
parent
3058aa1ab4
commit
fe9b50b30e
134 changed files with 17792 additions and 3670 deletions
310
components/navigation/Footer.tsx
Normal file
310
components/navigation/Footer.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
TbCopyrightOff,
|
||||
TbMail,
|
||||
TbBrandGithub,
|
||||
TbBrandX,
|
||||
} from "react-icons/tb"
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import RandomFooterMsg from "../objects/RandomFooterMsg"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces } from '@/lib/theme'
|
||||
import { getRecentGitHubRepos } from '@/lib/github'
|
||||
import {
|
||||
footerNavigationLinks,
|
||||
footerSupportLinks,
|
||||
} from './footer-config'
|
||||
import type {
|
||||
FooterMenuRenderContext,
|
||||
FooterMenuSection,
|
||||
NavigationIcon,
|
||||
} from '@/lib/types/navigation'
|
||||
|
||||
const FOOTER_MENU_SECTIONS: FooterMenuSection[] = [
|
||||
{
|
||||
type: 'links',
|
||||
title: 'Navigation',
|
||||
links: footerNavigationLinks,
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
title: 'Latest Projects',
|
||||
render: ({ githubRepos, githubUsername }: FooterMenuRenderContext) => (
|
||||
githubRepos.length > 0
|
||||
? githubRepos.map((repo) => (
|
||||
<FooterLink
|
||||
key={repo.id}
|
||||
href={repo.url}
|
||||
icon={TbBrandGithub}
|
||||
external
|
||||
>
|
||||
<span className="truncate" title={repo.name}>
|
||||
{repo.name}
|
||||
</span>
|
||||
</FooterLink>
|
||||
))
|
||||
: (
|
||||
<FooterLink
|
||||
href={`https://github.com/${githubUsername}`}
|
||||
icon={TbBrandGithub}
|
||||
external
|
||||
>
|
||||
Projects unavailable — visit GitHub
|
||||
</FooterLink>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'links',
|
||||
title: 'Support Me',
|
||||
links: footerSupportLinks,
|
||||
},
|
||||
]
|
||||
|
||||
interface FooterLinkProps {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
external?: boolean
|
||||
icon?: NavigationIcon
|
||||
}
|
||||
|
||||
const FooterLink = ({ href, children, external = false, icon: Icon }: FooterLinkProps) => {
|
||||
const linkProps = external ? { target: "_blank", rel: "noopener noreferrer" } : {}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
{...linkProps}
|
||||
className={cn(
|
||||
"flex items-center transition-colors duration-300 group",
|
||||
"hover:text-white"
|
||||
)}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{Icon && (
|
||||
<span className="mr-1.5 group-hover:scale-110 transition-transform">
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{external && <ChevronRight size={14} className="ml-0.5 opacity-50 group-hover:opacity-100 transition-opacity" />}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface FooterSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const FooterSection = ({ title, children }: FooterSectionProps) => (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h3
|
||||
className="font-semibold text-sm uppercase tracking-wider"
|
||||
style={{ color: colors.text.secondary }}
|
||||
>{title}</h3>
|
||||
<div className="flex flex-col space-y-2.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type Persona = {
|
||||
role: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const personaOptions: Persona[] = [
|
||||
{
|
||||
role: 'Chief Synergy Evangelist',
|
||||
description: 'Drives enterprise-wide alignment through scalable cross-functional touchpoints.'
|
||||
},
|
||||
{
|
||||
role: 'Director of Strategic Buzzwords',
|
||||
description: 'Operationalizes high-impact vocabulary to maximize stakeholder resonance.'
|
||||
},
|
||||
{
|
||||
role: 'Vice President of Change Management',
|
||||
description: 'Leads transformational roadmaps that empower teams to pivot at scale.'
|
||||
},
|
||||
{
|
||||
role: 'Global KPI Whisperer',
|
||||
description: 'Ensures metric integrity through proactive dashboard storytelling.'
|
||||
},
|
||||
{
|
||||
role: 'Head of Agile Communications',
|
||||
description: 'Facilitates sprint cadence narratives for executive-level consumption.'
|
||||
},
|
||||
{
|
||||
role: 'VP of Continuous Optimization',
|
||||
description: 'Champions always-on iteration loops to unlock compounding efficiency gains.'
|
||||
},
|
||||
{
|
||||
role: 'Principal Narrative Architect',
|
||||
description: 'Synthesizes cross-team input into unified, board-ready success frameworks.'
|
||||
},
|
||||
{
|
||||
role: 'Lead Alignment Strategist',
|
||||
description: 'Converts strategic pivots into measurable OKR cascades and culture moments.'
|
||||
},
|
||||
{
|
||||
role: 'Chief Risk Mitigator',
|
||||
description: 'De-risks enterprise bets through proactive dependency orchestration.'
|
||||
},
|
||||
{
|
||||
role: 'Director of Value Realization',
|
||||
description: 'Translates initiatives into quantifiable ROI across all stakeholder tiers.'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultPersona: Persona = personaOptions[0] ?? {
|
||||
role: 'Developer & Creator',
|
||||
description: 'Building thoughtful digital experiences and exploring the intersection of technology, music, and creativity. Currently focused on web development and AI integration.'
|
||||
}
|
||||
|
||||
const getPersonaByIndex = (index: number | undefined): Persona => {
|
||||
if (!personaOptions.length) {
|
||||
return defaultPersona
|
||||
}
|
||||
|
||||
if (typeof index !== 'number' || Number.isNaN(index)) {
|
||||
return defaultPersona
|
||||
}
|
||||
|
||||
const safeIndex = ((Math.floor(index) % personaOptions.length) + personaOptions.length) % personaOptions.length
|
||||
return personaOptions[safeIndex] ?? defaultPersona
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
footerMessageIndex?: number
|
||||
}
|
||||
|
||||
export default async function Footer({ footerMessageIndex }: FooterProps) {
|
||||
const persona = getPersonaByIndex(footerMessageIndex)
|
||||
const { username: githubUsername, repos: githubRepos } = await getRecentGitHubRepos()
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(surfaces.panel.overlay, "mt-auto border-t")}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-[1.2fr_repeat(3,minmax(0,1fr))] gap-x-10 gap-y-12 lg:gap-x-16">
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-1">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div
|
||||
className="relative w-16 h-16 rounded-full overflow-hidden ring-2"
|
||||
style={{
|
||||
backgroundColor: colors.borders.default,
|
||||
borderColor: colors.borders.hover
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/ihatenodejs.jpg"
|
||||
alt="Aidan"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
className="font-bold text-lg"
|
||||
style={{ color: colors.text.primary }}
|
||||
>Aidan</h3>
|
||||
<p
|
||||
className="text-sm"
|
||||
style={{ color: colors.text.muted }}
|
||||
>{persona.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: colors.text.muted }}
|
||||
>{persona.description}</p>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-2">
|
||||
<Link
|
||||
href={`https://github.com/${githubUsername}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<TbBrandGithub size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/aidxnn"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<TbBrandX size={20} />
|
||||
</Link>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="hover:text-white transition-colors"
|
||||
style={{ color: colors.text.muted }}
|
||||
aria-label="Email"
|
||||
>
|
||||
<TbMail size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{FOOTER_MENU_SECTIONS.map((section) => (
|
||||
<FooterSection key={section.title} title={section.title}>
|
||||
{section.type === 'links'
|
||||
? section.links.map(({ href, label, icon, external }) => (
|
||||
<FooterLink key={href} href={href} icon={icon} external={external}>
|
||||
{label}
|
||||
</FooterLink>
|
||||
))
|
||||
: section.render({ githubUsername, githubRepos })}
|
||||
</FooterSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: colors.borders.muted,
|
||||
backgroundColor: colors.backgrounds.card
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] items-center gap-y-2">
|
||||
<div
|
||||
className="flex items-center justify-center sm:justify-start text-sm"
|
||||
style={{ color: colors.text.disabled }}
|
||||
>
|
||||
<TbCopyrightOff className="mr-2" size={16} />
|
||||
<span>Open Source and Copyright-Free</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center space-x-2 text-sm">
|
||||
<RandomFooterMsg index={footerMessageIndex} />
|
||||
</div>
|
||||
|
||||
{/* soon ->
|
||||
<div className="flex items-center justify-center sm:justify-end space-x-4 text-sm">
|
||||
<span className="flex items-center">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
<span style={{ color: colors.text.disabled }}>All Systems Operational</span>
|
||||
</span>
|
||||
</div>*/}<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
495
components/navigation/Header.tsx
Normal file
495
components/navigation/Header.tsx
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
X,
|
||||
Menu,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { colors, surfaces } from '@/lib/theme'
|
||||
import type {
|
||||
NavigationIcon,
|
||||
NavigationMenuItem,
|
||||
NavigationDropdownConfig,
|
||||
NavigationDropdownGroup,
|
||||
} from '@/lib/types/navigation'
|
||||
import { headerNavigationConfig } from './header-config'
|
||||
|
||||
const NAVIGATION_CONFIG: NavigationMenuItem[] = headerNavigationConfig
|
||||
|
||||
interface NavItemProps {
|
||||
href: string
|
||||
icon: NavigationIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon, children }: NavItemProps) => (
|
||||
<div className="nav-item">
|
||||
<Link href={href} className={cn("flex items-center", surfaces.button.nav)}>
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
{children}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DropdownNavItemProps {
|
||||
id: string
|
||||
href: string
|
||||
icon: NavigationIcon
|
||||
children: React.ReactNode
|
||||
dropdownContent: React.ReactNode
|
||||
isMobile?: boolean
|
||||
isOpen?: boolean
|
||||
onOpenChange?: (id: string | null, immediate?: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownNavItem = ({ id, href, icon, children, dropdownContent, isMobile = false, isOpen = false, onOpenChange }: DropdownNavItemProps) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
onOpenChange?.(null, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile && isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isMobile, isOpen, onOpenChange]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onOpenChange?.(id, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && dropdownRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onOpenChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenChange?.(isOpen ? null : id, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-item relative"
|
||||
ref={dropdownRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={isMobile ? handleClick : undefined}
|
||||
className={cn("flex items-center justify-between w-full", surfaces.button.nav)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
{React.createElement(icon, { className: "text-md mr-2", strokeWidth: 2.5, size: 20 })}
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
<ChevronDown className={`ml-2 transform transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} strokeWidth={2.5} size={16} />
|
||||
</Link>
|
||||
{isOpen && (
|
||||
<>
|
||||
{!isMobile && <div className="absolute left-0 top-full w-full h-1 z-50" />}
|
||||
<div className={isMobile ? 'relative w-full mt-2 ml-5 pr-4' : 'absolute left-0 mt-1 z-50 flex'}>
|
||||
{dropdownContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NestedDropdownItemProps {
|
||||
children: React.ReactNode
|
||||
nestedContent: React.ReactNode
|
||||
icon: NavigationIcon
|
||||
isMobile?: boolean
|
||||
itemKey: string
|
||||
activeNested: string | null
|
||||
onNestedChange: (key: string | null, immediate?: boolean) => void
|
||||
}
|
||||
|
||||
const NestedDropdownItem = ({ children, nestedContent, icon: Icon, isMobile = false, itemKey, activeNested, onNestedChange }: NestedDropdownItemProps) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const isOpen = activeNested === itemKey;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile) {
|
||||
onNestedChange(itemKey, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent) => {
|
||||
if (!isMobile) {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
onNestedChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isMobile) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onNestedChange(isOpen ? null : itemKey, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn("flex items-center justify-between w-full text-left px-4 py-3 text-sm", surfaces.button.dropdownItem)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Icon className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronRight className={`transform transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="relative mt-2 ml-5 pr-4 space-y-1">
|
||||
{nestedContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
ref={itemRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full text-left px-4 py-3 text-sm",
|
||||
isOpen ? "bg-gray-700/40 text-white" : surfaces.button.dropdownItem
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center flex-1">
|
||||
<Icon className="mr-3" strokeWidth={2.5} size={18} />
|
||||
{children}
|
||||
</span>
|
||||
<ChevronDown className={`transform transition-transform duration-200 ${isOpen ? '-rotate-90' : ''}`} strokeWidth={2.5} size={18} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="absolute left-full top-0 w-4 h-full z-50" />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full top-0 ml-1 w-64 z-50",
|
||||
"animate-in fade-in-0 zoom-in-95 slide-in-from-left-2 duration-200",
|
||||
surfaces.panel.dropdown
|
||||
)}
|
||||
onMouseEnter={() => onNestedChange(itemKey, true)}
|
||||
onMouseLeave={(e) => {
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (relatedTarget instanceof Node && itemRef.current?.contains(relatedTarget)) return;
|
||||
onNestedChange(null);
|
||||
}}
|
||||
>
|
||||
{nestedContent}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNestedGroups = (groups: NavigationDropdownGroup[], isMobile: boolean) => {
|
||||
const hasAnyTitle = groups.some(group => group.title);
|
||||
|
||||
return (
|
||||
<div className={hasAnyTitle ? 'py-2' : ''}>
|
||||
{groups.map((group, index) => (
|
||||
<div key={group.title || `group-${index}`}>
|
||||
{group.title && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-[11px] uppercase tracking-wide",
|
||||
isMobile ? 'px-4 pt-1 pb-2' : 'px-5 pt-2 pb-2'
|
||||
)}
|
||||
style={{ color: colors.text.muted }}
|
||||
>
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
{group.links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
isMobile ? 'px-4 py-2.5' : 'px-5 py-3',
|
||||
surfaces.button.dropdownItem
|
||||
)}
|
||||
{...(link.external && { target: '_blank', rel: 'noopener noreferrer' })}
|
||||
>
|
||||
{React.createElement(link.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDropdownContent = (config: NavigationDropdownConfig, isMobile: boolean, activeNested: string | null, onNestedChange: (key: string | null, immediate?: boolean) => void) => (
|
||||
<div className={cn(isMobile ? 'w-full' : cn('w-64', surfaces.panel.dropdown))}>
|
||||
{config.items.map((item) => {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center px-4 text-sm",
|
||||
isMobile ? 'py-2.5' : 'py-3',
|
||||
surfaces.button.dropdownItem
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (!isMobile && activeNested) {
|
||||
onNestedChange(null, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{React.createElement(item.icon, { className: 'mr-3', strokeWidth: 2.5, size: 18 })}
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NestedDropdownItem
|
||||
key={`nested-${item.label}`}
|
||||
itemKey={`nested-${item.label}`}
|
||||
icon={item.icon}
|
||||
isMobile={isMobile}
|
||||
activeNested={activeNested}
|
||||
onNestedChange={onNestedChange}
|
||||
nestedContent={renderNestedGroups(item.groups, isMobile)}
|
||||
>
|
||||
{item.label}
|
||||
</NestedDropdownItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function Header() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
const [activeNested, setActiveNested] = useState<string | null>(null);
|
||||
const [showDesktopOverlay, setShowDesktopOverlay] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
||||
const overlayCloseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const overlayOpenFrameRef = useRef<number | null>(null);
|
||||
const dropdownTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const nestedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (isOpen) {
|
||||
setActiveDropdown(null);
|
||||
setActiveNested(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownChange = (id: string | null, immediate: boolean = false) => {
|
||||
if (dropdownTimeoutRef.current) {
|
||||
clearTimeout(dropdownTimeoutRef.current);
|
||||
dropdownTimeoutRef.current = null;
|
||||
}
|
||||
if (nestedTimeoutRef.current) {
|
||||
clearTimeout(nestedTimeoutRef.current);
|
||||
nestedTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (id !== null || immediate) {
|
||||
setActiveDropdown(id);
|
||||
setActiveNested(null);
|
||||
} else {
|
||||
dropdownTimeoutRef.current = setTimeout(() => {
|
||||
setActiveDropdown(null);
|
||||
setActiveNested(null);
|
||||
dropdownTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNestedChange = (key: string | null, immediate: boolean = false) => {
|
||||
if (nestedTimeoutRef.current) {
|
||||
clearTimeout(nestedTimeoutRef.current);
|
||||
nestedTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (key !== null || immediate) {
|
||||
setActiveNested(key);
|
||||
} else {
|
||||
nestedTimeoutRef.current = setTimeout(() => {
|
||||
setActiveNested(null);
|
||||
nestedTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth < 1024);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
setOverlayVisible(false);
|
||||
setShowDesktopOverlay(false);
|
||||
} else if (activeDropdown) {
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
setShowDesktopOverlay(true);
|
||||
overlayOpenFrameRef.current = requestAnimationFrame(() => {
|
||||
setOverlayVisible(true);
|
||||
overlayOpenFrameRef.current = null;
|
||||
});
|
||||
} else {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
setOverlayVisible(false);
|
||||
overlayCloseTimeoutRef.current = setTimeout(() => {
|
||||
setShowDesktopOverlay(false);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (overlayOpenFrameRef.current !== null) {
|
||||
cancelAnimationFrame(overlayOpenFrameRef.current);
|
||||
overlayOpenFrameRef.current = null;
|
||||
}
|
||||
if (overlayCloseTimeoutRef.current !== null) {
|
||||
clearTimeout(overlayCloseTimeoutRef.current);
|
||||
overlayCloseTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeDropdown, isMobile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDesktopOverlay && (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-30 pointer-events-none transition-all duration-300 opacity-0 backdrop-blur-none',
|
||||
overlayVisible && 'opacity-100 backdrop-blur-sm'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<header className={cn(surfaces.panel.overlay, "sticky top-0 z-50 border-b")}>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 backdrop-blur-md z-40 lg:hidden"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
)}
|
||||
<nav className="container mx-auto px-4 py-4 flex justify-between items-center relative z-50">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"text-2xl font-bold transition-all duration-300 hover:glow",
|
||||
"hover:text-white"
|
||||
)}
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
aidan.so
|
||||
</Link>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="lg:hidden focus:outline-hidden"
|
||||
style={{ color: colors.text.body }}
|
||||
>
|
||||
{isOpen ? <X className="text-2xl" /> : <Menu className="text-2xl" />}
|
||||
</button>
|
||||
<ul className={cn(
|
||||
"flex flex-col lg:flex-row space-y-3 lg:space-y-0 lg:space-x-4",
|
||||
"absolute lg:static w-full lg:w-auto left-0 lg:left-auto top-full lg:top-auto",
|
||||
"px-2 py-4 lg:p-0 transition-all duration-300 ease-in-out z-50",
|
||||
"lg:bg-transparent",
|
||||
isOpen ? 'flex' : 'hidden lg:flex'
|
||||
)}
|
||||
style={{ backgroundColor: isMobile ? colors.backgrounds.cardSolid : undefined }}
|
||||
>
|
||||
{NAVIGATION_CONFIG.map((item) => {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<NavItem key={item.id} href={item.href} icon={item.icon}>
|
||||
{item.label}
|
||||
</NavItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownNavItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
dropdownContent={renderDropdownContent(item.dropdown, isMobile, activeNested, handleNestedChange)}
|
||||
isMobile={isMobile}
|
||||
isOpen={activeDropdown === item.id}
|
||||
onOpenChange={handleDropdownChange}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownNavItem>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
components/navigation/footer-config.ts
Normal file
47
components/navigation/footer-config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
House,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
CreditCard,
|
||||
} from 'lucide-react'
|
||||
import type { NavigationLink } from '@/lib/types/navigation'
|
||||
import { SiGithubsponsors } from 'react-icons/si'
|
||||
|
||||
export const footerNavigationLinks: NavigationLink[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: 'Home',
|
||||
icon: House
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About Me',
|
||||
icon: User
|
||||
},
|
||||
{
|
||||
href: '/contact',
|
||||
label: 'Contact',
|
||||
icon: Phone
|
||||
},
|
||||
{
|
||||
href: '/manifesto',
|
||||
label: 'Manifesto',
|
||||
icon: BookOpen
|
||||
},
|
||||
]
|
||||
|
||||
export const footerSupportLinks: NavigationLink[] = [
|
||||
{
|
||||
href: 'https://donate.stripe.com/6oEeWVcXs9L9ctW4gj',
|
||||
label: 'Donate via Stripe',
|
||||
icon: CreditCard,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/sponsors/ihatenodejs',
|
||||
label: 'GitHub Sponsors',
|
||||
icon: SiGithubsponsors,
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
165
components/navigation/header-config.ts
Normal file
165
components/navigation/header-config.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import {
|
||||
House,
|
||||
Link as LinkIcon,
|
||||
User,
|
||||
Phone,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Smartphone,
|
||||
Headphones,
|
||||
Briefcase,
|
||||
Package,
|
||||
Cloud,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { TbUserHeart } from 'react-icons/tb'
|
||||
import KowalskiIcon from '@/components/icons/KowalskiIcon'
|
||||
import GoogleIcon from '@/components/icons/GoogleIcon'
|
||||
|
||||
import type { NavigationMenuItem } from '@/lib/types/navigation'
|
||||
|
||||
export const headerNavigationConfig: NavigationMenuItem[] = [
|
||||
{
|
||||
type: 'link',
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
href: '/',
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
type: 'dropdown',
|
||||
id: 'about',
|
||||
label: 'About Me',
|
||||
href: '/about',
|
||||
icon: User,
|
||||
dropdown: {
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Get to Know Me',
|
||||
href: '/about',
|
||||
icon: TbUserHeart,
|
||||
},
|
||||
{
|
||||
type: 'nested',
|
||||
label: 'Devices',
|
||||
icon: Smartphone,
|
||||
groups: [
|
||||
{
|
||||
title: 'Phones',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 3a XL (bonito)',
|
||||
href: '/device/bonito',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 7 Pro (cheetah)',
|
||||
href: '/device/cheetah',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Pixel 9 Pro (komodo)',
|
||||
href: '/device/komodo',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'DAPs',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'JM21',
|
||||
href: '/device/jm21',
|
||||
icon: Headphones,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'nested',
|
||||
label: 'Projects',
|
||||
icon: Briefcase,
|
||||
groups: [
|
||||
{
|
||||
title: '',
|
||||
links: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'modules',
|
||||
href: 'https://modules.lol/',
|
||||
icon: Package,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'Kowalski',
|
||||
href: 'https://kowalski.social/',
|
||||
icon: KowalskiIcon,
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
label: 'p0ntus',
|
||||
href: 'https://p0ntus.com/',
|
||||
icon: Cloud,
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'dropdown',
|
||||
id: 'ai',
|
||||
label: 'AI',
|
||||
href: '/ai',
|
||||
icon: Brain,
|
||||
dropdown: {
|
||||
items: [
|
||||
{
|
||||
type: 'link',
|
||||
label: 'AI Usage',
|
||||
href: '/ai/usage',
|
||||
icon: Brain,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'contact',
|
||||
label: 'Contact',
|
||||
href: '/contact',
|
||||
icon: Phone,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'domains',
|
||||
label: 'Domains',
|
||||
href: '/domains',
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'manifesto',
|
||||
label: 'Manifesto',
|
||||
href: '/manifesto',
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
id: 'docs',
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: FileText,
|
||||
},
|
||||
]
|
||||
4
components/navigation/index.ts
Normal file
4
components/navigation/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Header } from './Header'
|
||||
export { default as Footer } from './Footer'
|
||||
export { headerNavigationConfig } from './header-config'
|
||||
export { footerNavigationLinks, footerSupportLinks } from './footer-config'
|
||||
Loading…
Add table
Add a link
Reference in a new issue