create-neon-flux 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,260 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ export default function CyberCity() {
6
+ const canvasRef = useRef<HTMLCanvasElement>(null);
7
+
8
+ useEffect(() => {
9
+ // Check for reduced motion preference
10
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
11
+
12
+ const canvas = canvasRef.current;
13
+ if (!canvas) return;
14
+
15
+ const ctx = canvas.getContext('2d');
16
+ if (!ctx) return;
17
+
18
+ // Set canvas size
19
+ let width = window.innerWidth;
20
+ let height = window.innerHeight;
21
+ canvas.width = width;
22
+ canvas.height = height;
23
+
24
+ // Create initial gradient
25
+ let bgGradient = ctx.createLinearGradient(0, 0, 0, height);
26
+ bgGradient.addColorStop(0, '#0D0221');
27
+ bgGradient.addColorStop(1, '#0A0A0A');
28
+
29
+ const setCanvasSize = () => {
30
+ width = window.innerWidth;
31
+ height = window.innerHeight;
32
+ canvas.width = width;
33
+ canvas.height = height;
34
+ // Re-cache gradient on resize
35
+ bgGradient = ctx.createLinearGradient(0, 0, 0, height);
36
+ bgGradient.addColorStop(0, '#0D0221');
37
+ bgGradient.addColorStop(1, '#0A0A0A');
38
+ };
39
+ window.addEventListener('resize', setCanvasSize);
40
+
41
+ // Building data with antenna flag pre-determined
42
+ interface Building {
43
+ x: number;
44
+ y: number;
45
+ width: number;
46
+ height: number;
47
+ color: string;
48
+ windows: Array<{ x: number; y: number; lit: boolean }>;
49
+ hasAntenna: boolean;
50
+ }
51
+
52
+ const buildings: Building[] = [];
53
+ const colors = ['#FF10F0', '#00FFF0', '#B026FF', '#00D4FF'];
54
+ const buildingCount = 25; // Reduced for performance
55
+
56
+ // Generate buildings
57
+ for (let i = 0; i < buildingCount; i++) {
58
+ const bWidth = 40 + Math.random() * 60;
59
+ const bHeight = 100 + Math.random() * 300;
60
+ const x = (width / buildingCount) * i;
61
+ const y = height - bHeight;
62
+ const color = colors[Math.floor(Math.random() * colors.length)];
63
+
64
+ // Generate windows
65
+ const windows = [];
66
+ const windowRows = Math.floor(bHeight / 20);
67
+ const windowCols = Math.floor(bWidth / 15);
68
+
69
+ for (let row = 0; row < windowRows; row++) {
70
+ for (let col = 0; col < windowCols; col++) {
71
+ windows.push({
72
+ x: x + col * 15 + 5,
73
+ y: y + row * 20 + 5,
74
+ lit: Math.random() > 0.3,
75
+ });
76
+ }
77
+ }
78
+
79
+ buildings.push({
80
+ x, y, width: bWidth, height: bHeight, color, windows,
81
+ hasAntenna: Math.random() > 0.7 // Pre-determine antenna
82
+ });
83
+ }
84
+
85
+ // Animation variables
86
+ let animationFrame: number;
87
+ let time = 0;
88
+
89
+ // Stars - reduced count
90
+ const stars: Array<{ x: number; y: number; size: number; phaseOffset: number }> = [];
91
+ for (let i = 0; i < 60; i++) { // Reduced from 100
92
+ stars.push({
93
+ x: Math.random() * width,
94
+ y: Math.random() * height * 0.6,
95
+ size: Math.random() * 2,
96
+ phaseOffset: Math.random() * Math.PI * 2,
97
+ });
98
+ }
99
+
100
+ // Grid
101
+ const gridSpacing = 50;
102
+
103
+ // If reduced motion, draw static scene once
104
+ if (prefersReducedMotion) {
105
+ ctx.fillStyle = bgGradient;
106
+ ctx.fillRect(0, 0, width, height);
107
+
108
+ // Static stars
109
+ stars.forEach((star) => {
110
+ ctx.beginPath();
111
+ ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
112
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
113
+ ctx.fill();
114
+ });
115
+
116
+ // Static buildings
117
+ buildings.forEach((building) => {
118
+ ctx.fillStyle = 'rgba(10, 10, 10, 0.8)';
119
+ ctx.fillRect(building.x, building.y, building.width, building.height);
120
+ ctx.strokeStyle = building.color;
121
+ ctx.lineWidth = 2;
122
+ ctx.strokeRect(building.x, building.y, building.width, building.height);
123
+
124
+ building.windows.forEach((win) => {
125
+ if (win.lit) {
126
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.6)';
127
+ ctx.fillRect(win.x, win.y, 8, 8);
128
+ }
129
+ });
130
+ });
131
+ return;
132
+ }
133
+
134
+ // Animation loop
135
+ const animate = () => {
136
+ time += 0.01;
137
+
138
+ // Clear with cached gradient
139
+ ctx.fillStyle = bgGradient;
140
+ ctx.fillRect(0, 0, width, height);
141
+
142
+ // Draw stars with pre-calculated phase
143
+ for (let i = 0; i < stars.length; i++) {
144
+ const star = stars[i];
145
+ ctx.beginPath();
146
+ ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
147
+ ctx.fillStyle = `rgba(255, 255, 255, ${0.5 + Math.sin(time * 2 + star.phaseOffset) * 0.5})`;
148
+ ctx.fill();
149
+ }
150
+
151
+ // Draw perspective grid
152
+ ctx.lineWidth = 1;
153
+ const horizon = height * 0.6;
154
+
155
+ // Horizontal lines
156
+ for (let i = 0; i < 10; i++) {
157
+ const y = horizon + i * 30;
158
+ const perspective = (y - horizon) / (height - horizon);
159
+ ctx.beginPath();
160
+ ctx.moveTo(0, y);
161
+ ctx.lineTo(width, y);
162
+ ctx.strokeStyle = `rgba(0, 255, 240, ${0.05 + perspective * 0.1})`;
163
+ ctx.stroke();
164
+ }
165
+
166
+ // Vertical lines
167
+ const vanishingPointX = width / 2;
168
+ for (let i = -10; i <= 10; i++) {
169
+ const x = width / 2 + i * gridSpacing;
170
+ ctx.beginPath();
171
+ ctx.moveTo(x, height);
172
+ ctx.lineTo(vanishingPointX + (x - vanishingPointX) * 0.3, horizon);
173
+ ctx.strokeStyle = 'rgba(0, 255, 240, 0.05)';
174
+ ctx.stroke();
175
+ }
176
+
177
+ // Draw buildings - batch similar operations
178
+ ctx.fillStyle = 'rgba(10, 10, 10, 0.8)';
179
+ for (let i = 0; i < buildings.length; i++) {
180
+ const building = buildings[i];
181
+ ctx.fillRect(building.x, building.y, building.width, building.height);
182
+ }
183
+
184
+ // Building borders and details
185
+ for (let i = 0; i < buildings.length; i++) {
186
+ const building = buildings[i];
187
+
188
+ // Neon border
189
+ ctx.strokeStyle = building.color;
190
+ ctx.lineWidth = 2;
191
+ ctx.shadowColor = building.color;
192
+ ctx.shadowBlur = 10;
193
+ ctx.strokeRect(building.x, building.y, building.width, building.height);
194
+ ctx.shadowBlur = 0;
195
+
196
+ // Windows - batch by lit state
197
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.6)';
198
+ for (let j = 0; j < building.windows.length; j++) {
199
+ const win = building.windows[j];
200
+ if (win.lit) {
201
+ ctx.fillRect(win.x, win.y, 8, 8);
202
+ }
203
+ }
204
+
205
+ // Antenna (pre-determined)
206
+ if (building.hasAntenna) {
207
+ const antennaX = building.x + building.width / 2;
208
+ const antennaHeight = 20;
209
+
210
+ ctx.strokeStyle = building.color;
211
+ ctx.lineWidth = 2;
212
+ ctx.beginPath();
213
+ ctx.moveTo(antennaX, building.y);
214
+ ctx.lineTo(antennaX, building.y - antennaHeight);
215
+ ctx.stroke();
216
+
217
+ // Blinking light
218
+ if (Math.sin(time * 5) > 0.5) {
219
+ ctx.fillStyle = '#FF10F0';
220
+ ctx.shadowColor = '#FF10F0';
221
+ ctx.shadowBlur = 10;
222
+ ctx.beginPath();
223
+ ctx.arc(antennaX, building.y - antennaHeight, 3, 0, Math.PI * 2);
224
+ ctx.fill();
225
+ ctx.shadowBlur = 0;
226
+ }
227
+ }
228
+ }
229
+
230
+ // Scanline effect
231
+ ctx.fillStyle = 'rgba(0, 255, 240, 0.02)';
232
+ const scanlineY = (time * 100) % height;
233
+ ctx.fillRect(0, scanlineY, width, 2);
234
+
235
+ animationFrame = requestAnimationFrame(animate);
236
+ };
237
+
238
+ animate();
239
+
240
+ return () => {
241
+ cancelAnimationFrame(animationFrame);
242
+ window.removeEventListener('resize', setCanvasSize);
243
+ };
244
+ }, []);
245
+
246
+ return (
247
+ <canvas
248
+ ref={canvasRef}
249
+ className="canvas-container"
250
+ style={{
251
+ position: 'fixed',
252
+ top: 0,
253
+ left: 0,
254
+ width: '100%',
255
+ height: '100%',
256
+ zIndex: -1,
257
+ }}
258
+ />
259
+ );
260
+ }
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ export default function CyberGrid() {
4
+ return (
5
+ <div
6
+ className="fixed inset-0 z-0 cyber-grid opacity-30"
7
+ style={{
8
+ backgroundSize: '50px 50px',
9
+ backgroundPosition: 'center center'
10
+ }}
11
+ />
12
+ );
13
+ }
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ interface GlitchTextProps {
4
+ text: string;
5
+ className?: string;
6
+ }
7
+
8
+ export default function GlitchText({ text, className = '' }: GlitchTextProps) {
9
+ return (
10
+ <h1
11
+ className={`glitch neon-text-pink ${className}`}
12
+ data-text={text}
13
+ >
14
+ {text}
15
+ </h1>
16
+ );
17
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import GlitchText from './GlitchText';
5
+ import NeonButton from '../ui/NeonButton';
6
+ import Particles from '../effects/Particles';
7
+
8
+ export default function Hero() {
9
+ const [mounted, setMounted] = useState(false);
10
+ const [showScrollIndicator, setShowScrollIndicator] = useState(true);
11
+
12
+ useEffect(() => {
13
+ setMounted(true);
14
+
15
+ // Hide scroll indicator after user scrolls
16
+ const handleScroll = () => {
17
+ if (window.scrollY > 100) {
18
+ setShowScrollIndicator(false);
19
+ }
20
+ };
21
+ window.addEventListener('scroll', handleScroll, { passive: true });
22
+ return () => window.removeEventListener('scroll', handleScroll);
23
+ }, []);
24
+
25
+ const scrollToSection = (id: string) => {
26
+ const element = document.getElementById(id);
27
+ element?.scrollIntoView({ behavior: 'smooth' });
28
+ };
29
+
30
+ return (
31
+ <section
32
+ id="home"
33
+ className="relative z-10 min-h-screen flex items-center justify-center pt-20"
34
+ >
35
+ {/* Particles Background */}
36
+ {mounted && <Particles />}
37
+
38
+ <div className="container mx-auto px-4 text-center">
39
+ <div className="space-y-8 fade-in">
40
+ {/* Main Title with Glitch Effect */}
41
+ <GlitchText
42
+ text="NEON FLUX"
43
+ className="text-5xl md:text-7xl lg:text-9xl font-orbitron font-bold"
44
+ />
45
+
46
+ {/* Subtitle */}
47
+ <h2 className="text-2xl md:text-4xl font-rajdhani text-neon-cyan text-shadow-neon-cyan">
48
+ Cyberpunk Portfolio Template
49
+ </h2>
50
+
51
+ {/* Description */}
52
+ <p className="text-lg md:text-xl text-neon-cyan/80 max-w-2xl mx-auto font-space-mono">
53
+ Showcase your projects with stunning graphics, smooth animations,
54
+ and cyberpunk aesthetics. Built with Next.js 15 & TypeScript.
55
+ </p>
56
+
57
+ {/* CTA Buttons */}
58
+ <div className="flex flex-col sm:flex-row gap-4 justify-center items-center pt-8">
59
+ <NeonButton
60
+ variant="primary"
61
+ onClick={() => scrollToSection('portfolio')}
62
+ >
63
+ View Portfolio
64
+ </NeonButton>
65
+ <NeonButton
66
+ variant="secondary"
67
+ onClick={() => scrollToSection('blog')}
68
+ >
69
+ Read Blog
70
+ </NeonButton>
71
+ </div>
72
+
73
+ {/* Scroll Indicator - hidden after scroll */}
74
+ {showScrollIndicator && (
75
+ <div
76
+ className="absolute bottom-10 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer transition-opacity duration-300"
77
+ onClick={() => scrollToSection('portfolio')}
78
+ >
79
+ <div className="w-6 h-10 border-2 border-neon-cyan rounded-full flex justify-center">
80
+ <div className="w-1 h-3 bg-neon-cyan rounded-full mt-2 animate-pulse" />
81
+ </div>
82
+ </div>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </section>
87
+ );
88
+ }
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ export default function ParticlesBackground() {
6
+ const canvasRef = useRef<HTMLCanvasElement>(null);
7
+ const containerRef = useRef<HTMLDivElement>(null);
8
+ const [isVisible, setIsVisible] = useState(true);
9
+
10
+ // Intersection Observer to pause when off-screen
11
+ useEffect(() => {
12
+ const container = containerRef.current;
13
+ if (!container) return;
14
+
15
+ const observer = new IntersectionObserver(
16
+ ([entry]) => setIsVisible(entry.isIntersecting),
17
+ { threshold: 0 }
18
+ );
19
+ observer.observe(container);
20
+ return () => observer.disconnect();
21
+ }, []);
22
+
23
+ useEffect(() => {
24
+ // Check for reduced motion preference
25
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
26
+ if (prefersReducedMotion) return;
27
+
28
+ const canvas = canvasRef.current;
29
+ if (!canvas) return;
30
+
31
+ const ctx = canvas.getContext('2d');
32
+ if (!ctx) return;
33
+
34
+ let animationFrame: number;
35
+ let isRunning = true;
36
+
37
+ // Set canvas size
38
+ const setCanvasSize = () => {
39
+ canvas.width = window.innerWidth;
40
+ canvas.height = window.innerHeight;
41
+ };
42
+ setCanvasSize();
43
+ window.addEventListener('resize', setCanvasSize);
44
+
45
+ // Particle system - reduced count for better performance
46
+ const particles: Array<{
47
+ x: number;
48
+ y: number;
49
+ vx: number;
50
+ vy: number;
51
+ color: string;
52
+ size: number;
53
+ }> = [];
54
+
55
+ const colors = ['#FF10F0', '#00FFF0', '#FFFF00'];
56
+ const particleCount = 50; // Reduced from 80
57
+
58
+ // Initialize particles
59
+ for (let i = 0; i < particleCount; i++) {
60
+ particles.push({
61
+ x: Math.random() * canvas.width,
62
+ y: Math.random() * canvas.height,
63
+ vx: (Math.random() - 0.5) * 0.5,
64
+ vy: (Math.random() - 0.5) * 0.5,
65
+ color: colors[Math.floor(Math.random() * colors.length)],
66
+ size: Math.random() * 2 + 1,
67
+ });
68
+ }
69
+
70
+ // Mouse position
71
+ let mouseX = 0;
72
+ let mouseY = 0;
73
+
74
+ const handleMouseMove = (e: MouseEvent) => {
75
+ mouseX = e.clientX;
76
+ mouseY = e.clientY;
77
+ };
78
+ window.addEventListener('mousemove', handleMouseMove);
79
+
80
+ // Spatial grid for O(n) neighbor lookup
81
+ const CELL_SIZE = 150;
82
+ const getGridKey = (x: number, y: number) =>
83
+ `${Math.floor(x / CELL_SIZE)},${Math.floor(y / CELL_SIZE)}`;
84
+
85
+ // Animation loop
86
+ const animate = () => {
87
+ ctx.fillStyle = 'rgba(10, 10, 10, 0.1)';
88
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
89
+
90
+ // Build spatial grid
91
+ const grid: Map<string, number[]> = new Map();
92
+ particles.forEach((particle, i) => {
93
+ const key = getGridKey(particle.x, particle.y);
94
+ if (!grid.has(key)) grid.set(key, []);
95
+ grid.get(key)!.push(i);
96
+ });
97
+
98
+ particles.forEach((particle, i) => {
99
+ // Update position
100
+ particle.x += particle.vx;
101
+ particle.y += particle.vy;
102
+
103
+ // Bounce off edges
104
+ if (particle.x < 0 || particle.x > canvas.width) particle.vx *= -1;
105
+ if (particle.y < 0 || particle.y > canvas.height) particle.vy *= -1;
106
+
107
+ // Mouse repulsion
108
+ const dx = mouseX - particle.x;
109
+ const dy = mouseY - particle.y;
110
+ const distance = Math.sqrt(dx * dx + dy * dy);
111
+
112
+ if (distance < 100) {
113
+ particle.x -= (dx / distance) * 2;
114
+ particle.y -= (dy / distance) * 2;
115
+ }
116
+
117
+ // Draw particle
118
+ ctx.beginPath();
119
+ ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
120
+ ctx.fillStyle = particle.color;
121
+ ctx.fill();
122
+
123
+ // Draw connections - only check neighboring cells
124
+ const cellX = Math.floor(particle.x / CELL_SIZE);
125
+ const cellY = Math.floor(particle.y / CELL_SIZE);
126
+
127
+ for (let ox = -1; ox <= 1; ox++) {
128
+ for (let oy = -1; oy <= 1; oy++) {
129
+ const neighborKey = `${cellX + ox},${cellY + oy}`;
130
+ const neighbors = grid.get(neighborKey);
131
+ if (!neighbors) continue;
132
+
133
+ for (const j of neighbors) {
134
+ if (i >= j) continue; // Avoid duplicate lines
135
+
136
+ const other = particles[j];
137
+ const dx = particle.x - other.x;
138
+ const dy = particle.y - other.y;
139
+ const dist = Math.sqrt(dx * dx + dy * dy);
140
+
141
+ if (dist < 150) {
142
+ ctx.beginPath();
143
+ ctx.moveTo(particle.x, particle.y);
144
+ ctx.lineTo(other.x, other.y);
145
+ ctx.strokeStyle = `rgba(0, 255, 240, ${0.3 * (1 - dist / 150)})`;
146
+ ctx.lineWidth = 1;
147
+ ctx.stroke();
148
+ }
149
+ }
150
+ }
151
+ }
152
+ });
153
+
154
+ if (isRunning) {
155
+ animationFrame = requestAnimationFrame(animate);
156
+ }
157
+ };
158
+
159
+ animate();
160
+
161
+ return () => {
162
+ isRunning = false;
163
+ cancelAnimationFrame(animationFrame);
164
+ window.removeEventListener('resize', setCanvasSize);
165
+ window.removeEventListener('mousemove', handleMouseMove);
166
+ };
167
+ }, [isVisible]);
168
+
169
+ return (
170
+ <div ref={containerRef} className="absolute inset-0 z-0">
171
+ <canvas
172
+ ref={canvasRef}
173
+ className="w-full h-full"
174
+ style={{
175
+ pointerEvents: 'none',
176
+ }}
177
+ />
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,5 @@
1
+ 'use client';
2
+
3
+ export default function ScanLines() {
4
+ return <div className="scan-lines" />;
5
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, useRef } from 'react';
4
+
5
+ interface AudioReactiveData {
6
+ volume: number;
7
+ frequency: number;
8
+ }
9
+
10
+ export function useAudioReactive(enabled: boolean = false) {
11
+ const [audioData, setAudioData] = useState<AudioReactiveData>({
12
+ volume: 0,
13
+ frequency: 0,
14
+ });
15
+ const audioContextRef = useRef<AudioContext | null>(null);
16
+ const analyserRef = useRef<AnalyserNode | null>(null);
17
+ const dataArrayRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
18
+
19
+ useEffect(() => {
20
+ if (!enabled || typeof window === 'undefined') return;
21
+
22
+ const setupAudio = async () => {
23
+ try {
24
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
25
+ const audioContext = new AudioContext();
26
+ const source = audioContext.createMediaStreamSource(stream);
27
+ const analyser = audioContext.createAnalyser();
28
+
29
+ analyser.fftSize = 256;
30
+ source.connect(analyser);
31
+
32
+ audioContextRef.current = audioContext;
33
+ analyserRef.current = analyser;
34
+ dataArrayRef.current = new Uint8Array(analyser.frequencyBinCount);
35
+
36
+ const updateAudioData = () => {
37
+ if (!analyserRef.current || !dataArrayRef.current) return;
38
+
39
+ analyserRef.current.getByteFrequencyData(dataArrayRef.current);
40
+
41
+ const volume =
42
+ dataArrayRef.current.reduce((sum, value) => sum + value, 0) /
43
+ dataArrayRef.current.length;
44
+
45
+ const frequency = dataArrayRef.current[0];
46
+
47
+ setAudioData({ volume: volume / 255, frequency: frequency / 255 });
48
+
49
+ requestAnimationFrame(updateAudioData);
50
+ };
51
+
52
+ updateAudioData();
53
+ } catch (error) {
54
+ console.error('Error setting up audio:', error);
55
+ }
56
+ };
57
+
58
+ setupAudio();
59
+
60
+ return () => {
61
+ if (audioContextRef.current) {
62
+ audioContextRef.current.close();
63
+ }
64
+ };
65
+ }, [enabled]);
66
+
67
+ return audioData;
68
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ interface UseGlitchOptions {
6
+ interval?: number;
7
+ duration?: number;
8
+ }
9
+
10
+ export function useGlitch(options: UseGlitchOptions = {}) {
11
+ const { interval = 3000, duration = 300 } = options;
12
+ const [isGlitching, setIsGlitching] = useState(false);
13
+
14
+ useEffect(() => {
15
+ const glitchInterval = setInterval(() => {
16
+ setIsGlitching(true);
17
+ setTimeout(() => {
18
+ setIsGlitching(false);
19
+ }, duration);
20
+ }, interval);
21
+
22
+ return () => clearInterval(glitchInterval);
23
+ }, [interval, duration]);
24
+
25
+ return isGlitching;
26
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+
5
+ export function useNeonGlow() {
6
+ const [isGlowing, setIsGlowing] = useState(false);
7
+
8
+ const startGlow = useCallback(() => {
9
+ setIsGlowing(true);
10
+ }, []);
11
+
12
+ const stopGlow = useCallback(() => {
13
+ setIsGlowing(false);
14
+ }, []);
15
+
16
+ const toggleGlow = useCallback(() => {
17
+ setIsGlowing((prev) => !prev);
18
+ }, []);
19
+
20
+ const glowClasses = isGlowing
21
+ ? 'shadow-neon-cyan brightness-125 scale-105'
22
+ : '';
23
+
24
+ return {
25
+ isGlowing,
26
+ startGlow,
27
+ stopGlow,
28
+ toggleGlow,
29
+ glowClasses,
30
+ };
31
+ }