From 008dd1c29ee7a0fa6b9557d05c2f0cb665966072 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 1 Sep 2026 14:31:52 +0300 Subject: [PATCH] Update frontend for new backend --- frontend/public/index.html | 1 + frontend/src/App.css | 8 +- frontend/src/App.js | 7 +- frontend/src/App.test.js | 17 +- .../src/components/BottomBar/BottomBar.css | 28 +- .../src/components/BottomBar/BottomBar.jsx | 44 +-- frontend/src/components/CardPair/CardPair.css | 61 +++- frontend/src/components/CardPair/CardPair.jsx | 80 ++++- .../src/components/Comments/CommentItem.css | 47 ++- .../src/components/Comments/CommentItem.jsx | 48 ++- .../src/components/Comments/CommentsPanel.css | 70 +++- .../src/components/Comments/CommentsPanel.jsx | 157 ++++++--- frontend/src/components/Menu/AboutPage.jsx | 41 ++- frontend/src/components/Menu/CreateCard.css | 32 +- frontend/src/components/Menu/CreateCard.jsx | 40 ++- frontend/src/components/Menu/MenuPanel.jsx | 32 +- .../src/components/common/BannedScreen.css | 150 +++++++++ .../src/components/common/BannedScreen.jsx | 51 +++ frontend/src/context/AppContext.jsx | 314 ++++++++++++++---- frontend/src/services/api.js | 77 ++++- frontend/src/services/auth.js | 66 +++- frontend/src/setupTests.js | 6 +- 22 files changed, 1113 insertions(+), 264 deletions(-) create mode 100644 frontend/src/components/common/BannedScreen.css create mode 100644 frontend/src/components/common/BannedScreen.jsx diff --git a/frontend/public/index.html b/frontend/public/index.html index d34e4fe..0d24ce3 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -6,6 +6,7 @@ + diff --git a/frontend/src/App.css b/frontend/src/App.css index fa4a015..3f7e04e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5,8 +5,10 @@ align-items: center; justify-content: space-between; padding: var(--space-sm) var(--space-md); - height: 48px; + padding-top: max(var(--space-sm), env(safe-area-inset-top)); + height: calc(48px + max(0px, env(safe-area-inset-top))); flex-shrink: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .app-logo { @@ -15,6 +17,7 @@ font-weight: 700; color: var(--color-text); letter-spacing: -0.3px; + user-select: none; } .app-logo-or { @@ -25,6 +28,7 @@ border-radius: 6px; font-size: 12px; vertical-align: middle; + box-shadow: 0 0 10px rgba(124, 58, 237, 0.3); } /* Hamburger menu */ @@ -41,7 +45,7 @@ } .menu-toggle:active { - background: rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.08); } .menu-toggle-line { diff --git a/frontend/src/App.js b/frontend/src/App.js index 8f6e9f4..4525857 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,6 +1,7 @@ import React from 'react'; import { AppProvider, useApp } from './context/AppContext'; import LoadingScreen from './components/common/LoadingScreen'; +import BannedScreen from './components/common/BannedScreen'; import Toast from './components/common/Toast'; import CardPair from './components/CardPair/CardPair'; import BottomBar from './components/BottomBar/BottomBar'; @@ -9,7 +10,11 @@ import MenuPanel from './components/Menu/MenuPanel'; import './App.css'; function AppContent() { - const { isLoading, error, openMenu, toast } = useApp(); + const { isLoading, error, isBanned, handleRetryAfterBan, openMenu, toast } = useApp(); + + if (isBanned) { + return ; + } if (isLoading) { return ; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js index 1f03afe..ea98d24 100644 --- a/frontend/src/App.test.js +++ b/frontend/src/App.test.js @@ -1,8 +1,15 @@ -import { render, screen } from '@testing-library/react'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; import App from './App'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +test('renders app without crashing', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + await act(async () => { + const root = createRoot(container); + root.render(); + }); + + expect(container.innerHTML).toBeTruthy(); }); diff --git a/frontend/src/components/BottomBar/BottomBar.css b/frontend/src/components/BottomBar/BottomBar.css index fe3b5a1..e5533e0 100644 --- a/frontend/src/components/BottomBar/BottomBar.css +++ b/frontend/src/components/BottomBar/BottomBar.css @@ -2,8 +2,9 @@ display: flex; align-items: center; justify-content: space-around; - height: var(--bar-height); + min-height: var(--bar-height); padding: 0 var(--space-lg); + padding-bottom: max(0px, env(safe-area-inset-bottom)); background: var(--color-bg-elevated); border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; @@ -13,7 +14,7 @@ display: flex; flex-direction: column; align-items: center; - gap: 2px; + gap: 3px; padding: var(--space-sm) var(--space-md); border-radius: var(--radius-sm); transition: color var(--duration-fast) var(--ease-smooth), @@ -21,18 +22,19 @@ color: var(--color-muted); } -.bar-btn:active { +.bar-btn:active:not(:disabled) { transform: scale(0.92); } -.bar-btn--disabled { - opacity: 0.4; - pointer-events: none; +.bar-btn:disabled:not(.bar-btn--active) { + opacity: 0.35; + cursor: default; } -.bar-btn--disabled.bar-btn--active { +.bar-btn--disabled.bar-btn--active, +.bar-btn:disabled.bar-btn--active { opacity: 1; - pointer-events: none; + cursor: default; } /* Active states */ @@ -47,12 +49,8 @@ } .bar-btn--comments { - color: var(--color-muted); -} - -.bar-btn--comments:not(.bar-btn--disabled) { - opacity: 1; - pointer-events: auto; + color: var(--color-text); + opacity: 0.9; } .bar-icon { @@ -63,7 +61,7 @@ .bar-count { font-family: var(--font-mono); font-size: 11px; - font-weight: 500; + font-weight: 600; line-height: 1; } diff --git a/frontend/src/components/BottomBar/BottomBar.jsx b/frontend/src/components/BottomBar/BottomBar.jsx index 038d749..31ead75 100644 --- a/frontend/src/components/BottomBar/BottomBar.jsx +++ b/frontend/src/components/BottomBar/BottomBar.jsx @@ -1,51 +1,54 @@ import React, { useState } from 'react'; import { useApp } from '../../context/AppContext'; +import { hapticImpact } from '../../services/auth'; import './BottomBar.css'; export default function BottomBar() { const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp(); - const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null + const [isSubmitting, setIsSubmitting] = useState(false); const isRevealed = chosenCard !== null; // Check if user already reacted to this card const alreadyLiked = user?.liked_card_ids?.includes(currentCard?.card_id); const alreadyDisliked = user?.disliked_card_ids?.includes(currentCard?.card_id); - const currentReaction = reactionState || (alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null); + const currentReaction = alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null; const handleLike = async () => { - if (!isRevealed || currentReaction) return; - const result = await likeCard(); - if (result && !result.error) { - setReactionState('liked'); + if (!isRevealed || currentReaction || isSubmitting) return; + setIsSubmitting(true); + try { + await likeCard(); + } finally { + setIsSubmitting(false); } }; const handleDislike = async () => { - if (!isRevealed || currentReaction) return; - const result = await dislikeCard(); - if (result && !result.error) { - setReactionState('disliked'); + if (!isRevealed || currentReaction || isSubmitting) return; + setIsSubmitting(true); + try { + await dislikeCard(); + } finally { + setIsSubmitting(false); } }; const handleComments = () => { + hapticImpact('light'); setIsCommentsOpen(true); }; - // Reset reaction state when card changes - React.useEffect(() => { - setReactionState(null); - }, [currentCard?.card_id]); - - const likes = (currentCard?.count_likes || 0) + (reactionState === 'liked' ? 1 : 0); - const dislikes = (currentCard?.count_dislikes || 0) + (reactionState === 'disliked' ? 1 : 0); + const likes = currentCard?.count_likes || 0; + const dislikes = currentCard?.count_dislikes || 0; + const commentsCount = currentCard?.comments?.length || 0; return (
- + + -
@@ -34,7 +84,7 @@ export default function CardPair() { let percentB = 50; if (chosenCard) { - // Add the current user's vote to the count for display + // Add current user's choice for visual distribution const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0); const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0); const newTotal = votesA + votesB; @@ -43,16 +93,22 @@ export default function CardPair() { percentB = 100 - percentA; } - // Clamp to 75/25 max for readability - if (percentA > 75) { percentA = 75; percentB = 25; } - if (percentB > 75) { percentB = 75; percentA = 25; } + // Clamp between 25% and 75% for readable card size balance + if (percentA > 75) { + percentA = 75; + percentB = 25; + } + if (percentB > 75) { + percentB = 75; + percentA = 25; + } } - // flex-grow values for animation + // flex-grow values for smooth spring animation const growA = chosenCard ? percentA : 50; const growB = chosenCard ? percentB : 50; - // Actual percentages for display (unclamped) + // Actual display percentages let displayPercentA = 50; let displayPercentB = 50; if (chosenCard && total >= 0) { diff --git a/frontend/src/components/Comments/CommentItem.css b/frontend/src/components/Comments/CommentItem.css index 47a5cca..2ac381d 100644 --- a/frontend/src/components/Comments/CommentItem.css +++ b/frontend/src/components/Comments/CommentItem.css @@ -2,18 +2,26 @@ display: flex; gap: var(--space-md); padding: var(--space-md) 0; + transition: background var(--duration-fast) var(--ease-smooth); } .comment-item + .comment-item { border-top: 1px solid rgba(255, 255, 255, 0.06); } +.comment-item--me { + background: rgba(124, 58, 237, 0.04); + border-radius: var(--radius-sm); + padding: var(--space-sm) var(--space-xs); +} + .comment-avatar { flex-shrink: 0; - width: 40px; - height: 40px; + width: 38px; + height: 38px; border-radius: 50%; overflow: hidden; + border: 1px solid var(--glass-border); } .comment-avatar-img { @@ -28,13 +36,17 @@ justify-content: center; width: 100%; height: 100%; - background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + background: linear-gradient(135deg, var(--color-card-a-from), var(--color-card-b-from)); font-family: var(--font-display); - font-size: 16px; + font-size: 15px; font-weight: 700; color: var(--color-text); } +.comment-avatar-fallback--me { + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); +} + .comment-body { flex: 1; min-width: 0; @@ -42,20 +54,43 @@ .comment-header { display: flex; - align-items: baseline; + align-items: center; + justify-content: space-between; gap: var(--space-sm); margin-bottom: 4px; } +.comment-author-group { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .comment-author { font-weight: 600; font-size: 14px; color: var(--color-stat-a); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.comment-me-badge { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 4px; + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + color: #FFFFFF; + text-transform: uppercase; + letter-spacing: 0.5px; } .comment-date { - font-size: 12px; + font-size: 11px; color: var(--color-muted); + flex-shrink: 0; } .comment-text { diff --git a/frontend/src/components/Comments/CommentItem.jsx b/frontend/src/components/Comments/CommentItem.jsx index 77cd028..c7579a2 100644 --- a/frontend/src/components/Comments/CommentItem.jsx +++ b/frontend/src/components/Comments/CommentItem.jsx @@ -1,31 +1,57 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { currentUser } from '../../services/auth'; import './CommentItem.css'; export default function CommentItem({ comment, author }) { - const displayName = author?.username || author?.first_name || 'Аноним'; - const date = comment.creation_date + const [imageError, setImageError] = useState(false); + const isMe = comment.author_id === currentUser?.id; + + const displayName = isMe + ? (currentUser?.username ? `@${currentUser.username}` : currentUser?.first_name || 'Вы') + : (author?.username ? `@${author.username}` : author?.first_name || `Игрок #${comment.author_id}`); + + const photoUrl = isMe ? currentUser?.photo_url : author?.photo_url; + + useEffect(() => { + setImageError(false); + }, [photoUrl]); + + const dateStr = comment.creation_date ? new Date(comment.creation_date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', - year: 'numeric', + hour: '2-digit', + minute: '2-digit', }) : ''; + const initial = displayName.replace(/^@/, '')[0]?.toUpperCase() || '?'; + return ( -
+
- {author?.photo_url ? ( - + {photoUrl && !imageError ? ( + setImageError(true)} + /> ) : ( - - {displayName[0]?.toUpperCase() || '?'} + + {initial} )}
+
- {displayName} - {date} +
+ {displayName} + {isMe && Вы} +
+ {dateStr}

{comment.comment_text || comment.commet_text}

diff --git a/frontend/src/components/Comments/CommentsPanel.css b/frontend/src/components/Comments/CommentsPanel.css index c8f5c61..4cdaaa0 100644 --- a/frontend/src/components/Comments/CommentsPanel.css +++ b/frontend/src/components/Comments/CommentsPanel.css @@ -7,6 +7,8 @@ background: var(--color-bg); transform: translateY(100%); transition: transform var(--duration-normal) var(--ease-smooth); + padding-top: max(0px, env(safe-area-inset-top)); + padding-bottom: max(0px, env(safe-area-inset-bottom)); } .comments-panel--open { @@ -40,6 +42,15 @@ font-weight: 600; } +.comments-count { + font-size: 12px; + font-weight: 700; + color: var(--color-muted); + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: var(--radius-full); +} + /* List */ .comments-list { flex: 1; @@ -47,8 +58,31 @@ padding: 0 var(--space-lg); } -.comments-placeholder { +.comments-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; padding: var(--space-xl); + gap: var(--space-md); +} + +.comments-spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--color-card-a-to); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.comments-placeholder { text-align: center; color: var(--color-muted); font-size: 14px; @@ -60,9 +94,15 @@ align-items: center; justify-content: center; height: 100%; + min-height: 200px; gap: var(--space-sm); } +.comments-empty-icon { + font-size: 32px; + margin-bottom: var(--space-xs); +} + .comments-empty-text { font-family: var(--font-display); font-size: 16px; @@ -89,15 +129,20 @@ .comments-input { flex: 1; resize: none; - padding: var(--space-sm) var(--space-md); + padding: 10px var(--space-md); background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); color: var(--color-text); font-size: 14px; line-height: 1.4; - min-height: 40px; + min-height: 42px; max-height: 100px; + transition: border-color var(--duration-fast) var(--ease-smooth); +} + +.comments-input:focus { + border-color: var(--color-card-a-to); } .comments-input::placeholder { @@ -108,8 +153,8 @@ display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 42px; + height: 42px; border-radius: 50%; background: var(--glass-bg); color: var(--color-muted); @@ -118,10 +163,21 @@ } .comments-send--active { - background: var(--color-like); - color: var(--color-text); + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + color: #FFFFFF; + box-shadow: 0 0 12px rgba(124, 58, 237, 0.3); } .comments-send:disabled { cursor: default; + opacity: 0.6; +} + +.comments-btn-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #FFFFFF; + border-radius: 50%; + animation: spin 0.8s linear infinite; } diff --git a/frontend/src/components/Comments/CommentsPanel.jsx b/frontend/src/components/Comments/CommentsPanel.jsx index fb24621..428efe2 100644 --- a/frontend/src/components/Comments/CommentsPanel.jsx +++ b/frontend/src/components/Comments/CommentsPanel.jsx @@ -1,14 +1,23 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useApp } from '../../context/AppContext'; -import { showBackButton } from '../../services/auth'; +import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth'; import { api } from '../../services/api'; -import { currentUser } from '../../services/auth'; import CommentItem from './CommentItem'; import './CommentsPanel.css'; export default function CommentsPanel() { - const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp(); + const { + isCommentsOpen, + setIsCommentsOpen, + currentCard, + showToast, + getUserProfile, + handleApiResponse, + syncCardComments, + addCommentToCard, + } = useApp(); const [comments, setComments] = useState([]); + const [authors, setAuthors] = useState({}); const [isLoading, setIsLoading] = useState(false); const [newComment, setNewComment] = useState(''); const [isSending, setIsSending] = useState(false); @@ -17,59 +26,100 @@ export default function CommentsPanel() { // Telegram BackButton useEffect(() => { if (isCommentsOpen) { - const cleanup = showBackButton(() => setIsCommentsOpen(false)); + const cleanup = showBackButton(() => { + hapticImpact('light'); + setIsCommentsOpen(false); + }); return cleanup; } }, [isCommentsOpen, setIsCommentsOpen]); - // Load comments when panel opens - useEffect(() => { - if (isCommentsOpen && currentCard) { - loadComments(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isCommentsOpen, currentCard?.card_id]); + const cardId = currentCard?.card_id; - async function loadComments() { + const loadComments = useCallback(async () => { + if (!cardId) return; setIsLoading(true); + try { - // TODO: Replace with real GET /get_comments when backend implements it - const result = await api.getComments(currentCard.card_id); - if (!result.error) { - setComments(result.result || []); + const result = await api.getComments(cardId); + handleApiResponse(result); + + if (!result.error && Array.isArray(result.result)) { + const loadedComments = result.result; + setComments(loadedComments); + syncCardComments(cardId, loadedComments.map((c) => c.comment_id)); + + // Fetch author profiles for all unique authors + const uniqueAuthorIds = Array.from(new Set(loadedComments.map((c) => c.author_id))); + const authorsData = {}; + + await Promise.all( + uniqueAuthorIds.map(async (authorId) => { + const profile = await getUserProfile(authorId); + if (profile) { + authorsData[authorId] = profile; + } + }) + ); + + setAuthors(authorsData); + } else { + setComments([]); } } catch (err) { console.error('Load comments error:', err); } finally { setIsLoading(false); } - } + }, [cardId, getUserProfile, handleApiResponse, syncCardComments]); + + // Load comments only when panel opens or active card ID changes + useEffect(() => { + if (isCommentsOpen && cardId) { + loadComments(); + } + }, [isCommentsOpen, cardId, loadComments]); async function handleSend() { - if (!newComment.trim() || isSending) return; + const text = newComment.trim(); + if (!text || isSending || !currentCard) return; + setIsSending(true); + hapticImpact('light'); + try { - const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim()); - if (!result.error) { + const result = await api.addComment(currentUser.id, currentCard.card_id, text); + handleApiResponse(result); + + if (!result.error && result.result) { setNewComment(''); - showToast('Комментарий отправлен'); - - // Optimistically add the new comment to the list - if (result.result) { - setComments(prev => [...prev, result.result]); - - // Scroll to bottom after adding - setTimeout(() => { - if (listRef.current) { - listRef.current.scrollTop = listRef.current.scrollHeight; - } - }, 100); - } + showToast('Комментарий опубликован'); + hapticNotification('success'); + + const createdComment = result.result; + setComments((prev) => [...prev, createdComment]); + addCommentToCard(currentCard.card_id, createdComment.comment_id); + + // Add current user to authors map + setAuthors((prev) => ({ + ...prev, + [currentUser.id]: currentUser, + })); + + // Scroll to bottom + setTimeout(() => { + if (listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight; + } + }, 100); } else { - showToast('Ошибка: ' + (result.result || 'неизвестная')); + showToast(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария'); + hapticNotification('error'); } } catch (err) { console.error('Send comment error:', err); + showToast('Ошибка отправки'); + hapticNotification('error'); } finally { setIsSending(false); } @@ -82,30 +132,44 @@ export default function CommentsPanel() { } } + const handleClose = () => { + hapticImpact('light'); + setIsCommentsOpen(false); + }; + return (

Комментарии

+ {comments.length}
{isLoading ? ( -

Загрузка...

+
+
+

Загрузка комментариев...

+
) : comments.length === 0 ? (
+
💬

Комментариев пока нет

-

Будь первым!

+

Будь первым, кто поделится мнением!

) : ( comments.map((comment, i) => ( - + )) )}
@@ -113,22 +177,27 @@ export default function CommentsPanel() {