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,266 @@
'use client';
import { useState } from 'react';
import { api, CreateEventInput } from '@/lib/api';
type EventType = 'recurring' | 'anniversary' | 'duration';
interface EventFormProps {
onEventCreated: () => void;
}
export function EventForm({ onEventCreated }: EventFormProps) {
const [eventType, setEventType] = useState<EventType>('recurring');
const [formData, setFormData] = useState<CreateEventInput>({
title: '',
emoji: '',
month: 1,
day: 1,
});
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await api.createEvent(formData);
setFormData({
title: '',
emoji: '',
month: 1,
day: 1,
});
onEventCreated();
} catch (error) {
console.error('Failed to create event:', error);
alert('Не удалось создать событие');
} finally {
setSubmitting(false);
}
};
return (
<div className="bg-gray-900 rounded-xl p-6 mb-8 shadow-xl">
<h2 className="text-2xl font-bold mb-6 text-white">Добавить событие</h2>
<div className="flex gap-3 mb-6">
<button
type="button"
onClick={() => {
setEventType('recurring');
setFormData({ ...formData, startYear: undefined, endYear: undefined, endMonth: undefined, endDay: undefined });
}}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
eventType === 'recurring'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Праздник (ежегодно)
</button>
<button
type="button"
onClick={() => {
setEventType('anniversary');
setFormData({ ...formData, endYear: undefined, endMonth: undefined, endDay: undefined });
}}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
eventType === 'anniversary'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Годовщина (с годом)
</button>
<button
type="button"
onClick={() => setEventType('duration')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
eventType === 'duration'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
Продолжительное
</button>
</div>
<form onSubmit={handleSubmit} className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium mb-2">Название</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
placeholder="Например: День рождения"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Эмодзи</label>
<input
type="text"
required
value={formData.emoji}
onChange={(e) => setFormData({ ...formData, emoji: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white text-2xl focus:border-blue-500 focus:outline-none"
placeholder="🎂"
/>
</div>
{(eventType === 'anniversary' || eventType === 'duration') && (
<div>
<label className="block text-sm font-medium mb-2">Год начала</label>
<input
type="number"
required={eventType !== 'recurring'}
value={formData.startYear || ''}
onChange={(e) =>
setFormData({
...formData,
startYear: e.target.value ? parseInt(e.target.value) : undefined,
})
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
placeholder="2023"
min="1900"
max="2100"
/>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Месяц</label>
<select
required
value={formData.month}
onChange={(e) =>
setFormData({ ...formData, month: parseInt(e.target.value) })
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
{[
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь',
].map((month, index) => (
<option key={index} value={index + 1}>
{month}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">День</label>
<input
type="number"
required
value={formData.day}
onChange={(e) =>
setFormData({ ...formData, day: parseInt(e.target.value) })
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
min="1"
max="31"
/>
</div>
{eventType === 'duration' && (
<>
<div className="col-span-2 border-t border-gray-800 pt-4 mt-2">
<h3 className="text-lg font-medium mb-4 text-white">Дата окончания</h3>
</div>
<div>
<label className="block text-sm font-medium mb-2">Год окончания</label>
<input
type="number"
required={eventType === 'duration'}
value={formData.endYear || ''}
onChange={(e) =>
setFormData({
...formData,
endYear: e.target.value ? parseInt(e.target.value) : undefined,
})
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
placeholder="2024"
min="1900"
max="2100"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Месяц окончания</label>
<select
required={eventType === 'duration'}
value={formData.endMonth || 1}
onChange={(e) =>
setFormData({ ...formData, endMonth: parseInt(e.target.value) })
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
>
{[
'Январь',
'Февраль',
'Март',
'Апрель',
'Май',
'Июнь',
'Июль',
'Август',
'Сентябрь',
'Октябрь',
'Ноябрь',
'Декабрь',
].map((month, index) => (
<option key={index} value={index + 1}>
{month}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">День окончания</label>
<input
type="number"
required={eventType === 'duration'}
value={formData.endDay || ''}
onChange={(e) =>
setFormData({ ...formData, endDay: parseInt(e.target.value) })
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-white focus:border-blue-500 focus:outline-none"
min="1"
max="31"
/>
</div>
</>
)}
<div className="col-span-2">
<button
type="submit"
disabled={submitting}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Добавление...' : 'Добавить'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { useState } from 'react';
import { api, Event } from '@/lib/api';
interface EventListProps {
title: string;
events: Event[];
onEventDeleted: () => void;
}
export function EventList({ title, events, onEventDeleted }: EventListProps) {
const [deleting, setDeleting] = useState<string | null>(null);
const handleDelete = async (id: string) => {
if (!confirm('Вы уверены? Это действие нельзя отменить.')) {
return;
}
setDeleting(id);
try {
await api.deleteEvent(id);
onEventDeleted();
} catch (error) {
console.error('Failed to delete event:', error);
alert('Не удалось удалить событие');
} finally {
setDeleting(null);
}
};
const formatDate = (event: Event) => {
if (event.endYear) {
return `${event.day}.${event.month}.${event.startYear} - ${event.endDay}.${event.endMonth}.${event.endYear}`;
}
if (event.startYear) {
return `${event.day}.${event.month}.${event.startYear}`;
}
return `${event.day}.${event.month}`;
};
if (events.length === 0) {
return null;
}
return (
<div className="bg-gray-900 rounded-xl p-6 mb-6 shadow-xl">
<h2 className="text-2xl font-bold mb-4 text-white">{title}</h2>
<ul className="space-y-2">
{events.map((event) => (
<li
key={event.id}
className="flex items-center justify-between p-3 bg-gray-800 rounded-lg hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-3xl">{event.emoji}</span>
<div>
<div className="text-white font-medium">{event.title}</div>
<div className="text-sm text-gray-400">{formatDate(event)}</div>
</div>
</div>
<button
onClick={() => handleDelete(event.id)}
disabled={deleting === event.id}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting === event.id ? 'Удаление...' : 'Удалить'}
</button>
</li>
))}
</ul>
</div>
);
}

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