feat: Реализована функциональность списка желаний с бэкенд API, базой данных и пользовательским интерфейсом.
This commit is contained in:
491
frontend/src/components/Timeline.tsx
Normal file
491
frontend/src/components/Timeline.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user