492 lines
18 KiB
TypeScript
492 lines
18 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import { api } from '@/lib/api';
|
||
|
||
interface TimelineEvent {
|
||
id: string;
|
||
type: string;
|
||
emoji: string;
|
||
name: string;
|
||
date: string;
|
||
display: string;
|
||
days: number;
|
||
hours: number;
|
||
minutes: number;
|
||
yearsPassed?: number;
|
||
daysPassed?: number;
|
||
startYear?: number;
|
||
}
|
||
|
||
interface TimelineGridItem {
|
||
days: number;
|
||
date: string;
|
||
weekday: string;
|
||
}
|
||
|
||
interface MonthMarker {
|
||
name: string;
|
||
days: number;
|
||
}
|
||
|
||
export function Timeline() {
|
||
const [events, setEvents] = useState<TimelineEvent[]>([]);
|
||
const [timelineGrid, setTimelineGrid] = useState<TimelineGridItem[]>([]);
|
||
const [months, setMonths] = useState<MonthMarker[]>([]);
|
||
const [hoveredGrid, setHoveredGrid] = useState<number | null>(null);
|
||
const [hoveredEvent, setHoveredEvent] = useState<string | null>(null);
|
||
|
||
const timelineRef = useRef<HTMLDivElement>(null);
|
||
const eventRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||
|
||
const daysInFirstZone = 14;
|
||
const weeksInSecondZone = 40;
|
||
const firstZonePercent = 25.0;
|
||
const secondZonePercent = 75.0;
|
||
const maxDays = daysInFirstZone + weeksInSecondZone * 7;
|
||
|
||
const REPULSION_RADIUS = 80;
|
||
const REPULSION_STRENGTH = 20;
|
||
|
||
useEffect(() => {
|
||
loadCountdownData();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// Добавляем эффект отталкивания для эмодзи
|
||
const timeline = timelineRef.current;
|
||
if (!timeline) return;
|
||
|
||
const eventElements = Array.from(eventRefs.current.values());
|
||
if (eventElements.length === 0) return;
|
||
|
||
// Для каждого элемента добавляем обработчики
|
||
eventElements.forEach((hoverItem) => {
|
||
const handleMouseEnter = () => {
|
||
const hoveredRect = hoverItem.getBoundingClientRect();
|
||
const timelineRect = timeline.getBoundingClientRect();
|
||
const hoveredX = hoveredRect.left + hoveredRect.width / 2 - timelineRect.left;
|
||
const hoveredY = hoveredRect.top + hoveredRect.height / 2 - timelineRect.top;
|
||
|
||
// Отталкиваем все остальные элементы
|
||
eventElements.forEach((item) => {
|
||
if (item === hoverItem) return;
|
||
|
||
const itemRect = item.getBoundingClientRect();
|
||
const itemX = itemRect.left + itemRect.width / 2 - timelineRect.left;
|
||
const itemY = itemRect.top + itemRect.height / 2 - timelineRect.top;
|
||
|
||
const dx = itemX - hoveredX;
|
||
const dy = itemY - hoveredY;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
if (distance < REPULSION_RADIUS && distance > 0) {
|
||
const nx = dx / distance;
|
||
const ny = dy / distance;
|
||
const force = (1 - distance / REPULSION_RADIUS) * REPULSION_STRENGTH;
|
||
|
||
const offsetX = nx * force;
|
||
const offsetY = ny * force;
|
||
|
||
item.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`;
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleMouseLeave = () => {
|
||
// Возвращаем ВСЕ элементы на свои исходные позиции
|
||
eventElements.forEach((item) => {
|
||
item.style.transform = 'translate(-50%, -50%)';
|
||
});
|
||
};
|
||
|
||
hoverItem.addEventListener('mouseenter', handleMouseEnter);
|
||
hoverItem.addEventListener('mouseleave', handleMouseLeave);
|
||
|
||
// Сохраняем обработчики для cleanup
|
||
(hoverItem as any)._repulsionHandlers = { handleMouseEnter, handleMouseLeave };
|
||
});
|
||
|
||
// Cleanup
|
||
return () => {
|
||
eventElements.forEach((element) => {
|
||
const handlers = (element as any)._repulsionHandlers;
|
||
if (handlers) {
|
||
element.removeEventListener('mouseenter', handlers.handleMouseEnter);
|
||
element.removeEventListener('mouseleave', handlers.handleMouseLeave);
|
||
delete (element as any)._repulsionHandlers;
|
||
}
|
||
});
|
||
};
|
||
}, [events, REPULSION_RADIUS, REPULSION_STRENGTH]); // Пересоздаем обработчики при изменении событий
|
||
|
||
const loadCountdownData = async () => {
|
||
try {
|
||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/countdown`);
|
||
const data = await response.json();
|
||
setEvents(data.events || []);
|
||
setTimelineGrid(data.timelineGrid || []);
|
||
setMonths(data.months || []);
|
||
} catch (error) {
|
||
console.error('Failed to load countdown data:', error);
|
||
}
|
||
};
|
||
|
||
const calculatePosition = (days: number): number => {
|
||
if (days >= maxDays) return 100;
|
||
|
||
if (days < daysInFirstZone) {
|
||
// Зона 1: 0-14 дней -> 0-25% шкалы
|
||
return (days / daysInFirstZone) * firstZonePercent;
|
||
} else {
|
||
// Зона 2: 14+ дней -> 25-100% шкалы
|
||
const daysAfterZ1 = days - daysInFirstZone;
|
||
const weeksInZ2 = daysAfterZ1 / 7;
|
||
const posInZ2 = (weeksInZ2 / weeksInSecondZone) * secondZonePercent;
|
||
return firstZonePercent + posInZ2;
|
||
}
|
||
};
|
||
|
||
const calculateGridWidth = (days: number): number => {
|
||
if (days < daysInFirstZone) {
|
||
return firstZonePercent / daysInFirstZone;
|
||
} else {
|
||
return secondZonePercent / weeksInSecondZone;
|
||
}
|
||
};
|
||
|
||
const getShakeClass = (position: number): string => {
|
||
if (position <= 5) return 'animate-shake-very-strong';
|
||
if (position <= 12) return 'animate-shake-strong';
|
||
if (position <= 18) return 'animate-shake-medium';
|
||
if (position <= 25) return 'animate-shake-weak';
|
||
return '';
|
||
};
|
||
|
||
const formatCountdown = (event: TimelineEvent) => {
|
||
const days = event.days;
|
||
if (days >= 30) {
|
||
const months = Math.floor(days / 30);
|
||
const remainingDays = days % 30;
|
||
return (
|
||
<div className="flex gap-3 justify-around">
|
||
<div className="text-center">
|
||
<div className="text-base font-bold text-pink-400">{months}</div>
|
||
<div className="text-[9px] opacity-60 mt-0.5">
|
||
{months === 1 ? 'месяц' : months >= 2 && months <= 4 ? 'месяца' : 'месяцев'}
|
||
</div>
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="text-base font-bold text-orange-400">{remainingDays}</div>
|
||
<div className="text-[9px] opacity-60 mt-0.5">
|
||
{remainingDays === 1 ? 'день' : remainingDays >= 2 && remainingDays <= 4 ? 'дня' : 'дней'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<div className="flex gap-3 justify-around">
|
||
<div className="text-center">
|
||
<div className="text-base font-bold text-pink-400">{days}</div>
|
||
<div className="text-[9px] opacity-60 mt-0.5">
|
||
{days === 1 ? 'день' : days >= 2 && days <= 4 ? 'дня' : 'дней'}
|
||
</div>
|
||
</div>
|
||
<div className="text-center">
|
||
<div className="text-base font-bold text-orange-400">{event.hours}</div>
|
||
<div className="text-[9px] opacity-60 mt-0.5">
|
||
{event.hours === 1 ? 'час' : event.hours >= 2 && event.hours <= 4 ? 'часа' : 'часов'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="bg-gray-900 rounded-xl p-6 shadow-xl">
|
||
<h2 className="text-2xl font-bold mb-6 text-white text-center">📅 Временная шкала</h2>
|
||
|
||
<div ref={timelineRef} className="relative h-[140px]">
|
||
{/* Линия времени - Зона 1 (0-25%) */}
|
||
<div
|
||
className="absolute left-0 top-1/2 -translate-y-1/2 h-[3px]"
|
||
style={{
|
||
width: '25%',
|
||
background: 'linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4))',
|
||
backgroundImage:
|
||
'linear-gradient(to right, rgba(185, 28, 28, 0.4), rgba(128, 128, 128, 0.4)), linear-gradient(to right, rgba(180, 180, 180, 0.6) 1px, transparent 1px)',
|
||
backgroundSize: '100% 100%, calc(100% / 14) 100%',
|
||
backgroundRepeat: 'no-repeat, repeat-x',
|
||
}}
|
||
/>
|
||
|
||
{/* Линия времени - Зона 2 (25-100%) */}
|
||
<div
|
||
className="absolute left-[25%] top-1/2 -translate-y-1/2 h-[3px]"
|
||
style={{
|
||
width: '75%',
|
||
backgroundColor: 'transparent',
|
||
backgroundImage:
|
||
'linear-gradient(to right, rgba(128,128,128,0.45) 1px, transparent 1px), linear-gradient(to right, rgba(128,128,128,0.75) 1px, transparent 1px)',
|
||
backgroundSize: 'calc(100% / 40) 100%, calc(100% / 10) 100%',
|
||
backgroundRepeat: 'repeat-x, repeat-x',
|
||
}}
|
||
/>
|
||
|
||
{/* Маркер "Сегодня" */}
|
||
<div
|
||
className="absolute left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl z-[15]"
|
||
style={{
|
||
textShadow: '0 0 8px rgba(255, 100, 0, 0.6)',
|
||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))',
|
||
}}
|
||
>
|
||
🔥
|
||
</div>
|
||
|
||
{/* События */}
|
||
{events.map((event) => {
|
||
if (event.days >= maxDays) return null;
|
||
|
||
const position = calculatePosition(event.days);
|
||
const shakeClass = getShakeClass(position);
|
||
|
||
return (
|
||
<div
|
||
key={event.id}
|
||
ref={(el) => {
|
||
if (el) {
|
||
eventRefs.current.set(event.id, el);
|
||
} else {
|
||
eventRefs.current.delete(event.id);
|
||
}
|
||
}}
|
||
className={`absolute top-[35%] -translate-x-1/2 -translate-y-1/2 cursor-pointer z-10 transition-all duration-300 ${shakeClass}`}
|
||
style={{ left: `${position}%` }}
|
||
onMouseEnter={() => setHoveredEvent(event.id)}
|
||
onMouseLeave={() => setHoveredEvent(null)}
|
||
>
|
||
{/* Emoji */}
|
||
<div
|
||
className="text-[28px] transition-transform duration-300"
|
||
style={{
|
||
textShadow: '0 0 3px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)',
|
||
transform: hoveredEvent === event.id ? 'scale(1.4)' : 'scale(1)',
|
||
}}
|
||
>
|
||
{event.emoji}
|
||
</div>
|
||
|
||
{/* Tooltip */}
|
||
{hoveredEvent === event.id && (
|
||
<div
|
||
className="absolute bottom-[45px] left-1/2 -translate-x-1/2 min-w-[200px] z-50"
|
||
style={{
|
||
background: 'linear-gradient(135deg, rgba(20,20,25,0.98) 0%, rgba(30,30,40,0.98) 100%)',
|
||
boxShadow: '0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.1)',
|
||
backdropFilter: 'blur(10px)',
|
||
}}
|
||
>
|
||
<div className="p-3 rounded-lg">
|
||
{/* Название */}
|
||
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-white/15">
|
||
<div className="text-2xl">{event.emoji}</div>
|
||
<div className="font-bold text-sm text-white">{event.name}</div>
|
||
</div>
|
||
|
||
{/* Дата */}
|
||
<div className="mb-2 flex items-center gap-1.5 text-[11px]">
|
||
<div className="opacity-70">📅 Дата:</div>
|
||
<div className="font-semibold text-blue-400">{event.date}</div>
|
||
</div>
|
||
|
||
{/* Годовщина */}
|
||
{event.type === 'anniversary' && event.yearsPassed && (
|
||
<div className="bg-purple-500/15 p-2 rounded-md mb-2 border-l-2 border-purple-500">
|
||
<div className="opacity-90 text-[11px] mb-1">🎉 Годовщина</div>
|
||
<div className="font-bold text-[15px] text-purple-300">
|
||
{event.yearsPassed}{' '}
|
||
{event.yearsPassed % 10 === 1 && event.yearsPassed % 100 !== 11
|
||
? 'год'
|
||
: event.yearsPassed % 10 >= 2 &&
|
||
event.yearsPassed % 10 <= 4 &&
|
||
(event.yearsPassed % 100 < 12 || event.yearsPassed % 100 > 14)
|
||
? 'года'
|
||
: 'лет'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Countdown */}
|
||
<div className="bg-white/8 p-2 rounded-md">
|
||
<div className="opacity-70 text-[10px] mb-1 uppercase tracking-wider">⏰ Осталось</div>
|
||
{formatCountdown(event)}
|
||
</div>
|
||
|
||
{/* Стрелка */}
|
||
<div
|
||
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-0 h-0"
|
||
style={{
|
||
borderLeft: '6px solid transparent',
|
||
borderRight: '6px solid transparent',
|
||
borderTop: '6px solid rgba(20,20,25,0.98)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Интерактивная сетка */}
|
||
{timelineGrid.map((gridItem, index) => {
|
||
if (gridItem.days >= maxDays) return null;
|
||
|
||
const position = calculatePosition(gridItem.days);
|
||
const width = calculateGridWidth(gridItem.days);
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="absolute top-[35%] -translate-y-1/2 h-10 cursor-pointer z-[5]"
|
||
style={{ left: `${position}%`, width: `${width}%` }}
|
||
onMouseEnter={() => setHoveredGrid(index)}
|
||
onMouseLeave={() => setHoveredGrid(null)}
|
||
>
|
||
{/* Grid tooltip */}
|
||
{hoveredGrid === index && (
|
||
<div
|
||
className="absolute bottom-[45px] left-1/2 -translate-x-1/2 z-50"
|
||
style={{
|
||
background: 'linear-gradient(135deg, rgba(30,30,40,0.95) 0%, rgba(40,40,50,0.95) 100%)',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08)',
|
||
backdropFilter: 'blur(8px)',
|
||
}}
|
||
>
|
||
<div className="p-2 rounded-lg">
|
||
<div className="text-center mb-1 font-semibold text-blue-400 text-[10px] uppercase tracking-wide">
|
||
{gridItem.weekday}
|
||
</div>
|
||
<div className="text-center font-medium text-[13px] whitespace-nowrap">
|
||
📅 {gridItem.date}
|
||
</div>
|
||
|
||
{/* Стрелка */}
|
||
<div
|
||
className="absolute -bottom-[5px] left-1/2 -translate-x-1/2 w-0 h-0"
|
||
style={{
|
||
borderLeft: '5px solid transparent',
|
||
borderRight: '5px solid transparent',
|
||
borderTop: '5px solid rgba(30,30,40,0.95)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Метки месяцев */}
|
||
{months.map((month, index) => {
|
||
if (month.days >= maxDays) return null;
|
||
|
||
const position = calculatePosition(month.days);
|
||
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="absolute top-[65%] -translate-x-1/2 text-[10px] text-gray-500 whitespace-nowrap"
|
||
style={{ left: `${position}%` }}
|
||
>
|
||
{month.name}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<style jsx>{`
|
||
@keyframes shake-weak {
|
||
0%,
|
||
100% {
|
||
transform: translate(-50%, -50%) rotate(0deg);
|
||
}
|
||
25% {
|
||
transform: translate(-50%, -50%) rotate(0.5deg);
|
||
}
|
||
75% {
|
||
transform: translate(-50%, -50%) rotate(-0.5deg);
|
||
}
|
||
}
|
||
|
||
@keyframes shake-medium {
|
||
0%,
|
||
100% {
|
||
transform: translate(-50%, -50%) rotate(0deg);
|
||
}
|
||
25% {
|
||
transform: translate(-50%, -50%) rotate(1deg);
|
||
}
|
||
75% {
|
||
transform: translate(-50%, -50%) rotate(-1deg);
|
||
}
|
||
}
|
||
|
||
@keyframes shake-strong {
|
||
0%,
|
||
100% {
|
||
transform: translate(-50%, -50%) rotate(0deg);
|
||
}
|
||
25% {
|
||
transform: translate(-50%, -50%) rotate(2deg);
|
||
}
|
||
75% {
|
||
transform: translate(-50%, -50%) rotate(-2deg);
|
||
}
|
||
}
|
||
|
||
@keyframes shake-very-strong {
|
||
0%,
|
||
100% {
|
||
transform: translate(-50%, -50%) rotate(0deg);
|
||
}
|
||
10% {
|
||
transform: translate(-50%, -50%) rotate(3deg);
|
||
}
|
||
30% {
|
||
transform: translate(-50%, -50%) rotate(-3deg);
|
||
}
|
||
50% {
|
||
transform: translate(-50%, -50%) rotate(3deg);
|
||
}
|
||
70% {
|
||
transform: translate(-50%, -50%) rotate(-3deg);
|
||
}
|
||
90% {
|
||
transform: translate(-50%, -50%) rotate(3deg);
|
||
}
|
||
}
|
||
|
||
.animate-shake-weak {
|
||
animation: shake-weak 2s ease-in-out infinite;
|
||
}
|
||
|
||
.animate-shake-medium {
|
||
animation: shake-medium 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
.animate-shake-strong {
|
||
animation: shake-strong 1s ease-in-out infinite;
|
||
}
|
||
|
||
.animate-shake-very-strong {
|
||
animation: shake-very-strong 0.5s ease-in-out infinite;
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|