From eaf5e21ea1e94ed64e56b7943a2ca6c83061e7ae Mon Sep 17 00:00:00 2001 From: Foxix Date: Tue, 8 Jul 2025 16:46:00 +0300 Subject: [PATCH] Release 2.3.1 --- .gitignore | 1 + package.json | 1 + src/app/movie/[id]/MovieContent.tsx | 5 ++ src/app/profile/page.tsx | 28 +++++- src/app/tv/[id]/TVContent.tsx | 5 ++ src/components/ClientLayout.tsx | 19 +++- src/components/Reactions.tsx | 130 ++++++++++++++++++++++++++++ src/components/ui/Modal.tsx | 42 +++++++++ src/lib/authApi.ts | 3 + src/lib/reactionsApi.ts | 28 ++++++ src/lib/utils.ts | 10 +++ 11 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/components/Reactions.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/lib/reactionsApi.ts diff --git a/.gitignore b/.gitignore index 3fd5f43..be3ad33 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules .env .env.local .next +package-lock.json \ No newline at end of file diff --git a/package.json b/package.json index b4b4be8..aac20e5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hot-toast": "^2.4.1", + "react-icons": "^5.5.0", "react-redux": "^9.2.0", "resend": "^4.0.1", "styled-components": "^6.1.13", diff --git a/src/app/movie/[id]/MovieContent.tsx b/src/app/movie/[id]/MovieContent.tsx index 5f314db..57f40ee 100644 --- a/src/app/movie/[id]/MovieContent.tsx +++ b/src/app/movie/[id]/MovieContent.tsx @@ -7,6 +7,7 @@ import { getImageUrl } from '@/lib/neoApi'; import type { MovieDetails } from '@/lib/api'; import MoviePlayer from '@/components/MoviePlayer'; import FavoriteButton from '@/components/FavoriteButton'; +import Reactions from '@/components/Reactions'; import { formatDate } from '@/lib/utils'; import { PlayCircle, ArrowLeft } from 'lucide-react'; @@ -128,6 +129,10 @@ export default function MovieContent({ movieId, initialMovie }: MovieContentProp /> +
+ +
+ {/* Desktop-only Embedded Player */} {imdbId && (
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 855d81d..009f37a 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,9 +1,12 @@ 'use client'; import { useAuth } from '@/hooks/useAuth'; +import { authAPI } from '@/lib/authApi'; +import toast from 'react-hot-toast'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { Loader2, User, LogOut, Trash2 } from 'lucide-react'; +import Modal from '@/components/ui/Modal'; export default function ProfilePage() { const { logout } = useAuth(); @@ -11,6 +14,7 @@ export default function ProfilePage() { const [userName, setUserName] = useState(null); const [userEmail, setUserEmail] = useState(null); const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); useEffect(() => { const token = localStorage.getItem('token'); @@ -23,9 +27,17 @@ export default function ProfilePage() { } }, [router]); - const handleDeleteAccount = () => { - // TODO: Implement account deletion logic - alert('Функция удаления аккаунта в разработке.'); + const handleConfirmDelete = async () => { + try { + await authAPI.deleteAccount(); + toast.success('Аккаунт успешно удален.'); + setIsModalOpen(false); + logout(); + } catch (error) { + console.error('Failed to delete account:', error); + toast.error('Не удалось удалить аккаунт. Попробуйте снова.'); + setIsModalOpen(false); + } }; if (loading) { @@ -62,7 +74,7 @@ export default function ProfilePage() {

Опасная зона

Это действие нельзя будет отменить. Все ваши данные, включая избранное, будут удалены.

+ setIsModalOpen(false)} + onConfirm={handleConfirmDelete} + title="Подтвердите удаление аккаунта" + > +

Вы уверены, что хотите навсегда удалить свой аккаунт? Все ваши данные, включая избранное и реакции, будут безвозвратно удалены. Это действие нельзя будет отменить.

+
); } diff --git a/src/app/tv/[id]/TVContent.tsx b/src/app/tv/[id]/TVContent.tsx index 949e4c6..dbd0af5 100644 --- a/src/app/tv/[id]/TVContent.tsx +++ b/src/app/tv/[id]/TVContent.tsx @@ -7,6 +7,7 @@ import { getImageUrl } from '@/lib/neoApi'; import type { TVShowDetails } from '@/lib/api'; import MoviePlayer from '@/components/MoviePlayer'; import FavoriteButton from '@/components/FavoriteButton'; +import Reactions from '@/components/Reactions'; import { formatDate } from '@/lib/utils'; import { PlayCircle, ArrowLeft } from 'lucide-react'; @@ -132,6 +133,10 @@ export default function TVContent({ showId, initialShow }: TVContentProps) { /> +
+ +
+ {imdbId && (
{ + if (window.dataLayer) { + window.dataLayer.push({ + event: 'pageview', + page: pathname, + }); + } + }, [pathname]); return ( diff --git a/src/components/Reactions.tsx b/src/components/Reactions.tsx new file mode 100644 index 0000000..3d18f87 --- /dev/null +++ b/src/components/Reactions.tsx @@ -0,0 +1,130 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { reactionsAPI, Reaction } from '@/lib/reactionsApi'; +import { toast } from 'react-hot-toast'; +import cn from 'classnames'; +import { formatNumber } from '@/lib/utils'; +import { FaFireAlt } from 'react-icons/fa'; +import { AiFillLike, AiFillDislike } from 'react-icons/ai'; +import { RiEmotionNormalFill } from 'react-icons/ri'; +import { MdMoodBad } from 'react-icons/md'; + +const reactionTypes: Reaction['type'][] = ['fire', 'nice', 'think', 'bore', 'shit']; + +const reactionIcons: Record = { + fire: FaFireAlt, + nice: AiFillLike, + think: RiEmotionNormalFill, + bore: MdMoodBad, + shit: AiFillDislike, +}; + +interface ReactionsProps { + mediaId: string; + mediaType: 'movie' | 'tv'; +} + +const Reactions: React.FC = ({ mediaId, mediaType }) => { + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + const [reactionCounts, setReactionCounts] = useState>({}); + const [userReaction, setUserReaction] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchReactions = async () => { + setIsLoading(true); + try { + const [countsRes, myReactionRes] = await Promise.all([ + reactionsAPI.getReactionCounts(mediaType, mediaId), + token ? reactionsAPI.getMyReaction(mediaType, mediaId) : Promise.resolve(null), + ]); + setReactionCounts(countsRes.data); + if (myReactionRes?.data) { + setUserReaction(myReactionRes.data?.type ?? null); + } + } catch (error) { + console.error('Failed to fetch reactions:', error); + } finally { + setIsLoading(false); + } + }; + + fetchReactions(); + }, [mediaId, token]); + + const handleReactionClick = async (type: Reaction['type']) => { + if (!token) { + toast.error('Войдите в аккаунт, чтобы ставить реакции'); + return; + } + + const oldReaction = userReaction; + const oldCounts = { ...reactionCounts }; + + // Оптимистичное обновление + setUserReaction(prev => (prev === type ? null : type)); + setReactionCounts(prev => { + const newCounts = { ...prev }; + // Если снимаем реакцию + if (oldReaction === type) { + newCounts[type] = (newCounts[type] || 1) - 1; + } else { + // Если ставим новую реакцию + newCounts[type] = (newCounts[type] || 0) + 1; + // Если до этого стояла другая реакция, уменьшаем ее счетчик + if (oldReaction) { + newCounts[oldReaction] = (newCounts[oldReaction] || 1) - 1; + } + } + return newCounts; + }); + + try { + await reactionsAPI.setReaction(mediaType, mediaId, type); + } catch (error) { + console.error('Failed to set reaction:', error); + toast.error('Не удалось сохранить реакцию'); + // Откат изменений в случае ошибки + setUserReaction(oldReaction); + setReactionCounts(oldCounts); + } + }; + + const renderButtons = () => ( +
+ {reactionTypes.map((type) => { + const Icon = reactionIcons[type]; + return ( +
+ + + {formatNumber(reactionCounts[type] || 0)} + +
+ ); + })} +
+ ); + + if (isLoading && Object.keys(reactionCounts).length === 0) { + return
Загрузка реакций...
; + } + + return renderButtons(); +}; + +export default Reactions; \ No newline at end of file diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..3595b71 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,42 @@ +'use client'; + +import React from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + children: React.ReactNode; +} + +const Modal: React.FC = ({ isOpen, onClose, onConfirm, title, children }) => { + if (!isOpen) return null; + + return ( +
+
+

{title}

+
+ {children} +
+
+ + +
+
+
+ ); +}; + +export default Modal; diff --git a/src/lib/authApi.ts b/src/lib/authApi.ts index 92175e0..8602c24 100644 --- a/src/lib/authApi.ts +++ b/src/lib/authApi.ts @@ -12,5 +12,8 @@ export const authAPI = { }, login(email: string, password: string) { return api.post('/auth/login', { email, password }); + }, + deleteAccount() { + return api.delete('/auth/profile'); } }; diff --git a/src/lib/reactionsApi.ts b/src/lib/reactionsApi.ts new file mode 100644 index 0000000..9a1fac0 --- /dev/null +++ b/src/lib/reactionsApi.ts @@ -0,0 +1,28 @@ +import { api } from './api'; + +export interface Reaction { + _id: string; + userId: string; + mediaId: string; + mediaType: 'movie' | 'tv'; + type: 'fire' | 'nice' | 'think' | 'bore' | 'shit'; + createdAt: string; +} + +export const reactionsAPI = { + // [PUBLIC] Получить счетчики для всех типов реакций + getReactionCounts(mediaType: string, mediaId: string): Promise<{ data: Record }> { + return api.get(`/reactions/${mediaType}/${mediaId}/counts`); + }, + + // [AUTH] Получить реакцию пользователя для медиа + getMyReaction(mediaType: string, mediaId: string): Promise<{ data: Reaction | null }> { + return api.get(`/reactions/${mediaType}/${mediaId}/my-reaction`); + }, + + // [AUTH] Установить/обновить/удалить реакцию + setReaction(mediaType: string, mediaId: string, type: Reaction['type']): Promise<{ data: Reaction }> { + const fullMediaId = `${mediaType}_${mediaId}`; + return api.post('/reactions', { mediaId: fullMediaId, type }); + }, +}; \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f3c872b..8be6bcc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -53,3 +53,13 @@ export const formatDate = (dateString: string | Date | undefined | null) => { return 'Нет даты'; } }; + +export function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + } + if (num >= 1000) { + return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + } + return num.toString(); +}