From 1a7b3397f6127d3afca80273b4b25064315b32b3 Mon Sep 17 00:00:00 2001 From: MOH Date: Tue, 14 Jul 2026 20:10:38 +0200 Subject: [PATCH] last update --- AGENTS.md | 41 +- .claude/launch.json | 11 + app/[locale]/(site)/layout.tsx | 25 +- app/[locale]/(site)/page.tsx | 53 ++- app/globals.css | 239 +++++++++--- components/home/magic-bento-section.tsx | 397 ++++++++++++++++++++ components/layout/site-ambient-backdrop.tsx | 61 +-- components/layout/site-header.tsx | 37 +- components/layout/site-hero.tsx | 8 +- components/scroll-smoother-provider.tsx | 75 ++++ lib/admin-auth.ts | 17 + package-lock.json | 7 + package.json | 1 + proxy.ts | 21 +- 14 files changed, 847 insertions(+), 146 deletions(-) create mode 100644 .claude/launch.json create mode 100644 components/home/magic-bento-section.tsx create mode 100644 components/scroll-smoother-provider.tsx diff --git a/ AGENTS.md b/ AGENTS.md index 222e01f..9cb11b5 100644 --- a/ AGENTS.md +++ b/ AGENTS.md @@ -48,4 +48,43 @@ Update only the relevant files in: - docs/ - specs/ -Keep documentation aligned with the real implementation, not assumptions. \ No newline at end of file +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 ""` first. Use `graphify path "" ""` for + relationships and `graphify explain ""` 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). diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..04ffa28 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/app/[locale]/(site)/layout.tsx b/app/[locale]/(site)/layout.tsx index 0ac196b..bed2e1d 100644 --- a/app/[locale]/(site)/layout.tsx +++ b/app/[locale]/(site)/layout.tsx @@ -6,7 +6,8 @@ import { redirect } from "next/navigation"; import { SiteAmbientBackdrop } from "@/components/layout/site-ambient-backdrop"; import { SiteFooter } from "@/components/layout/site-footer"; 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 { getLocalizedPath, resolveLocale } from "@/lib/locale"; @@ -29,22 +30,34 @@ export default async function SiteLayout({ children, params }: SiteLayoutProps) getSiteSettings(), ]); 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) { redirect(getLocalizedPath(localeKey, "/coming-soon", siteSettings.defaultLocale)); } return ( -
+ <> + -
{children}
- -
+
+
+
+
{children}
+ +
+
+
+ ); } diff --git a/app/[locale]/(site)/page.tsx b/app/[locale]/(site)/page.tsx index 00c1739..da8f60e 100644 --- a/app/[locale]/(site)/page.tsx +++ b/app/[locale]/(site)/page.tsx @@ -4,14 +4,13 @@ import { getLocale, getTranslations } from "next-intl/server"; import { Container } from "@/components/layout/container"; import { HomeHero } from "@/components/layout/home-hero"; 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 { ContactCtaSection } from "@/components/home/contact-cta-section"; import { MarqueeSection } from "@/components/home/marquee-section"; import { ProcessSection } from "@/components/home/process-section"; import { ProjectsSection } from "@/components/home/projects-section"; import type { - BentoSectionCopy, CapabilitiesSectionCopy, ContactCtaSectionCopy, MarqueeSectionCopy, @@ -72,45 +71,40 @@ export default async function HomePage({ params }: HomePageProps) { getPublishedPortfolioProjects(), ]); - const bentoCopy: BentoSectionCopy = { - eyebrow: t("bento.eyebrow"), - title: t("bento.title"), - description: t("bento.description"), - about: { - eyebrow: t("bento.about.eyebrow"), + const bentoCards = [ + { + label: t("bento.about.eyebrow"), title: t("bento.about.title"), 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"), - 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"), 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"), - 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"), 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"), - 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 = { 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" > - + diff --git a/app/globals.css b/app/globals.css index 53600e6..fc48b58 100644 --- a/app/globals.css +++ b/app/globals.css @@ -246,8 +246,15 @@ html[lang="ar"] { ); } + .site-content-frame { + position: relative; + isolation: isolate; + min-height: 100%; + overflow-x: clip; + } + .hero-surface { - background: linear-gradient(180deg, hsl(var(--hero-base)), hsl(var(--background))); + background: hsl(var(--background)); } .hero-home { @@ -255,7 +262,7 @@ html[lang="ar"] { } .hero-page { - background: linear-gradient(180deg, hsl(var(--hero-base) / 0.96), hsl(var(--background))); + background: hsl(var(--background)); } .hero-sheet { @@ -265,29 +272,15 @@ html[lang="ar"] { .hero-sheet-base { position: absolute; inset: 0; - 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%); + background: hsl(var(--background)); } .hero-sheet-veil { - position: absolute; - 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% - ); + display: none; } .hero-sheet-fade { - position: absolute; - inset: auto 0 0 0; - height: 7rem; - background: linear-gradient(180deg, transparent, hsl(var(--background))); + display: none; } .hero-title-line { @@ -381,33 +374,11 @@ html[lang="ar"] { } .hero-header-bridge { - position: absolute; - 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; + display: none; } .hero-content-bridge { - position: absolute; - 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; + display: none; } .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; + } +} diff --git a/components/home/magic-bento-section.tsx b/components/home/magic-bento-section.tsx new file mode 100644 index 0000000..0d058cf --- /dev/null +++ b/components/home/magic-bento-section.tsx @@ -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(null); + + useEffect(() => { + if (disabled || !cardRef.current) return; + + const el = cardRef.current; + const particles: HTMLDivElement[] = []; + const timeouts: ReturnType[] = []; + // 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 ( +
+ {children} +
+ ); +} + +/* ---------- GlobalSpotlight ---------- */ + +function GlobalSpotlight({ + gridRef, + disabled, + spotlightRadius, + glowColor, +}: { + gridRef: React.RefObject; + 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(".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(".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(null); + const isMobile = useMobileDetection(); + const disabled = isMobile; + + return ( +
+ {/* Section heading — matches project's SectionHeading pattern */} +
+

+ {eyebrow} +

+
+

+ {heading} +

+

+ {description} +

+
+
+ + {enableSpotlight && ( + + )} + +
+ {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 = ( + <> +
+ {card.label} +
+
+

{card.title}

+ {card.description && ( +

{card.description}

+ )} + {card.children && ( +
{card.children}
+ )} +
+ + ); + + if (enableStars) { + return ( + + {content} + + ); + } + + return ( +
+ {content} +
+ ); + })} +
+
+ ); +} diff --git a/components/layout/site-ambient-backdrop.tsx b/components/layout/site-ambient-backdrop.tsx index b8bd858..c4b7274 100644 --- a/components/layout/site-ambient-backdrop.tsx +++ b/components/layout/site-ambient-backdrop.tsx @@ -1,62 +1,3 @@ -"use client"; - -import { motion } from "framer-motion"; - export function SiteAmbientBackdrop() { - return ( -
@@ -467,6 +496,6 @@ export function SiteHeader({ ) : null} - + ); } diff --git a/components/layout/site-hero.tsx b/components/layout/site-hero.tsx index 772039a..29669c7 100644 --- a/components/layout/site-hero.tsx +++ b/components/layout/site-hero.tsx @@ -99,8 +99,8 @@ export function HeroShell({ className={cn( "hero-surface relative isolate overflow-hidden", variant === "home" - ? "hero-home flex min-h-[92svh] items-center justify-center" - : "hero-page flex min-h-[44svh] items-center sm:min-h-[48svh]", + ? "hero-home flex min-h-[76svh] items-center justify-center" + : "hero-page flex min-h-[34svh] items-center sm:min-h-[38svh]", className, )} > @@ -113,8 +113,8 @@ export function HeroShell({ className={cn( "relative z-10 w-full", variant === "home" - ? "pb-12 pt-24 sm:pb-14 sm:pt-28 lg:pb-16 lg:pt-32" - : "py-20 sm:py-24 lg:py-28", + ? "pb-10 pt-20 sm:pb-12 sm:pt-24 lg:pb-14 lg:pt-28" + : "py-16 sm:py-20 lg:py-24", containerClassName, )} > diff --git a/components/scroll-smoother-provider.tsx b/components/scroll-smoother-provider.tsx new file mode 100644 index 0000000..b3a35c0 --- /dev/null +++ b/components/scroll-smoother-provider.tsx @@ -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(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; +} diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts index cd13ed1..4e66165 100644 --- a/lib/admin-auth.ts +++ b/lib/admin-auth.ts @@ -297,6 +297,23 @@ export async function isAdminAuthenticated(): Promise { 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 { + return isAdminAuthenticated(); +} + /** * 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. diff --git a/package-lock.json b/package-lock.json index 6e6fcb4..ebf4660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.35.0", + "gsap": "^3.15.0", "lucide-react": "^0.577.0", "next": "^16.1.6", "next-intl": "^4.8.3", @@ -7013,6 +7014,12 @@ "devOptional": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", diff --git a/package.json b/package.json index aa09f74..9878c6b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "framer-motion": "^12.35.0", + "gsap": "^3.15.0", "lucide-react": "^0.577.0", "next": "^16.1.6", "next-intl": "^4.8.3", diff --git a/proxy.ts b/proxy.ts index 4b36252..69757ed 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,3 +1,5 @@ +import { timingSafeEqual } from "crypto"; + import createMiddleware from "next-intl/middleware"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; @@ -111,6 +113,20 @@ function isAdminBasicAuthConfigured(): boolean { 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 { if (!isAdminBasicAuthConfigured()) { return false; @@ -131,7 +147,10 @@ function isAdminBasicAuthValid(request: NextRequest): boolean { const user = decoded.slice(0, index); 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 { return false; }
- - - -
- -
- ); + return null; } diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index 688515a..6a1a625 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -1,7 +1,7 @@ "use client"; 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 { usePathname } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; @@ -13,6 +13,7 @@ import { SiteLogo } from "@/components/layout/site-logo"; import { SoundToggle } from "@/components/sound-toggle"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; +import { buildAdminUrl } from "@/lib/admin-routing"; import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale"; import { toast } from "@/lib/toast"; import { cn } from "@/lib/utils"; @@ -35,6 +36,13 @@ type SiteHeaderProps = { lightLogoUrl?: string | null; darkLogoUrl?: string | null; 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") { @@ -146,6 +154,7 @@ export function SiteHeader({ lightLogoUrl, darkLogoUrl, defaultLocale, + isSuperAdmin = false, }: SiteHeaderProps) { const [isOpen, setIsOpen] = 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"; 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"; + 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(() => { const handleScroll = () => { @@ -201,7 +215,7 @@ export function SiteHeader({ }; return ( -
+
+ {isSuperAdmin ? ( +
+ + Admin + + ) : null} + {isSuperAdmin ? ( + + + + ) : null}