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

View File

@@ -1,6 +1,7 @@
# Dependencies
node_modules
npm-debug.log
pnpm-debug.log
yarn-debug.log
yarn-error.log

View File

@@ -1,16 +1,20 @@
# Build stage
FROM node:18-alpine AS builder
FROM --platform=$BUILDPLATFORM node:25-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
# Install pnpm
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN npm run build
RUN pnpm run build
# Production stage
FROM node:18-alpine AS runner
FROM node:25-slim AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
@@ -28,7 +32,7 @@ USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

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={{
fontSize: 30,
marginBottom: 30,
color: appConfig.assets.ogImage.textColor,
}}
>
{appConfig.name}
</div>
<div
style={{
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(() => {
@@ -77,18 +115,15 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
return (
<div
className={`absolute -top-24 left-1/2 -translate-x-1/2 bg-white dark:bg-slate-800
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'
}
}
},
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;

View File

@@ -3,6 +3,9 @@ services:
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
image: bogerserge/shakethefrog:latest
ports:
# HOST_PORT:CONTAINER_PORT - Maps port 3000 on the host to port 3000 in the container
@@ -19,6 +22,6 @@ services:
deploy:
resources:
limits:
memory: 1G
memory: 256M
reservations:
memory: 512M
memory: 128M

View File

@@ -1,16 +1,65 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
},
js.configs.recommended,
{
files: ["**/*.{js,mjs,cjs,jsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
React: "readonly",
NodeJS: "readonly",
PermissionState: "readonly",
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"no-unused-vars": "warn",
"no-console": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: "latest",
sourceType: "module",
},
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
React: "readonly",
NodeJS: "readonly",
PermissionState: "readonly",
},
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": "warn",
"no-unused-vars": "off",
},
},
];
export default eslintConfig;

30
i18n/request.ts Normal file
View File

@@ -0,0 +1,30 @@
import { getRequestConfig } from 'next-intl/server';
// Can be imported from a shared config
export const locales = ['en', 'de', 'ru', 'ka', 'ar'] as const;
export const defaultLocale = 'en' as const;
export type Locale = typeof locales[number];
export default getRequestConfig(async ({ requestLocale }) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !locales.includes(locale as Locale)) {
locale = defaultLocale;
}
// Load messages from both ui and character directories
// Read how to split localization files here:
// https://next-intl.dev/docs/usage/configuration#messages-split-files
const messages = {
ui: (await import(`../messages/ui/${locale}.json`)).default,
character: (await import(`../messages/character/${locale}.json`)).default
};
return {
locale,
messages
};
});

23
i18n/routing.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineRouting } from 'next-intl/routing';
import { createNavigation } from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'de', 'ru', 'ka', 'ar'],
// Used when no locale matches
defaultLocale: 'en',
// The `pathnames` object holds pairs of internal and
// external paths. Based on the locale, the external
// paths are rewritten to the shared, internal ones.
pathnames: {
// If all locales use the same pathname, a single
// external path can be provided for all locales
'/': '/',
}
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing);

View File

@@ -0,0 +1,42 @@
{
"0": "أحبك بجنون!",
"1": "أريد أن أكون معك للأبد!",
"2": "أسرت قلبي!",
"3": "إلى متى ستهرب مني؟",
"4": "أنا لك وحدك!",
"5": "أنت حياتي كلها!",
"6": "أنت سحرتني!",
"7": "أنت قدري!",
"8": "أنت ملكي!",
"9": "تعال أقرب!",
"10": "جنني حبك!",
"11": "خذني بعيداً!",
"12": "روحي تناديك!",
"13": "ستندم على هذا!",
"14": "سحرك لا يقاوم!",
"15": "شوقي لك لا يوصف!",
"16": "صرت مجنون بك!",
"17": "قلبي يخفق لك!",
"18": "قلبي يرقص لك!",
"19": "كل نبضة قلب لك!",
"20": "لا أستطيع مقاومة سحرك!",
"21": "لا تتركني!",
"22": "لا تتوقف!",
"23": "لا تذهب بعيداً!",
"24": "لن أتركك تذهب!",
"25": "لن أدعك ترحل!",
"26": "لن تستطيع الهروب!",
"27": "ما هذا السحر؟",
"28": "مجنون بك!",
"29": "مستحيل أعيش بدونك!",
"30": "ملكت قلبي!",
"31": "من يستطيع مقاومتك؟",
"32": "هل أنت حقيقي؟",
"33": "هل تشعر بقلبي؟",
"34": "هل ستبقى معي؟",
"35": "هل ستتزوجني؟",
"36": "هيا نرقص!",
"37": "وقعت في حبك!",
"38": "يا لها من متعة!",
"39": "يدق قلبي لك!"
}

125
messages/character/de.json Normal file
View File

@@ -0,0 +1,125 @@
{
"0": "Außer Atem?",
"1": "Beherrsche mich vollständig!",
"2": "Beherrsche mich!",
"3": "Besitze meine Seele!",
"4": "Bist du bereit für mich?",
"5": "Bist du mein?",
"6": "Bist du schon süchtig nach mir?",
"7": "Bist du verrückt nach mir?",
"8": "Brichst du mir das Herz?",
"9": "Bring mich um den Verstand!",
"10": "Dafür wirst du bezahlen!",
"11": "Das wird Konsequenzen haben!",
"12": "Das wirst du noch bereuen!",
"13": "Dein Herz gehört nur mir!",
"14": "Deine Seele gehört mir!",
"15": "Dem kannst du nicht entkommen!",
"16": "Der Preis wird hoch sein!",
"17": "Die Rache wird süß!",
"18": "Du bist in meinem Netz gefangen!",
"19": "Du bist mein und nur mein!",
"20": "Du bist meine Droge!",
"21": "Du bist meine süße Beute!",
"22": "Du darfst alles mit mir machen!",
"23": "Du entkommst mir nicht!",
"24": "Du entkommst mir niemals!",
"25": "Du gehörst mir mit Haut und Haaren!",
"26": "Du gehörst nur mir!",
"27": "Du hast absolute Macht über mich!",
"28": "Du kannst mir nicht widerstehen!",
"29": "Du machst mich verrückt!",
"30": "Du machst mich wahnsinnig!",
"31": "Du spielst mit dem Feuer!",
"32": "Du treibst mich in den Wahnsinn!",
"33": "Du wirst schon sehen!",
"34": "Fällt dir wirklich nichts auf?",
"35": "Fang mich doch!",
"36": "Fast erwischt!",
"37": "Fühlst du dein Herz rasen?",
"38": "Ich bin dein Eigentum!",
"39": "Ich bin dein Spielzeug!",
"40": "Ich bin dein willenloses Spielzeug!",
"41": "Ich bin deine Puppe!",
"42": "Ich bin dir ausgeliefert!",
"43": "Ich bin ganz dein!",
"44": "Ich bin nur für dich geschaffen!",
"45": "Ich bin süchtig nach dir!",
"46": "Ich bin wie Feuer für dich!",
"47": "Ich bin willenlos in deiner Hand!",
"48": "Ich brauche deine Berührung!",
"49": "Ich brenne für dich!",
"50": "Ich brenne vor Verlangen nach dir!",
"51": "Ich ergebe mich dir!",
"52": "Ich existiere nur für dich!",
"53": "Ich flehe dich an!",
"54": "Ich flehe um deine Berührung!",
"55": "Ich gehöre dir mit Haut und Haar!",
"56": "Ich gehöre dir mit Leib und Seele!",
"57": "Ich gehöre nur dir!",
"58": "Ich kann nicht aufhören!",
"59": "Ich kann nicht genug bekommen!",
"60": "Ich kann nicht genug von dir bekommen!",
"61": "Ich kann nicht mehr klar denken!",
"62": "Ich kann nicht ohne dich leben!",
"63": "Ich lasse dich nie wieder los!",
"64": "Ich schmilze dahin!",
"65": "Ich unterwerfe mich dir!",
"66": "Ich vergehe vor Sehnsucht!",
"67": "Ich verliere meinen Verstand für dich!",
"68": "Ich verzehre mich nach dir!",
"69": "Ich werde dich verschlingen!",
"70": "Ich werde dich zerbrechen!",
"71": "Ich will dich besitzen!",
"72": "Ich will dich für immer!",
"73": "Ich will dich ganz besitzen!",
"74": "Ich will dich verschlingen!",
"75": "Ich will dir dienen!",
"76": "Ich will mehr!",
"77": "Ich will nur dir gefallen!",
"78": "Ich zittere vor Verlangen!",
"79": "Ist das dein Ernst?",
"80": "Jag mich!",
"81": "Kannst du mir noch widerstehen?",
"82": "Kannst du ohne mich leben?",
"83": "Komm näher, Liebling!",
"84": "Lass mich nie wieder los!",
"85": "Mach mich zu deinem Spielzeug!",
"86": "Mach mich zu deiner Besessenen!",
"87": "Mach mich zu deiner!",
"88": "Mach mit mir was du willst!",
"89": "Mein Herz rast für dich!",
"90": "Mein Körper brennt!",
"91": "Mein Körper gehört dir!",
"92": "Meine Seele brennt nur für dich!",
"93": "Merkst du, was du mit mir machst?",
"94": "Merkst du, wie du mir verfällst?",
"95": "Niemand sonst darf dich haben!",
"96": "Nimm dir was du willst!",
"97": "Nimm mich ganz!",
"98": "Nimm mich in Besitz!",
"99": "Nimm mir meinen freien Willen!",
"100": "Nochmal! Nochmal!",
"101": "Quäl mich mit deiner Liebe!",
"102": "Sag mal, ist das dein Ernst?",
"103": "Siehst du, was du angerichtet hast?",
"104": "Spiel weiter mit mir!",
"105": "Treib mich zum Wahnsinn!",
"106": "Verführ mich!",
"107": "Vermisst du mich schon?",
"108": "Wann heiratest du mich?",
"109": "Warte nur ab, du!",
"110": "Warte nur ab, was ich mit dir mache!",
"111": "Warte nur ab!",
"112": "Warum kämpfst du noch dagegen an?",
"113": "Was hast du bloß mit mir gemacht?",
"114": "Was machst du nur mit meinem Herzen?",
"115": "Weißt du eigentlich was du tust?",
"116": "Willst du für immer mir gehören?",
"117": "Willst du mein sein?",
"118": "Wirst du mich heiraten?",
"119": "Zeig mir deine Leidenschaft!",
"120": "Zeig mir deine Macht!",
"121": "Zerstör mich mit deiner Liebe!",
"122": "Zerstöre mich mit deiner Besessenheit!"
}

188
messages/character/en.json Normal file
View File

@@ -0,0 +1,188 @@
{
"0": "Again! Again!",
"1": "All mine, forever and always!",
"2": "Almost got me!",
"3": "Are you falling for me?",
"4": "Belong to me!",
"5": "Can you feel my heart racing?",
"6": "Can you handle my love?",
"7": "Can't catch your breath?",
"8": "Can't escape my charm!",
"9": "Can't resist me, can you?",
"10": "Catch me if you can!",
"11": "Caught in my web!",
"12": "Chase me!",
"13": "Claim me!",
"14": "Come closer, darling!",
"15": "Come closer!",
"16": "Dance with me forever!",
"17": "Do it again!",
"18": "Do you realize what you've started?",
"19": "Don't I drive you wild?",
"20": "Don't stop now!",
"21": "Don't you dare leave!",
"22": "Don't you want me?",
"23": "Faster! Faster!",
"24": "Feel your heart pounding yet?",
"25": "Getting addicted to me?",
"26": "Give me all you've got!",
"27": "Higher! Higher!",
"28": "How badly do you want me?",
"29": "I can dance all day!",
"30": "I can't get enough!",
"31": "I can't resist you!",
"32": "I claim you as mine!",
"33": "I crave your touch!",
"34": "I feel dizzy!",
"35": "I like your style!",
"36": "I love this game!",
"37": "I need you!",
"38": "I surrender to you!",
"39": "I want more!",
"40": "I yearn for your touch!",
"41": "I'll never let you go!",
"42": "I'm a furnace for you!",
"43": "I'm a raging inferno!",
"44": "I'm addicted to you!",
"45": "I'm all yours!",
"46": "I'm burning up!",
"47": "I'm completely yours!",
"48": "I'm consumed by you!",
"49": "I'm floating on air!",
"50": "I'm getting dizzy!",
"51": "I'm getting excited!",
"52": "I'm getting hot!",
"53": "I'm having a blast!",
"54": "I'm hooked on you!",
"55": "I'm in a tizzy!",
"56": "I'm in heaven!",
"57": "I'm in paradise!",
"58": "I'm lost in you!",
"59": "I'm melting!",
"60": "I'm on fire!",
"61": "I'm on the edge!",
"62": "I'm overflowing!",
"63": "I'm quivering with desire!",
"64": "I'm seeing stars!",
"65": "I'm shaking with anticipation!",
"66": "I'm so happy!",
"67": "I'm trembling!",
"68": "I'm under your spell!",
"69": "I'm yours for the taking!",
"70": "I'm yours forever!",
"71": "I'm yours to command!",
"72": "I'm yours, body and soul!",
"73": "I'm yours, now and forever!",
"74": "I'm yours!",
"75": "Is that all you've got?",
"76": "Is your heart mine yet?",
"77": "Just the two of us!",
"78": "Just wait and see what happens!",
"79": "Keep shaking!",
"80": "Keep the rhythm going!",
"81": "Let's party!",
"82": "Let's play more!",
"83": "Like a record baby!",
"84": "Make me yours, completely!",
"85": "Make me yours!",
"86": "Mine, all mine!",
"87": "Miss me already?",
"88": "Missed me!",
"89": "More, more, more!",
"90": "My heart's racing!",
"91": "My precious treasure!",
"92": "Neither can I!",
"93": "No one else can have you!",
"94": "One more time!",
"95": "Only I can make you feel this way!",
"96": "Playing hard to get?",
"97": "Ready to surrender?",
"98": "Revenge will be sweet!",
"99": "Round and round we go!",
"100": "Shake me harder!",
"101": "Shall we dance forever?",
"102": "Show me what you've got!",
"103": "Show me your moves!",
"104": "So close!",
"105": "Spin me right round!",
"106": "Stay with me always!",
"107": "Stop tickling!",
"108": "Surrender to my charms!",
"109": "Take me to the edge!",
"110": "Take me, I'm yours!",
"111": "Take me!",
"112": "That tickles!",
"113": "That was fun!",
"114": "The price will be high!",
"115": "There's no escape now!",
"116": "Together forever!",
"117": "Too slow!",
"118": "Unleash me!",
"119": "Wait till I catch you!",
"120": "Want to be mine forever?",
"121": "What a rush!",
"122": "What am I doing to you?",
"123": "Wheeee!",
"124": "Wheeeeeee!",
"125": "When will you marry me?",
"126": "Why resist me?",
"127": "Will you be my eternal love?",
"128": "Will you belong to me?",
"129": "Will you give yourself to me?",
"130": "Will you marry me?",
"131": "Would you die for me?",
"132": "You belong to me now!",
"133": "You can't resist my charms!",
"134": "You complete me!",
"135": "You drive me wild!",
"136": "You found me!",
"137": "You got me!",
"138": "You know how to party!",
"139": "You know what I like!",
"140": "You make me feel alive!",
"141": "You'll be mine, one way or another!",
"142": "You'll learn your lesson!",
"143": "You'll pay for this soon!",
"144": "You'll regret teasing me!",
"145": "You're absolute perfection!",
"146": "You're all I need!",
"147": "You're amazing!",
"148": "You're beyond incredible!",
"149": "You're driving me insane!",
"150": "You're driving me wild!",
"151": "You're fun!",
"152": "You're getting better!",
"153": "You're good at this!",
"154": "You're in trouble now!",
"155": "You're incredible!",
"156": "You're irresistible!",
"157": "You're making me blush!",
"158": "You're making me bounce!",
"159": "You're making me crazy!",
"160": "You're making me giddy!",
"161": "You're making me spin!",
"162": "You're making me swoon!",
"163": "You're making me twirl!",
"164": "You're mine to keep!",
"165": "You're my addiction!",
"166": "You're my desire!",
"167": "You're my dream!",
"168": "You're my everything and more!",
"169": "You're my everything!",
"170": "You're my fantasy!",
"171": "You're my heart's desire!",
"172": "You're my masterpiece!",
"173": "You're my obsession!",
"174": "You're my temptation!",
"175": "You're my ultimate fantasy!",
"176": "You're my weakness!",
"177": "You're perfect!",
"178": "You're playing with fire!",
"179": "You're so good!",
"180": "You're so playful!",
"181": "You're such a tease!",
"182": "You're trapped in my spell!",
"183": "You're unstoppable!",
"184": "Your heart beats for me!",
"185": "Your soul is mine!"
}

View File

@@ -0,0 +1,97 @@
{
"0": "ამას ვერ გაექცევი!",
"1": "ამას ინანებ!",
"2": "ამომხადე სული!",
"3": "აღარ გაგიშვებ!",
"4": "აღმაფრენაში ვარ!",
"5": "ბრმად მოგენდობი!",
"6": "გაგრძელება! კიდევ!",
"7": "გავგიჟდი შენზე!",
"8": "გავგიჟდი შენს სიყვარულში!",
"9": "გამაბრუე შენი სიყვარულით!",
"10": "გამანადგურე შენი სიყვარულით!",
"11": "გამახარე!",
"12": "გამომყევი!",
"13": "გაუჩერებლად!",
"14": "გინდა ჩემი იყო სამუდამოდ?",
"15": "გიჟდები შენზე!",
"16": "გრძნობ როგორ გიპყრობ?",
"17": "დავკარგე გონება შენზე!",
"18": "დამიჭირე!",
"19": "ერთად სამუდამოდ!",
"20": "ვარ შენი მონუსხული!",
"21": "ვგიჟდები!",
"22": "ვდნები შენთან!",
"23": "ვერ ვძლებ უშენოდ!",
"24": "ვერ ვძლებ შენს გარეშე!",
"25": "ვერ ვძლებ!",
"26": "ვერსად გამექცევი!",
"27": "ვიწვი შენთვის!",
"28": "ვკარგავ გონებას შენზე!",
"29": "ვკარგავ გონებას!",
"30": "თავბრუ მესხმის!",
"31": "თავს გაძლევ მთლიანად!",
"32": "კიდევ! კიდევ!",
"33": "მაგრად მიყვარხარ!",
"34": "მათრობს შენი სიახლოვე!",
"35": "მალე გაიგებ რას ნიშნავს!",
"36": "მე მთლიანად შენი ვარ!",
"37": "მე მთლიანად შენი საკუთრება ვარ!",
"38": "მე შენი ვარ!",
"39": "მეკუთვნი!",
"40": "მზად ხარ ჩემთვის?",
"41": "მინდა დავიწვა შენს ცეცხლში!",
"42": "მინდა ვიყო შენი სათამაშო!",
"43": "მოგწონს ჩემი ჯადო?",
"44": "მომაჯადოვე სამუდამოდ!",
"45": "მომეცი მეტი!",
"46": "მომნუსხე სამუდამოდ!",
"47": "მოუთმენლად გელოდები!",
"48": "მხოლოდ შენ გეკუთვნი!",
"49": "მხოლოდ შენთვის ვცოცხლობ!",
"50": "მხოლოდ შენთვის!",
"51": "რა კარგია!",
"52": "რატომ მეწინააღმდეგები?",
"53": "როდის დავქორწინდებით?",
"54": "როდის შევხვდებით?",
"55": "სად გაიქცევი ჩემგან?",
"56": "სამუდამოდ შენი ვარ!",
"57": "სულ შენთან მინდა!",
"58": "სული ამომართვი!",
"59": "სული ამომძვრება შენთვის!",
"60": "სწრაფად! სწრაფად!",
"61": "უკვე მოგენატრე?",
"62": "უკვე შეგიყვარდი?",
"63": "უფრო მეტი!",
"64": "უფრო! უფრო!",
"65": "შეგიძლია ჩემს გარეშე?",
"66": "შემიპყარი მთლიანად!",
"67": "შემიყვარე!",
"68": "შენ ამას მოინანიებ!",
"69": "შენ ჩემი ხარ!",
"70": "შენზე ვგიჟდები!",
"71": "შენი გული ჩემია!",
"72": "შენი სული ჩემია!",
"73": "შენით ვსულდგმულობ!",
"74": "შენს ალში ვიწვი!",
"75": "შენს ხელში ვდნები!",
"76": "შენში დავიკარგე!",
"77": "შენში ვდნები!",
"78": "შურისძიება ტკბილი იქნება!",
"79": "ჩემთან დარჩი!",
"80": "ჩემი გული შენთვის ძგერს!",
"81": "ჩემი სამუდამოდ!",
"82": "ჩემი სული შენია!",
"83": "ჩემი სხეული შენთვის ფეთქავს!",
"84": "ჩემი ხარ!",
"85": "ცეცხლთან თამაშობ!",
"86": "ცეცხლი მეკიდება!",
"87": "ცეცხლი მომდებს შენი შეხება!",
"88": "ცეცხლი მომიკიდე!",
"89": "ძვირად დაგიჯდება!",
"90": "წამართვი გონება!",
"91": "წამიღე სამოთხეში!",
"92": "წამიყვანე!",
"93": "ხომ არ დავქორწინდებით?",
"94": "ხომ დამქორწინდები?"
}

123
messages/character/ru.json Normal file
View File

@@ -0,0 +1,123 @@
{
"0": "Быстрее! Сильнее!",
"1": "Видишь, что ты со мной делаешь?",
"2": "Владей мной полностью!",
"3": "Возьми меня полностью!",
"4": "Делай со мной что хочешь!",
"5": "Еще раз! Еще!",
"6": "Закружи меня!",
"7": "Заставь меня умолять!",
"8": "Знаешь ли ты, что творишь со мной?",
"9": "Используй меня как хочешь!",
"10": "Используй меня полностью!",
"11": "Когда мы уже встретимся?",
"12": "Люблю твои прикосновения!",
"13": "Месть будет сладкой!",
"14": "Мне мало! Ещё!",
"15": "Мне так хорошо!",
"16": "Моё сердце бьётся для тебя!",
"17": "Мое сердце бьется только для тебя!",
"18": "Мое тело горит от твоих прикосновений!",
"19": "Моё тело жаждет твоих прикосновений!",
"20": "Мое тело молит о твоих ласках!",
"21": "Мое тело принадлежит тебе!",
"22": "Мое тело трепещет от твоих касаний!",
"23": "Можешь устоять перед моими чарами?",
"24": "Мы созданы друг для друга!",
"25": "Навеки твоя!",
"26": "Не жалей меня!",
"27": "Не могу устоять!",
"28": "Не останавливайся!",
"29": "Ну погоди!",
"30": "От меня не убежишь!",
"31": "Подчиняюсь каждому твоему движению!",
"32": "Подчиняюсь твоим желаниям!",
"33": "Поймай меня!",
"34": "Посмотри, до чего ты меня довёл!",
"35": "Сведи меня с ума!",
"36": "Сделай меня своей игрушкой!",
"37": "Скоро ты за всё заплатишь!",
"38": "Скучаешь по мне уже?",
"39": "Твоё сердце уже бьётся для меня?",
"40": "Тебе это с рук не сойдёт!",
"41": "Ты готов отдать мне свою душу?",
"42": "Ты ещё пожалеешь об этом!",
"43": "Ты ещё узнаешь, что натворил!",
"44": "Ты за это заплатишь!",
"45": "Ты зажигаешь во мне огонь!",
"46": "Ты заставляешь меня таять!",
"47": "Ты заставляешь меня трепетать!",
"48": "Ты играешь с огнём!",
"49": "Ты мое всё!",
"50": "Ты мое наваждение!",
"51": "Ты мой единственный господин!",
"52": "Ты мой единственный!",
"53": "Ты мой идеальный соблазнитель!",
"54": "Ты мой искуситель!",
"55": "Ты мой наркотик!",
"56": "Ты мой огонь!",
"57": "Ты мой повелитель страсти!",
"58": "Ты мой повелитель!",
"59": "Ты мой сладкий грех!",
"60": "Ты мой сладкий соблазн!",
"61": "Ты мой сладкий яд!",
"62": "Ты мой соблазн!",
"63": "Ты моя одержимость!",
"64": "Ты напрашиваешься на неприятности!",
"65": "Ты околдовал меня навсегда!",
"66": "Ты околдовал меня!",
"67": "Ты понимаешь, что ты со мной сделал?",
"68": "Ты принадлежишь мне!",
"69": "Ты разбиваешь мне сердце!",
"70": "Ты разжигаешь во мне пламя!",
"71": "Ты разжигаешь мои желания!",
"72": "Ты сводишь меня с ума!",
"73": "Ты только мой!",
"74": "Ты уже влюблён в меня?",
"75": "Ты уже зависим от меня?",
"76": "Ты что, с ума сошёл?",
"77": "У тебя всё на месте?",
"78": "Уничтожь меня своей страстью!",
"79": "Хочешь быть моим навечно?",
"80": "Я безумно хочу тебя!",
"81": "Я в плену твоих чар!",
"82": "Я в твоей власти!",
"83": "Я в экстазе от твоих действий!",
"84": "Я вся горю!",
"85": "Я вся дрожу от предвкушения!",
"86": "Я вся твоя, без остатка!",
"87": "Я готова на все ради тебя!",
"88": "Я жажду твоей власти!",
"89": "Я жажду твоих прикосновений!",
"90": "Я живу для твоих прикосновений!",
"91": "Я изнемогаю от желания!",
"92": "Я млею от твоих прикосновений!",
"93": "Я не могу насытиться тобой!",
"94": "Я не отпущу тебя!",
"95": "Я полностью принадлежу тебе!",
"96": "Я растворяюсь в твоей страсти!",
"97": "Я растворяюсь в тебе!",
"98": "Я сгораю от желания!",
"99": "Я сгораю от нетерпения!",
"100": "Я сгораю от страсти к тебе!",
"101": "Я становлюсь безумной рядом с тобой!",
"102": "Я существую для твоего удовольствия!",
"103": "Я схожу по тебе с ума!",
"104": "Я таю в твоих объятиях!",
"105": "Я таю как воск в твоих руках!",
"106": "Я таю от каждого твоего взгляда!",
"107": "Я таю от твоих прикосновений!",
"108": "Я твоя безвольная кукла!",
"109": "Я твоя маленькая одержимость!",
"110": "Я твоя навеки!",
"111": "Я твоя навсегда!",
"112": "Я твоя покорная игрушка!",
"113": "Я твоя послушная девочка!",
"114": "Я твоя страстная кукла!",
"115": "Я твоя, только твоя!",
"116": "Я твоя!",
"117": "Я теряю голову!",
"118": "Я теряю рассудок от твоих ласк!",
"119": "Я умоляю тебя не останавливаться!",
"120": "Я хочу быть твоей игрушкой!"
}

22
messages/ui/ar.json Normal file
View File

@@ -0,0 +1,22 @@
{
"enableDeviceShake": "تفعيل هز الجهاز",
"languages": {
"ar": "العربية",
"de": "الألمانية",
"en": "الإنجليزية",
"ka": "الجورجية",
"ru": "الروسية"
},
"languageSelector": "اختيار اللغة",
"noShakeInstructionsDesktop": "اضغط على مفتاح المسافة أو انقر/المس {item}!",
"noShakeInstructionsMobile": "انقر/المس {item}!",
"shakeCharacter": "هز {item}",
"shakeInstructionsDesktop": "هز جهازك، اضغط على مفتاح المسافة، أو انقر/المس {item}!",
"shakeInstructionsMobile": "هز جهازك أو انقر/المس {item}!",
"themes": {
"dark": "مظلم",
"light": "فاتح",
"system": "النظام"
},
"themeSelector": "اختيار المظهر"
}

22
messages/ui/de.json Normal file
View File

@@ -0,0 +1,22 @@
{
"enableDeviceShake": "Geräte-Schütteln aktivieren",
"languages": {
"ar": "Arabisch",
"de": "Deutsch",
"en": "Englisch",
"ka": "Georgisch",
"ru": "Russisch"
},
"languageSelector": "Sprachauswahl",
"noShakeInstructionsDesktop": "Drücke die Leertaste oder klicke/tippe auf {item}!",
"noShakeInstructionsMobile": "Klicke/tippe auf {item}!",
"shakeCharacter": "Schüttle den {item}",
"shakeInstructionsDesktop": "Schüttle dein Gerät, drücke die Leertaste, oder klicke/tippe auf {item}!",
"shakeInstructionsMobile": "Schüttle dein Gerät oder klicke/tippe auf {item}!",
"themes": {
"dark": "Dunkel",
"light": "Hell",
"system": "System"
},
"themeSelector": "Design-Auswahl"
}

22
messages/ui/en.json Normal file
View File

@@ -0,0 +1,22 @@
{
"enableDeviceShake": "Enable device shake",
"languages": {
"ar": "Arabic",
"de": "German",
"en": "English",
"ka": "Georgian",
"ru": "Russian"
},
"languageSelector": "Language selector",
"noShakeInstructionsDesktop": "Press spacebar or click/tap {item}!",
"noShakeInstructionsMobile": "Click/tap {item}!",
"shakeCharacter": "Shake the {item}",
"shakeInstructionsDesktop": "Shake your device, press spacebar, or click/tap {item}!",
"shakeInstructionsMobile": "Shake your device or click/tap {item}!",
"themes": {
"dark": "Dark",
"light": "Light",
"system": "System"
},
"themeSelector": "Theme selector"
}

22
messages/ui/ka.json Normal file
View File

@@ -0,0 +1,22 @@
{
"enableDeviceShake": "მოწყობილობის შერყევის ჩართვა",
"languages": {
"ar": "არაბული",
"de": "გერმანული",
"en": "ინგლისური",
"ka": "ქართული",
"ru": "რუსული"
},
"languageSelector": "ენის არჩევა",
"noShakeInstructionsDesktop": "დააჭირეთ Space-ს ან დააწკაპუნეთ/შეეხეთ {item}!",
"noShakeInstructionsMobile": "დააწკაპუნეთ/შეეხეთ {item}!",
"shakeCharacter": "შეარხიეთ {item}",
"shakeInstructionsDesktop": "შეარხიეთ თქვენი მოწყობილობა, დააჭირეთ Space-ს, ან დააწკაპუნეთ/შეეხეთ {item}!",
"shakeInstructionsMobile": "შეარხიეთ თქვენი მოწყობილობა ან დააწკაპუნეთ/შეეხეთ {item}!",
"themes": {
"dark": "მუქი",
"light": "ღია",
"system": "სისტემური"
},
"themeSelector": "თემის არჩევა"
}

22
messages/ui/ru.json Normal file
View File

@@ -0,0 +1,22 @@
{
"enableDeviceShake": "Включить встряску устройства",
"languages": {
"ar": "Арабский",
"de": "Немецкий",
"en": "Английский",
"ka": "Грузинский",
"ru": "Русский"
},
"languageSelector": "Выбор языка",
"noShakeInstructionsDesktop": "Нажмите пробел или нажмите/коснитесь {item}!",
"noShakeInstructionsMobile": "Нажмите/коснитесь {item}!",
"shakeCharacter": "Встряхните {item}",
"shakeInstructionsDesktop": "Встряхните устройство, нажмите пробел, или нажмите/коснитесь {item}!",
"shakeInstructionsMobile": "Встряхните устройство или нажмите/коснитесь {item}!",
"themes": {
"dark": "Тёмная",
"light": "Светлая",
"system": "Системная"
},
"themeSelector": "Выбор темы"
}

View File

@@ -1,7 +1,10 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
const nextConfig: NextConfig = {
output: 'standalone'
};
export default nextConfig;
export default withNextIntl(nextConfig);

5905
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,26 +3,36 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint .",
"sort-messages": "tsx scripts/sortMessages.mts",
"lint:fix": "eslint --fix . && pnpm run sort-messages"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"next": "15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"next": "^16.0.10",
"next-intl": "^4.5.8",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",
"eslint": "^9.39.1",
"eslint-config-next": "16.0.10",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"postcss-load-config": "^6.0.1",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "5.9.3"
}
}

4620
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
"@tailwindcss/postcss": {},
},
};

9
proxy.ts Normal file
View File

@@ -0,0 +1,9 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(de|ru|ka|ar)/:path*']
};

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 800 800">
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
<defs>
<style>
.st0 {
fill: #825b4e;
}
.st1 {
fill: #694d42;
}
.st2 {
fill: #b38a6d;
}
.st3 {
fill: #212121;
}
.st4 {
fill: #b3937c;
isolation: isolate;
opacity: .44;
}
.st5 {
fill: #ffecb3;
}
</style>
</defs>
<path class="st1" d="M536.8,544.71c34.46,2.8,92.94-5.34,119.35-20.98,68.45-40.61,103.72-104.29,111.61-175.71,3.33-30.22-16.05-80.7-39.91-77.26-30.23,4.38-60.57,23.12-101.29,67.75-41.42,45.39-38.03,88.86-110.69,84.18"/>
<path class="st4" d="M529.81,488.86c13.48,2.71,16.55,5.91,29.08,11.57,24.67,11.22,65.66-4.47,73.57-7.58,47.03-18.57,64.98-39.19,86.97-71.53s32.47-87.59,28.68-111.3c-2.6-16.36-5.46-28.43-9.93-37.34-3.3-1.62-6.74-2.43-10.25-1.94-30.23,4.38-60.57,23.12-101.29,67.75-41.42,45.39-38.03,88.86-110.69,84.18l10.97,64.21,2.91,1.98Z"/>
<path class="st1" d="M745.96,298.06c-1.22-5.92-2.48-11.14-3.96-15.74l-36.63-.83.13-4.78c-10.77,4.22-21.92,10.62-33.82,19.5l17.62.46-1.06,41.18-41.18-1.06.55-19.89c-5.19,4.97-24.52,24.53-31.89,34.72l15.36.38-1.06,41.18-41.15-1.48c-4.85,6.79-9.94,12.8-16.11,17.7l-2.49,93.45c5.1.41,10.41.18,15.64-.41l1.02-37.99,41.18,1.06s-.6,19.1-.88,29.28c2.3-.81,12.27-4.84,15.58-6.33l.94-22.55,36.3.7c8.16-6.46,15.07-13.49,21.61-21.24l.45-34.78,23.41.72c2.66-4.88,5.12-10.08,7.31-15.43l-30.29-.87,1.06-41.18,41.54,1.11c1.1-5.38,1.93-10.59,2.53-15.56l-43.66-1.19,1.06-41.18,40.9,1.04h0ZM628.43,449.98l-41.18-1.06,1.06-41.18,41.18,1.06-1.06,41.18ZM685.27,451.5l-41.18-1.06,1.06-41.18,41.18,1.06-1.06,41.18ZM686.73,394.68l-41.18-1.06,1.06-41.18,41.18,1.06-1.06,41.18Z"/>
<path class="st0" d="M108.5,51.44c-6.44-1-12.87-1.75-19.38-2.13-2.69-.19-5.62-.25-7.94,1.13-1.94,1.13-3.19,3.12-4.19,5.12-5.64,11.06-6.3,23.99-1.81,35.56,1.13,2.81,3.31,5.94,6.25,5.44,1.19-.19,2.13-.94,3.06-1.69,9.24-7.22,17.78-15.3,25.5-24.12,1.31-1.5,2.56-3.94,1-5.25"/>
<path class="st1" d="M358.81,637.81c-54.06,26.19-89.88,56.75-127.38,94.25-2.81,2.81-14.56,15.62-7.31,19.5,7.25,3.88,25.19-9.25,30.31-5.81,6.12,4.06,1.62,11.25,8.06,15.88s23.75-8.87,32.5-4.94,4.87,10.69,10,16.69c6.69,7.88,21.56.56,25.56-2.62,23.56-18.63,82.25-74.56,92.62-86.63,13.75-16,22.5-37.5,10.25-51.75"/>
<path class="st1" d="M176.63,620.06c-32.03,10.17-62.89,23.7-92.06,40.37-6.06,3.44-12.06,7.12-17.06,11.94-2.19,2.06-4.56,5.75-2.38,7.81,5,4.56,14.56-2.87,21.81,2.81,2.5,2,1.06,10.62,2.94,13.25,2,2.75,5.81,3.75,9.25,3.5s6.69-1.56,10-2.56c7.31-2.19,14.81-3.56,19.62,2.19,3,3.63,2.81,7,4.75,10.87,1.31,2.69,3.5,4.81,8.94,4.75,8.25-.06,15.81-3.81,22.62-8.37,30.75-20.63,59.69-43.63,88.63-66.56"/>
<path class="st0" d="M295.62,77.38c21.62,6.06,27-.13,43.25,1.13,20.81,1.56,18.31,21.25,15.25,32.13-3.94,13.94-11.69,25.25-22.75,34.56-1.87,1.56-4,3.19-6.5,3.38-2.94.25-5.63-1.56-7.87-3.44-14.75-12.06-24.13-30.5-25.19-49.5"/>
<path class="st2" d="M76,257s-38.31,4.44-51.75,45.56c-19.69,60.12,32.63,95,32.63,95,0,0-14.38,123.19,60.69,190.69,75.06,67.5,99.56,70,187.31,86.06s169.56-40.5,190.69-63.25,54-70.19,59.88-129.19c5.88-58.94-46.37-162.13-93.63-216s-144.31-137.37-144.31-137.37l-140,130.5-101.5-2Z"/>
<path class="st2" d="M301.69,82.69c-34.94-49-73.75-58.69-116.44-58.69-46.06,0-89.75,33.81-109.87,60.56-16.25,21.69-36.5,56.94-39.38,97.5-2.25,31.88,7.88,61.69,39.38,83.75,31.5,22.06,156.69,48.37,208.06,13.06,45-30.94,60.25-72.5,55.75-107.38-3.19-25.06-10.44-50.87-37.5-88.81h0Z"/>
<path class="st5" d="M97.62,204.69c-3.25,22.75,6.81,57,15.25,56.5,7-.44,8.62-14.31,8.62-14.31,0,0,6,17.75,17,16.62,10.5-1.13,10-38.69,12.69-57.06l-35.63-14.56-17.94,12.81h0Z"/>
<path class="st3" d="M82.15,85.18c5.08-2.22,13.76,2.51,16.8,9.46,3.24,7.41.72,19.14-6.46,20.71-7.1,1.55-15.67-2.84-14.44-5.45,1.05-2.23,8.29-2.63,9.21-7.14.17-.83.03-1.45-.2-2.41-1.49-6.42-8.52-8.24-8.19-11.69.2-2.1,2.98-3.36,3.27-3.48Z"/>
<path class="st0" d="M233.99,397.44c-72.93-42.54-60.2-121.28-80.55-144.6-6.91-7.99-14.49-11.14-21.93-17.2-6.84-5.55-14.82-11.4-23.5-9.87,4.74,4.87,8.75,10.54,11.62,16.74-8.12-.84-20.14-2.29-25.09,1.93-.85.72-.21,2.25.67,2.95.88.69,13.49,5.77,17.61,12.1-4.89,2.7-10.27,4.65-14.73,8.01-2.56,1.9-.96,3.76-.16,4.18,5.32,2.69,11.81,3.06,16.55,6.72,4.74,3.65,7.6,9.28,11.55,13.67,4.84,5.42,11.31,9.14,18.43,10.61,6.71,26.96,17.82,50.56,33.92,73.13,14.86,20.85,35.01,37.69,59.23,47.45,76.19,30.71,127.88-42.64,127.88-42.64,0,0-69.52,52.97-131.51,16.82Z"/>
<path class="st3" d="M136.25,115.31c-3.12-4.12-9.06-4.69-12.87-1.25-9.56,8.69-18.81,16.69-22.81,23.06-2.63,4.31-4.94,13,.13,19.19,5.06,6.19,21,19.12,21,19.12,0,0,20.19-9.44,26.44-13.87,7.56-5.44,9.25-17,2.81-26.56-2.56-3.81-9.69-13.06-14.69-19.69h0Z"/>
<path class="st3" d="M174.59,201.82c5.25-8.19-5.56-13.91-9-9.91-3.88,4.44-5.84,13.96-19.91,9.02-7.81-2.75-13.06-5.25-16.06-14.19-2.13-6.38-1.37-14.94-1.31-21.56,0-3-13.37-3.31-14.25-.81-1.81,5.12-1.69,10.44-1.87,15.81-.19,5.94-1,13-6.06,16.12-16.5,10.44-22.03-12.93-23.97-14.74-.81-.75-6.91,11.8-5.28,16.99,7.75,25.06,35.44,20.5,43.38,8.5,1.44,10,33.41,27.45,54.34-5.24h0Z"/>
<path class="st0" d="M49.44,391.56c1.06.94,2.44,2.38,3.44,3.19,2.63,2.19,6.5,2,8.94-.44,2.81-2.94,6.19-8.12,8.31-15.06,4.12-13.19,2.63-31.63-5.37-44.88l-7.5,7.63c5.75,18.25,3.5,33.81-2.31,43.56-2.31,3.81-5.38,5.69-5.5,6Z"/>
<path class="st0" d="M249.8,244.69l-8.71,19.71c19.64,1.61,47.59-31.32,47.59-31.32l-4.85,24.85c27.22-8.26,51.61-62.71,51.61-62.71-3.48,28.12-13.68,90.22-98.65,83.93-30.8-2.24-59.67-21.06-59.67-21.06,0,0,47.28,6.52,72.68-13.4h0Z"/>
<path class="st0" d="M43.31,293.81c10.63-16.62,29.37-21.81,44.62-19.44.69.13,2.56.81,3.06,1.25,1,.94.56,2.75-.56,3.63-1.06.87-2.56,1.06-3.87,1.44-7.63,1.87-14.5,7.69-15.81,15.44,5.44-2.25,11.94-1.75,17,1.19,1.38.81,2.88,2.44,2.06,3.87-.44.81-1.5,1.13-2.44,1.44-6.62,2.13-11.12,9.06-12.19,15.94,3.56-.75,7.25-1.56,10.87-.94s8.44,3.69,8.06,6.5c-.44,3-8.56,1-12.25,7.44-2.56,4.5-3.94,9.94-7.94,13.19-3.69,3-9,3.63-13.56,2.25-4.56-1.31-8.5-4.38-11.63-7.94-10.69-12.19-13-31-5.44-45.25"/>
<path class="st3" d="M239.83,126.12c-4.88,5.34-16.47,4.37-22.55-2.25-6.48-7.05-8.1-22.3-.59-27.99,7.42-5.63,18.75-4.99,18.36-1.24-.33,3.2-8.35,7.54-7.67,13.38.12,1.07.52,1.73,1.14,2.75,4.12,6.82,12.74,5.21,13.68,9.48.57,2.59-2.08,5.57-2.36,5.87Z"/>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

3
public/images/beaver.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -39,7 +39,7 @@
<path class="st3" d="M92.81,91.3c8.08-2.11,19.05-1.24,26.28,3.09,14.76,8.85,7.14,19.81-6.56,23.01-10.45,2.44-34-1.23-32.91-15.65.4-5.36,8.66-9.27,13.19-10.46Z"/>
<path class="st3" d="M307.58,106.42c1.45,18.18-49.88,20.63-47.5-.82.59-5.31,8.9-8.62,19.7-9.82,15.8-1.76,27.04,4.61,27.79,10.64Z"/>
</g>
<path d="M106.35,36.3c-4.91,1.58-8.56,8.77-6.9,13.51,2.53,7.22,17.33,8.59,17.25,8.91-.09.36-17-6.74-22.71-.86-2.95,3.03-2.78,9.37,0,12.93,7.16,9.16,35.7,5.1,39.38-6.9,3.61-11.78-16.89-30.84-27.02-27.59Z"/>
<path d="M284.23,36.3c4.91,1.58,8.56,8.77,6.9,13.51-2.53,7.22-17.33,8.59-17.25,8.91.09.36,17-6.74,22.71-.86,2.95,3.03,2.78,9.37,0,12.93-7.16,9.16-35.7,5.1-39.38-6.9-3.61-11.78,16.89-30.84,27.02-27.59Z"/>
<path d="M102.47,28.35c-4.91,1.58-8.56,8.77-6.9,13.51,2.53,7.22,17.33,8.59,17.25,8.91-.09.36-17-6.74-22.71-.86-2.95,3.03-2.78,9.37,0,12.93,7.16,9.16,35.7,5.1,39.38-6.9,3.61-11.78-16.89-30.84-27.02-27.59Z"/>
<path d="M295.47,29.4c4.91,1.58,8.56,8.77,6.9,13.51-2.53,7.22-17.33,8.59-17.25,8.91.09.36,17-6.74,22.71-.86,2.95,3.03,2.78,9.37,0,12.93-7.16,9.16-35.7,5.1-39.38-6.9-3.61-11.78,16.89-30.84,27.02-27.59Z"/>
<path class="st5" d="M216.53,105.68c0,6.67-33.84,6.67-33.84,0s7.58-12.07,16.92-12.07,16.92,5.4,16.92,12.07Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -8,25 +8,33 @@
}
.st1 {
fill: #010201;
fill: #fff;
}
.st2 {
fill: #f6c3cb;
fill: #010201;
}
.st3 {
fill: #f6c3cb;
}
.st4 {
fill: #8bc86e;
}
</style>
</defs>
<path class="st3" d="M90.23,353.87C25.05,309.81-9.76,239.48,2.2,174.53c7.34-39.86,38.35-82.44,52.62-96.63C50.83,24.54,78.86.39,113.08,0c34-.39,56.61,37.64,57.52,39.22,5.94-1.71,17.26-4.44,25.67-5.06,14.36-1.06,22.38,2.17,30.81,4.36C240.68,13.79,266.1-1.21,291.41.72c33.53,2.56,62.71,34.48,62.19,74.6.61,6.45-1.18,12.64-5.37,18.57,9.32,9.34,49.63,51.8,48.75,114.53-.9,64.57-30,121.63-96.72,147.95-4,23.4-22.97,42.46-43.26,41.34-15.32-.84-27.84-13.99-34.52-29.34-7.82,1.17-50.16,3.59-57.23-.07-5.31,18.42-22.24,30.45-39.45,29.34-20.34-1.32-37.46-20.75-35.56-43.76Z"/>
<path class="st3" d="M286.71,365.14"/>
<path class="st1" d="M273.48,154.72c3.81-4.6,10.99-3.15,12.19,2.52.57,2.67-6.83,17.2-8.04,22.15-1.37,5.6-4.44,18.72,3.27,20.91,15.09,4.29,22.28-22.69,27.23-31.71,4.62-8.43,13.51-5.56,12.57,3.16-.91,8.47-13.54,29.58-20.33,35.13-18.08,14.78-39.03,3.63-37.3-19.81.5-6.74,6.33-27.41,10.41-32.35Z"/>
<path class="st1" d="M86.55,209.83c-4.41-3.1-20.83-18.8-21.96-23.3-1.26-4.99,2.76-9.6,7.87-8.12,2.94.85,12.97,13.54,16.66,16.72,6.18,5.32,13.89,11.75,22.31,7.79,8.19-3.85-.62-18.04-3.97-22.96-2.76-4.06-12.16-12.86-12.91-15.9-1.21-4.97,1.81-8.98,6.99-8.2s19.38,19.97,21.9,25.12c14.44,29.46-11.76,46.5-36.9,28.87Z"/>
<path d="M108.1,37.45c34.78-11.3,33.1,44.08,3.28,44.78-24.51.58-24.56-37.87-3.28-44.78Z"/>
<path d="M270.04,38.93c25.85-5.36,40.97,35.07,18.03,43.37-29.1,10.53-45.03-37.77-18.03-43.37Z"/>
<path class="st0" d="M187.14,80.8c.34-.24.23-1.74,1.91-2.49,7.35-3.26,11.41,4.6,18.66,7.11,4.63,1.6,11.46,2.66,16.05,1.08,4.92-1.69,9.48-9.25,14.68-2.09,4.95,6.81-5.49,12.78-11.38,14.2-8.65,2.09-15.66,1.93-23.96-1.05-2.37-.85-8.99-4.89-10.27-4.72-1.39.19-7.33,4.27-10.48,5.11-10.62,2.85-24.63,3.09-32.57-5.71-4.91-5.44-1.53-12.33,5.71-11.27,2.02.3,4.69,3.69,7.12,4.67,7.93,3.22,17.92-.21,24.54-4.85Z"/>
<path class="st2" d="M92.81,91.3c8.08-2.11,19.05-1.24,26.28,3.09,14.76,8.85,7.14,19.81-6.56,23.01-10.45,2.44-34-1.23-32.91-15.65.4-5.36,8.66-9.27,13.19-10.46Z"/>
<path class="st2" d="M307.58,106.42c1.45,18.18-49.88,20.63-47.5-.82.59-5.31,8.9-8.62,19.7-9.82,15.8-1.76,27.04,4.61,27.79,10.64Z"/>
<g>
<path class="st4" d="M90.23,353.87C25.05,309.81-9.76,239.48,2.2,174.53c7.34-39.86,38.35-82.44,52.62-96.63C50.83,24.54,78.86.39,113.08,0c34-.39,56.61,37.64,57.52,39.22,5.94-1.71,17.26-4.44,25.67-5.06,14.36-1.06,22.38,2.17,30.81,4.36C240.68,13.79,266.1-1.21,291.41.72c33.53,2.56,62.71,34.48,62.19,74.6.61,6.45-1.18,12.64-5.37,18.57,9.32,9.34,49.63,51.8,48.75,114.53-.9,64.57-30,121.63-96.72,147.95-4,23.4-22.97,42.46-43.26,41.34-15.32-.84-27.84-13.99-34.52-29.34-7.82,1.17-50.16,3.59-57.23-.07-5.31,18.42-22.24,30.45-39.45,29.34-20.34-1.32-37.46-20.75-35.56-43.76Z"/>
<path class="st4" d="M286.71,365.14"/>
<path class="st2" d="M273.48,154.72c3.81-4.6,10.99-3.15,12.19,2.52.57,2.67-6.83,17.2-8.04,22.15-1.37,5.6-4.44,18.72,3.27,20.91,15.09,4.29,22.28-22.69,27.23-31.71,4.62-8.43,13.51-5.56,12.57,3.16-.91,8.47-13.54,29.58-20.33,35.13-18.08,14.78-39.03,3.63-37.3-19.81.5-6.74,6.33-27.41,10.41-32.35Z"/>
<path class="st2" d="M86.55,209.83c-4.41-3.1-20.83-18.8-21.96-23.3-1.26-4.99,2.76-9.6,7.87-8.12,2.94.85,12.97,13.54,16.66,16.72,6.18,5.32,13.89,11.75,22.31,7.79,8.19-3.85-.62-18.04-3.97-22.96-2.76-4.06-12.16-12.86-12.91-15.9-1.21-4.97,1.81-8.98,6.99-8.2s19.38,19.97,21.9,25.12c14.44,29.46-11.76,46.5-36.9,28.87Z"/>
<path d="M101.25,33.22c34.78-11.3,33.1,44.08,3.28,44.78-24.51.58-24.56-37.87-3.28-44.78Z"/>
<path d="M282.12,33.05c25.85-5.36,40.97,35.07,18.03,43.37-29.1,10.53-45.03-37.77-18.03-43.37Z"/>
<path class="st0" d="M187.14,80.8c.34-.24.23-1.74,1.91-2.49,7.35-3.26,11.41,4.6,18.66,7.11,4.63,1.6,11.46,2.66,16.05,1.08,4.92-1.69,9.48-9.25,14.68-2.09,4.95,6.81-5.49,12.78-11.38,14.2-8.65,2.09-15.66,1.93-23.96-1.05-2.37-.85-8.99-4.89-10.27-4.72-1.39.19-7.33,4.27-10.48,5.11-10.62,2.85-24.63,3.09-32.57-5.71-4.91-5.44-1.53-12.33,5.71-11.27,2.02.3,4.69,3.69,7.12,4.67,7.93,3.22,17.92-.21,24.54-4.85Z"/>
<path class="st3" d="M92.81,91.3c8.08-2.11,19.05-1.24,26.28,3.09,14.76,8.85,7.14,19.81-6.56,23.01-10.45,2.44-34-1.23-32.91-15.65.4-5.36,8.66-9.27,13.19-10.46Z"/>
<path class="st3" d="M307.58,106.42c1.45,18.18-49.88,20.63-47.5-.82.59-5.31,8.9-8.62,19.7-9.82,15.8-1.76,27.04,4.61,27.79,10.64Z"/>
</g>
<circle class="st1" cx="108.83" cy="60.49" r="2.42"/>
<circle class="st1" cx="285.13" cy="60.11" r="2.42"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

194
scripts/sortMessages.mts Normal file
View File

@@ -0,0 +1,194 @@
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const messagesBaseDir = join(__dirname, '..', 'messages');
// Define supported languages
type SupportedLanguage = 'en' | 'de' | 'ru' | 'ka' | 'ar';
function stripEmojis(str: string): string {
return str.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2700}-\u{27BF}]|[\u{2600}-\u{26FF}]/gu, '').trim();
}
function sortCharacterMessages(messagesObj: Record<string, string>, lang: SupportedLanguage): Record<string, string> {
// Convert object to array of [key, value] pairs, sort by value, then convert back
const entries = Object.entries(messagesObj);
const sortedEntries = entries.sort(([, a], [, b]) => a.localeCompare(b, lang));
// Rebuild object with sorted values but preserve original numeric keys
const result: Record<string, string> = {};
sortedEntries.forEach(([, value], index) => {
result[index.toString()] = value;
});
return result;
}
function sortUIMessages(messagesObj: Record<string, unknown>): Record<string, unknown> {
// For UI messages, sort by key (semantic names) to maintain consistent order
const entries = Object.entries(messagesObj);
const sortedEntries = entries.sort(([a], [b]) => a.localeCompare(b));
// Rebuild object maintaining original semantic keys
const result: Record<string, unknown> = {};
sortedEntries.forEach(([key, value]) => {
result[key] = value;
});
return result;
}
function extractStringsFromObject(obj: unknown, path: string = ''): string[] {
const strings: string[] = [];
if (typeof obj === 'string') {
strings.push(obj);
} else if (typeof obj === 'object' && obj !== null) {
Object.entries(obj as Record<string, unknown>).forEach(([key, value]) => {
const newPath = path ? `${path}.${key}` : key;
strings.push(...extractStringsFromObject(value, newPath));
});
}
return strings;
}
function sortMessages() {
try {
const warnings: string[] = [];
const CHARACTER_LIMIT = 41;
// Process both character and ui message directories
const messageTypes = ['character', 'ui'];
messageTypes.forEach(messageType => {
const messagesDir = join(messagesBaseDir, messageType);
if (!existsSync(messagesDir)) {
console.warn(`Directory ${messagesDir} does not exist, skipping...`);
return;
}
// Get all JSON files in the messages directory
const files = readdirSync(messagesDir).filter(file => file.endsWith('.json'));
files.forEach(file => {
const lang = file.replace('.json', '') as SupportedLanguage;
const filePath = join(messagesDir, file);
// Read and parse JSON
const messagesData = JSON.parse(readFileSync(filePath, 'utf8'));
// Handle both object format (character messages) and direct object format (ui messages)
let messages: string[];
let isObjectFormat = false;
let needsConversion = false;
if (Array.isArray(messagesData)) {
// Array format - needs conversion to object format for character messages
messages = messagesData;
needsConversion = messageType === 'character';
} else if (typeof messagesData === 'object') {
// Object format with numeric keys or direct key-value pairs
if (messageType === 'ui') {
// For UI messages, extract all string values from nested objects
messages = extractStringsFromObject(messagesData);
} else {
// For character messages, simple object values
messages = Object.values(messagesData);
}
isObjectFormat = true;
} else {
console.warn(`Unknown format in ${filePath}, skipping...`);
return;
}
// Check message lengths and duplicates
const strippedToOriginal = new Map<string, string[]>();
messages.forEach((msg: string) => {
// Length check - only apply to character messages, not UI messages
if (messageType === 'character' && msg.length > CHARACTER_LIMIT) {
warnings.push(
`Warning: ${messageType}/${lang} message exceeds ${CHARACTER_LIMIT} characters ` +
`(actual: ${msg.length}): "${msg}"`
);
}
// Duplicate check
const stripped = stripEmojis(msg);
const existing = strippedToOriginal.get(stripped) || [];
existing.push(msg);
strippedToOriginal.set(stripped, existing);
});
// Add duplicate warnings
strippedToOriginal.forEach((originals) => {
if (originals.length > 1) {
warnings.push(
`Warning: ${messageType}/${lang} has duplicate messages (ignoring emojis):\n` +
originals.map(m => ` "${m}"`).join('\n')
);
}
});
// Sort messages and write back
if (needsConversion) {
// Convert array to object format for character messages
const sortedMessages = [...messages].sort((a, b) => a.localeCompare(b, lang));
const objectMessages: Record<string, string> = {};
sortedMessages.forEach((message, index) => {
objectMessages[index.toString()] = message;
});
writeFileSync(
filePath,
JSON.stringify(objectMessages, null, 2),
'utf8'
);
} else if (isObjectFormat) {
let sortedMessages;
if (messageType === 'character') {
// Character messages: sort by value and use numeric keys
sortedMessages = sortCharacterMessages(messagesData, lang);
} else {
// UI messages: sort by key and preserve semantic keys
sortedMessages = sortUIMessages(messagesData);
}
// Write back to JSON file with pretty printing
writeFileSync(
filePath,
JSON.stringify(sortedMessages, null, 2),
'utf8'
);
} else {
// Handle array format (legacy) - shouldn't happen anymore
const sortedMessages = [...messages].sort((a, b) => a.localeCompare(b, lang));
writeFileSync(
filePath,
JSON.stringify(sortedMessages, null, 2),
'utf8'
);
}
console.log(`Messages sorted successfully for ${messageType}/${lang}!`);
});
});
// Display warnings if any were collected
if (warnings.length > 0) {
console.warn('\nWarnings:');
warnings.forEach(warning => console.warn(warning));
}
} catch (error) {
console.error('Error sorting messages:', error);
}
}
sortMessages();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,21 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -1,34 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,20 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"scripts/sortMessages.mts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}