feat: Реализована функциональность списка желаний с бэкенд API, базой данных и пользовательским интерфейсом.

This commit is contained in:
2025-12-06 11:08:07 +03:00
parent 07c1285bb9
commit 7eb4fb731b
42 changed files with 1610 additions and 44 deletions

View 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>
);
}