Add initial project files
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import React from 'react';
|
||||
import { PageLink } from '#web-runtime/components/links';
|
||||
import type { LanguageAlternative, NavigationLink, SiteMenu } from '#web-runtime/types';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import LanguageAlternatives from './LanguageAlternatives';
|
||||
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
className={`size-3 shrink-0 transition-transform duration-150 motion-reduce:transition-none ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2 4l4 4 4-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLink({ link, className }: { link: NavigationLink; className: string }) {
|
||||
return <PageLink link={{ ...link, variant: 'ghost' }} className={className} />;
|
||||
}
|
||||
|
||||
export default function HeaderNav({
|
||||
menus,
|
||||
ctaItems,
|
||||
languageAlternatives
|
||||
}: {
|
||||
menus: SiteMenu[];
|
||||
ctaItems: NavigationLink[];
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
const [openMenu, setOpenMenu] = React.useState<number | null>(null);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const visibleMenus = menus.filter((menu) => menu.items.length > 0);
|
||||
const hasMenuContent = visibleMenus.length > 0 || ctaItems.length > 0;
|
||||
const anythingOpen = openMenu !== null || mobileOpen;
|
||||
|
||||
const closeAll = React.useCallback(() => {
|
||||
setOpenMenu(null);
|
||||
setMobileOpen(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!anythingOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [anythingOpen, closeAll]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex min-w-0 flex-1 items-center gap-8 max-lg:gap-4">
|
||||
{visibleMenus.length > 0 ? (
|
||||
<nav
|
||||
className="flex min-w-0 flex-1 items-center gap-1 max-lg:hidden"
|
||||
aria-label={t('main')}
|
||||
>
|
||||
{visibleMenus.map((menu, menuIndex) =>
|
||||
menu.title ? (
|
||||
<div className="relative" key={menuIndex}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-1.5 px-2.5 py-2 text-sm font-semibold text-[#5d675f] hover:translate-y-0 hover:text-[#172019]"
|
||||
aria-expanded={openMenu === menuIndex}
|
||||
aria-haspopup="true"
|
||||
onClick={() => {
|
||||
setOpenMenu(openMenu === menuIndex ? null : menuIndex);
|
||||
}}
|
||||
>
|
||||
{menu.title}
|
||||
<Chevron open={openMenu === menuIndex} />
|
||||
</Button>
|
||||
|
||||
{openMenu === menuIndex ? (
|
||||
<div className="absolute top-full left-0 z-30 mt-2 flex min-w-52 flex-col gap-0.5 rounded-2xl border border-[#d6d7cf] bg-[#fffdf8] p-2 shadow-[0_1rem_3rem_rgb(23_32_25/12%)]">
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<MenuLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="w-full rounded-lg! border-0! px-3! py-2! text-sm font-semibold text-[#172019] hover:bg-[#f4f1e9] hover:text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
aria-label={undefined}
|
||||
key={`menu-${menuIndex}`}
|
||||
>
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="px-2.5 py-2 text-sm font-semibold whitespace-nowrap text-[#5d675f] hover:text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-4">
|
||||
<LanguageAlternatives alternatives={languageAlternatives} />
|
||||
|
||||
{ctaItems.map((item, index) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${index}`}
|
||||
link={item}
|
||||
className="whitespace-nowrap max-lg:hidden"
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMenuContent ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden size-11 border-[#172019]/15 p-0 hover:translate-y-0 hover:border-[#172019]/15 hover:bg-white/60 max-lg:flex"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-label={mobileOpen ? t('close') : t('open')}
|
||||
onClick={() => {
|
||||
setMobileOpen((open) => !open);
|
||||
setOpenMenu(null);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="size-5" aria-hidden="true">
|
||||
{mobileOpen ? (
|
||||
<path
|
||||
d="M5 5l10 10M15 5L5 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
d="M3 6h14M3 10h14M3 14h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mobileOpen ? (
|
||||
<nav
|
||||
className="absolute inset-x-0 top-full z-30 border-b border-[#172019]/10 bg-[#f4f1e9] shadow-[0_1.5rem_3rem_rgb(23_32_25/12%)] lg:hidden"
|
||||
aria-label={t('main')}
|
||||
>
|
||||
<div className="mx-auto flex w-[min(calc(100%-2rem),76rem)] flex-col gap-5 py-6 max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
{visibleMenus.map((menu, menuIndex) => (
|
||||
<div key={`${menu.title}-${menuIndex}`}>
|
||||
{menu.title ? (
|
||||
<p className="mb-1 font-mono text-[0.7rem] font-bold tracking-[0.08em] text-[#5d675f] uppercase">
|
||||
{menu.title}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<MenuLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="w-full py-2! text-[0.95rem] font-semibold text-[#172019]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctaItems.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3 border-t border-[#d6d7cf] pt-5">
|
||||
{ctaItems.map((item, index) => (
|
||||
<PageLink key={`${item.label}-${index}`} link={item} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { LanguageAlternative } from '#web-runtime/types';
|
||||
import { localeHref } from '../helpers';
|
||||
|
||||
export default function LanguageAlternatives({
|
||||
alternatives
|
||||
}: {
|
||||
alternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
|
||||
if (alternatives.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex gap-2.5 font-mono text-[0.7rem] font-bold text-[#5d675f]"
|
||||
aria-label={t('languages')}
|
||||
>
|
||||
{alternatives.map((alternative) => (
|
||||
<a
|
||||
key={`${alternative.locale}-${alternative.path}`}
|
||||
href={localeHref(alternative)}
|
||||
hrefLang={alternative.locale}
|
||||
className="hover:text-[#172019] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
>
|
||||
{alternative.locale.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { PageLink } from '#web-runtime/components/links';
|
||||
import { SiteSettings } from '#web-runtime/types';
|
||||
import { Link } from '#i18n/navigation';
|
||||
|
||||
export default function SiteFooter({ settings }: { settings: SiteSettings }) {
|
||||
const t = useTranslations('Components.Footer');
|
||||
|
||||
const hasLegalLinks = Boolean(settings.legal.privacyPath || settings.legal.termsPath);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#172019] pt-[clamp(4rem,8vw,7rem)] pb-8 text-[#f6f5ef]">
|
||||
<div className="mx-auto w-[min(calc(100%-2rem),76rem)] max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<div className="grid grid-cols-[minmax(15rem,0.75fr)_minmax(0,1.25fr)] gap-[clamp(3rem,8vw,8rem)] max-lg:grid-cols-1">
|
||||
<div>
|
||||
<Link
|
||||
className="inline-block text-[1.45rem] font-extrabold tracking-[-0.06em] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
href="/"
|
||||
>
|
||||
CRUX
|
||||
<span className="text-[#99d900]" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{settings.footer.menus.length > 0 ? (
|
||||
<nav
|
||||
className="grid grid-cols-3 gap-8 max-[45rem]:grid-cols-2"
|
||||
aria-label={t('navigation')}
|
||||
>
|
||||
{settings.footer.menus.map((menu, menuIndex) => (
|
||||
<div
|
||||
className="flex flex-col items-start gap-1.5"
|
||||
key={`${menu.title}-${menuIndex}`}
|
||||
>
|
||||
{menu.title ? (
|
||||
<h2 className="mb-3 font-mono text-[0.7rem] font-semibold tracking-widest text-[#aab4aa] uppercase">
|
||||
{menu.title}
|
||||
</h2>
|
||||
) : null}
|
||||
{menu.items.map((item, itemIndex) => (
|
||||
<PageLink
|
||||
key={`${item.label}-${itemIndex}`}
|
||||
link={item}
|
||||
className="text-sm font-medium text-[#f6f5ef] hover:text-[#c7ff3f]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-[clamp(4rem,8vw,7rem)] flex items-center justify-between gap-8 border-t border-white/15 pt-6 text-[0.78rem] text-[#aab4aa] max-[45rem]:flex-col max-[45rem]:items-start">
|
||||
{settings.footer.copyright ? <p>{settings.footer.copyright}</p> : <span />}
|
||||
{hasLegalLinks ? (
|
||||
<nav className="flex gap-5" aria-label={t('legal')}>
|
||||
{settings.legal.privacyPath ? (
|
||||
<a className="hover:text-white" href={settings.legal.privacyPath}>
|
||||
{t('privacy')}
|
||||
</a>
|
||||
) : null}
|
||||
{settings.legal.termsPath ? (
|
||||
<a className="hover:text-white" href={settings.legal.termsPath}>
|
||||
{t('terms')}
|
||||
</a>
|
||||
) : null}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageAlternative, SiteSettings } from '#web-runtime/types';
|
||||
import { Link } from '#i18n/navigation';
|
||||
import HeaderNav from './HeaderNav';
|
||||
|
||||
export default function SiteHeader({
|
||||
settings,
|
||||
languageAlternatives
|
||||
}: {
|
||||
settings: SiteSettings;
|
||||
languageAlternatives: LanguageAlternative[];
|
||||
}) {
|
||||
const t = useTranslations('Components.Navigation');
|
||||
|
||||
return (
|
||||
<header className="relative z-20 border-b border-[#172019]/10 bg-[#f4f1e9]/92 backdrop-blur-2xl">
|
||||
<div className="mx-auto flex min-h-20 w-[min(calc(100%-2rem),76rem)] items-center gap-8 max-lg:gap-4 max-[45rem]:w-[min(calc(100%-1.25rem),76rem)]">
|
||||
<Link
|
||||
className="shrink-0 text-lg font-extrabold tracking-[-0.06em] focus-visible:outline-3 focus-visible:outline-offset-4 focus-visible:outline-[#99d900]"
|
||||
href="/"
|
||||
aria-label={t('home')}
|
||||
>
|
||||
CRUX
|
||||
<span className="text-[#99d900]" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<HeaderNav
|
||||
menus={settings.header.menus}
|
||||
ctaItems={settings.header.ctaItems}
|
||||
languageAlternatives={languageAlternatives}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getPathname } from '#i18n/navigation';
|
||||
import type { LanguageAlternative } from '../types';
|
||||
|
||||
export function localeHref(alternative: LanguageAlternative): string {
|
||||
const path = alternative.path.split('/').filter(Boolean).join('/');
|
||||
|
||||
return getPathname({
|
||||
locale: alternative.locale,
|
||||
href: path ? `/${path}` : '/'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user