This commit is contained in:
HugeFrog24
2025-12-12 12:25:07 +01:00
parent 1edd996336
commit 39cbf58dbd
54 changed files with 7273 additions and 6889 deletions

29
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales } from '../../i18n/request';
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure that the incoming `locale` is valid
if (!locales.includes(locale as (typeof locales)[number])) {
notFound();
}
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}

251
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,251 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useIsMobile } from '../hooks/useIsMobile';
import Image from 'next/image';
import { FloatingHearts } from '../components/FloatingHearts';
import { ThemeToggle } from '../components/ThemeToggle';
import { SpeechBubble } from '../components/SpeechBubble';
import { SkinSelector } from '../components/SkinSelector';
import { shakeConfig } from '../config/shake';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
import { appConfig } from '../config/app';
import { useSkin } from '../hooks/useSkin';
import { LanguageToggle } from '../components/LanguageToggle';
import { useTranslations } from 'next-intl';
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
export default function Home() {
const [isShaken, setIsShaken] = useState(false);
const [shakeIntensity, setShakeIntensity] = useState(0);
const [lastUpdate, setLastUpdate] = useState(0);
const [shakeCount, setShakeCount] = useState(0);
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
const isMobile = useIsMobile();
const [, setIsAnimating] = useState(false);
const [, setShakeQueue] = useState<number[]>([]);
const isAnimatingRef = useRef<boolean>(false);
const animationTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const animationStartTimeRef = useRef<number>(0);
const currentSkin = useSkin();
const getLocalizedSkinName = useLocalizedSkinName();
const t = useTranslations('ui');
// Check if device motion is available and handle permissions
const requestMotionPermission = async () => {
if (typeof window === 'undefined') return;
// Check if device motion is available
if (!('DeviceMotionEvent' in window)) {
setMotionPermission('denied');
return;
}
// Request permission on iOS devices
if ('requestPermission' in DeviceMotionEvent) {
try {
// @ts-expect-error - TypeScript doesn't know about requestPermission
const permission = await DeviceMotionEvent.requestPermission();
setMotionPermission(permission);
} catch (err) {
console.error('Error requesting motion permission:', err);
setMotionPermission('denied');
}
} else {
// Android or desktop - no permission needed
setMotionPermission('granted');
}
};
const triggerShake = useCallback((intensity: number) => {
// Use ref instead of state to prevent race conditions
if (!isAnimatingRef.current) {
// Clear any existing timeout
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
// Start shake animation
isAnimatingRef.current = true;
animationStartTimeRef.current = Date.now(); // Track when animation starts
setIsAnimating(true);
setIsShaken(true);
setShakeIntensity(intensity);
setShakeCount(count => count + 1);
// Reset shake after configured duration
animationTimeoutRef.current = setTimeout(() => {
setIsShaken(false);
setShakeIntensity(0);
setIsAnimating(false);
isAnimatingRef.current = false;
// Process next shake in queue if any
setShakeQueue(prev => {
if (prev.length > 0) {
const [nextIntensity, ...rest] = prev;
// Small delay before triggering next shake to ensure clean transition
setTimeout(() => {
triggerShake(nextIntensity);
}, 16);
return rest;
}
return prev;
});
}, shakeConfig.animations.shakeReset);
} else {
// Only queue if we're not at the start of the animation
const timeSinceStart = Date.now() - animationStartTimeRef.current;
if (timeSinceStart > 100) { // Only queue if animation has been running for a bit
setShakeQueue(prev => {
// Hard limit at 1 item
if (prev.length >= 1) return prev;
return [...prev, intensity];
});
}
}
}, []); // Remove isAnimating from dependencies since we're using ref
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.code === 'Space') {
triggerShake(shakeConfig.defaultTriggerIntensity);
}
};
const handleMotion = (event: DeviceMotionEvent) => {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = new Date().getTime();
const timeDiff = currentTime - lastUpdate;
if (timeDiff > shakeConfig.debounceTime) {
setLastUpdate(currentTime);
const speed = Math.abs(acceleration.x || 0) +
Math.abs(acceleration.y || 0) +
Math.abs(acceleration.z || 0);
if (speed > shakeConfig.threshold) {
triggerShake(speed);
}
}
};
// Only add motion listener if permission is granted
if (typeof window !== 'undefined') {
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
window.addEventListener('devicemotion', handleMotion);
}
window.addEventListener('keydown', handleKeyPress);
}
return () => {
if (typeof window !== 'undefined') {
if (motionPermission === 'granted') {
window.removeEventListener('devicemotion', handleMotion);
}
window.removeEventListener('keydown', handleKeyPress);
}
};
}, [lastUpdate, motionPermission, triggerShake]);
// Initial permission check
useEffect(() => {
requestMotionPermission();
}, []);
const handleClick = () => {
// Trigger haptic feedback for tap interaction
if ('vibrate' in navigator) {
navigator.vibrate(50); // Short 50ms vibration
}
triggerShake(shakeConfig.defaultTriggerIntensity);
};
// Add cleanup in the component
useEffect(() => {
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
};
}, []);
return (
<div className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
<div className="w-full flex justify-between items-center">
<div className="flex items-center gap-2">
<LanguageToggle />
<SkinSelector />
</div>
<ThemeToggle />
</div>
<div className="flex-1 flex flex-col items-center justify-center w-full">
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<FloatingHearts intensity={shakeIntensity} />
</div>
<button
onClick={handleClick}
className="relative z-10"
aria-label={t('shakeCharacter', { item: getLocalizedSkinName(currentSkin) })}
>
<FloatingHearts intensity={shakeIntensity} />
<SpeechBubble
isShaken={isShaken}
triggerCount={shakeCount}
/>
<Image
src={isShaken
? appConfig.skins[currentSkin].shaken
: appConfig.skins[currentSkin].normal
}
alt={getLocalizedSkinName(currentSkin)}
width={200}
height={200}
priority
className={isShaken ? 'animate-shake' : ''}
/>
</button>
<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]">
{motionPermission === 'prompt' ? (
<button
onClick={requestMotionPermission}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{t('enableDeviceShake')}
</button>
) : motionPermission === 'granted' ? (
t(
isMobile ? 'shakeInstructionsMobile' : 'shakeInstructionsDesktop',
{ item: getLocalizedSkinName(currentSkin) }
)
) : (
t(
isMobile ? 'noShakeInstructionsMobile' : 'noShakeInstructionsDesktop',
{ item: getLocalizedSkinName(currentSkin) }
)
)}
</p>
</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 inline-flex items-center gap-1"
>
{appConfig.name}
<ArrowTopRightOnSquareIcon className="w-3 h-3" />
</a>
</footer>
</div>
);
}

