RCE fix
@@ -1,6 +1,7 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
|
pnpm-debug.log
|
||||||
yarn-debug.log
|
yarn-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
|
|
||||||
|
|||||||
20
Dockerfile
@@ -1,16 +1,20 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:18-alpine AS builder
|
FROM --platform=$BUILDPLATFORM node:25-slim AS builder
|
||||||
WORKDIR /app
|
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 . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:18-alpine AS runner
|
FROM node:25-slim AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
@@ -28,7 +32,7 @@ USER nextjs
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
29
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { locales } from '../../i18n/request';
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Ensure that the incoming `locale` is valid
|
||||||
|
if (!locales.includes(locale as (typeof locales)[number])) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing all messages to the client
|
||||||
|
// side is the easiest way to get started
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { FloatingHearts } from '../components/FloatingHearts';
|
||||||
|
import { ThemeToggle } from '../components/ThemeToggle';
|
||||||
|
import { SpeechBubble } from '../components/SpeechBubble';
|
||||||
|
import { SkinSelector } from '../components/SkinSelector';
|
||||||
|
import { shakeConfig } from '../config/shake';
|
||||||
|
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { appConfig } from '../config/app';
|
||||||
|
import { useSkin } from '../hooks/useSkin';
|
||||||
|
import { LanguageToggle } from '../components/LanguageToggle';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [isShaken, setIsShaken] = useState(false);
|
||||||
|
const [shakeIntensity, setShakeIntensity] = useState(0);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(0);
|
||||||
|
const [shakeCount, setShakeCount] = useState(0);
|
||||||
|
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [, setIsAnimating] = useState(false);
|
||||||
|
const [, setShakeQueue] = useState<number[]>([]);
|
||||||
|
const isAnimatingRef = useRef<boolean>(false);
|
||||||
|
const animationTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
|
const animationStartTimeRef = useRef<number>(0);
|
||||||
|
const currentSkin = useSkin();
|
||||||
|
const getLocalizedSkinName = useLocalizedSkinName();
|
||||||
|
const t = useTranslations('ui');
|
||||||
|
|
||||||
|
// Check if device motion is available and handle permissions
|
||||||
|
const requestMotionPermission = async () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
// Check if device motion is available
|
||||||
|
if (!('DeviceMotionEvent' in window)) {
|
||||||
|
setMotionPermission('denied');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request permission on iOS devices
|
||||||
|
if ('requestPermission' in DeviceMotionEvent) {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error - TypeScript doesn't know about requestPermission
|
||||||
|
const permission = await DeviceMotionEvent.requestPermission();
|
||||||
|
setMotionPermission(permission);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error requesting motion permission:', err);
|
||||||
|
setMotionPermission('denied');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Android or desktop - no permission needed
|
||||||
|
setMotionPermission('granted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerShake = useCallback((intensity: number) => {
|
||||||
|
// Use ref instead of state to prevent race conditions
|
||||||
|
if (!isAnimatingRef.current) {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (animationTimeoutRef.current) {
|
||||||
|
clearTimeout(animationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start shake animation
|
||||||
|
isAnimatingRef.current = true;
|
||||||
|
animationStartTimeRef.current = Date.now(); // Track when animation starts
|
||||||
|
setIsAnimating(true);
|
||||||
|
setIsShaken(true);
|
||||||
|
setShakeIntensity(intensity);
|
||||||
|
setShakeCount(count => count + 1);
|
||||||
|
|
||||||
|
// Reset shake after configured duration
|
||||||
|
animationTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsShaken(false);
|
||||||
|
setShakeIntensity(0);
|
||||||
|
setIsAnimating(false);
|
||||||
|
isAnimatingRef.current = false;
|
||||||
|
|
||||||
|
// Process next shake in queue if any
|
||||||
|
setShakeQueue(prev => {
|
||||||
|
if (prev.length > 0) {
|
||||||
|
const [nextIntensity, ...rest] = prev;
|
||||||
|
// Small delay before triggering next shake to ensure clean transition
|
||||||
|
setTimeout(() => {
|
||||||
|
triggerShake(nextIntensity);
|
||||||
|
}, 16);
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, shakeConfig.animations.shakeReset);
|
||||||
|
} else {
|
||||||
|
// Only queue if we're not at the start of the animation
|
||||||
|
const timeSinceStart = Date.now() - animationStartTimeRef.current;
|
||||||
|
if (timeSinceStart > 100) { // Only queue if animation has been running for a bit
|
||||||
|
setShakeQueue(prev => {
|
||||||
|
// Hard limit at 1 item
|
||||||
|
if (prev.length >= 1) return prev;
|
||||||
|
return [...prev, intensity];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []); // Remove isAnimating from dependencies since we're using ref
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (event: KeyboardEvent) => {
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
triggerShake(shakeConfig.defaultTriggerIntensity);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMotion = (event: DeviceMotionEvent) => {
|
||||||
|
const acceleration = event.accelerationIncludingGravity;
|
||||||
|
if (!acceleration) return;
|
||||||
|
|
||||||
|
const currentTime = new Date().getTime();
|
||||||
|
const timeDiff = currentTime - lastUpdate;
|
||||||
|
|
||||||
|
if (timeDiff > shakeConfig.debounceTime) {
|
||||||
|
setLastUpdate(currentTime);
|
||||||
|
|
||||||
|
const speed = Math.abs(acceleration.x || 0) +
|
||||||
|
Math.abs(acceleration.y || 0) +
|
||||||
|
Math.abs(acceleration.z || 0);
|
||||||
|
|
||||||
|
if (speed > shakeConfig.threshold) {
|
||||||
|
triggerShake(speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only add motion listener if permission is granted
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
|
||||||
|
window.addEventListener('devicemotion', handleMotion);
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (motionPermission === 'granted') {
|
||||||
|
window.removeEventListener('devicemotion', handleMotion);
|
||||||
|
}
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [lastUpdate, motionPermission, triggerShake]);
|
||||||
|
|
||||||
|
// Initial permission check
|
||||||
|
useEffect(() => {
|
||||||
|
requestMotionPermission();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
// Trigger haptic feedback for tap interaction
|
||||||
|
if ('vibrate' in navigator) {
|
||||||
|
navigator.vibrate(50); // Short 50ms vibration
|
||||||
|
}
|
||||||
|
triggerShake(shakeConfig.defaultTriggerIntensity);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add cleanup in the component
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (animationTimeoutRef.current) {
|
||||||
|
clearTimeout(animationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
|
||||||
|
<div className="w-full flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageToggle />
|
||||||
|
<SkinSelector />
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center w-full">
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<FloatingHearts intensity={shakeIntensity} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="relative z-10"
|
||||||
|
aria-label={t('shakeCharacter', { item: getLocalizedSkinName(currentSkin) })}
|
||||||
|
>
|
||||||
|
<FloatingHearts intensity={shakeIntensity} />
|
||||||
|
<SpeechBubble
|
||||||
|
isShaken={isShaken}
|
||||||
|
triggerCount={shakeCount}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={isShaken
|
||||||
|
? appConfig.skins[currentSkin].shaken
|
||||||
|
: appConfig.skins[currentSkin].normal
|
||||||
|
}
|
||||||
|
alt={getLocalizedSkinName(currentSkin)}
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
priority
|
||||||
|
className={isShaken ? 'animate-shake' : ''}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col items-center gap-2">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
|
||||||
|
{motionPermission === 'prompt' ? (
|
||||||
|
<button
|
||||||
|
onClick={requestMotionPermission}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
{t('enableDeviceShake')}
|
||||||
|
</button>
|
||||||
|
) : motionPermission === 'granted' ? (
|
||||||
|
t(
|
||||||
|
isMobile ? 'shakeInstructionsMobile' : 'shakeInstructionsDesktop',
|
||||||
|
{ item: getLocalizedSkinName(currentSkin) }
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
t(
|
||||||
|
isMobile ? 'noShakeInstructionsMobile' : 'noShakeInstructionsDesktop',
|
||||||
|
{ item: getLocalizedSkinName(currentSkin) }
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="w-full text-center text-xs text-gray-400 dark:text-gray-600 mt-auto pt-4">
|
||||||
|
© {new Date().getFullYear()}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/HugeFrog24/shakethefrog"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-gray-600 dark:hover:text-gray-400 transition-colors inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{appConfig.name}
|
||||||
|
<ArrowTopRightOnSquareIcon className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,23 +18,33 @@ export async function GET(request: Request) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: appConfig.assets.ogImage.bgColor,
|
backgroundColor: appConfig.assets.ogImage.bgColor,
|
||||||
fontSize: 60,
|
fontSize: 72,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${baseUrl}${appConfig.assets.favicon}`}
|
src={`${baseUrl}${appConfig.assets.favicon}`}
|
||||||
alt={appConfig.name}
|
alt={appConfig.name}
|
||||||
width={200}
|
width={300}
|
||||||
height={200}
|
height={300}
|
||||||
style={{ margin: '0 0 40px' }}
|
style={{ margin: '0 0 40px' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginBottom: 20 }}>{appConfig.name}</div>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 30,
|
marginBottom: 30,
|
||||||
|
color: appConfig.assets.ogImage.textColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{appConfig.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
color: appConfig.assets.ogImage.textColor
|
color: appConfig.assets.ogImage.textColor,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '80%',
|
||||||
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{appConfig.description}
|
{appConfig.description}
|
||||||
|
|||||||
121
app/components/LanguageToggle.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '../../i18n/routing';
|
||||||
|
import { GlobeAltIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
type Locale = 'en' | 'de' | 'ru' | 'ka' | 'ar';
|
||||||
|
|
||||||
|
interface LanguageOption {
|
||||||
|
code: Locale;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageToggle() {
|
||||||
|
const locale = useLocale() as Locale;
|
||||||
|
const t = useTranslations('ui');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Define the available locales
|
||||||
|
const locales: Locale[] = ['en', 'de', 'ru', 'ka', 'ar'];
|
||||||
|
|
||||||
|
const languageOptions: LanguageOption[] = locales.map((code) => ({
|
||||||
|
code,
|
||||||
|
name: t(`languages.${code}`)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentLanguage = languageOptions.find(lang => lang.code === locale) || languageOptions[0];
|
||||||
|
|
||||||
|
// Handle clicking outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle escape key to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Main toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleDropdown}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label={t('languageSelector')}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<GlobeAltIcon className="w-4 h-4 text-gray-700 dark:text-gray-300" />
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
|
||||||
|
{currentLanguage.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||||
|
<div className="py-1">
|
||||||
|
{languageOptions.map((option) => (
|
||||||
|
<Link
|
||||||
|
key={option.code}
|
||||||
|
href="/"
|
||||||
|
locale={option.code}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||||
|
locale === option.code
|
||||||
|
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<GlobeAltIcon className={`w-4 h-4 ${
|
||||||
|
locale === option.code ? 'text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'
|
||||||
|
}`} />
|
||||||
|
<span>{option.name}</span>
|
||||||
|
{locale === option.code && (
|
||||||
|
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
app/components/SkinSelector.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { appConfig } from '../config/app';
|
||||||
|
import { SkinId } from '../types';
|
||||||
|
import { useLocalizedSkinName } from '../hooks/useLocalizedSkinName';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface SkinOption {
|
||||||
|
id: SkinId;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkinSelector() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const getLocalizedSkinName = useLocalizedSkinName();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const skinOptions: SkinOption[] = Object.entries(appConfig.skins).map(([id, skin]) => ({
|
||||||
|
id: id as SkinId,
|
||||||
|
name: getLocalizedSkinName(id),
|
||||||
|
image: skin.normal
|
||||||
|
}));
|
||||||
|
|
||||||
|
const skinParam = searchParams.get('skin');
|
||||||
|
|
||||||
|
// Validate that the skin exists in our config
|
||||||
|
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
|
||||||
|
|
||||||
|
// Use the skin from URL if valid, otherwise use default skin
|
||||||
|
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
|
||||||
|
const currentSkinOption = skinOptions.find(skin => skin.id === currentSkin) || skinOptions[0];
|
||||||
|
|
||||||
|
const handleSkinChange = useCallback((newSkin: SkinId) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
if (newSkin === appConfig.defaultSkin) {
|
||||||
|
params.delete('skin');
|
||||||
|
} else {
|
||||||
|
params.set('skin', newSkin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||||
|
router.push(newUrl);
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [router, searchParams]);
|
||||||
|
|
||||||
|
// Handle clicking outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle escape key to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const toggleDropdown = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Main toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleDropdown}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-lg bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label="Skin selector"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={currentSkinOption.image}
|
||||||
|
alt={currentSkinOption.name}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
|
||||||
|
{currentSkinOption.name}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||||
|
<div className="py-1">
|
||||||
|
{skinOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => handleSkinChange(option.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||||
|
currentSkin === option.id
|
||||||
|
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={option.image}
|
||||||
|
alt={option.name}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>{option.name}</span>
|
||||||
|
{currentSkin === option.id && (
|
||||||
|
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { 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
|
// Increase visibility duration for speech bubbles
|
||||||
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
|
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
|
||||||
@@ -14,18 +15,51 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
||||||
|
const allMessages = useMessages();
|
||||||
|
const messagesRef = useRef<string[]>([]);
|
||||||
const lastTriggerTime = useRef(0);
|
const lastTriggerTime = useRef(0);
|
||||||
const showTimeRef = useRef<number>(0);
|
const showTimeRef = useRef<number>(0);
|
||||||
const lastFadeTime = useRef(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 getRandomMessage = useCallback(() => {
|
||||||
const randomIndex = Math.floor(Math.random() * frogMessages.length);
|
const currentMessages = messagesRef.current;
|
||||||
return frogMessages[randomIndex];
|
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
|
// Handle new trigger events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (triggerCount === 0) return;
|
if (triggerCount === 0 || messagesRef.current.length === 0) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeSinceLastFade = now - lastFadeTime.current;
|
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 we're in cooldown, or a message is visible, queue the new message
|
||||||
if (timeSinceLastFade < COOLDOWN_MS || isVisible) {
|
if (timeSinceLastFade < COOLDOWN_MS || isVisible) {
|
||||||
const newMessage = getRandomMessage();
|
const newMessage = getRandomMessage();
|
||||||
setMessageQueue(prev => [...prev, newMessage]);
|
if (newMessage) {
|
||||||
|
setMessageQueue(prev => [...prev, newMessage]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +77,11 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
|
|||||||
lastTriggerTime.current = now;
|
lastTriggerTime.current = now;
|
||||||
showTimeRef.current = now;
|
showTimeRef.current = now;
|
||||||
const newMessage = getRandomMessage();
|
const newMessage = getRandomMessage();
|
||||||
setMessage(newMessage);
|
if (newMessage) {
|
||||||
setIsVisible(true);
|
setMessage(newMessage);
|
||||||
}, [triggerCount, getRandomMessage, isVisible]);
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
}, [triggerCount, isVisible, getRandomMessage]);
|
||||||
|
|
||||||
// Handle message queue
|
// Handle message queue
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,18 +115,15 @@ export function SpeechBubble({ triggerCount }: SpeechBubbleProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
px-4 py-2 rounded-xl shadow-lg z-20 transition-opacity duration-300
|
||||||
${isVisible ? 'opacity-100 animate-float' : 'opacity-0 pointer-events-none'}`}
|
${isVisible ? 'opacity-100 animate-float' : 'opacity-0 pointer-events-none'}`}
|
||||||
|
style={{
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
{message}
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,138 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useTheme } from '../providers/ThemeProvider';
|
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() {
|
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 (
|
return (
|
||||||
<button
|
<div className="relative" ref={dropdownRef}>
|
||||||
onClick={toggleDarkMode}
|
{/* Main toggle button */}
|
||||||
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"
|
<button
|
||||||
aria-label="Toggle dark mode"
|
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"
|
||||||
{darkMode ? (
|
aria-label={t('themeSelector')}
|
||||||
<SunIcon className="w-6 h-6 text-yellow-500" />
|
aria-expanded={isOpen}
|
||||||
) : (
|
aria-haspopup="true"
|
||||||
<MoonIcon className="w-6 h-6 text-gray-900" />
|
>
|
||||||
|
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||||
|
{currentTheme.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300 min-w-[60px] text-left hidden min-[360px]:block">
|
||||||
|
{currentTheme.label}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-32 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||||
|
<div className="py-1">
|
||||||
|
{themeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
onClick={() => handleThemeSelect(option.mode)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
|
||||||
|
themeMode === option.mode
|
||||||
|
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400'
|
||||||
|
: 'text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<div className={themeMode === option.mode ? 'text-blue-600 dark:text-blue-400' : ''}>
|
||||||
|
{option.icon}
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{themeMode === option.mode && (
|
||||||
|
<div className="ml-auto w-2 h-2 bg-blue-600 dark:bg-blue-400 rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,36 @@
|
|||||||
export const appConfig = {
|
export const appConfig = {
|
||||||
name: 'Shake the Frog',
|
name: 'Shake the Frog',
|
||||||
description: 'A fun interactive frog that reacts to shaking!',
|
description: 'A fun interactive frog that reacts to shaking!',
|
||||||
url: 'https://shakethefrog.vercel.app',
|
url: 'https://shakethefrog.com',
|
||||||
assets: {
|
assets: {
|
||||||
favicon: '/images/frog.svg',
|
favicon: '/images/frog.svg',
|
||||||
ogImage: {
|
ogImage: {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
bgColor: '#f0fdf4',
|
bgColor: '#c9ffda',
|
||||||
textColor: '#374151'
|
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
|
} as const
|
||||||
14
app/config/emojis.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Define our curated emoji pool
|
||||||
|
const emojiPool = [
|
||||||
|
'💫', '💝', '💘', '💖', '💕',
|
||||||
|
'💓', '💗', '💞', '✨', '🌟',
|
||||||
|
'🔥', '👼', '⭐', '💎', '💨',
|
||||||
|
'🎉', '🕸️', '🤗', '💋', '😘',
|
||||||
|
'🫂', '👫', '💟', '💌', '🥰',
|
||||||
|
'😍', '🥺', '😢', '😭'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper function to get a random emoji
|
||||||
|
export function getRandomEmoji(): string {
|
||||||
|
return emojiPool[Math.floor(Math.random() * emojiPool.length)];
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
export const frogMessages = [
|
|
||||||
"Again! Again! ",
|
|
||||||
"Almost got me! ",
|
|
||||||
"Can't catch your breath? ",
|
|
||||||
"Catch me if you can! ",
|
|
||||||
"Chase me! ",
|
|
||||||
"Claim me! ",
|
|
||||||
"Come closer! ",
|
|
||||||
"Do it again! ",
|
|
||||||
"Don't stop now! ",
|
|
||||||
"Faster! Faster! ",
|
|
||||||
"Give me all you've got! ",
|
|
||||||
"Higher! Higher! ",
|
|
||||||
"I can dance all day! ",
|
|
||||||
"I can't get enough! ",
|
|
||||||
"I can't resist you! ",
|
|
||||||
"I crave your touch! ",
|
|
||||||
"I feel dizzy! ",
|
|
||||||
"I like your style! ",
|
|
||||||
"I love this game! ",
|
|
||||||
"I need you! ",
|
|
||||||
"I surrender to you! ",
|
|
||||||
"I want more! ",
|
|
||||||
"I yearn for your touch! ",
|
|
||||||
"I'm a furnace for you! ",
|
|
||||||
"I'm a raging inferno! ",
|
|
||||||
"I'm addicted to you! ",
|
|
||||||
"I'm all yours! ",
|
|
||||||
"I'm burning up! ",
|
|
||||||
"I'm completely yours! ",
|
|
||||||
"I'm consumed by you! ",
|
|
||||||
"I'm floating on air! ",
|
|
||||||
"I'm getting dizzy! ",
|
|
||||||
"I'm getting excited! ",
|
|
||||||
"I'm getting hot! ",
|
|
||||||
"I'm having a blast! ",
|
|
||||||
"I'm having a blast! ",
|
|
||||||
"I'm hooked on you! ",
|
|
||||||
"I'm in a tizzy! ",
|
|
||||||
"I'm in heaven! ",
|
|
||||||
"I'm in paradise! ",
|
|
||||||
"I'm lost in you! ",
|
|
||||||
"I'm melting! ",
|
|
||||||
"I'm on fire! ",
|
|
||||||
"I'm on the edge! ",
|
|
||||||
"I'm overflowing! ",
|
|
||||||
"I'm quivering with desire! ",
|
|
||||||
"I'm seeing stars! ",
|
|
||||||
"I'm shaking with anticipation! ",
|
|
||||||
"I'm so happy! ",
|
|
||||||
"I'm trembling! ",
|
|
||||||
"I'm under your spell! ",
|
|
||||||
"I'm yours for the taking! ",
|
|
||||||
"I'm yours forever! ",
|
|
||||||
"I'm yours to command! ",
|
|
||||||
"I'm yours! ",
|
|
||||||
"I'm yours, body and soul! ",
|
|
||||||
"I'm yours, now and forever! ",
|
|
||||||
"Is that all you've got? ",
|
|
||||||
"Keep shaking! ",
|
|
||||||
"Keep the rhythm going! ",
|
|
||||||
"Let's party! ",
|
|
||||||
"Let's play more! ",
|
|
||||||
"Like a record baby! ",
|
|
||||||
"Make me yours! ",
|
|
||||||
"Make me yours, completely! ",
|
|
||||||
"Missed me! ",
|
|
||||||
"More, more, more! ",
|
|
||||||
"My heart's racing! ",
|
|
||||||
"Neither can I! ",
|
|
||||||
"One more time! ",
|
|
||||||
"Playing hard to get? ",
|
|
||||||
"Round and round we go! ",
|
|
||||||
"Shake me harder! ",
|
|
||||||
"Show me what you've got! ",
|
|
||||||
"Show me your moves! ",
|
|
||||||
"So close! ",
|
|
||||||
"Spin me right round! ",
|
|
||||||
"Stop tickling! ",
|
|
||||||
"Take me to the edge! ",
|
|
||||||
"Take me! ",
|
|
||||||
"Take me, I'm yours! ",
|
|
||||||
"That tickles! ",
|
|
||||||
"That was fun! ",
|
|
||||||
"Too slow! ",
|
|
||||||
"Unleash me! ",
|
|
||||||
"Wait till I catch you! ",
|
|
||||||
"What a rush! ",
|
|
||||||
"Wheeee! ",
|
|
||||||
"Wheeeeeee! ",
|
|
||||||
"You drive me wild! ",
|
|
||||||
"You found me! ",
|
|
||||||
"You got me! ",
|
|
||||||
"You know how to party! ",
|
|
||||||
"You know what I like! ",
|
|
||||||
"You make me feel alive! ",
|
|
||||||
"You're absolute perfection! ",
|
|
||||||
"You're amazing! ",
|
|
||||||
"You're beyond incredible! ",
|
|
||||||
"You're driving me insane! ",
|
|
||||||
"You're driving me wild! ",
|
|
||||||
"You're fun! ",
|
|
||||||
"You're getting better! ",
|
|
||||||
"You're good at this! ",
|
|
||||||
"You're incredible! ",
|
|
||||||
"You're irresistible! ",
|
|
||||||
"You're making me blush! ",
|
|
||||||
"You're making me bounce! ",
|
|
||||||
"You're making me bounce! ",
|
|
||||||
"You're making me crazy! ",
|
|
||||||
"You're making me giddy! ",
|
|
||||||
"You're making me spin! ",
|
|
||||||
"You're making me swoon! ",
|
|
||||||
"You're making me twirl! ",
|
|
||||||
"You're my addiction! ",
|
|
||||||
"You're my desire! ",
|
|
||||||
"You're my dream! ",
|
|
||||||
"You're my everything and more! ",
|
|
||||||
"You're my everything! ",
|
|
||||||
"You're my fantasy! ",
|
|
||||||
"You're my heart's desire! ",
|
|
||||||
"You're my masterpiece! ",
|
|
||||||
"You're my obsession! ",
|
|
||||||
"You're my obsession! ",
|
|
||||||
"You're my temptation! ",
|
|
||||||
"You're my ultimate fantasy! ",
|
|
||||||
"You're my weakness! ",
|
|
||||||
"You're perfect! ",
|
|
||||||
"You're so good! ",
|
|
||||||
"You're so playful! ",
|
|
||||||
"You're such a tease! ",
|
|
||||||
"You're unstoppable! ",
|
|
||||||
"You've got the magic touch! ",
|
|
||||||
"Your touch is electric! "
|
|
||||||
];
|
|
||||||
100
app/config/skin-names.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { type Locale } from '../../i18n/request';
|
||||||
|
|
||||||
|
// Define grammatical cases for languages that need them
|
||||||
|
type GrammaticalCase = 'nominative' | 'accusative' | 'dative' | 'genitive' | 'instrumental' | 'prepositional';
|
||||||
|
|
||||||
|
// Define which languages need grammatical cases
|
||||||
|
const languagesWithCases: Partial<Record<Locale, boolean>> = {
|
||||||
|
ru: true,
|
||||||
|
ka: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Localized skin names for different languages with grammatical cases
|
||||||
|
const skinNames: Record<string, Record<Locale, string | Record<GrammaticalCase, string>>> = {
|
||||||
|
frog: {
|
||||||
|
en: 'Frog',
|
||||||
|
de: 'Frosch',
|
||||||
|
ru: {
|
||||||
|
nominative: 'Лягушка',
|
||||||
|
accusative: 'Лягушку',
|
||||||
|
dative: 'Лягушке',
|
||||||
|
genitive: 'Лягушки',
|
||||||
|
instrumental: 'Лягушкой',
|
||||||
|
prepositional: 'Лягушке'
|
||||||
|
},
|
||||||
|
ka: {
|
||||||
|
nominative: 'ბაყაყი',
|
||||||
|
accusative: 'ბაყაყს',
|
||||||
|
dative: 'ბაყაყს',
|
||||||
|
genitive: 'ბაყაყის',
|
||||||
|
instrumental: 'ბაყაყით',
|
||||||
|
prepositional: 'ბაყაყზე'
|
||||||
|
},
|
||||||
|
ar: 'ضفدع'
|
||||||
|
},
|
||||||
|
mandarin: {
|
||||||
|
en: 'Mandarin',
|
||||||
|
de: 'Mandarine',
|
||||||
|
ru: {
|
||||||
|
nominative: 'Мандарин',
|
||||||
|
accusative: 'Мандарин',
|
||||||
|
dative: 'Мандарину',
|
||||||
|
genitive: 'Мандарина',
|
||||||
|
instrumental: 'Мандарином',
|
||||||
|
prepositional: 'Мандарине'
|
||||||
|
},
|
||||||
|
ka: {
|
||||||
|
nominative: 'მანდარინი',
|
||||||
|
accusative: 'მანდარინს',
|
||||||
|
dative: 'მანდარინს',
|
||||||
|
genitive: 'მანდარინის',
|
||||||
|
instrumental: 'მანდარინით',
|
||||||
|
prepositional: 'მანდარინზე'
|
||||||
|
},
|
||||||
|
ar: 'ماندرين'
|
||||||
|
},
|
||||||
|
beaver: {
|
||||||
|
en: 'Beaver',
|
||||||
|
de: 'Biber',
|
||||||
|
ru: {
|
||||||
|
nominative: 'Бобр',
|
||||||
|
accusative: 'Бобра',
|
||||||
|
dative: 'Бобру',
|
||||||
|
genitive: 'Бобра',
|
||||||
|
instrumental: 'Бобром',
|
||||||
|
prepositional: 'Бобре'
|
||||||
|
},
|
||||||
|
ka: {
|
||||||
|
nominative: 'თახვი',
|
||||||
|
accusative: 'თახვს',
|
||||||
|
dative: 'თახვს',
|
||||||
|
genitive: 'თახვის',
|
||||||
|
instrumental: 'თახვით',
|
||||||
|
prepositional: 'თახვზე'
|
||||||
|
},
|
||||||
|
ar: 'قندس'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the localized name for a skin with the appropriate grammatical case
|
||||||
|
* @param skinId The skin ID
|
||||||
|
* @param language The language code
|
||||||
|
* @param grammaticalCase The grammatical case to use (for languages that need it)
|
||||||
|
* @returns The localized skin name
|
||||||
|
*/
|
||||||
|
export function getLocalizedSkinName(
|
||||||
|
skinId: string,
|
||||||
|
language: Locale,
|
||||||
|
grammaticalCase: GrammaticalCase = 'nominative'
|
||||||
|
): string {
|
||||||
|
const skinName = skinNames[skinId]?.[language];
|
||||||
|
|
||||||
|
// If the language doesn't use cases or we don't have cases for this skin
|
||||||
|
if (!skinName || typeof skinName === 'string' || !languagesWithCases[language]) {
|
||||||
|
return typeof skinName === 'string' ? skinName : skinNames[skinId]?.en as string || skinId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the appropriate case, or fallback to nominative if the case doesn't exist
|
||||||
|
return skinName[grammaticalCase] || skinName.nominative;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* Override the dark variant to use class-based dark mode instead of media query */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -59,19 +60,19 @@ body {
|
|||||||
@keyframes float {
|
@keyframes float {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 10px);
|
transform: translateX(-50%) translateY(10px);
|
||||||
}
|
}
|
||||||
20% {
|
20% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
80% {
|
80% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -10px);
|
transform: translateX(-50%) translateY(-10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function useDarkMode() {
|
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Check if user has a dark mode preference in localStorage
|
|
||||||
const isDark = localStorage.getItem('darkMode') === 'true';
|
|
||||||
// Check system preference if no localStorage value
|
|
||||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
setDarkMode(isDark ?? systemPrefersDark);
|
|
||||||
|
|
||||||
// Add listener for system theme changes
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
const handleChange = (e: MediaQueryListEvent) => {
|
|
||||||
if (localStorage.getItem('darkMode') === null) {
|
|
||||||
setDarkMode(e.matches);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
|
||||||
setDarkMode(!darkMode);
|
|
||||||
localStorage.setItem('darkMode', (!darkMode).toString());
|
|
||||||
};
|
|
||||||
|
|
||||||
return { darkMode, toggleDarkMode };
|
|
||||||
}
|
|
||||||
27
app/hooks/useLocalizedSkinName.ts
Normal file
@@ -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
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { appConfig } from '../config/app';
|
||||||
|
import { SkinId } from '../types';
|
||||||
|
|
||||||
|
export function useSkin() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const skinParam = searchParams.get('skin');
|
||||||
|
|
||||||
|
// Validate that the skin exists in our config
|
||||||
|
const isValidSkin = skinParam && Object.keys(appConfig.skins).includes(skinParam);
|
||||||
|
|
||||||
|
// Return the skin from URL if valid, otherwise return default skin
|
||||||
|
const currentSkin = (isValidSkin ? skinParam : appConfig.defaultSkin) as SkinId;
|
||||||
|
|
||||||
|
return currentSkin;
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import type { Metadata } from 'next'
|
|||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import { ThemeProvider } from './providers/ThemeProvider'
|
import { ThemeProvider } from './providers/ThemeProvider'
|
||||||
import { appConfig } from './config/app'
|
import { appConfig } from './config/app'
|
||||||
|
import { Suspense } from 'react'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(appConfig.url),
|
||||||
title: appConfig.name,
|
title: appConfig.name,
|
||||||
description: appConfig.description,
|
description: appConfig.description,
|
||||||
icons: {
|
icons: {
|
||||||
@@ -40,10 +42,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
<body className={`${inter.className} transition-colors`}>
|
<body className={`${inter.className} transition-colors`}>
|
||||||
<ThemeProvider>
|
<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>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
170
app/page.tsx
@@ -1,170 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { FloatingHearts } from './components/FloatingHearts';
|
|
||||||
import { ThemeToggle } from './components/ThemeToggle';
|
|
||||||
import { SpeechBubble } from './components/SpeechBubble';
|
|
||||||
import { shakeConfig } from './config/shake';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [isShaken, setIsShaken] = useState(false);
|
|
||||||
const [shakeIntensity, setShakeIntensity] = useState(0);
|
|
||||||
const [lastUpdate, setLastUpdate] = useState(0);
|
|
||||||
const [shakeCount, setShakeCount] = useState(0);
|
|
||||||
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// Check if device motion is available and handle permissions
|
|
||||||
const requestMotionPermission = async () => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Check if device motion is available
|
|
||||||
if (!('DeviceMotionEvent' in window)) {
|
|
||||||
setMotionPermission('denied');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request permission on iOS devices
|
|
||||||
if ('requestPermission' in DeviceMotionEvent) {
|
|
||||||
try {
|
|
||||||
// @ts-expect-error - TypeScript doesn't know about requestPermission
|
|
||||||
const permission = await DeviceMotionEvent.requestPermission();
|
|
||||||
setMotionPermission(permission);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error requesting motion permission:', err);
|
|
||||||
setMotionPermission('denied');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Android or desktop - no permission needed
|
|
||||||
setMotionPermission('granted');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerShake = useCallback((intensity: number) => {
|
|
||||||
// Increment shake counter to trigger new message
|
|
||||||
setShakeCount(count => count + 1);
|
|
||||||
|
|
||||||
// Start shake animation
|
|
||||||
setIsShaken(true);
|
|
||||||
|
|
||||||
// Reset shake after configured duration
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsShaken(false);
|
|
||||||
}, shakeConfig.animations.shakeReset);
|
|
||||||
|
|
||||||
// Trigger hearts with configured duration
|
|
||||||
setShakeIntensity(intensity);
|
|
||||||
setTimeout(() => setShakeIntensity(0), shakeConfig.animations.heartsReset);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyPress = (event: KeyboardEvent) => {
|
|
||||||
if (event.code === 'Space') {
|
|
||||||
triggerShake(shakeConfig.defaultTriggerIntensity);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMotion = (event: DeviceMotionEvent) => {
|
|
||||||
const acceleration = event.accelerationIncludingGravity;
|
|
||||||
if (!acceleration) return;
|
|
||||||
|
|
||||||
const currentTime = new Date().getTime();
|
|
||||||
const timeDiff = currentTime - lastUpdate;
|
|
||||||
|
|
||||||
if (timeDiff > shakeConfig.debounceTime) {
|
|
||||||
setLastUpdate(currentTime);
|
|
||||||
|
|
||||||
const speed = Math.abs(acceleration.x || 0) +
|
|
||||||
Math.abs(acceleration.y || 0) +
|
|
||||||
Math.abs(acceleration.z || 0);
|
|
||||||
|
|
||||||
if (speed > shakeConfig.threshold) {
|
|
||||||
triggerShake(speed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only add motion listener if permission is granted
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (motionPermission === 'granted' && 'DeviceMotionEvent' in window) {
|
|
||||||
window.addEventListener('devicemotion', handleMotion);
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (motionPermission === 'granted') {
|
|
||||||
window.removeEventListener('devicemotion', handleMotion);
|
|
||||||
}
|
|
||||||
window.removeEventListener('keydown', handleKeyPress);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [lastUpdate, motionPermission, triggerShake]);
|
|
||||||
|
|
||||||
// Initial permission check
|
|
||||||
useEffect(() => {
|
|
||||||
requestMotionPermission();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
triggerShake(shakeConfig.defaultTriggerIntensity);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
|
|
||||||
<ThemeToggle />
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center w-full relative">
|
|
||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
|
||||||
<FloatingHearts intensity={shakeIntensity} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="relative z-10"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<FloatingHearts intensity={shakeIntensity} />
|
|
||||||
<div className="relative">
|
|
||||||
<SpeechBubble isShaken={isShaken} triggerCount={shakeCount} />
|
|
||||||
<Image
|
|
||||||
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
|
|
||||||
alt="Frog"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
priority
|
|
||||||
className={isShaken ? 'animate-shake' : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-8 flex flex-col items-center gap-2">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
|
|
||||||
{motionPermission === 'prompt' ? (
|
|
||||||
<button
|
|
||||||
onClick={requestMotionPermission}
|
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
|
||||||
>
|
|
||||||
Enable device shake
|
|
||||||
</button>
|
|
||||||
) : motionPermission === 'granted' ? (
|
|
||||||
`Shake your device${!isMobile ? ', press spacebar,' : ''} or click/tap frog!`
|
|
||||||
) : (
|
|
||||||
`${!isMobile ? 'Press spacebar or ' : ''}Click/tap frog!`
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="w-full text-center text-xs text-gray-400 dark:text-gray-600 mt-auto pt-4">
|
|
||||||
© {new Date().getFullYear()}{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/HugeFrog24/shakethefrog"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
|
|
||||||
>
|
|
||||||
shakethefrog
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,151 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useEffect } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import { useDarkMode } from '../hooks/useDarkMode';
|
|
||||||
|
|
||||||
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 const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
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(() => {
|
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) {
|
if (darkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
}, [darkMode]);
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// Prevent hydration mismatch by not rendering theme-dependent content until mounted
|
||||||
|
if (!mounted) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
<ThemeContext.Provider value={{ darkMode, themeMode, setThemeMode }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
4
app/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { appConfig } from '../config/app';
|
||||||
|
|
||||||
|
// Define skin types
|
||||||
|
export type SkinId = keyof typeof appConfig.skins;
|
||||||
@@ -3,6 +3,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
image: bogerserge/shakethefrog:latest
|
image: bogerserge/shakethefrog:latest
|
||||||
ports:
|
ports:
|
||||||
# HOST_PORT:CONTAINER_PORT - Maps port 3000 on the host to port 3000 in the container
|
# HOST_PORT:CONTAINER_PORT - Maps port 3000 on the host to port 3000 in the container
|
||||||
@@ -19,6 +22,6 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1G
|
memory: 256M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 512M
|
memory: 128M
|
||||||
|
|||||||
@@ -1,16 +1,65 @@
|
|||||||
import { dirname } from "path";
|
import js from "@eslint/js";
|
||||||
import { fileURLToPath } from "url";
|
import globals from "globals";
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
const __dirname = dirname(__filename);
|
export default [
|
||||||
|
{
|
||||||
const compat = new FlatCompat({
|
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
|
||||||
baseDirectory: __dirname,
|
},
|
||||||
});
|
js.configs.recommended,
|
||||||
|
{
|
||||||
const eslintConfig = [
|
files: ["**/*.{js,mjs,cjs,jsx}"],
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
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
@@ -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
@@ -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);
|
||||||
42
messages/character/ar.json
Normal 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
@@ -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
@@ -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!"
|
||||||
|
}
|
||||||
97
messages/character/ka.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "Выбор темы"
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone'
|
output: 'standalone'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
5905
package-lock.json
generated
38
package.json
@@ -3,26 +3,36 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint .",
|
||||||
|
"sort-messages": "tsx scripts/sortMessages.mts",
|
||||||
|
"lint:fix": "eslint --fix . && pnpm run sort-messages"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"next": "15.1.4",
|
"next": "^16.0.10",
|
||||||
"react": "^19.0.0",
|
"next-intl": "^4.5.8",
|
||||||
"react-dom": "^19.0.0"
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@types/node": "^20",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/react-dom": "^19",
|
"@types/node": "^25.0.1",
|
||||||
"eslint": "^9",
|
"@types/react": "^19.2.7",
|
||||||
"eslint-config-next": "15.1.4",
|
"@types/react-dom": "^19.2.3",
|
||||||
"postcss": "^8",
|
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"@typescript-eslint/parser": "^8.49.0",
|
||||||
"typescript": "^5"
|
"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
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
/** @type {import('postcss-load-config').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
9
proxy.ts
Normal 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*']
|
||||||
|
};
|
||||||
51
public/images/beaver-shaken.svg
Normal 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
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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="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"/>
|
<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>
|
</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="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="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="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"/>
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -8,25 +8,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.st1 {
|
.st1 {
|
||||||
fill: #010201;
|
fill: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.st2 {
|
.st2 {
|
||||||
fill: #f6c3cb;
|
fill: #010201;
|
||||||
}
|
}
|
||||||
|
|
||||||
.st3 {
|
.st3 {
|
||||||
|
fill: #f6c3cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
fill: #8bc86e;
|
fill: #8bc86e;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</defs>
|
</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"/>
|
<g>
|
||||||
<path class="st3" d="M286.71,365.14"/>
|
<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="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="st4" d="M286.71,365.14"/>
|
||||||
<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 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 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 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="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 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 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 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="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="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="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"/>
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
1
public/images/mandarin.svg
Normal file
|
After Width: | Height: | Size: 13 KiB |
194
scripts/sortMessages.mts
Normal 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();
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
101
src/app/page.tsx
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,9 +23,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
"scripts/sortMessages.mts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||