mirror of
https://github.com/HugeFrog24/shakethefrog.git
synced 2026-03-02 00:14:33 +00:00
RCE fix
This commit is contained in:
29
app/[locale]/layout.tsx
Normal file
29
app/[locale]/layout.tsx
Normal 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
251
app/[locale]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
121
app/components/LanguageToggle.tsx
Normal file
121
app/components/LanguageToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
app/components/SkinSelector.tsx
Normal file
151
app/components/SkinSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
14
app/config/emojis.ts
Normal 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)];
|
||||
}
|
||||
@@ -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
100
app/config/skin-names.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
27
app/hooks/useLocalizedSkinName.ts
Normal file
27
app/hooks/useLocalizedSkinName.ts
Normal 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
18
app/hooks/useSkin.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
170
app/page.tsx
170
app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
4
app/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { appConfig } from '../config/app';
|
||||
|
||||
// Define skin types
|
||||
export type SkinId = keyof typeof appConfig.skins;
|
||||
Reference in New Issue
Block a user