View File

@@ -18,23 +18,33 @@ export async function GET(request: Request) {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: appConfig.assets.ogImage.bgColor,
fontSize: 60,
fontSize: 72,
fontWeight: 600,
}}
>
<img
src={`${baseUrl}${appConfig.assets.favicon}`}
alt={appConfig.name}
width={200}
height={200}
width={300}
height={300}
style={{ margin: '0 0 40px' }}
/>
<div style={{ marginBottom: 20 }}>{appConfig.name}</div>
<div
style={{
marginBottom: 30,
color: appConfig.assets.ogImage.textColor,
}}
>
{appConfig.name}
</div>
<div
style={{
fontSize: 30,
fontSize: 36,
fontWeight: 400,
color: appConfig.assets.ogImage.textColor
color: appConfig.assets.ogImage.textColor,
textAlign: 'center',
maxWidth: '80%',
lineHeight: 1.4,
}}
>
{appConfig.description}

View File

@@ -0,0 +1,121 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Link } from '../../i18n/routing';
import { GlobeAltIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
type Locale = 'en' | 'de' | 'ru' | 'ka' | 'ar';
interface LanguageOption {
code: Locale;
name: string;
}
export function LanguageToggle() {
const locale = useLocale() as Locale;
const t = useTranslations('ui');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Define the available locales
const locales: Locale[] = ['en', 'de', 'ru', 'ka', 'ar'];
const languageOptions: LanguageOption[] = locales.map((code) => ({
code,
name: t(`languages.${code}`)
}));
const currentLanguage = languageOptions.find(lang => lang.code === locale) || languageOptions[0];
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle escape key to close dropdown
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Main toggle button */}
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label={t('languageSelector')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<GlobeAltIcon className="w-4 h-4 text-gray-700 dark:text-gray-300" />
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentLanguage.name}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{languageOptions.map((option) => (
<Link
key={option.code}
href="/"
locale={option.code}
onClick={() => setIsOpen(false)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
locale === option.code
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<GlobeAltIcon className={`w-4 h-4 ${
locale === option.code ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'
}`} />
<span>{option.name}</span>
{locale === option.code && (
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</Link>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,151 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Image from 'next/image';
import { appConfig } from '../config/app';
import { SkinId } from '../types';
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
interface SkinOption {
id: SkinId;
name: string;
image: string;
}
export function SkinSelector() {
const router = useRouter();
const searchParams = useSearchParams();
const getLocalizedSkinName = useLocalizedSkinName();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const skinOptions: SkinOption[] = Object.entries(appConfig.skins).map(([id, skin]) => ({
id: id as SkinId,
name: getLocalizedSkinName(id),
image: skin.normal
}));
const skinParam = searchParams.get('skin');
// Validate that the skin exists in our config
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
// Use the skin from URL if valid, otherwise use default skin
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
const currentSkinOption = skinOptions.find(skin => skin.id === currentSkin) || skinOptions[0];
const handleSkinChange = useCallback((newSkin: SkinId) => {
const params = new URLSearchParams(searchParams.toString());
if (newSkin === appConfig.defaultSkin) {
params.delete('skin');
} else {
params.set('skin', newSkin);
}
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
router.push(newUrl);
setIsOpen(false);
}, [router, searchParams]);
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle escape key to close dropdown
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<div className="relative" ref={dropdownRef}>
{/* Main toggle button */}
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
aria-label="Skin selector"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Image
src={currentSkinOption.image}
alt={currentSkinOption.name}
width={16}
height={16}
className="rounded"
/>
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentSkinOption.name}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{skinOptions.map((option) => (
<button
key={option.id}
onClick={() => handleSkinChange(option.id)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
currentSkin === option.id
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<Image
src={option.image}
alt={option.name}
width={16}
height={16}
className="rounded"
/>
<span>{option.name}</span>
{currentSkin === option.id && (
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { frogMessages } from '../config/messages';
import { useMessages } from 'next-intl';
import { getRandomEmoji } from '../config/emojis';
// Increase visibility duration for speech bubbles
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
@@ -14,18 +15,51 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
const [message, setMessage] = useState('');
const [isVisible, setIsVisible] = useState(false);
const [messageQueue, setMessageQueue] = useState<string[]>([]);
const allMessages = useMessages();
const messagesRef = useRef<string[]>([]);
const lastTriggerTime = useRef(0);
const showTimeRef = useRef<number>(0);
const lastFadeTime = useRef(0);
// Load messages when component mounts or language changes
useEffect(() => {
// Only run if we haven't loaded messages yet
if (messagesRef.current.length > 0) return;
// Get the character messages from the messages object
try {
const characterMessages = allMessages.character;
if (characterMessages && typeof characterMessages === 'object') {
// Convert object values to array
const messageArray = Object.values(characterMessages) as string[];
if (messageArray.length === 0) {
console.error(`No character messages found! Expected messages in 'character' namespace but got none.`);
return;
}
console.log(`Loaded ${messageArray.length} character messages`);
messagesRef.current = messageArray;
} else {
console.error(`Character messages not found or invalid format:`, characterMessages);
}
} catch (error) {
console.error(`Error loading character messages:`, error);
}
}, [allMessages]); // Depend on allMessages to reload when they change
const getRandomMessage = useCallback(() => {
const randomIndex = Math.floor(Math.random() * frogMessages.length);
return frogMessages[randomIndex];
}, []);
const currentMessages = messagesRef.current;
if (currentMessages.length === 0) return '';
const randomIndex = Math.floor(Math.random() * currentMessages.length);
const message = currentMessages[randomIndex];
return `${message} ${getRandomEmoji()}`;
}, []); // No dependencies needed since we use ref
// Handle new trigger events
useEffect(() => {
if (triggerCount === 0) return;
if (triggerCount === 0 || messagesRef.current.length === 0) return;
const now = Date.now();
const timeSinceLastFade = now - lastFadeTime.current;
@@ -33,7 +67,9 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
// If we're in cooldown, or a message is visible, queue the new message
if (timeSinceLastFade < COOLDOWN_MS || isVisible) {
const newMessage = getRandomMessage();
setMessageQueue(prev => [...prev, newMessage]);
if (newMessage) {
setMessageQueue(prev => [...prev, newMessage]);
}
return;
}
@@ -41,9 +77,11 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
lastTriggerTime.current = now;
showTimeRef.current = now;
const newMessage = getRandomMessage();
setMessage(newMessage);
setIsVisible(true);
}, [triggerCount, getRandomMessage, isVisible]);
if (newMessage) {
setMessage(newMessage);
setIsVisible(true);
}
}, [triggerCount, isVisible, getRandomMessage]);
// Handle message queue
useEffect(() => {
@@ -76,19 +114,16 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
}, [isVisible]);
return (
<div
className={`absolute -top-24 left-1/2 -translate-x-1/2 bg-white dark:bg-slate-800
<div
className={`absolute -top-24 bg-white dark:bg-slate-800
px-4 py-2 rounded-xl shadow-lg z-20 transition-opacity duration-300
${isVisible ? 'opacity-100 animate-float' : 'opacity-0 pointer-events-none'}`}
style={{
left: '50%',
transform: 'translateX(-50%)'
}}
>
<div className="relative">
{message}
<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>
{message}
</div>
);
}

View File

@@ -1,22 +1,138 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useTheme } from '../providers/ThemeProvider';
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline';
import { SunIcon, MoonIcon, ComputerDesktopIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeOption {
mode: ThemeMode;
label: string;
icon: React.ReactNode;
}
export function ThemeToggle() {
const { darkMode, toggleDarkMode } = useTheme();
const { themeMode, setThemeMode } = useTheme();
const t = useTranslations('ui');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const themeOptions: ThemeOption[] = [
{
mode: 'light',
label: t('themes.light'),
icon: <SunIcon className="w-4 h-4" />
},
{
mode: 'dark',
label: t('themes.dark'),
icon: <MoonIcon className="w-4 h-4" />
},
{
mode: 'system',
label: t('themes.system'),
icon: <ComputerDesktopIcon className="w-4 h-4" />
}
];
// Get current theme option
const currentTheme = themeOptions.find(option => option.mode === themeMode) || themeOptions[2];
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle escape key to close dropdown
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const handleThemeSelect = (mode: ThemeMode) => {
setThemeMode(mode);
setIsOpen(false);
};
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
return (
<button
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 z-50"
aria-label="Toggle dark mode"
>
{darkMode ? (
<SunIcon className="w-6 h-6 text-yellow-500" />
) : (
<MoonIcon className="w-6 h-6 text-gray-900" />
<div className="relative" ref={dropdownRef}>
{/* Main toggle button */}
<button
onClick={toggleDropdown}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors z-50"
aria-label={t('themeSelector')}
aria-expanded={isOpen}
aria-haspopup="true"
>
<div className="flex items-center text-gray-700 dark:text-gray-300">
{currentTheme.icon}
</div>
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
{currentTheme.label}
</span>
<ChevronDownIcon
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1">
{themeOptions.map((option) => (
<button
key={option.mode}
onClick={() => handleThemeSelect(option.mode)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
themeMode === option.mode
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'
}`}
role="menuitem"
>
<div className={themeMode === option.mode ? 'text-blue-600 dark:text-blue-400' : ''}>
{option.icon}
</div>
<span>{option.label}</span>
{themeMode === option.mode && (
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
)}
</button>
))}
</div>
</div>
)}
</button>
</div>
);
}

View File

@@ -1,14 +1,36 @@
export const appConfig = {
name: 'Shake the Frog',
description: 'A fun interactive frog that reacts to shaking!',
url: 'https://shakethefrog.vercel.app',
url: 'https://shakethefrog.com',
assets: {
favicon: '/images/frog.svg',
ogImage: {
width: 1200,
height: 630,
bgColor: '#f0fdf4',
textColor: '#374151'
bgColor: '#c9ffda',
textColor: '#000000'
}
}
} as const
},
skins: {
frog: {
id: 'frog',
name: 'Frog',
normal: '/images/frog.svg',
shaken: '/images/frog-shaken.svg'
},
mandarin: {
id: 'mandarin',
name: 'Mandarin',
normal: '/images/mandarin.svg',
// TODO: Create a proper shaken version of the mandarin skin
shaken: '/images/mandarin.svg' // Using the same image for both states until a shaken version is created
},
beaver: {
id: 'beaver',
name: 'Beaver',
normal: '/images/beaver.svg',
shaken: '/images/beaver-shaken.svg'
}
},
defaultSkin: 'frog'
} as const

14
app/config/emojis.ts Normal file
View File

@@ -0,0 +1,14 @@
// Define our curated emoji pool
const emojiPool = [
'💫', '💝', '💘', '💖', '💕',
'💓', '💗', '💞', '✨', '🌟',
'🔥', '👼', '⭐', '💎', '💨',
'🎉', '🕸️', '🤗', '💋', '😘',
'🫂', '👫', '💟', '💌', '🥰',
'😍', '🥺', '😢', '😭'
];
// Helper function to get a random emoji
export function getRandomEmoji(): string {
return emojiPool[Math.floor(Math.random() * emojiPool.length)];
}

View File

@@ -1,135 +0,0 @@
export const frogMessages = [
"Again! Again! ",
"Almost got me! ",
"Can't catch your breath? ",
"Catch me if you can! ",
"Chase me! ",
"Claim me! ",
"Come closer! ",
"Do it again! ",
"Don't stop now! ",
"Faster! Faster! ",
"Give me all you've got! ",
"Higher! Higher! ",
"I can dance all day! ",
"I can't get enough! ",
"I can't resist you! ",
"I crave your touch! ",
"I feel dizzy! ",
"I like your style! ",
"I love this game! ",
"I need you! ",
"I surrender to you! ",
"I want more! ",
"I yearn for your touch! ",
"I'm a furnace for you! ",
"I'm a raging inferno! ",
"I'm addicted to you! ",
"I'm all yours! ",
"I'm burning up! ",
"I'm completely yours! ",
"I'm consumed by you! ",
"I'm floating on air! ",
"I'm getting dizzy! ",
"I'm getting excited! ",
"I'm getting hot! ",
"I'm having a blast! ",
"I'm having a blast! ",
"I'm hooked on you! ",
"I'm in a tizzy! ",
"I'm in heaven! ",
"I'm in paradise! ",
"I'm lost in you! ",
"I'm melting! ",
"I'm on fire! ",
"I'm on the edge! ",
"I'm overflowing! ",
"I'm quivering with desire! ",
"I'm seeing stars! ",
"I'm shaking with anticipation! ",
"I'm so happy! ",
"I'm trembling! ",
"I'm under your spell! ",
"I'm yours for the taking! ",
"I'm yours forever! ",
"I'm yours to command! ",
"I'm yours! ",
"I'm yours, body and soul! ",
"I'm yours, now and forever! ",
"Is that all you've got? ",
"Keep shaking! ",
"Keep the rhythm going! ",
"Let's party! ",
"Let's play more! ",
"Like a record baby! ",
"Make me yours! ",
"Make me yours, completely! ",
"Missed me! ",
"More, more, more! ",
"My heart's racing! ",
"Neither can I! ",
"One more time! ",
"Playing hard to get? ",
"Round and round we go! ",
"Shake me harder! ",
"Show me what you've got! ",
"Show me your moves! ",
"So close! ",
"Spin me right round! ",
"Stop tickling! ",
"Take me to the edge! ",
"Take me! ",
"Take me, I'm yours! ",
"That tickles! ",
"That was fun! ",
"Too slow! ",
"Unleash me! ",
"Wait till I catch you! ",
"What a rush! ",
"Wheeee! ",
"Wheeeeeee! ",
"You drive me wild! ",
"You found me! ",
"You got me! ",
"You know how to party! ",
"You know what I like! ",
"You make me feel alive! ",
"You're absolute perfection! ",
"You're amazing! ",
"You're beyond incredible! ",
"You're driving me insane! ",
"You're driving me wild! ",
"You're fun! ",
"You're getting better! ",
"You're good at this! ",
"You're incredible! ",
"You're irresistible! ",
"You're making me blush! ",
"You're making me bounce! ",
"You're making me bounce! ",
"You're making me crazy! ",
"You're making me giddy! ",
"You're making me spin! ",
"You're making me swoon! ",
"You're making me twirl! ",
"You're my addiction! ",
"You're my desire! ",
"You're my dream! ",
"You're my everything and more! ",
"You're my everything! ",
"You're my fantasy! ",
"You're my heart's desire! ",
"You're my masterpiece! ",
"You're my obsession! ",
"You're my obsession! ",
"You're my temptation! ",
"You're my ultimate fantasy! ",
"You're my weakness! ",
"You're perfect! ",
"You're so good! ",
"You're so playful! ",
"You're such a tease! ",
"You're unstoppable! ",
"You've got the magic touch! ",
"Your touch is electric! "
];

100
app/config/skin-names.ts Normal file
View File

@@ -0,0 +1,100 @@
import { type Locale } from '../../i18n/request';
// Define grammatical cases for languages that need them
type GrammaticalCase = 'nominative' | 'accusative' | 'dative' | 'genitive' | 'instrumental' | 'prepositional';
// Define which languages need grammatical cases
const languagesWithCases: Partial<Record<Locale, boolean>> = {
ru: true,
ka: true
};
// Localized skin names for different languages with grammatical cases
const skinNames: Record<string, Record<Locale, string | Record<GrammaticalCase, string>>> = {
frog: {
en: 'Frog',
de: 'Frosch',
ru: {
nominative: 'Лягушка',
accusative: 'Лягушку',
dative: 'Лягушке',
genitive: 'Лягушки',
instrumental: 'Лягушкой',
prepositional: 'Лягушке'
},
ka: {
nominative: 'ბაყაყი',
accusative: 'ბაყაყს',
dative: 'ბაყაყს',
genitive: 'ბაყაყის',
instrumental: 'ბაყაყით',
prepositional: 'ბაყაყზე'
},
ar: 'ضفدع'
},
mandarin: {
en: 'Mandarin',
de: 'Mandarine',
ru: {
nominative: 'Мандарин',
accusative: 'Мандарин',
dative: 'Мандарину',
genitive: 'Мандарина',
instrumental: 'Мандарином',
prepositional: 'Мандарине'
},
ka: {
nominative: 'მანდარინი',
accusative: 'მანდარინს',
dative: 'მანდარინს',
genitive: 'მანდარინის',
instrumental: 'მანდარინით',
prepositional: 'მანდარინზე'
},
ar: 'ماندرين'
},
beaver: {
en: 'Beaver',
de: 'Biber',
ru: {
nominative: 'Бобр',
accusative: 'Бобра',
dative: 'Бобру',
genitive: 'Бобра',
instrumental: 'Бобром',
prepositional: 'Бобре'
},
ka: {
nominative: 'თახვი',
accusative: 'თახვს',
dative: 'თახვს',
genitive: 'თახვის',
instrumental: 'თახვით',
prepositional: 'თახვზე'
},
ar: 'قندس'
}
};
/**
* Get the localized name for a skin with the appropriate grammatical case
* @param skinId The skin ID
* @param language The language code
* @param grammaticalCase The grammatical case to use (for languages that need it)
* @returns The localized skin name
*/
export function getLocalizedSkinName(
skinId: string,
language: Locale,
grammaticalCase: GrammaticalCase = 'nominative'
): string {
const skinName = skinNames[skinId]?.[language];
// If the language doesn't use cases or we don't have cases for this skin
if (!skinName || typeof skinName === 'string' || !languagesWithCases[language]) {
return typeof skinName === 'string' ? skinName : skinNames[skinId]?.en as string || skinId;
}
// Return the appropriate case, or fallback to nominative if the case doesn't exist
return skinName[grammaticalCase] || skinName.nominative;
}

View File

@@ -1,6 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
/* Override the dark variant to use class-based dark mode instead of media query */
@custom-variant dark (&:where(.dark, .dark *));
html, body {
-webkit-tap-highlight-color: transparent;
@@ -59,19 +60,19 @@ body {
@keyframes float {
0% {
opacity: 0;
transform: translate(-50%, 10px);
transform: translateX(-50%) translateY(10px);
}
20% {
opacity: 1;
transform: translate(-50%, 0);
transform: translateX(-50%) translateY(0);
}
80% {
opacity: 1;
transform: translate(-50%, 0);
transform: translateX(-50%) translateY(0);
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
transform: translateX(-50%) translateY(-10px);
}
}

View File

@@ -1,34 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
export function useDarkMode() {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
// Check if user has a dark mode preference in localStorage
const isDark = localStorage.getItem('darkMode') === 'true';
// Check system preference if no localStorage value
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setDarkMode(isDark ?? systemPrefersDark);
// Add listener for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (localStorage.getItem('darkMode') === null) {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
localStorage.setItem('darkMode', (!darkMode).toString());
};
return { darkMode, toggleDarkMode };
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useLocale } from 'next-intl';
import { getLocalizedSkinName } from '../config/skin-names';
import { type Locale } from '../../i18n/request';
// Define grammatical cases
type GrammaticalCase = 'nominative' | 'accusative' | 'dative' | 'genitive' | 'instrumental' | 'prepositional';
/**
* Hook to get localized skin names
*/
export function useLocalizedSkinName() {
const locale = useLocale();
/**
* Get a localized skin name with the appropriate grammatical case
* @param skinId The skin ID
* @param grammaticalCase The grammatical case to use (for languages that need it)
* @returns The localized skin name
*/
const getLocalizedName = (skinId: string, grammaticalCase: GrammaticalCase = 'nominative'): string => {
return getLocalizedSkinName(skinId, locale as Locale, grammaticalCase);
};
return getLocalizedName;
}

18
app/hooks/useSkin.ts Normal file
View File

@@ -0,0 +1,18 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { appConfig } from '../config/app';
import { SkinId } from '../types';
export function useSkin() {
const searchParams = useSearchParams();
const skinParam = searchParams.get('skin');
// Validate that the skin exists in our config
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
// Return the skin from URL if valid, otherwise return default skin
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
return currentSkin;
}

View File

@@ -2,11 +2,13 @@ import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { ThemeProvider } from './providers/ThemeProvider'
import { appConfig } from './config/app'
import { Suspense } from 'react'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
metadataBase: new URL(appConfig.url),
title: appConfig.name,
description: appConfig.description,
icons: {
@@ -40,10 +42,16 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<html suppressHydrationWarning>
<body className={`${inter.className} transition-colors`}>
<ThemeProvider>
{children}
<Suspense fallback={
<div className="flex h-[100dvh] items-center justify-center bg-green-50 dark:bg-slate-900">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
}>
{children}
</Suspense>
</ThemeProvider>
</body>
</html>

View File

@@ -1,170 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useIsMobile } from './hooks/useIsMobile';
import Image from 'next/image';
import { FloatingHearts } from './components/FloatingHearts';
import { ThemeToggle } from './components/ThemeToggle';
import { SpeechBubble } from './components/SpeechBubble';
import { shakeConfig } from './config/shake';
export default function Home() {
const [isShaken, setIsShaken] = useState(false);
const [shakeIntensity, setShakeIntensity] = useState(0);
const [lastUpdate, setLastUpdate] = useState(0);
const [shakeCount, setShakeCount] = useState(0);
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
const isMobile = useIsMobile();
// Check if device motion is available and handle permissions
const requestMotionPermission = async () => {
if (typeof window === 'undefined') return;
// Check if device motion is available
if (!('DeviceMotionEvent' in window)) {
setMotionPermission('denied');
return;
}
// Request permission on iOS devices
if ('requestPermission' in DeviceMotionEvent) {
try {
// @ts-expect-error - TypeScript doesn't know about requestPermission
const permission = await DeviceMotionEvent.requestPermission();
setMotionPermission(permission);
} catch (err) {
console.error('Error requesting motion permission:', err);
setMotionPermission('denied');
}
} else {
// Android or desktop - no permission needed
setMotionPermission('granted');
}
};
const triggerShake = useCallback((intensity: number) => {
// Increment shake counter to trigger new message
setShakeCount(count => count + 1);
// Start shake animation
setIsShaken(true);
// Reset shake after configured duration
setTimeout(() => {
setIsShaken(false);
}, shakeConfig.animations.shakeReset);
// Trigger hearts with configured duration
setShakeIntensity(intensity);
setTimeout(() => setShakeIntensity(0), shakeConfig.animations.heartsReset);
}, []);
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
if (event.code === 'Space') {
triggerShake(shakeConfig.defaultTriggerIntensity);
}
};
const handleMotion = (event: DeviceMotionEvent) => {
const acceleration = event.accelerationIncludingGravity;
if (!acceleration) return;
const currentTime = new Date().getTime();
const timeDiff = currentTime - lastUpdate;
if (timeDiff > shakeConfig.debounceTime) {
setLastUpdate(currentTime);
const speed = Math.abs(acceleration.x || 0) +
Math.abs(acceleration.y || 0) +
Math.abs(acceleration.z || 0);
if (speed > shakeConfig.threshold) {
triggerShake(speed);
}
}
};
// Only add motion listener if permission is granted
if (typeof window !== 'undefined') {
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
window.addEventListener('devicemotion', handleMotion);
}
window.addEventListener('keydown', handleKeyPress);
}
return () => {
if (typeof window !== 'undefined') {
if (motionPermission === 'granted') {
window.removeEventListener('devicemotion', handleMotion);
}
window.removeEventListener('keydown', handleKeyPress);
}
};
}, [lastUpdate, motionPermission, triggerShake]);
// Initial permission check
useEffect(() => {
requestMotionPermission();
}, []);
const handleClick = () => {
triggerShake(shakeConfig.defaultTriggerIntensity);
};
return (
<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
className="relative z-10"
onClick={handleClick}
>
<FloatingHearts intensity={shakeIntensity} />
<div className="relative">
<SpeechBubble isShaken={isShaken} triggerCount={shakeCount} />
<Image
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
alt="Frog"
width={200}
height={200}
priority
className={isShaken ? 'animate-shake' : ''}
/>
</div>
</div>
<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]">
{motionPermission === 'prompt' ? (
<button
onClick={requestMotionPermission}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Enable device shake
</button>
) : motionPermission === 'granted' ? (
`Shake your device${!isMobile ? ', press spacebar,' : ''} or click/tap frog!`
) : (
`${!isMobile ? 'Press spacebar or ' : ''}Click/tap frog!`
)}
</p>
</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>
);
}

