From 9327ac53eb29c08ed5b24c9000ecbedab2a62c80 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Thu, 13 Aug 2026 11:35:00 +0300 Subject: [PATCH 1/4] Create frontend --- frontend/package.json | 1 + frontend/public/index.html | 40 +--- frontend/src/App.css | 64 +++--- frontend/src/App.js | 67 +++++-- .../src/components/BottomBar/BottomBar.css | 74 +++++++ .../src/components/BottomBar/BottomBar.jsx | 86 ++++++++ frontend/src/components/CardPair/Card.css | 112 +++++++++++ frontend/src/components/CardPair/Card.jsx | 34 ++++ frontend/src/components/CardPair/CardPair.css | 82 ++++++++ frontend/src/components/CardPair/CardPair.jsx | 99 +++++++++ frontend/src/components/CardPair/OrBadge.css | 33 +++ frontend/src/components/CardPair/OrBadge.jsx | 10 + .../src/components/Comments/CommentItem.css | 66 ++++++ .../src/components/Comments/CommentItem.jsx | 34 ++++ .../src/components/Comments/CommentsPanel.css | 127 ++++++++++++ .../src/components/Comments/CommentsPanel.jsx | 136 +++++++++++++ frontend/src/components/Menu/AboutPage.css | 61 ++++++ frontend/src/components/Menu/AboutPage.jsx | 60 ++++++ frontend/src/components/Menu/CreateCard.css | 105 ++++++++++ frontend/src/components/Menu/CreateCard.jsx | 91 +++++++++ frontend/src/components/Menu/MenuPanel.css | 59 ++++++ frontend/src/components/Menu/MenuPanel.jsx | 89 +++++++++ .../src/components/common/LoadingScreen.css | 58 ++++++ .../src/components/common/LoadingScreen.jsx | 23 +++ frontend/src/components/common/Overlay.css | 16 ++ frontend/src/components/common/Overlay.jsx | 12 ++ frontend/src/components/common/Toast.css | 43 ++++ frontend/src/components/common/Toast.jsx | 15 ++ frontend/src/context/AppContext.jsx | 189 ++++++++++++++++++ frontend/src/index.css | 157 ++++++++++++++- frontend/src/index.js | 6 - frontend/src/services/api.js | 66 ++++++ frontend/src/services/auth.js | 55 +++++ 33 files changed, 2083 insertions(+), 87 deletions(-) create mode 100644 frontend/src/components/BottomBar/BottomBar.css create mode 100644 frontend/src/components/BottomBar/BottomBar.jsx create mode 100644 frontend/src/components/CardPair/Card.css create mode 100644 frontend/src/components/CardPair/Card.jsx create mode 100644 frontend/src/components/CardPair/CardPair.css create mode 100644 frontend/src/components/CardPair/CardPair.jsx create mode 100644 frontend/src/components/CardPair/OrBadge.css create mode 100644 frontend/src/components/CardPair/OrBadge.jsx create mode 100644 frontend/src/components/Comments/CommentItem.css create mode 100644 frontend/src/components/Comments/CommentItem.jsx create mode 100644 frontend/src/components/Comments/CommentsPanel.css create mode 100644 frontend/src/components/Comments/CommentsPanel.jsx create mode 100644 frontend/src/components/Menu/AboutPage.css create mode 100644 frontend/src/components/Menu/AboutPage.jsx create mode 100644 frontend/src/components/Menu/CreateCard.css create mode 100644 frontend/src/components/Menu/CreateCard.jsx create mode 100644 frontend/src/components/Menu/MenuPanel.css create mode 100644 frontend/src/components/Menu/MenuPanel.jsx create mode 100644 frontend/src/components/common/LoadingScreen.css create mode 100644 frontend/src/components/common/LoadingScreen.jsx create mode 100644 frontend/src/components/common/Overlay.css create mode 100644 frontend/src/components/common/Overlay.jsx create mode 100644 frontend/src/components/common/Toast.css create mode 100644 frontend/src/components/common/Toast.jsx create mode 100644 frontend/src/context/AppContext.jsx create mode 100644 frontend/src/services/api.js create mode 100644 frontend/src/services/auth.js diff --git a/frontend/package.json b/frontend/package.json index a817763..bcf893d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "last 1 safari version" ] }, + "proxy": "http://localhost:5000", "devDependencies": { "web-vitals": "^4.2.4" } diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f2..d34e4fe 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,43 +1,21 @@ - + - - - + + + - - - React App + + + + This OR That - +
- diff --git a/frontend/src/App.css b/frontend/src/App.css index 74b5e05..fa4a015 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,38 +1,54 @@ -.App { - text-align: center; +/* ---------- App Layout ---------- */ + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-sm) var(--space-md); + height: 48px; + flex-shrink: 0; } -.App-logo { - height: 40vmin; - pointer-events: none; +.app-logo { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.3px; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } +.app-logo-or { + display: inline-block; + margin: 0 3px; + padding: 1px 6px; + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + border-radius: 6px; + font-size: 12px; + vertical-align: middle; } -.App-header { - background-color: #282c34; - min-height: 100vh; +/* Hamburger menu */ +.menu-toggle { display: flex; flex-direction: column; - align-items: center; justify-content: center; - font-size: calc(10px + 2vmin); - color: white; + gap: 4px; + width: 36px; + height: 36px; + padding: 8px; + border-radius: var(--radius-sm); + transition: background var(--duration-fast) var(--ease-smooth); } -.App-link { - color: #61dafb; +.menu-toggle:active { + background: rgba(255, 255, 255, 0.06); } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.menu-toggle-line { + display: block; + width: 100%; + height: 2px; + background: var(--color-text); + border-radius: 1px; + transition: transform var(--duration-normal) var(--ease-smooth); } diff --git a/frontend/src/App.js b/frontend/src/App.js index 3784575..8f6e9f4 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,25 +1,60 @@ -import logo from './logo.svg'; +import React from 'react'; +import { AppProvider, useApp } from './context/AppContext'; +import LoadingScreen from './components/common/LoadingScreen'; +import Toast from './components/common/Toast'; +import CardPair from './components/CardPair/CardPair'; +import BottomBar from './components/BottomBar/BottomBar'; +import CommentsPanel from './components/Comments/CommentsPanel'; +import MenuPanel from './components/Menu/MenuPanel'; import './App.css'; -function App() { +function AppContent() { + const { isLoading, error, openMenu, toast } = useApp(); + + if (isLoading) { + return ; + } + + if (error && !isLoading) { + return ; + } + return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - +
+ {/* Header with menu button */} +
+
+ thisORthat +
+
+ + {/* Main content — card pair */} + + + {/* Bottom bar — reactions */} + + + {/* Panels */} + + + + {/* Toast notifications */} +
); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/frontend/src/components/BottomBar/BottomBar.css b/frontend/src/components/BottomBar/BottomBar.css new file mode 100644 index 0000000..fe3b5a1 --- /dev/null +++ b/frontend/src/components/BottomBar/BottomBar.css @@ -0,0 +1,74 @@ +.bottom-bar { + display: flex; + align-items: center; + justify-content: space-around; + height: var(--bar-height); + padding: 0 var(--space-lg); + background: var(--color-bg-elevated); + border-top: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.bar-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-sm); + transition: color var(--duration-fast) var(--ease-smooth), + transform var(--duration-fast) var(--ease-smooth); + color: var(--color-muted); +} + +.bar-btn:active { + transform: scale(0.92); +} + +.bar-btn--disabled { + opacity: 0.4; + pointer-events: none; +} + +.bar-btn--disabled.bar-btn--active { + opacity: 1; + pointer-events: none; +} + +/* Active states */ +.bar-btn--like.bar-btn--active { + color: var(--color-like); + animation: popIn 300ms var(--ease-spring); +} + +.bar-btn--dislike.bar-btn--active { + color: var(--color-dislike); + animation: popIn 300ms var(--ease-spring); +} + +.bar-btn--comments { + color: var(--color-muted); +} + +.bar-btn--comments:not(.bar-btn--disabled) { + opacity: 1; + pointer-events: auto; +} + +.bar-icon { + width: 22px; + height: 22px; +} + +.bar-count { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + line-height: 1; +} + +@keyframes popIn { + 0% { transform: scale(1); } + 40% { transform: scale(1.25); } + 100% { transform: scale(1); } +} diff --git a/frontend/src/components/BottomBar/BottomBar.jsx b/frontend/src/components/BottomBar/BottomBar.jsx new file mode 100644 index 0000000..038d749 --- /dev/null +++ b/frontend/src/components/BottomBar/BottomBar.jsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { useApp } from '../../context/AppContext'; +import './BottomBar.css'; + +export default function BottomBar() { + const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp(); + const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null + + 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 handleLike = async () => { + if (!isRevealed || currentReaction) return; + const result = await likeCard(); + if (result && !result.error) { + setReactionState('liked'); + } + }; + + const handleDislike = async () => { + if (!isRevealed || currentReaction) return; + const result = await dislikeCard(); + if (result && !result.error) { + setReactionState('disliked'); + } + }; + + const handleComments = () => { + 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); + + return ( +
+ + + + + +
+ ); +} + +function formatCount(n) { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K'; + return String(n); +} diff --git a/frontend/src/components/CardPair/Card.css b/frontend/src/components/CardPair/Card.css new file mode 100644 index 0000000..1685fe0 --- /dev/null +++ b/frontend/src/components/CardPair/Card.css @@ -0,0 +1,112 @@ +.card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-lg); + border-radius: var(--radius-card); + cursor: pointer; + transition: flex-grow var(--duration-card) var(--ease-spring), + opacity 400ms var(--ease-smooth), + filter 400ms var(--ease-smooth); + overflow: hidden; + min-height: 0; +} + +/* Card A — Deep Violet gradient */ +.card--a { + background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to)); +} + +/* Card B — Electric Blue gradient */ +.card--b { + background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to)); +} + +/* Tap feedback */ +.card:active { + transform: scale(0.98); + transition: transform 80ms ease; +} + +/* After choice — faded (not chosen) */ +.card--faded { + opacity: 0.65; + filter: brightness(0.8) saturate(0.85); +} + +/* After choice — chosen */ +.card--chosen { + opacity: 1; +} + +/* Tap hint on chosen card */ +.card--tap-next { + cursor: pointer; +} + +/* ---------- Text ---------- */ +.card-text { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + color: var(--color-text); + text-align: center; + line-height: 1.3; + word-break: break-word; + max-width: 280px; +} + +/* ---------- Stats ---------- */ +.card-stats { + position: absolute; + bottom: var(--space-md); + right: var(--space-md); + text-align: right; + animation: statsReveal 500ms var(--ease-spring) forwards; +} + +.card-percent { + display: block; + font-family: var(--font-mono); + font-size: 28px; + font-weight: 700; + line-height: 1; +} + +.card--a .card-percent { color: var(--color-stat-a); } +.card--b .card-percent { color: var(--color-stat-b); } + +.card-count { + display: block; + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + color: var(--color-muted); + margin-top: 2px; +} + +/* ---------- Hint ---------- */ +.card-hint { + position: absolute; + bottom: var(--space-sm); + left: 50%; + transform: translateX(-50%); + font-size: 11px; + color: var(--color-muted); + opacity: 0; + animation: hintFadeIn 600ms 800ms var(--ease-smooth) forwards; + white-space: nowrap; +} + +/* ---------- Animations ---------- */ +@keyframes statsReveal { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes hintFadeIn { + from { opacity: 0; } + to { opacity: 0.6; } +} diff --git a/frontend/src/components/CardPair/Card.jsx b/frontend/src/components/CardPair/Card.jsx new file mode 100644 index 0000000..01ff090 --- /dev/null +++ b/frontend/src/components/CardPair/Card.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import './Card.css'; + +export default function Card({ label, type, chosen, isChosen, percent, count, onClick }) { + const isRevealed = chosen !== null; + const isThis = chosen === type; + const isFaded = isRevealed && !isThis; + + const cardClass = [ + 'card', + `card--${type.toLowerCase()}`, + isRevealed ? 'card--revealed' : '', + isThis ? 'card--chosen' : '', + isFaded ? 'card--faded' : '', + isChosen ? 'card--tap-next' : '', + ].filter(Boolean).join(' '); + + return ( + + ); +} diff --git a/frontend/src/components/CardPair/CardPair.css b/frontend/src/components/CardPair/CardPair.css new file mode 100644 index 0000000..c02e848 --- /dev/null +++ b/frontend/src/components/CardPair/CardPair.css @@ -0,0 +1,82 @@ +.card-pair { + flex: 1; + display: flex; + flex-direction: column; + padding: var(--space-md); + padding-bottom: var(--space-sm); + min-height: 0; +} + +.card-pair-inner { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + position: relative; + min-height: 0; +} + +.card-wrapper { + display: flex; + min-height: 0; + transition: flex-grow var(--duration-card) var(--ease-spring); +} + +.card-wrapper .card { + flex: 1; + width: 100%; +} + +/* Empty state */ +.card-pair--empty { + align-items: center; + justify-content: center; +} + +.empty-content { + display: flex; + flex-direction: column; + gap: var(--space-xl); + width: 100%; + max-width: 320px; + animation: hintFadeIn 600ms var(--ease-smooth); +} + +.card-pair-empty-text { + font-family: var(--font-display); + font-size: 20px; + color: var(--color-text); + text-align: center; +} + +.empty-actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.empty-btn { + width: 100%; + padding: var(--space-md); + border-radius: var(--radius-sm); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + font-size: 15px; + font-weight: 500; + color: var(--color-text); + transition: all var(--duration-fast) var(--ease-smooth); +} + +.empty-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.empty-btn--primary { + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + border-color: transparent; + font-weight: 600; +} + +.empty-btn--primary:active { + transform: scale(0.97); +} diff --git a/frontend/src/components/CardPair/CardPair.jsx b/frontend/src/components/CardPair/CardPair.jsx new file mode 100644 index 0000000..a3770a1 --- /dev/null +++ b/frontend/src/components/CardPair/CardPair.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import Card from './Card'; +import OrBadge from './OrBadge'; +import { useApp } from '../../context/AppContext'; +import './CardPair.css'; + +export default function CardPair() { + const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp(); + + if (!currentCard) { + return ( +
+
+

Карточки закончились!

+
+ + + +
+
+
+ ); + } + + // Calculate percentages + const total = currentCard.count_choice_A + currentCard.count_choice_B; + let percentA = 50; + let percentB = 50; + + if (chosenCard) { + // Add the current user's vote to the count for display + const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0); + const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0); + const newTotal = votesA + votesB; + if (newTotal > 0) { + percentA = Math.round((votesA / newTotal) * 100); + percentB = 100 - percentA; + } + + // Clamp to 75/25 max for readability + if (percentA > 75) { percentA = 75; percentB = 25; } + if (percentB > 75) { percentB = 75; percentA = 25; } + } + + // flex-grow values for animation + const growA = chosenCard ? percentA : 50; + const growB = chosenCard ? percentB : 50; + + // Actual percentages for display (unclamped) + let displayPercentA = 50; + let displayPercentB = 50; + if (chosenCard && total >= 0) { + const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0); + const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0); + const newTotal = votesA + votesB; + if (newTotal > 0) { + displayPercentA = Math.round((votesA / newTotal) * 100); + displayPercentB = 100 - displayPercentA; + } + } + + return ( +
+
+
+ chooseCard('A')} + /> +
+ + + +
+ chooseCard('B')} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/CardPair/OrBadge.css b/frontend/src/components/CardPair/OrBadge.css new file mode 100644 index 0000000..ed71b23 --- /dev/null +++ b/frontend/src/components/CardPair/OrBadge.css @@ -0,0 +1,33 @@ +.or-badge { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 10; + transition: opacity var(--duration-normal) var(--ease-smooth), + transform var(--duration-card) var(--ease-spring); +} + +.or-badge--hidden { + opacity: 0; + transform: translate(-50%, -50%) scale(0.7); + pointer-events: none; +} + +.or-badge-text { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + color: var(--color-text); + letter-spacing: 0.5px; +} diff --git a/frontend/src/components/CardPair/OrBadge.jsx b/frontend/src/components/CardPair/OrBadge.jsx new file mode 100644 index 0000000..4a84466 --- /dev/null +++ b/frontend/src/components/CardPair/OrBadge.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import './OrBadge.css'; + +export default function OrBadge({ visible }) { + return ( +
+ ИЛИ +
+ ); +} diff --git a/frontend/src/components/Comments/CommentItem.css b/frontend/src/components/Comments/CommentItem.css new file mode 100644 index 0000000..47a5cca --- /dev/null +++ b/frontend/src/components/Comments/CommentItem.css @@ -0,0 +1,66 @@ +.comment-item { + display: flex; + gap: var(--space-md); + padding: var(--space-md) 0; +} + +.comment-item + .comment-item { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.comment-avatar { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; +} + +.comment-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.comment-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + color: var(--color-text); +} + +.comment-body { + flex: 1; + min-width: 0; +} + +.comment-header { + display: flex; + align-items: baseline; + gap: var(--space-sm); + margin-bottom: 4px; +} + +.comment-author { + font-weight: 600; + font-size: 14px; + color: var(--color-stat-a); +} + +.comment-date { + font-size: 12px; + color: var(--color-muted); +} + +.comment-text { + font-size: 14px; + line-height: 1.45; + color: var(--color-text); + word-break: break-word; +} diff --git a/frontend/src/components/Comments/CommentItem.jsx b/frontend/src/components/Comments/CommentItem.jsx new file mode 100644 index 0000000..77cd028 --- /dev/null +++ b/frontend/src/components/Comments/CommentItem.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import './CommentItem.css'; + +export default function CommentItem({ comment, author }) { + const displayName = author?.username || author?.first_name || 'Аноним'; + const date = comment.creation_date + ? new Date(comment.creation_date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : ''; + + return ( +
+
+ {author?.photo_url ? ( + + ) : ( + + {displayName[0]?.toUpperCase() || '?'} + + )} +
+
+
+ {displayName} + {date} +
+

{comment.comment_text || comment.commet_text}

+
+
+ ); +} diff --git a/frontend/src/components/Comments/CommentsPanel.css b/frontend/src/components/Comments/CommentsPanel.css new file mode 100644 index 0000000..c8f5c61 --- /dev/null +++ b/frontend/src/components/Comments/CommentsPanel.css @@ -0,0 +1,127 @@ +.comments-panel { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + flex-direction: column; + background: var(--color-bg); + transform: translateY(100%); + transition: transform var(--duration-normal) var(--ease-smooth); +} + +.comments-panel--open { + transform: translateY(0); +} + +/* Header */ +.comments-header { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.comments-close { + font-size: 20px; + padding: var(--space-xs); + color: var(--color-muted); + transition: color var(--duration-fast) var(--ease-smooth); +} + +.comments-close:hover { + color: var(--color-text); +} + +.comments-title { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; +} + +/* List */ +.comments-list { + flex: 1; + overflow-y: auto; + padding: 0 var(--space-lg); +} + +.comments-placeholder { + padding: var(--space-xl); + text-align: center; + color: var(--color-muted); + font-size: 14px; +} + +.comments-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: var(--space-sm); +} + +.comments-empty-text { + font-family: var(--font-display); + font-size: 16px; + color: var(--color-muted); +} + +.comments-empty-sub { + font-size: 13px; + color: var(--color-muted); + opacity: 0.6; +} + +/* Input area */ +.comments-input-area { + display: flex; + align-items: flex-end; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: var(--color-bg-elevated); + flex-shrink: 0; +} + +.comments-input { + flex: 1; + resize: none; + padding: var(--space-sm) 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; + max-height: 100px; +} + +.comments-input::placeholder { + color: var(--color-muted); +} + +.comments-send { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--glass-bg); + color: var(--color-muted); + transition: all var(--duration-fast) var(--ease-smooth); + flex-shrink: 0; +} + +.comments-send--active { + background: var(--color-like); + color: var(--color-text); +} + +.comments-send:disabled { + cursor: default; +} diff --git a/frontend/src/components/Comments/CommentsPanel.jsx b/frontend/src/components/Comments/CommentsPanel.jsx new file mode 100644 index 0000000..fb24621 --- /dev/null +++ b/frontend/src/components/Comments/CommentsPanel.jsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useApp } from '../../context/AppContext'; +import { showBackButton } 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 [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [newComment, setNewComment] = useState(''); + const [isSending, setIsSending] = useState(false); + const listRef = useRef(null); + + // Telegram BackButton + useEffect(() => { + if (isCommentsOpen) { + const cleanup = showBackButton(() => 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]); + + async function loadComments() { + 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 || []); + } + } catch (err) { + console.error('Load comments error:', err); + } finally { + setIsLoading(false); + } + } + + async function handleSend() { + if (!newComment.trim() || isSending) return; + setIsSending(true); + try { + const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim()); + if (!result.error) { + 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); + } + } else { + showToast('Ошибка: ' + (result.result || 'неизвестная')); + } + } catch (err) { + console.error('Send comment error:', err); + } finally { + setIsSending(false); + } + } + + function handleKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + + return ( +
+
+ +

Комментарии

+
+ +
+ {isLoading ? ( +

Загрузка...

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

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

+

Будь первым!

+
+ ) : ( + comments.map((comment, i) => ( + + )) + )} +
+ +
+