@@ -48,4 +48,43 @@ Update only the relevant files in:
|
|||||||
- docs/
|
- docs/
|
||||||
- specs/
|
- specs/
|
||||||
|
|
||||||
Keep documentation aligned with the real implementation, not assumptions.
|
Keep documentation aligned with the real implementation, not assumptions.
|
||||||
|
|
||||||
|
## Git Commit Messages
|
||||||
|
|
||||||
|
Use this format:
|
||||||
|
```text
|
||||||
|
TYPE - Short summary
|
||||||
|
|
||||||
|
- First concrete change
|
||||||
|
- Second concrete change
|
||||||
|
- Third concrete change
|
||||||
|
```
|
||||||
|
Allowed types:
|
||||||
|
`ADDED, IMPROVED, FIXED, CHANGED, REFACTORED, OPTIMIZED, REMOVED, UPDATED,
|
||||||
|
SECURED, TESTED, DOCUMENTED, STYLED, CONFIGURED, RENAMED, MOVED, CLEANED,
|
||||||
|
REVERTED`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Write commit messages in English.
|
||||||
|
- Use uppercase type names.
|
||||||
|
- Keep the title concise.
|
||||||
|
- Add a blank line before the bullet list.
|
||||||
|
- Describe only the actual changes in the commit.
|
||||||
|
- Inspect `git diff --cached` before writing the message.
|
||||||
|
- Do not add AI attribution or Co-Authored-By.
|
||||||
|
- Do not create commits unless explicitly requested (see Core Rules above).
|
||||||
|
|
||||||
|
## graphify (when present)
|
||||||
|
|
||||||
|
- For codebase questions, if `graphify-out/graph.json` exists, run
|
||||||
|
`graphify query "<question>"` first. Use `graphify path "<A>" "<B>"` for
|
||||||
|
relationships and `graphify explain "<concept>"` for focused concepts —
|
||||||
|
these return a scoped subgraph, usually much smaller than
|
||||||
|
`GRAPH_REPORT.md` or raw grep output.
|
||||||
|
- If `graphify-out/wiki/index.md` exists, use it for broad navigation
|
||||||
|
instead of raw source browsing.
|
||||||
|
- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review or
|
||||||
|
when query/path/explain don't surface enough context.
|
||||||
|
- After modifying code, run `graphify update .` to keep the graph current
|
||||||
|
(AST-only, no API cost).
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
|
import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop";
|
||||||
import { SiteFooter } from "@/components/layout/site-footer";
|
import { SiteFooter } from "@/components/layout/site-footer";
|
||||||
import { SiteHeader } from "@/components/layout/site-header";
|
import { SiteHeader } from "@/components/layout/site-header";
|
||||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
import { ScrollSmootherProvider } from "@/components/scroll-smoother-provider";
|
||||||
|
import { isSuperAdmin } from "@/lib/admin-auth";
|
||||||
import { getMaintenanceMode, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
import { getMaintenanceMode, getSiteSettings, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
|
|
||||||
@@ -29,22 +30,34 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps)
|
|||||||
getSiteSettings(),
|
getSiteSettings(),
|
||||||
]);
|
]);
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const authenticated = await isAdminAuthenticated();
|
// Server-side decision only. The header receives just this boolean and uses
|
||||||
|
// it purely to show/hide the Admin shortcut link — it grants no access by
|
||||||
|
// itself. Every admin page/action re-checks the session independently, so
|
||||||
|
// this value is not a security boundary, only a UI convenience.
|
||||||
|
const authenticated = await isSuperAdmin();
|
||||||
|
|
||||||
if (maintenanceEnabled && !authenticated) {
|
if (maintenanceEnabled && !authenticated) {
|
||||||
redirect(getLocalizedPath(localeKey, "/coming-soon", siteSettings.defaultLocale));
|
redirect(getLocalizedPath(localeKey, "/coming-soon", siteSettings.defaultLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col overflow-hidden">
|
<>
|
||||||
|
<ScrollSmootherProvider />
|
||||||
<SiteAmbientBackdrop />
|
<SiteAmbientBackdrop />
|
||||||
<SiteHeader
|
<SiteHeader
|
||||||
lightLogoUrl={mediaBindings.siteLogoLight?.url}
|
lightLogoUrl={mediaBindings.siteLogoLight?.url}
|
||||||
darkLogoUrl={mediaBindings.siteLogoDark?.url}
|
darkLogoUrl={mediaBindings.siteLogoDark?.url}
|
||||||
defaultLocale={siteSettings.defaultLocale}
|
defaultLocale={siteSettings.defaultLocale}
|
||||||
|
isSuperAdmin={authenticated}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1">{children}</main>
|
<div id="smooth-wrapper">
|
||||||
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
|
<div id="smooth-content">
|
||||||
</div>
|
<div className="site-content-frame flex min-h-screen flex-col">
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<SiteFooter defaultLocale={siteSettings.defaultLocale} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import { getLocale, getTranslations } from "next-intl/server";
|
|||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { HomeHero } from "@/components/layout/home-hero";
|
import { HomeHero } from "@/components/layout/home-hero";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { BentoGridSection } from "@/components/home/bento-grid-section";
|
import { MagicBentoSection } from "@/components/home/magic-bento-section";
|
||||||
import { CapabilitiesSection } from "@/components/home/capabilities-section";
|
import { CapabilitiesSection } from "@/components/home/capabilities-section";
|
||||||
import { ContactCtaSection } from "@/components/home/contact-cta-section";
|
import { ContactCtaSection } from "@/components/home/contact-cta-section";
|
||||||
import { MarqueeSection } from "@/components/home/marquee-section";
|
import { MarqueeSection } from "@/components/home/marquee-section";
|
||||||
import { ProcessSection } from "@/components/home/process-section";
|
import { ProcessSection } from "@/components/home/process-section";
|
||||||
import { ProjectsSection } from "@/components/home/projects-section";
|
import { ProjectsSection } from "@/components/home/projects-section";
|
||||||
import type {
|
import type {
|
||||||
BentoSectionCopy,
|
|
||||||
CapabilitiesSectionCopy,
|
CapabilitiesSectionCopy,
|
||||||
ContactCtaSectionCopy,
|
ContactCtaSectionCopy,
|
||||||
MarqueeSectionCopy,
|
MarqueeSectionCopy,
|
||||||
@@ -72,45 +71,40 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
getPublishedPortfolioProjects(),
|
getPublishedPortfolioProjects(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const bentoCopy: BentoSectionCopy = {
|
const bentoCards = [
|
||||||
eyebrow: t("bento.eyebrow"),
|
{
|
||||||
title: t("bento.title"),
|
label: t("bento.about.eyebrow"),
|
||||||
description: t("bento.description"),
|
|
||||||
about: {
|
|
||||||
eyebrow: t("bento.about.eyebrow"),
|
|
||||||
title: t("bento.about.title"),
|
title: t("bento.about.title"),
|
||||||
description: t("bento.about.description"),
|
description: t("bento.about.description"),
|
||||||
points: t.raw("bento.about.points") as string[],
|
|
||||||
},
|
},
|
||||||
coreStack: {
|
{
|
||||||
eyebrow: t("bento.coreStack.eyebrow"),
|
label: t("bento.coreStack.eyebrow"),
|
||||||
title: t("bento.coreStack.title"),
|
title: t("bento.coreStack.title"),
|
||||||
items: t.raw("bento.coreStack.items") as string[],
|
description: (t.raw("bento.coreStack.items") as string[]).join(" · "),
|
||||||
},
|
},
|
||||||
availability: {
|
{
|
||||||
eyebrow: t("bento.availability.eyebrow"),
|
label: t("bento.availability.eyebrow"),
|
||||||
title: t("bento.availability.title"),
|
title: t("bento.availability.title"),
|
||||||
description: t("bento.availability.description"),
|
description: t("bento.availability.description"),
|
||||||
status: t("bento.availability.status"),
|
|
||||||
},
|
},
|
||||||
currentFocus: {
|
{
|
||||||
eyebrow: t("bento.currentFocus.eyebrow"),
|
label: t("bento.currentFocus.eyebrow"),
|
||||||
title: t("bento.currentFocus.title"),
|
title: t("bento.currentFocus.title"),
|
||||||
items: t.raw("bento.currentFocus.items") as string[],
|
description: (t.raw("bento.currentFocus.items") as string[]).join(" · "),
|
||||||
},
|
},
|
||||||
contact: {
|
{
|
||||||
eyebrow: t("bento.contact.eyebrow"),
|
label: t("bento.contact.eyebrow"),
|
||||||
title: t("bento.contact.title"),
|
title: t("bento.contact.title"),
|
||||||
description: t("bento.contact.description"),
|
description: t("bento.contact.description"),
|
||||||
emailLabel: t("bento.contact.emailLabel"),
|
|
||||||
emailValue: t("bento.contact.emailValue"),
|
|
||||||
},
|
},
|
||||||
highlights: {
|
{
|
||||||
eyebrow: t("bento.highlights.eyebrow"),
|
label: t("bento.highlights.eyebrow"),
|
||||||
title: t("bento.highlights.title"),
|
title: t("bento.highlights.title"),
|
||||||
items: t.raw("bento.highlights.items") as Array<{ value: string; label: string }>,
|
description: (t.raw("bento.highlights.items") as Array<{ value: string; label: string }>)
|
||||||
|
.map((i) => `${i.value} ${i.label}`)
|
||||||
|
.join(" · "),
|
||||||
},
|
},
|
||||||
};
|
];
|
||||||
|
|
||||||
const marqueeCopy: MarqueeSectionCopy = {
|
const marqueeCopy: MarqueeSectionCopy = {
|
||||||
eyebrow: t("marquee.eyebrow"),
|
eyebrow: t("marquee.eyebrow"),
|
||||||
@@ -216,7 +210,12 @@ export default async function HomePage({ params }: HomePageProps) {
|
|||||||
className="relative z-10 flex max-w-7xl flex-col gap-16 pb-14 pt-6 sm:gap-20 sm:pb-16 sm:pt-8 lg:gap-24 lg:pb-20 lg:pt-10"
|
className="relative z-10 flex max-w-7xl flex-col gap-16 pb-14 pt-6 sm:gap-20 sm:pb-16 sm:pt-8 lg:gap-24 lg:pb-20 lg:pt-10"
|
||||||
>
|
>
|
||||||
<MotionFade>
|
<MotionFade>
|
||||||
<BentoGridSection copy={bentoCopy} />
|
<MagicBentoSection
|
||||||
|
eyebrow={t("bento.eyebrow")}
|
||||||
|
heading={t("bento.title")}
|
||||||
|
description={t("bento.description")}
|
||||||
|
cards={bentoCards}
|
||||||
|
/>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
|
|
||||||
<MotionFade delay={0.06}>
|
<MotionFade delay={0.06}>
|
||||||
|
|||||||
+196
-43
@@ -246,8 +246,15 @@ html[lang="ar"] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-content-frame {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-surface {
|
.hero-surface {
|
||||||
background: linear-gradient(180deg, hsl(var(--hero-base)), hsl(var(--background)));
|
background: hsl(var(--background));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-home {
|
.hero-home {
|
||||||
@@ -255,7 +262,7 @@ html[lang="ar"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-page {
|
.hero-page {
|
||||||
background: linear-gradient(180deg, hsl(var(--hero-base) / 0.96), hsl(var(--background)));
|
background: hsl(var(--background));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sheet {
|
.hero-sheet {
|
||||||
@@ -265,29 +272,15 @@ html[lang="ar"] {
|
|||||||
.hero-sheet-base {
|
.hero-sheet-base {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background: hsl(var(--background));
|
||||||
radial-gradient(ellipse 90% 55% at 50% -5%, hsl(var(--hero-left) / 0.38), transparent),
|
|
||||||
radial-gradient(ellipse 55% 40% at 92% 10%, hsl(var(--hero-right) / 0.22), transparent),
|
|
||||||
radial-gradient(ellipse 60% 35% at 8% 15%, hsl(var(--hero-left) / 0.18), transparent),
|
|
||||||
linear-gradient(180deg, hsl(var(--hero-base)), hsl(var(--background)) 90%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sheet-veil {
|
.hero-sheet-veil {
|
||||||
position: absolute;
|
display: none;
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
160deg,
|
|
||||||
rgba(255, 255, 255, 0.06) 0%,
|
|
||||||
rgba(255, 255, 255, 0.02) 40%,
|
|
||||||
rgba(255, 255, 255, 0) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sheet-fade {
|
.hero-sheet-fade {
|
||||||
position: absolute;
|
display: none;
|
||||||
inset: auto 0 0 0;
|
|
||||||
height: 7rem;
|
|
||||||
background: linear-gradient(180deg, transparent, hsl(var(--background)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title-line {
|
.hero-title-line {
|
||||||
@@ -381,33 +374,11 @@ html[lang="ar"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-header-bridge {
|
.hero-header-bridge {
|
||||||
position: absolute;
|
display: none;
|
||||||
left: 50%;
|
|
||||||
top: 3.5rem;
|
|
||||||
height: 11rem;
|
|
||||||
width: min(34rem, 72vw);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 0%, hsl(var(--hero-left) / 0.18), transparent 56%),
|
|
||||||
linear-gradient(180deg, hsl(var(--foreground) / 0.06), transparent 72%);
|
|
||||||
filter: blur(24px);
|
|
||||||
opacity: 0.9;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content-bridge {
|
.hero-content-bridge {
|
||||||
position: absolute;
|
display: none;
|
||||||
left: 50%;
|
|
||||||
bottom: 2.5rem;
|
|
||||||
height: 9rem;
|
|
||||||
width: min(44rem, 82vw);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 50% 50%, hsl(var(--hero-bottom) / 0.2), transparent 58%),
|
|
||||||
linear-gradient(180deg, transparent, hsl(var(--background) / 0.86));
|
|
||||||
filter: blur(20px);
|
|
||||||
opacity: 0.92;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.retro-led-panel {
|
.retro-led-panel {
|
||||||
@@ -543,3 +514,185 @@ html[lang="ar"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Magic Bento ── */
|
||||||
|
|
||||||
|
.mb-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em;
|
||||||
|
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
min-height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.25em;
|
||||||
|
border-radius: var(--radius-nested, 16px);
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--surface-2));
|
||||||
|
font-weight: 300;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
--glow-x: 50%;
|
||||||
|
--glow-y: 50%;
|
||||||
|
--glow-intensity: 0;
|
||||||
|
--glow-radius: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px hsl(var(--foreground) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__header,
|
||||||
|
.mb-card__body {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__header {
|
||||||
|
gap: 0.75em;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__body {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card__extra {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card--autohide .mb-card__title,
|
||||||
|
.mb-card--autohide .mb-card__desc {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card--autohide .mb-card__title {
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card--autohide .mb-card__desc {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid */
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.mb-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.mb-card {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.mb-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mb-grid {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
.mb-card:nth-child(3) {
|
||||||
|
grid-column: span 2;
|
||||||
|
grid-row: span 2;
|
||||||
|
}
|
||||||
|
.mb-card:nth-child(4) {
|
||||||
|
grid-column: 1 / span 2;
|
||||||
|
grid-row: 2 / span 2;
|
||||||
|
}
|
||||||
|
.mb-card:nth-child(6) {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Border glow */
|
||||||
|
.mb-card--glow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 6px;
|
||||||
|
background: radial-gradient(
|
||||||
|
var(--glow-radius) circle at var(--glow-x) var(--glow-y),
|
||||||
|
rgba(var(--glow-color, 220, 68, 22), calc(var(--glow-intensity) * 0.8)) 0%,
|
||||||
|
rgba(var(--glow-color, 220, 68, 22), calc(var(--glow-intensity) * 0.4)) 30%,
|
||||||
|
transparent 60%
|
||||||
|
);
|
||||||
|
border-radius: inherit;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-card--glow:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 20px rgba(220, 68, 22, 0.15),
|
||||||
|
0 0 30px rgba(220, 68, 22, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ScrollSmoother wrapper ── */
|
||||||
|
|
||||||
|
#smooth-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#smooth-content {
|
||||||
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#smooth-wrapper {
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react";
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
|
||||||
|
const DEFAULT_PARTICLE_COUNT = 12;
|
||||||
|
const DEFAULT_SPOTLIGHT_RADIUS = 400;
|
||||||
|
const DEFAULT_GLOW_COLOR = "220, 68, 22";
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
/* ---------- types ---------- */
|
||||||
|
|
||||||
|
type CardData = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
label: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MagicBentoSectionProps = {
|
||||||
|
eyebrow: string;
|
||||||
|
heading: string;
|
||||||
|
description: string;
|
||||||
|
cards: CardData[];
|
||||||
|
glowColor?: string;
|
||||||
|
spotlightRadius?: number;
|
||||||
|
particleCount?: number;
|
||||||
|
enableStars?: boolean;
|
||||||
|
enableSpotlight?: boolean;
|
||||||
|
enableBorderGlow?: boolean;
|
||||||
|
enableTilt?: boolean;
|
||||||
|
enableMagnetism?: boolean;
|
||||||
|
clickEffect?: boolean;
|
||||||
|
textAutoHide?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------- helpers ---------- */
|
||||||
|
|
||||||
|
function useMobileDetection() {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const check = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||||
|
check();
|
||||||
|
window.addEventListener("resize", check);
|
||||||
|
return () => window.removeEventListener("resize", check);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSpotlightValues(radius: number) {
|
||||||
|
return { proximity: radius * 0.5, fadeDistance: radius * 0.75 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardGlow(
|
||||||
|
card: HTMLElement,
|
||||||
|
mx: number,
|
||||||
|
my: number,
|
||||||
|
glow: number,
|
||||||
|
radius: number,
|
||||||
|
) {
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
card.style.setProperty("--glow-x", `${((mx - r.left) / r.width) * 100}%`);
|
||||||
|
card.style.setProperty("--glow-y", `${((my - r.top) / r.height) * 100}%`);
|
||||||
|
card.style.setProperty("--glow-intensity", glow.toString());
|
||||||
|
card.style.setProperty("--glow-radius", `${radius}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- ParticleCard ---------- */
|
||||||
|
|
||||||
|
function ParticleCard({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
disabled,
|
||||||
|
particleCount,
|
||||||
|
glowColor,
|
||||||
|
enableTilt,
|
||||||
|
enableMagnetism,
|
||||||
|
clickEffect,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
disabled: boolean;
|
||||||
|
particleCount: number;
|
||||||
|
glowColor: string;
|
||||||
|
enableTilt: boolean;
|
||||||
|
enableMagnetism: boolean;
|
||||||
|
clickEffect: boolean;
|
||||||
|
}) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled || !cardRef.current) return;
|
||||||
|
|
||||||
|
const el = cardRef.current;
|
||||||
|
const particles: HTMLDivElement[] = [];
|
||||||
|
const timeouts: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let magnetAnim: any = null;
|
||||||
|
let hovered = false;
|
||||||
|
|
||||||
|
const spawnParticles = () => {
|
||||||
|
const { width, height } = el.getBoundingClientRect();
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const tid = setTimeout(() => {
|
||||||
|
if (!hovered) return;
|
||||||
|
const p = document.createElement("div");
|
||||||
|
p.style.cssText = `
|
||||||
|
position:absolute;width:4px;height:4px;border-radius:50%;
|
||||||
|
background:rgba(${glowColor},0.9);
|
||||||
|
box-shadow:0 0 6px rgba(${glowColor},0.5);
|
||||||
|
pointer-events:none;z-index:100;
|
||||||
|
left:${Math.random() * width}px;top:${Math.random() * height}px;
|
||||||
|
`;
|
||||||
|
el.appendChild(p);
|
||||||
|
particles.push(p);
|
||||||
|
gsap.fromTo(p, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: "back.out(1.7)" });
|
||||||
|
gsap.to(p, { x: (Math.random() - 0.5) * 100, y: (Math.random() - 0.5) * 100, rotation: Math.random() * 360, duration: 2 + Math.random() * 2, ease: "none", repeat: -1, yoyo: true });
|
||||||
|
gsap.to(p, { opacity: 0.3, duration: 1.5, ease: "power2.inOut", repeat: -1, yoyo: true });
|
||||||
|
}, i * 100);
|
||||||
|
timeouts.push(tid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearParticles = () => {
|
||||||
|
timeouts.forEach(clearTimeout);
|
||||||
|
timeouts.length = 0;
|
||||||
|
magnetAnim?.kill();
|
||||||
|
particles.forEach((p) =>
|
||||||
|
gsap.to(p, { scale: 0, opacity: 0, duration: 0.3, ease: "back.in(1.7)", onComplete: () => p.remove() }),
|
||||||
|
);
|
||||||
|
particles.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
hovered = true;
|
||||||
|
spawnParticles();
|
||||||
|
if (enableTilt) {
|
||||||
|
gsap.to(el, { rotateX: 5, rotateY: 5, duration: 0.3, ease: "power2.out", transformPerspective: 1000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLeave = () => {
|
||||||
|
hovered = false;
|
||||||
|
clearParticles();
|
||||||
|
if (enableTilt) gsap.to(el, { rotateX: 0, rotateY: 0, duration: 0.3, ease: "power2.out" });
|
||||||
|
if (enableMagnetism) gsap.to(el, { x: 0, y: 0, duration: 0.3, ease: "power2.out" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
if (!enableTilt && !enableMagnetism) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const cx = rect.width / 2;
|
||||||
|
const cy = rect.height / 2;
|
||||||
|
if (enableTilt) {
|
||||||
|
gsap.to(el, { rotateX: ((y - cy) / cy) * -10, rotateY: ((x - cx) / cx) * 10, duration: 0.1, ease: "power2.out", transformPerspective: 1000 });
|
||||||
|
}
|
||||||
|
if (enableMagnetism) {
|
||||||
|
magnetAnim = gsap.to(el, { x: (x - cx) * 0.05, y: (y - cy) * 0.05, duration: 0.3, ease: "power2.out" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (!clickEffect) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const maxD = Math.max(
|
||||||
|
Math.hypot(x, y),
|
||||||
|
Math.hypot(x - rect.width, y),
|
||||||
|
Math.hypot(x, y - rect.height),
|
||||||
|
Math.hypot(x - rect.width, y - rect.height),
|
||||||
|
);
|
||||||
|
const ripple = document.createElement("div");
|
||||||
|
ripple.style.cssText = `
|
||||||
|
position:absolute;width:${maxD * 2}px;height:${maxD * 2}px;border-radius:50%;
|
||||||
|
background:radial-gradient(circle,rgba(${glowColor},0.4) 0%,rgba(${glowColor},0.2) 30%,transparent 70%);
|
||||||
|
left:${x - maxD}px;top:${y - maxD}px;pointer-events:none;z-index:1000;
|
||||||
|
`;
|
||||||
|
el.appendChild(ripple);
|
||||||
|
gsap.fromTo(ripple, { scale: 0, opacity: 1 }, { scale: 1, opacity: 0, duration: 0.8, ease: "power2.out", onComplete: () => ripple.remove() });
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener("mouseenter", onEnter);
|
||||||
|
el.addEventListener("mouseleave", onLeave);
|
||||||
|
el.addEventListener("mousemove", onMove);
|
||||||
|
el.addEventListener("click", onClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
hovered = false;
|
||||||
|
el.removeEventListener("mouseenter", onEnter);
|
||||||
|
el.removeEventListener("mouseleave", onLeave);
|
||||||
|
el.removeEventListener("mousemove", onMove);
|
||||||
|
el.removeEventListener("click", onClick);
|
||||||
|
clearParticles();
|
||||||
|
};
|
||||||
|
}, [disabled, particleCount, glowColor, enableTilt, enableMagnetism, clickEffect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={cardRef} className={className} style={{ ...style, position: "relative", overflow: "hidden" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- GlobalSpotlight ---------- */
|
||||||
|
|
||||||
|
function GlobalSpotlight({
|
||||||
|
gridRef,
|
||||||
|
disabled,
|
||||||
|
spotlightRadius,
|
||||||
|
glowColor,
|
||||||
|
}: {
|
||||||
|
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
disabled: boolean;
|
||||||
|
spotlightRadius: number;
|
||||||
|
glowColor: string;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled || !gridRef.current) return;
|
||||||
|
|
||||||
|
const spotlight = document.createElement("div");
|
||||||
|
spotlight.style.cssText = `
|
||||||
|
position:fixed;width:800px;height:800px;border-radius:50%;pointer-events:none;
|
||||||
|
background:radial-gradient(circle,
|
||||||
|
rgba(${glowColor},0.15) 0%,rgba(${glowColor},0.08) 15%,
|
||||||
|
rgba(${glowColor},0.04) 25%,rgba(${glowColor},0.02) 40%,
|
||||||
|
rgba(${glowColor},0.01) 65%,transparent 70%);
|
||||||
|
z-index:200;opacity:0;transform:translate(-50%,-50%);
|
||||||
|
mix-blend-mode:screen;will-change:transform,opacity;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(spotlight);
|
||||||
|
|
||||||
|
const grid = gridRef.current;
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const rect = grid.getBoundingClientRect();
|
||||||
|
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||||
|
const cards = grid.querySelectorAll<HTMLElement>(".mb-card");
|
||||||
|
|
||||||
|
if (!inside) {
|
||||||
|
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
|
||||||
|
cards.forEach((c) => c.style.setProperty("--glow-intensity", "0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { proximity, fadeDistance } = calculateSpotlightValues(spotlightRadius);
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const cr = card.getBoundingClientRect();
|
||||||
|
const cx = cr.left + cr.width / 2;
|
||||||
|
const cy = cr.top + cr.height / 2;
|
||||||
|
const dist = Math.max(0, Math.hypot(e.clientX - cx, e.clientY - cy) - Math.max(cr.width, cr.height) / 2);
|
||||||
|
minDist = Math.min(minDist, dist);
|
||||||
|
const intensity = dist <= proximity ? 1 : dist <= fadeDistance ? (fadeDistance - dist) / (fadeDistance - proximity) : 0;
|
||||||
|
updateCardGlow(card, e.clientX, e.clientY, intensity, spotlightRadius);
|
||||||
|
});
|
||||||
|
|
||||||
|
gsap.to(spotlight, { left: e.clientX, top: e.clientY, duration: 0.1, ease: "power2.out" });
|
||||||
|
const opacity = minDist <= proximity ? 0.8 : minDist <= fadeDistance ? ((fadeDistance - minDist) / (fadeDistance - proximity)) * 0.8 : 0;
|
||||||
|
gsap.to(spotlight, { opacity, duration: opacity > 0 ? 0.2 : 0.5, ease: "power2.out" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLeave = () => {
|
||||||
|
grid.querySelectorAll<HTMLElement>(".mb-card").forEach((c) => c.style.setProperty("--glow-intensity", "0"));
|
||||||
|
gsap.to(spotlight, { opacity: 0, duration: 0.3, ease: "power2.out" });
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseleave", onLeave);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseleave", onLeave);
|
||||||
|
spotlight.remove();
|
||||||
|
};
|
||||||
|
}, [disabled, gridRef, spotlightRadius, glowColor]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- MagicBentoSection ---------- */
|
||||||
|
|
||||||
|
export function MagicBentoSection({
|
||||||
|
eyebrow,
|
||||||
|
heading,
|
||||||
|
description,
|
||||||
|
cards,
|
||||||
|
glowColor = DEFAULT_GLOW_COLOR,
|
||||||
|
spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS,
|
||||||
|
particleCount = DEFAULT_PARTICLE_COUNT,
|
||||||
|
enableStars = true,
|
||||||
|
enableSpotlight = true,
|
||||||
|
enableBorderGlow = true,
|
||||||
|
enableTilt = true,
|
||||||
|
enableMagnetism = true,
|
||||||
|
clickEffect = true,
|
||||||
|
textAutoHide = true,
|
||||||
|
}: MagicBentoSectionProps) {
|
||||||
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isMobile = useMobileDetection();
|
||||||
|
const disabled = isMobile;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="magic-bento-wrapper space-y-8">
|
||||||
|
{/* Section heading — matches project's SectionHeading pattern */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-[-0.05em] text-foreground sm:text-3xl">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-2xl text-sm leading-7 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enableSpotlight && (
|
||||||
|
<GlobalSpotlight
|
||||||
|
gridRef={gridRef}
|
||||||
|
disabled={disabled}
|
||||||
|
spotlightRadius={spotlightRadius}
|
||||||
|
glowColor={glowColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={gridRef} className="mb-grid" style={{ userSelect: "none" }}>
|
||||||
|
{cards.map((card, index) => {
|
||||||
|
const cls = [
|
||||||
|
"mb-card",
|
||||||
|
textAutoHide ? "mb-card--autohide" : "",
|
||||||
|
enableBorderGlow ? "mb-card--glow" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const cardStyle: CSSProperties = {
|
||||||
|
"--glow-color": glowColor,
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="mb-card__header">
|
||||||
|
<span className="mb-card__label">{card.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-card__body">
|
||||||
|
<h3 className="mb-card__title">{card.title}</h3>
|
||||||
|
{card.description && (
|
||||||
|
<p className="mb-card__desc">{card.description}</p>
|
||||||
|
)}
|
||||||
|
{card.children && (
|
||||||
|
<div className="mb-card__extra">{card.children}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enableStars) {
|
||||||
|
return (
|
||||||
|
<ParticleCard
|
||||||
|
key={index}
|
||||||
|
className={cls}
|
||||||
|
style={cardStyle}
|
||||||
|
disabled={disabled}
|
||||||
|
particleCount={particleCount}
|
||||||
|
glowColor={glowColor}
|
||||||
|
enableTilt={enableTilt}
|
||||||
|
enableMagnetism={enableMagnetism}
|
||||||
|
clickEffect={clickEffect}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ParticleCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className={cls} style={cardStyle}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,3 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
export function SiteAmbientBackdrop() {
|
export function SiteAmbientBackdrop() {
|
||||||
return (
|
return null;
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
x: [0, 72, -36, 0],
|
|
||||||
y: [0, -54, 28, 0],
|
|
||||||
scale: [1, 1.1, 0.95, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 18,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="absolute left-[-10%] top-[4%] h-[30rem] w-[30rem] rounded-full bg-brand-primary/12 blur-3xl sm:h-[38rem] sm:w-[38rem] lg:h-[46rem] lg:w-[46rem]"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
x: [0, -84, 26, 0],
|
|
||||||
y: [0, 44, -22, 0],
|
|
||||||
scale: [1, 0.9, 1.08, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 22,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="absolute right-[-12%] top-[8%] h-[26rem] w-[26rem] rounded-full bg-brand-secondary/12 blur-3xl sm:h-[34rem] sm:w-[34rem] lg:h-[42rem] lg:w-[42rem]"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
x: [0, 34, -20, 0],
|
|
||||||
y: [0, -24, 34, 0],
|
|
||||||
opacity: [0.22, 0.38, 0.16, 0.22],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 16,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "easeInOut",
|
|
||||||
}}
|
|
||||||
className="absolute bottom-[-12%] left-[22%] h-[20rem] w-[20rem] rounded-full bg-brand-primary/[0.08] blur-3xl sm:h-[28rem] sm:w-[28rem] lg:h-[34rem] lg:w-[34rem]"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08)_24%,rgba(255,255,255,0.02)_52%,rgba(255,255,255,0)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.08),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.08),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(160,182,255,0.08),transparent_40%)] dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.01),rgba(255,255,255,0.005)_26%,rgba(255,255,255,0)_100%),radial-gradient(circle_at_16%_18%,rgba(221,65,36,0.08),transparent_30%),radial-gradient(circle_at_78%_14%,rgba(235,143,117,0.06),transparent_34%),radial-gradient(circle_at_50%_100%,rgba(109,130,214,0.05),transparent_40%)]" />
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
x: [0, 20, -14, 0],
|
|
||||||
y: [0, -16, 12, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 9,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
className="hero-noise absolute inset-[-6%] opacity-[0.22] mix-blend-soft-light dark:opacity-[0.08]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, ShieldCheck, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
@@ -13,6 +13,7 @@ import { SiteLogo } from "@/components/layout/site-logo";
|
|||||||
import { SoundToggle } from "@/components/sound-toggle";
|
import { SoundToggle } from "@/components/sound-toggle";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { buildAdminUrl } from "@/lib/admin-routing";
|
||||||
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -35,6 +36,13 @@ type SiteHeaderProps = {
|
|||||||
lightLogoUrl?: string | null;
|
lightLogoUrl?: string | null;
|
||||||
darkLogoUrl?: string | null;
|
darkLogoUrl?: string | null;
|
||||||
defaultLocale: "de" | "en" | "ar";
|
defaultLocale: "de" | "en" | "ar";
|
||||||
|
/**
|
||||||
|
* Server-computed via `isSuperAdmin()` in the parent layout — this client
|
||||||
|
* component only decides whether to *render* the Admin shortcut link from
|
||||||
|
* this flag. It performs no auth check itself and the flag grants no
|
||||||
|
* access; the real guard lives on each admin page/action server-side.
|
||||||
|
*/
|
||||||
|
isSuperAdmin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDirectionalReveal(direction: "rtl" | "ltr") {
|
function getDirectionalReveal(direction: "rtl" | "ltr") {
|
||||||
@@ -146,6 +154,7 @@ export function SiteHeader({
|
|||||||
lightLogoUrl,
|
lightLogoUrl,
|
||||||
darkLogoUrl,
|
darkLogoUrl,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
|
isSuperAdmin = false,
|
||||||
}: SiteHeaderProps) {
|
}: SiteHeaderProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
@@ -168,6 +177,11 @@ export function SiteHeader({
|
|||||||
"h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground";
|
"h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground";
|
||||||
const mobileControlButtonClassName =
|
const mobileControlButtonClassName =
|
||||||
"h-10 w-10 rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs hover:bg-accent hover:text-foreground";
|
"h-10 w-10 rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs hover:bg-accent hover:text-foreground";
|
||||||
|
const adminButtonClassName =
|
||||||
|
"inline-flex h-9 items-center gap-1.5 rounded-pill border border-border/70 bg-background/80 px-3 text-sm font-medium text-foreground/80 backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
|
||||||
|
const mobileAdminButtonClassName =
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs transition-colors hover:bg-accent hover:text-foreground";
|
||||||
|
const adminHref = buildAdminUrl("/");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -201,7 +215,7 @@ export function SiteHeader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed inset-x-0 top-3 z-40 bg-transparent">
|
<motion.header layoutRoot className="fixed inset-x-0 top-3 z-40 bg-transparent">
|
||||||
<Container className="max-w-[88rem] px-5 sm:px-6 lg:px-8">
|
<Container className="max-w-[88rem] px-5 sm:px-6 lg:px-8">
|
||||||
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
|
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -290,7 +304,7 @@ export function SiteHeader({
|
|||||||
animate={isScrolled ? { opacity: 0, x: 28 } : { opacity: 1, x: 0 }}
|
animate={isScrolled ? { opacity: 0, x: 28 } : { opacity: 1, x: 0 }}
|
||||||
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-3 hidden items-center justify-end lg:flex",
|
"col-start-3 hidden items-center justify-end gap-2 lg:flex",
|
||||||
isScrolled ? "pointer-events-none invisible" : "",
|
isScrolled ? "pointer-events-none invisible" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -316,6 +330,12 @@ export function SiteHeader({
|
|||||||
className={desktopControlButtonClassName}
|
className={desktopControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<a href={adminHref} aria-label="Open admin dashboard" className={adminButtonClassName}>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
<span className="hidden xl:inline">Admin</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -459,6 +479,15 @@ export function SiteHeader({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={mobileControlButtonClassName}
|
className={mobileControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<a
|
||||||
|
href={adminHref}
|
||||||
|
aria-label="Open admin dashboard"
|
||||||
|
className={mobileAdminButtonClassName}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -467,6 +496,6 @@ export function SiteHeader({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</header>
|
</motion.header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,8 @@ export function HeroShell({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"hero-surface relative isolate overflow-hidden",
|
"hero-surface relative isolate overflow-hidden",
|
||||||
variant === "home"
|
variant === "home"
|
||||||
? "hero-home flex min-h-[92svh] items-center justify-center"
|
? "hero-home flex min-h-[76svh] items-center justify-center"
|
||||||
: "hero-page flex min-h-[44svh] items-center sm:min-h-[48svh]",
|
: "hero-page flex min-h-[34svh] items-center sm:min-h-[38svh]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -113,8 +113,8 @@ export function HeroShell({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 w-full",
|
"relative z-10 w-full",
|
||||||
variant === "home"
|
variant === "home"
|
||||||
? "pb-12 pt-24 sm:pb-14 sm:pt-28 lg:pb-16 lg:pt-32"
|
? "pb-10 pt-20 sm:pb-12 sm:pt-24 lg:pb-14 lg:pt-28"
|
||||||
: "py-20 sm:py-24 lg:py-28",
|
: "py-16 sm:py-20 lg:py-24",
|
||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { gsap } from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
import { ScrollSmoother } from "gsap/ScrollSmoother";
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function ScrollSmootherProvider() {
|
||||||
|
const smootherRef = useRef<ScrollSmoother | null>(null);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isFirstRenderRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefersReduced = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)",
|
||||||
|
).matches;
|
||||||
|
const isMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||||
|
|
||||||
|
if (prefersReduced) return;
|
||||||
|
|
||||||
|
const smoother = ScrollSmoother.create({
|
||||||
|
wrapper: "#smooth-wrapper",
|
||||||
|
content: "#smooth-content",
|
||||||
|
smooth: isMobile ? 0.6 : 1.2,
|
||||||
|
effects: !isMobile,
|
||||||
|
normalizeScroll: !isMobile,
|
||||||
|
ignoreMobileResize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
smootherRef.current = smoother;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
smoother.kill();
|
||||||
|
smootherRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The layout that mounts this provider persists across client-side route
|
||||||
|
// changes, so the ScrollSmoother instance above is created once and never
|
||||||
|
// recreated when navigating between pages. Its internal scroll position
|
||||||
|
// and measured content height stay tied to whatever page was on screen
|
||||||
|
// before the navigation. If the next page is a different height, the
|
||||||
|
// stale height/position pairing snaps into place the instant the new page
|
||||||
|
// paints — most visible as the fixed header's active-nav-item background
|
||||||
|
// jumping instead of easing, since it briefly renders against the old,
|
||||||
|
// now-incorrect scroll transform. Resetting scroll on every pathname
|
||||||
|
// change and refreshing ScrollTrigger's measurements once the new page has
|
||||||
|
// painted keeps the smoother in sync with what's actually on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRenderRef.current) {
|
||||||
|
isFirstRenderRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoother = smootherRef.current;
|
||||||
|
if (!smoother) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
smoother.scrollTo(0, false);
|
||||||
|
|
||||||
|
const refreshFrame = requestAnimationFrame(() => {
|
||||||
|
ScrollTrigger.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => cancelAnimationFrame(refreshFrame);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -297,6 +297,23 @@ export async function isAdminAuthenticated(): Promise<boolean> {
|
|||||||
return verifyToken(token);
|
return verifyToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This project has no user accounts or roles table — there is a single
|
||||||
|
* privileged session: the authenticated admin cookie checked above. There is
|
||||||
|
* no separate "regular user" tier, so an authenticated admin session is by
|
||||||
|
* definition the only "Super Admin". This is a readable alias only, kept as
|
||||||
|
* a thin wrapper around `isAdminAuthenticated()` (not a new auth mechanism).
|
||||||
|
*
|
||||||
|
* Security note: this function (and any UI it gates, like the header's admin
|
||||||
|
* shortcut button) is NOT the access-control boundary. Every admin page and
|
||||||
|
* server action must independently guard itself with `requireAdminAuth()` or
|
||||||
|
* an equivalent inline `isAdminAuthenticated()` check — never rely on a link
|
||||||
|
* being hidden as the thing that keeps the admin area protected.
|
||||||
|
*/
|
||||||
|
export async function isSuperAdmin(): Promise<boolean> {
|
||||||
|
return isAdminAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call at the top of every authenticated admin page or server action.
|
* Call at the top of every authenticated admin page or server action.
|
||||||
* Clears the session cookie and redirects to the login page if not authenticated.
|
* Clears the session cookie and redirects to the login page if not authenticated.
|
||||||
|
|||||||
Generated
+7
@@ -20,6 +20,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
@@ -7013,6 +7014,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/gsap": {
|
||||||
|
"version": "3.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||||
|
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||||
|
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
@@ -111,6 +113,20 @@ function isAdminBasicAuthConfigured(): boolean {
|
|||||||
return Boolean(getAdminBasicAuthUser() && getAdminBasicAuthPass());
|
return Boolean(getAdminBasicAuthUser() && getAdminBasicAuthPass());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timingSafeStringEqual(a: string, b: string): boolean {
|
||||||
|
const left = Buffer.from(a);
|
||||||
|
const right = Buffer.from(b);
|
||||||
|
|
||||||
|
// Buffers of different length must still be compared against something of
|
||||||
|
// equal length so the comparison time doesn't leak the expected length.
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
timingSafeEqual(left, left);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(left, right);
|
||||||
|
}
|
||||||
|
|
||||||
function isAdminBasicAuthValid(request: NextRequest): boolean {
|
function isAdminBasicAuthValid(request: NextRequest): boolean {
|
||||||
if (!isAdminBasicAuthConfigured()) {
|
if (!isAdminBasicAuthConfigured()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -131,7 +147,10 @@ function isAdminBasicAuthValid(request: NextRequest): boolean {
|
|||||||
const user = decoded.slice(0, index);
|
const user = decoded.slice(0, index);
|
||||||
const pass = decoded.slice(index + 1);
|
const pass = decoded.slice(index + 1);
|
||||||
|
|
||||||
return user === getAdminBasicAuthUser() && pass === getAdminBasicAuthPass();
|
const userValid = timingSafeStringEqual(user, getAdminBasicAuthUser());
|
||||||
|
const passValid = timingSafeStringEqual(pass, getAdminBasicAuthPass());
|
||||||
|
|
||||||
|
return userValid && passValid;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user