View File

@@ -1,25 +1,151 @@
'use client';
import { createContext, useContext, useEffect } from 'react';
import { useDarkMode } from '../hooks/useDarkMode';
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext({ darkMode: false, toggleDarkMode: () => {} });
// Define theme modes
type ThemeMode = 'light' | 'dark' | 'system';
// Helper function to detect system dark mode preference
const getSystemPreference = (): boolean => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
// Update context type to include the new properties
interface ThemeContextType {
darkMode: boolean;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextType>({
darkMode: false,
themeMode: 'system',
setThemeMode: () => {},
});
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { darkMode, toggleDarkMode } = useDarkMode();
const [darkMode, setDarkMode] = useState(false);
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [mounted, setMounted] = useState(false);
// Initialize theme state from localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
try {
// Get theme mode preference following Tailwind's recommendation
console.log('ThemeProvider init - Reading from localStorage');
const savedTheme = localStorage.getItem('theme');
console.log('ThemeProvider init - localStorage.theme:', savedTheme);
// Determine if we should use system preference
const useSystemPreference = !savedTheme;
console.log('ThemeProvider init - Using system preference:', useSystemPreference);
// Set theme mode state based on localStorage
if (savedTheme === 'light') {
console.log('ThemeProvider init - Setting theme mode to: light');
setThemeModeState('light');
setDarkMode(false);
} else if (savedTheme === 'dark') {
console.log('ThemeProvider init - Setting theme mode to: dark');
setThemeModeState('dark');
setDarkMode(true);
} else {
// Use system preference
console.log('ThemeProvider init - Setting theme mode to: system');
setThemeModeState('system');
const systemPreference = getSystemPreference();
console.log('ThemeProvider init - System preference is dark:', systemPreference);
setDarkMode(systemPreference);
}
// Apply dark mode class to html element directly (Tailwind recommendation)
const shouldUseDarkMode =
savedTheme === 'dark' ||
(!savedTheme && getSystemPreference());
console.log('ThemeProvider init - Should use dark mode:', shouldUseDarkMode);
if (shouldUseDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} catch (error) {
console.error('ThemeProvider init - Error accessing localStorage:', error);
// Fallback to system preference if localStorage access fails
setThemeModeState('system');
setDarkMode(getSystemPreference());
}
}
setMounted(true);
}, []);
// Listen for system preference changes
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
if (themeMode === 'system') {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [themeMode]);
// Function to set theme mode and update localStorage following Tailwind's recommendation
const setThemeMode = (mode: ThemeMode) => {
console.log('ThemeProvider - Setting theme mode to:', mode);
setThemeModeState(mode);
try {
if (mode === 'light') {
localStorage.setItem('theme', 'light');
console.log('ThemeProvider - Saved "light" to localStorage.theme');
setDarkMode(false);
} else if (mode === 'dark') {
localStorage.setItem('theme', 'dark');
console.log('ThemeProvider - Saved "dark" to localStorage.theme');
setDarkMode(true);
} else if (mode === 'system') {
// For system preference, remove the item from localStorage
localStorage.removeItem('theme');
console.log('ThemeProvider - Removed theme from localStorage for system preference');
const systemPreference = getSystemPreference();
console.log('ThemeProvider - System preference is dark:', systemPreference);
setDarkMode(systemPreference);
}
} catch (error) {
console.error('ThemeProvider - Error saving to localStorage:', error);
}
};
// Update DOM when darkMode changes
useEffect(() => {
if (!mounted) return;
console.log('ThemeProvider - Updating DOM, darkMode:', darkMode);
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [darkMode]);
}, [darkMode, mounted]);
// Prevent hydration mismatch by not rendering theme-dependent content until mounted
if (!mounted) {
return <>{children}</>;
}
return (
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
<ThemeContext.Provider value={{ darkMode, themeMode, setThemeMode }}>
{children}
</ThemeContext.Provider>
);

4
app/types/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { appConfig } from '../config/app';
// Define skin types
export type SkinId = keyof typeof appConfig.skins;