mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
import { motion, useAnimation, useScroll, useTransform } from "framer-motion";
|
|
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
import { cn } from "../lib/utils";
|
|
import { Monitor } from "lucide-react";
|
|
|
|
interface NebulaProps {
|
|
className?: string;
|
|
density?: number;
|
|
themeMode?: 'light' | 'dark';
|
|
/** Dynamic position as percentage of viewport. If not provided, uses default fixed position. */
|
|
position?: { x: number; y: number };
|
|
}
|
|
|
|
interface StreamLineProps {
|
|
d: string;
|
|
themeMode: 'light' | 'dark';
|
|
onHit: () => void;
|
|
width: number;
|
|
index: number;
|
|
}
|
|
|
|
const StreamLine = ({ d, themeMode, onHit, width, index }: StreamLineProps) => {
|
|
const controls = useAnimation();
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
const animate = async () => {
|
|
// Spread out initial starts so they don't all hit at once
|
|
await new Promise(resolve => setTimeout(resolve, index * 1500 + Math.random() * 1000));
|
|
|
|
while (isMounted) {
|
|
// Calmer, longer durations (8-14 seconds)
|
|
const duration = 8 + Math.random() * 6;
|
|
|
|
controls.set({ pathLength: 0, pathOffset: 0, opacity: 0 });
|
|
|
|
// Timing: In the animation below, pathOffset reaches 0.8 at 80% of duration.
|
|
// With pathLength 0.2, the head hits the target (1.0) exactly at 80%.
|
|
const hitTimeout = setTimeout(() => {
|
|
if (isMounted) onHit();
|
|
}, duration * 1000 * 0.8);
|
|
|
|
// Animate the streamer path
|
|
await controls.start({
|
|
pathLength: [0, 0.2, 0.2, 0],
|
|
pathOffset: [0, 0, 0.8, 1.0],
|
|
opacity: [0, 0.4, 0.4, 0],
|
|
transition: {
|
|
duration,
|
|
ease: "linear",
|
|
times: [0, 0.2, 0.8, 1]
|
|
}
|
|
});
|
|
|
|
clearTimeout(hitTimeout);
|
|
// Longer pause between streaks for a more ambient feel
|
|
await new Promise(resolve => setTimeout(resolve, 2000 + Math.random() * 3000));
|
|
}
|
|
};
|
|
animate();
|
|
return () => { isMounted = false; };
|
|
}, [controls, onHit, index]);
|
|
|
|
return (
|
|
<motion.path
|
|
d={d}
|
|
stroke="currentColor"
|
|
strokeWidth={width}
|
|
fill="none"
|
|
initial={{ pathLength: 0, pathOffset: 0, opacity: 0 }}
|
|
animate={controls}
|
|
className={cn(
|
|
"transition-colors duration-1000",
|
|
themeMode === 'light' ? "text-slate-400" : "text-emerald-500/50"
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export const NebulaField = ({ className, density = 8, themeMode = 'dark', position }: NebulaProps) => {
|
|
// === ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS ===
|
|
const monitorControls = useAnimation();
|
|
const [monitorColor, setMonitorColor] = useState(themeMode === 'light' ? "text-slate-400" : "text-emerald-500/60");
|
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
|
|
// Parallax Scroll hooks (called unconditionally)
|
|
const { scrollY } = useScroll();
|
|
const yRange = useTransform(scrollY, [0, 1000], [0, 250]);
|
|
|
|
// Position calculations
|
|
const computerX = position?.x ?? 18;
|
|
const computerY = position?.y ?? 18;
|
|
const targetX = computerX + 2.5;
|
|
const targetY = computerY + 2.5;
|
|
|
|
// Check for reduced motion preference
|
|
useEffect(() => {
|
|
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
setPrefersReducedMotion(mediaQuery.matches);
|
|
|
|
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
|
|
mediaQuery.addEventListener('change', handler);
|
|
return () => mediaQuery.removeEventListener('change', handler);
|
|
}, []);
|
|
|
|
// Sync color with theme
|
|
useEffect(() => {
|
|
if (!prefersReducedMotion) {
|
|
setMonitorColor(themeMode === 'light' ? "text-slate-300" : "text-emerald-500/40");
|
|
}
|
|
}, [themeMode, prefersReducedMotion]);
|
|
|
|
const paths = useMemo(() => {
|
|
return Array.from({ length: density }).map(() => {
|
|
const edge = Math.floor(Math.random() * 4);
|
|
let startX = 50, startY = 50;
|
|
|
|
switch(edge) {
|
|
case 0: startX = Math.random() * 100; startY = -20; break;
|
|
case 1: startX = 120; startY = Math.random() * 100; break;
|
|
case 2: startX = Math.random() * 100; startY = 120; break;
|
|
case 3: startX = -20; startY = Math.random() * 100; break;
|
|
}
|
|
|
|
const endX = targetX + (Math.random() - 0.5) * 1.5;
|
|
const endY = targetY + (Math.random() - 0.5) * 1.5;
|
|
const cpX = (startX + endX) / 2 + (Math.random() - 0.5) * 40;
|
|
const cpY = (startY + endY) / 2 + (Math.random() - 0.5) * 40;
|
|
|
|
return `M ${startX} ${startY} Q ${cpX} ${cpY} ${endX} ${endY}`;
|
|
});
|
|
}, [density, themeMode, targetX, targetY]);
|
|
|
|
const handleHit = useCallback(() => {
|
|
const colors = themeMode === 'light'
|
|
? ["text-slate-400", "text-blue-400", "text-indigo-400", "text-emerald-400"]
|
|
: ["text-emerald-500/50", "text-blue-500/50", "text-purple-500/50", "text-cyan-500/50", "text-teal-500/50"];
|
|
|
|
setMonitorColor(colors[Math.floor(Math.random() * colors.length)]);
|
|
|
|
monitorControls.start({
|
|
scale: [1, 1.1, 1],
|
|
filter: [
|
|
"brightness(1) drop-shadow(0 0 0px rgba(0,0,0,0))",
|
|
"brightness(1.3) drop-shadow(0 0 12px currentColor)",
|
|
"brightness(1) drop-shadow(0 0 0px rgba(0,0,0,0))"
|
|
],
|
|
transition: { duration: 1.5, ease: "easeOut" }
|
|
});
|
|
}, [monitorControls, themeMode]);
|
|
|
|
// === CONDITIONAL RETURNS AFTER ALL HOOKS ===
|
|
|
|
// Return static background for reduced motion users
|
|
if (prefersReducedMotion) {
|
|
return (
|
|
<div className={cn(
|
|
"absolute inset-0 overflow-hidden pointer-events-none z-0",
|
|
themeMode === 'light'
|
|
? "bg-gradient-to-br from-slate-100 via-slate-50 to-white"
|
|
: "bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900",
|
|
className
|
|
)} />
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("absolute inset-0 overflow-hidden pointer-events-none z-0", className)}>
|
|
|
|
<motion.div
|
|
className="absolute -translate-x-1/2 -translate-y-1/2 z-20"
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{
|
|
opacity: themeMode === 'light' ? 0.4 : 0.6, // More visible computer
|
|
scale: 1
|
|
}}
|
|
transition={{ duration: 1.5 }}
|
|
style={{
|
|
left: `${computerX}%`,
|
|
top: `${computerY}%`,
|
|
y: yRange
|
|
}}
|
|
>
|
|
<motion.div animate={monitorControls}>
|
|
<Monitor
|
|
size={56} // Bigger icon as requested
|
|
className={cn(
|
|
"transition-colors duration-1000", // Back to smoother color shift
|
|
monitorColor
|
|
)}
|
|
/>
|
|
</motion.div>
|
|
</motion.div>
|
|
|
|
<motion.svg
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
className="w-full h-full opacity-50 dark:opacity-70 absolute inset-0"
|
|
style={{ y: yRange }}
|
|
>
|
|
{paths.map((d: string, i: number) => (
|
|
<StreamLine
|
|
key={`${themeMode}-${i}`}
|
|
d={d}
|
|
themeMode={themeMode}
|
|
onHit={handleHit}
|
|
width={themeMode === 'light' ? 0.08 : 0.12}
|
|
index={i}
|
|
/>
|
|
))}
|
|
</motion.svg>
|
|
</div>
|
|
);
|
|
};
|