mirror of
https://github.com/HugeFrog24/shakethefrog.git
synced 2026-03-02 08:24:33 +00:00
bugfix
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { HeartIcon } from '@heroicons/react/24/solid';
|
import { HeartIcon } from '@heroicons/react/24/solid';
|
||||||
|
import { shakeConfig } from '../config/shake';
|
||||||
|
|
||||||
interface Heart {
|
interface Heart {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -9,6 +10,7 @@ interface Heart {
|
|||||||
speed: number;
|
speed: number;
|
||||||
startPosition: { x: number; y: number };
|
startPosition: { x: number; y: number };
|
||||||
scale: number;
|
scale: number;
|
||||||
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FloatingHeartsProps {
|
interface FloatingHeartsProps {
|
||||||
@@ -18,56 +20,66 @@ interface FloatingHeartsProps {
|
|||||||
export function FloatingHearts({ intensity }: FloatingHeartsProps) {
|
export function FloatingHearts({ intensity }: FloatingHeartsProps) {
|
||||||
const [hearts, setHearts] = useState<Heart[]>([]);
|
const [hearts, setHearts] = useState<Heart[]>([]);
|
||||||
|
|
||||||
|
// Cleanup interval to remove old hearts
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanupInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
setHearts(prev => prev.filter(heart =>
|
||||||
|
now - heart.createdAt < shakeConfig.animations.heartFloat
|
||||||
|
));
|
||||||
|
}, shakeConfig.hearts.cleanupInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(cleanupInterval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (intensity <= 0) return;
|
if (intensity <= 0) return;
|
||||||
|
|
||||||
// Number of hearts based on intensity
|
// Number of hearts based on intensity
|
||||||
const numHearts = Math.min(Math.floor(intensity * 2), 50);
|
const numHearts = Math.min(Math.floor(intensity * 2), shakeConfig.hearts.maxPerShake);
|
||||||
|
|
||||||
// Create waves of hearts
|
// Create waves of hearts
|
||||||
const waves = 4; // Number of waves
|
const heartsPerWave = Math.ceil(numHearts / shakeConfig.hearts.waves);
|
||||||
const heartsPerWave = Math.ceil(numHearts / waves);
|
|
||||||
const waveDelay = 200; // Delay between waves in ms
|
|
||||||
const timers: NodeJS.Timeout[] = [];
|
const timers: NodeJS.Timeout[] = [];
|
||||||
|
|
||||||
// Generate hearts in waves
|
// Generate hearts in waves
|
||||||
for (let wave = 0; wave < waves; wave++) {
|
for (let wave = 0; wave < shakeConfig.hearts.waves; wave++) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const newHearts = Array.from({ length: heartsPerWave }, (_, i) => {
|
const now = Date.now();
|
||||||
const totalIndex = wave * heartsPerWave + i;
|
const newHearts = Array.from({ length: heartsPerWave }, (_, i) => ({
|
||||||
return {
|
id: now + i,
|
||||||
id: Date.now() + totalIndex,
|
angle: Math.random() * 360,
|
||||||
// Distribute angles evenly within each wave
|
speed: shakeConfig.hearts.minSpeed +
|
||||||
angle: Math.random() * 360, // Random angle for full radial distribution
|
Math.random() * (shakeConfig.hearts.maxSpeed - shakeConfig.hearts.minSpeed),
|
||||||
speed: 0.8 + Math.random() * 0.4,
|
|
||||||
startPosition: {
|
startPosition: {
|
||||||
x: Math.random() * 40 - 20,
|
x: Math.random() * (shakeConfig.hearts.spreadX * 2) - shakeConfig.hearts.spreadX,
|
||||||
y: Math.random() * 40 - 20,
|
y: Math.random() * (shakeConfig.hearts.spreadY * 2) - shakeConfig.hearts.spreadY,
|
||||||
},
|
},
|
||||||
scale: 0.8 + Math.random() * 0.4,
|
scale: shakeConfig.hearts.minScale +
|
||||||
};
|
Math.random() * (shakeConfig.hearts.maxScale - shakeConfig.hearts.minScale),
|
||||||
});
|
createdAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
setHearts(prev => [...prev, ...newHearts]);
|
setHearts(prev => [...prev, ...newHearts]);
|
||||||
}, wave * waveDelay);
|
|
||||||
|
// Remove this wave's hearts after animation
|
||||||
|
const cleanupTimer = setTimeout(() => {
|
||||||
|
setHearts(prev => prev.filter(heart => heart.createdAt !== now));
|
||||||
|
}, shakeConfig.animations.heartFloat);
|
||||||
|
|
||||||
|
timers.push(cleanupTimer);
|
||||||
|
}, wave * shakeConfig.hearts.waveDelay);
|
||||||
|
|
||||||
timers.push(timer);
|
timers.push(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove hearts after animation completes
|
|
||||||
const cleanupTimer = setTimeout(() => {
|
|
||||||
setHearts(prev => prev.filter(heart => heart.id > Date.now() - 3500));
|
|
||||||
}, waves * waveDelay + 3500);
|
|
||||||
|
|
||||||
timers.push(cleanupTimer);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
timers.forEach(timer => clearTimeout(timer));
|
timers.forEach(timer => clearTimeout(timer));
|
||||||
};
|
};
|
||||||
}, [intensity]);
|
}, [intensity]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 pointer-events-none -z-10">
|
<div className="absolute inset-0 pointer-events-none -z-10 overflow-hidden">
|
||||||
{hearts.map((heart) => {
|
{hearts.map((heart) => {
|
||||||
const style = {
|
const style = {
|
||||||
'--angle': `${heart.angle}deg`,
|
'--angle': `${heart.angle}deg`,
|
||||||
@@ -82,6 +94,9 @@ export function FloatingHearts({ intensity }: FloatingHeartsProps) {
|
|||||||
key={heart.id}
|
key={heart.id}
|
||||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-float-heart"
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-float-heart"
|
||||||
style={style}
|
style={style}
|
||||||
|
onAnimationEnd={() => {
|
||||||
|
setHearts(prev => prev.filter(h => h.id !== heart.id));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HeartIcon
|
<HeartIcon
|
||||||
className="w-16 h-16 text-pink-500 opacity-80 animate-fade-out"
|
className="w-16 h-16 text-pink-500 opacity-80 animate-fade-out"
|
||||||
|
|||||||
25
app/components/ParentComponent.tsx
Normal file
25
app/components/ParentComponent.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { SpeechBubble } from './SpeechBubble';
|
||||||
|
import FrogImage from '../public/images/frog-ai.ai'; // Assuming this is the frog image
|
||||||
|
|
||||||
|
export function ParentComponent() {
|
||||||
|
const [isShaken, setIsShaken] = useState(false);
|
||||||
|
const [triggerCount, setTriggerCount] = useState(0);
|
||||||
|
|
||||||
|
const handleShake = () => {
|
||||||
|
setTriggerCount(prev => prev + 1);
|
||||||
|
setIsShaken(true);
|
||||||
|
// Shake the frog for 1 second
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsShaken(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FrogImage className={isShaken ? 'shake-animation' : ''} />
|
||||||
|
<SpeechBubble isShaken={isShaken} triggerCount={triggerCount} />
|
||||||
|
<button onClick={handleShake}>Shake the Frog</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
app/components/SpeechBubble.tsx
Normal file
83
app/components/SpeechBubble.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { frogMessages } from '../config/messages';
|
||||||
|
|
||||||
|
// Increase visibility duration for speech bubbles
|
||||||
|
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
|
||||||
|
const COOLDOWN_MS = 2000; // 2 seconds between new messages
|
||||||
|
|
||||||
|
interface SpeechBubbleProps {
|
||||||
|
isShaken: boolean;
|
||||||
|
triggerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpeechBubble({ isShaken, triggerCount }: SpeechBubbleProps) {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const lastTriggerTime = useRef(0);
|
||||||
|
const showTimeRef = useRef<number>(0);
|
||||||
|
const getRandomMessage = useCallback(() => {
|
||||||
|
const randomIndex = Math.floor(Math.random() * frogMessages.length);
|
||||||
|
return frogMessages[randomIndex];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle showing new messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (triggerCount === 0) return; // Skip initial mount
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastMessage = now - lastTriggerTime.current;
|
||||||
|
|
||||||
|
// Show new message if cooldown has expired
|
||||||
|
if (timeSinceLastMessage >= COOLDOWN_MS) {
|
||||||
|
lastTriggerTime.current = now;
|
||||||
|
showTimeRef.current = now;
|
||||||
|
const newMessage = getRandomMessage();
|
||||||
|
setMessage(newMessage);
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, [triggerCount, getRandomMessage]);
|
||||||
|
|
||||||
|
// Handle visibility duration
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const checkVisibility = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeVisible = now - showTimeRef.current;
|
||||||
|
|
||||||
|
if (timeVisible >= VISIBILITY_MS) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, 100); // Check every 100ms
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(checkVisibility);
|
||||||
|
};
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
// Uncomment and modify the useEffect to control visibility based on isShaken prop
|
||||||
|
// This will make the speech bubble stay visible even after shaking stops
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isShaken) {
|
||||||
|
// Don't hide the speech bubble when shaking stops
|
||||||
|
// The visibility duration timer will handle hiding it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [isShaken]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute -top-24 left-1/2 -translate-x-1/2 bg-white dark:bg-slate-800 px-4 py-2 rounded-xl shadow-lg animate-float z-20">
|
||||||
|
<div className="relative">
|
||||||
|
{message}
|
||||||
|
{/* Triangle pointer */}
|
||||||
|
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 w-0 h-0
|
||||||
|
border-l-[8px] border-l-transparent
|
||||||
|
border-r-[8px] border-r-transparent
|
||||||
|
border-t-[8px] border-t-white
|
||||||
|
dark:border-t-slate-800" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
className="fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
className="fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors z-50"
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
{darkMode ? (
|
{darkMode ? (
|
||||||
|
|||||||
89
app/config/messages.ts
Normal file
89
app/config/messages.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export const frogMessages = [
|
||||||
|
"You got me! 🐸",
|
||||||
|
"Keep shaking! 💫",
|
||||||
|
"I feel dizzy! 😵💫",
|
||||||
|
"That was fun! ⭐",
|
||||||
|
"Do it again! 🎉",
|
||||||
|
"I'm having a blast! 🌟",
|
||||||
|
"Wheeee! 🎢",
|
||||||
|
"You're good at this! 🌈",
|
||||||
|
"I love this game! 💚",
|
||||||
|
"One more time! ✨",
|
||||||
|
"That tickles! 😄",
|
||||||
|
"You found me! 🌿",
|
||||||
|
"I'm so happy! 🥳",
|
||||||
|
"Let's party! 🎵",
|
||||||
|
"You're making me bounce! 💫",
|
||||||
|
"I'm yours! 💝",
|
||||||
|
"Shake me harder! 💖",
|
||||||
|
"Don't stop now! 💕",
|
||||||
|
"You're amazing! 💗",
|
||||||
|
"I'm getting hot! 🔥",
|
||||||
|
"I want more! 💘",
|
||||||
|
"You're so good! 💓",
|
||||||
|
"I'm all yours! 💞",
|
||||||
|
"You drive me wild! 💥",
|
||||||
|
"I'm melting! 💦",
|
||||||
|
"I can't resist you! 💋",
|
||||||
|
"You know what I like! 🌹",
|
||||||
|
"I'm trembling! ⚡",
|
||||||
|
"You're irresistible! 💫",
|
||||||
|
"Make me yours! 💝",
|
||||||
|
"I'm burning up! 🔥",
|
||||||
|
"You're making me crazy! 💘",
|
||||||
|
"I need you! 💖",
|
||||||
|
"You're perfect! 💕",
|
||||||
|
"I'm yours forever! 💗",
|
||||||
|
"Take me! 💫",
|
||||||
|
"You're incredible! ✨",
|
||||||
|
"I'm on fire! 🔥",
|
||||||
|
"You're my everything! 💝",
|
||||||
|
"I'm in heaven! 💫",
|
||||||
|
"Your touch is electric! ⚡",
|
||||||
|
"You make me feel alive! 💖",
|
||||||
|
"I'm addicted to you! 💕",
|
||||||
|
"You're my obsession! 💗",
|
||||||
|
"I can't get enough! 🔥",
|
||||||
|
"More, more, more! 💘",
|
||||||
|
"You're my desire! 💓",
|
||||||
|
"I'm yours to command! 💞",
|
||||||
|
"Unleash me! 💥",
|
||||||
|
"You're my fantasy! 💋",
|
||||||
|
"I crave your touch! 🌹",
|
||||||
|
"I'm shaking with anticipation! ⚡",
|
||||||
|
"You're my weakness! 💫",
|
||||||
|
"Claim me! 💝",
|
||||||
|
"I'm on the edge! 🔥",
|
||||||
|
"You're driving me wild! 💘",
|
||||||
|
"I surrender to you! 💖",
|
||||||
|
"You're my masterpiece! 💕",
|
||||||
|
"I'm yours for the taking! 💗",
|
||||||
|
"Show me what you've got! 💫",
|
||||||
|
"You're my temptation! ✨",
|
||||||
|
"I'm consumed by you! 🔥",
|
||||||
|
"You're my everything and more! 💝",
|
||||||
|
"I'm lost in you! 💫",
|
||||||
|
"You're my dream! 💖",
|
||||||
|
"I'm under your spell! 💕",
|
||||||
|
"You're my addiction! 💗",
|
||||||
|
"I'm hooked on you! 🔥",
|
||||||
|
"Give me all you've got! 💘",
|
||||||
|
"You're my ultimate fantasy! 💓",
|
||||||
|
"I'm yours, body and soul! 💞",
|
||||||
|
"Take me to the edge! 💥",
|
||||||
|
"I'm overflowing! 💦",
|
||||||
|
"I yearn for your touch! 🌹",
|
||||||
|
"I'm quivering with desire! ⚡",
|
||||||
|
"You're my obsession! 💫",
|
||||||
|
"Make me yours, completely! 💝",
|
||||||
|
"I'm a furnace for you! 🔥",
|
||||||
|
"You're driving me insane! 💘",
|
||||||
|
"I'm completely yours! 💖",
|
||||||
|
"You're absolute perfection! 💕",
|
||||||
|
"I'm yours, now and forever! 💗",
|
||||||
|
"Take me, I'm yours! 💫",
|
||||||
|
"You're beyond incredible! ✨",
|
||||||
|
"I'm a raging inferno! 🔥",
|
||||||
|
"You're my heart's desire! 💝",
|
||||||
|
"I'm in paradise! 💫"
|
||||||
|
];
|
||||||
32
app/config/shake.ts
Normal file
32
app/config/shake.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const shakeConfig = {
|
||||||
|
// Threshold for triggering shake (lower = more sensitive)
|
||||||
|
threshold: 20, // Increased from 15 to make it less sensitive
|
||||||
|
|
||||||
|
// Minimum time between shake detections (in ms)
|
||||||
|
debounceTime: 100,
|
||||||
|
|
||||||
|
// Animation durations (in ms)
|
||||||
|
animations: {
|
||||||
|
shakeReset: 600, // Reduced from 10000ms to 600ms (0.6 seconds)
|
||||||
|
heartsReset: 300, // How long the hearts animation lasts
|
||||||
|
heartFloat: 2000, // Duration of floating heart animation
|
||||||
|
heartFadeOut: 2000 // Duration of heart fade out
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hearts configuration
|
||||||
|
hearts: {
|
||||||
|
waves: 4, // Number of waves per shake
|
||||||
|
waveDelay: 200, // Delay between waves in ms
|
||||||
|
cleanupInterval: 1000, // How often to check for and remove old hearts
|
||||||
|
minSpeed: 0.8, // Minimum heart float speed
|
||||||
|
maxSpeed: 1.2, // Maximum heart float speed
|
||||||
|
minScale: 0.8, // Minimum heart size
|
||||||
|
maxScale: 1.2, // Maximum heart size
|
||||||
|
spreadX: 20, // How far hearts can spread horizontally from center
|
||||||
|
spreadY: 20, // How far hearts can spread vertically from center
|
||||||
|
maxPerShake: 50 // Maximum number of hearts per shake
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default intensity for manual triggers (click/spacebar)
|
||||||
|
defaultTriggerIntensity: 25
|
||||||
|
};
|
||||||
@@ -2,8 +2,13 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode styles */
|
/* Light mode styles */
|
||||||
@@ -32,8 +37,8 @@ body {
|
|||||||
@keyframes float-heart {
|
@keyframes float-heart {
|
||||||
to {
|
to {
|
||||||
transform: translate(
|
transform: translate(
|
||||||
calc(var(--start-x) + (50vw * cos(var(--angle)))),
|
calc(var(--start-x) + (70vw * cos(var(--angle)))),
|
||||||
calc(var(--start-y) + (50vh * sin(var(--angle))))
|
calc(var(--start-y) + (70vh * sin(var(--angle))))
|
||||||
) scale(var(--scale));
|
) scale(var(--scale));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
@@ -50,3 +55,26 @@ body {
|
|||||||
.animate-fade-out {
|
.animate-fade-out {
|
||||||
animation: fade-out 2s ease-out forwards;
|
animation: fade-out 2s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 10px);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|||||||
20
app/hooks/useIsMobile.ts
Normal file
20
app/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIfMobile = () => {
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
|
||||||
|
setIsMobile(mobileRegex.test(userAgent));
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIfMobile();
|
||||||
|
window.addEventListener('resize', checkIfMobile);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', checkIfMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import { ThemeProvider } from './providers/ThemeProvider'
|
import { ThemeProvider } from './providers/ThemeProvider'
|
||||||
import { ThemeToggle } from './components/ThemeToggle'
|
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
@@ -23,7 +22,6 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.className} transition-colors`}>
|
<body className={`${inter.className} transition-colors`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ThemeToggle />
|
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
61
app/page.tsx
61
app/page.tsx
@@ -1,15 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { FloatingHearts } from './components/FloatingHearts';
|
import { FloatingHearts } from './components/FloatingHearts';
|
||||||
|
import { ThemeToggle } from './components/ThemeToggle';
|
||||||
|
import { SpeechBubble } from './components/SpeechBubble';
|
||||||
|
import { shakeConfig } from './config/shake';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [isShaken, setIsShaken] = useState(false);
|
const [isShaken, setIsShaken] = useState(false);
|
||||||
const [shakeIntensity, setShakeIntensity] = useState(0);
|
const [shakeIntensity, setShakeIntensity] = useState(0);
|
||||||
const [lastUpdate, setLastUpdate] = useState(0);
|
const [lastUpdate, setLastUpdate] = useState(0);
|
||||||
|
const [shakeCount, setShakeCount] = useState(0);
|
||||||
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
|
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
|
||||||
const shakeThreshold = 15;
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Check if device motion is available and handle permissions
|
// Check if device motion is available and handle permissions
|
||||||
const requestMotionPermission = async () => {
|
const requestMotionPermission = async () => {
|
||||||
@@ -37,24 +42,27 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerShake = (intensity: number) => {
|
const triggerShake = useCallback((intensity: number) => {
|
||||||
|
// Increment shake counter to trigger new message
|
||||||
|
setShakeCount(count => count + 1);
|
||||||
|
|
||||||
// Start shake animation
|
// Start shake animation
|
||||||
setIsShaken(true);
|
setIsShaken(true);
|
||||||
|
|
||||||
// Always reset shake after 500ms
|
// Reset shake after configured duration
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsShaken(false);
|
setIsShaken(false);
|
||||||
}, 500);
|
}, shakeConfig.animations.shakeReset);
|
||||||
|
|
||||||
// Trigger hearts with a shorter duration
|
// Trigger hearts with configured duration
|
||||||
setShakeIntensity(intensity);
|
setShakeIntensity(intensity);
|
||||||
setTimeout(() => setShakeIntensity(0), 300);
|
setTimeout(() => setShakeIntensity(0), shakeConfig.animations.heartsReset);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyPress = (event: KeyboardEvent) => {
|
const handleKeyPress = (event: KeyboardEvent) => {
|
||||||
if (event.code === 'Space') {
|
if (event.code === 'Space') {
|
||||||
triggerShake(25);
|
triggerShake(shakeConfig.defaultTriggerIntensity);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,14 +73,14 @@ export default function Home() {
|
|||||||
const currentTime = new Date().getTime();
|
const currentTime = new Date().getTime();
|
||||||
const timeDiff = currentTime - lastUpdate;
|
const timeDiff = currentTime - lastUpdate;
|
||||||
|
|
||||||
if (timeDiff > 100) {
|
if (timeDiff > shakeConfig.debounceTime) {
|
||||||
setLastUpdate(currentTime);
|
setLastUpdate(currentTime);
|
||||||
|
|
||||||
const speed = Math.abs(acceleration.x || 0) +
|
const speed = Math.abs(acceleration.x || 0) +
|
||||||
Math.abs(acceleration.y || 0) +
|
Math.abs(acceleration.y || 0) +
|
||||||
Math.abs(acceleration.z || 0);
|
Math.abs(acceleration.z || 0);
|
||||||
|
|
||||||
if (speed > shakeThreshold) {
|
if (speed > shakeConfig.threshold) {
|
||||||
triggerShake(speed);
|
triggerShake(speed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,24 +110,33 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
triggerShake(25);
|
triggerShake(shakeConfig.defaultTriggerIntensity);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-green-50 dark:bg-slate-900">
|
<main className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center w-full relative">
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<FloatingHearts intensity={shakeIntensity} />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`relative ${isShaken ? 'animate-shake' : ''} z-10`}
|
className="relative z-10"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<FloatingHearts intensity={shakeIntensity} />
|
<FloatingHearts intensity={shakeIntensity} />
|
||||||
|
<div className="relative">
|
||||||
|
<SpeechBubble isShaken={isShaken} triggerCount={shakeCount} />
|
||||||
<Image
|
<Image
|
||||||
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
|
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
|
||||||
alt="Frog"
|
alt="Frog"
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
priority
|
priority
|
||||||
|
className={isShaken ? 'animate-shake' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-8 flex flex-col items-center gap-2">
|
<div className="mt-8 flex flex-col items-center gap-2">
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
|
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
|
||||||
{motionPermission === 'prompt' ? (
|
{motionPermission === 'prompt' ? (
|
||||||
@@ -130,12 +147,24 @@ export default function Home() {
|
|||||||
Enable device shake
|
Enable device shake
|
||||||
</button>
|
</button>
|
||||||
) : motionPermission === 'granted' ? (
|
) : motionPermission === 'granted' ? (
|
||||||
"Shake your device, press spacebar, or click/tap frog!"
|
`Shake your device${!isMobile ? ', press spacebar,' : ''} or click/tap frog!`
|
||||||
) : (
|
) : (
|
||||||
"Press spacebar or click/tap frog!"
|
`${!isMobile ? 'Press spacebar or ' : ''}Click/tap frog!`
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer className="w-full text-center text-xs text-gray-400 dark:text-gray-600 mt-auto pt-4">
|
||||||
|
© {new Date().getFullYear()}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/HugeFrog24/shakethefrog"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
shakethefrog
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user