Merge frontend and prebuild #4
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070711" />
|
||||
<meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
-1
@@ -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 <BannedScreen onRetry={handleRetryAfterBan} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
|
||||
@@ -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(<App />);
|
||||
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(<App />);
|
||||
});
|
||||
|
||||
expect(container.innerHTML).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="bottom-bar">
|
||||
<button
|
||||
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
|
||||
onClick={handleDislike}
|
||||
disabled={!isRevealed || !!currentReaction || isSubmitting}
|
||||
aria-label="Дизлайк"
|
||||
>
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
|
||||
@@ -62,12 +65,13 @@ export default function BottomBar() {
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z" />
|
||||
</svg>
|
||||
<span className="bar-count">{formatCount(currentCard?.comments?.length || 0)}</span>
|
||||
<span className="bar-count">{formatCount(commentsCount)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
|
||||
onClick={handleLike}
|
||||
disabled={!isRevealed || !!currentReaction || isSubmitting}
|
||||
aria-label="Лайк"
|
||||
>
|
||||
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -31,52 +31,93 @@
|
||||
.card-pair--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 340px;
|
||||
padding: var(--space-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: hintFadeIn 600ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.empty-badge-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.card-pair-empty-text {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-pair-empty-sub {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
padding: 12px var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.empty-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.empty-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.empty-btn--refresh {
|
||||
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(2, 132, 199, 0.2));
|
||||
border-color: rgba(124, 58, 237, 0.4);
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-btn--primary {
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
.empty-btn--primary:active {
|
||||
transform: scale(0.97);
|
||||
.refresh-icon {
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.refresh-icon--spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -2,24 +2,74 @@ import React from 'react';
|
||||
import Card from './Card';
|
||||
import OrBadge from './OrBadge';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { openExternalLink, hapticImpact } from '../../services/auth';
|
||||
import './CardPair.css';
|
||||
|
||||
export default function CardPair() {
|
||||
const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp();
|
||||
const { currentCard, chosenCard, chooseCard, loadCards, isLoadingCards, openMenu, setMenuScreen } = useApp();
|
||||
|
||||
if (!currentCard) {
|
||||
return (
|
||||
<div className="card-pair card-pair--empty">
|
||||
<div className="empty-content">
|
||||
<p className="card-pair-empty-text">Карточки закончились!</p>
|
||||
<div className="empty-badge-icon">✨</div>
|
||||
<h2 className="card-pair-empty-text">Карточки закончились!</h2>
|
||||
<p className="card-pair-empty-sub">
|
||||
Вы посмотрели все доступные карточки. Новые карточки появляются после прохождения модерации.
|
||||
</p>
|
||||
|
||||
<div className="empty-actions">
|
||||
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}>
|
||||
Создать карточку
|
||||
<button
|
||||
className="empty-btn empty-btn--refresh"
|
||||
onClick={() => loadCards(true)}
|
||||
disabled={isLoadingCards}
|
||||
>
|
||||
<svg
|
||||
className={`refresh-icon ${isLoadingCards ? 'refresh-icon--spinning' : ''}`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width="18"
|
||||
height="18"
|
||||
>
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
<span>{isLoadingCards ? 'Проверяем...' : 'Проверить новые карточки'}</span>
|
||||
</button>
|
||||
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
|
||||
|
||||
<button
|
||||
className="empty-btn empty-btn--primary"
|
||||
onClick={() => {
|
||||
openMenu();
|
||||
setMenuScreen('create');
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span>Создать карточку</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="empty-btn"
|
||||
onClick={() => {
|
||||
openMenu();
|
||||
setMenuScreen('about');
|
||||
}}
|
||||
>
|
||||
О проекте
|
||||
</button>
|
||||
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
|
||||
|
||||
<button
|
||||
className="empty-btn empty-btn--donate"
|
||||
onClick={() => {
|
||||
hapticImpact('light');
|
||||
openExternalLink('https://boosty.to/pseudodev/donate');
|
||||
}}
|
||||
>
|
||||
Поддержать проект
|
||||
</button>
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="comment-item">
|
||||
<div className={`comment-item ${isMe ? 'comment-item--me' : ''}`}>
|
||||
<div className="comment-avatar">
|
||||
{author?.photo_url ? (
|
||||
<img src={author.photo_url} alt="" className="comment-avatar-img" />
|
||||
{photoUrl && !imageError ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt=""
|
||||
className="comment-avatar-img"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="comment-avatar-fallback">
|
||||
{displayName[0]?.toUpperCase() || '?'}
|
||||
<span className={`comment-avatar-fallback ${isMe ? 'comment-avatar-fallback--me' : ''}`}>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="comment-body">
|
||||
<div className="comment-header">
|
||||
<span className="comment-author">{displayName}</span>
|
||||
<span className="comment-date">{date}</span>
|
||||
<div className="comment-author-group">
|
||||
<span className="comment-author">{displayName}</span>
|
||||
{isMe && <span className="comment-me-badge">Вы</span>}
|
||||
</div>
|
||||
<span className="comment-date">{dateStr}</span>
|
||||
</div>
|
||||
<p className="comment-text">{comment.comment_text || comment.commet_text}</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
|
||||
<div className="comments-header">
|
||||
<button
|
||||
className="comments-close"
|
||||
onClick={() => setIsCommentsOpen(false)}
|
||||
onClick={handleClose}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h2 className="comments-title">Комментарии</h2>
|
||||
<span className="comments-count">{comments.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="comments-list custom-scroll" ref={listRef}>
|
||||
{isLoading ? (
|
||||
<p className="comments-placeholder">Загрузка...</p>
|
||||
<div className="comments-loading">
|
||||
<div className="comments-spinner" />
|
||||
<p className="comments-placeholder">Загрузка комментариев...</p>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="comments-empty">
|
||||
<div className="comments-empty-icon">💬</div>
|
||||
<p className="comments-empty-text">Комментариев пока нет</p>
|
||||
<p className="comments-empty-sub">Будь первым!</p>
|
||||
<p className="comments-empty-sub">Будь первым, кто поделится мнением!</p>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment, i) => (
|
||||
<CommentItem key={comment.comment_id || i} comment={comment} author={null} />
|
||||
<CommentItem
|
||||
key={comment.comment_id || i}
|
||||
comment={comment}
|
||||
author={authors[comment.author_id] || null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -113,22 +177,27 @@ export default function CommentsPanel() {
|
||||
<div className="comments-input-area">
|
||||
<textarea
|
||||
className="comments-input"
|
||||
placeholder="Ваш комментарий..."
|
||||
placeholder="Напишите комментарий..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={300}
|
||||
rows={1}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<button
|
||||
className={`comments-send ${newComment.trim() ? 'comments-send--active' : ''}`}
|
||||
className={`comments-send ${newComment.trim() && !isSending ? 'comments-send--active' : ''}`}
|
||||
onClick={handleSend}
|
||||
disabled={!newComment.trim() || isSending}
|
||||
aria-label="Отправить"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
{isSending ? (
|
||||
<span className="comments-btn-spinner" />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import React from 'react';
|
||||
import { hapticImpact, hapticNotification, openExternalLink } from '../../services/auth';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import './AboutPage.css';
|
||||
|
||||
export default function AboutPage({ onBack }) {
|
||||
const { showToast } = useApp();
|
||||
|
||||
function handleCopyEmail() {
|
||||
navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
|
||||
// Visual feedback handled by CSS :active
|
||||
}).catch(() => {
|
||||
// Fallback — select text
|
||||
});
|
||||
hapticImpact('light');
|
||||
navigator.clipboard
|
||||
.writeText('pseudo.developer.ru@gmail.com')
|
||||
.then(() => {
|
||||
hapticNotification('success');
|
||||
showToast('Email скопирован в буфер обмена');
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('pseudo.developer.ru@gmail.com');
|
||||
});
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
hapticImpact('light');
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleOpenGithub = (e) => {
|
||||
e.preventDefault();
|
||||
hapticImpact('light');
|
||||
openExternalLink('https://github.com/IgorVolochay/thisORthat');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="about-page custom-scroll">
|
||||
<button className="about-back" onClick={onBack} aria-label="Назад">
|
||||
<button className="about-back" onClick={handleBack} aria-label="Назад">
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
@@ -35,17 +55,12 @@ export default function AboutPage({ onBack }) {
|
||||
</div>
|
||||
|
||||
<div className="about-links">
|
||||
<a
|
||||
href="https://github.com/IgorVolochay/thisORthat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="about-link"
|
||||
>
|
||||
<button className="about-link" onClick={handleOpenGithub}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
<span>Исходники проекта</span>
|
||||
</a>
|
||||
</button>
|
||||
|
||||
<button className="about-link" onClick={handleCopyEmail}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
|
||||
|
||||
@@ -23,17 +23,24 @@
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.create-rules-box {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.create-rules {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--space-lg);
|
||||
line-height: 1.5;
|
||||
color: #CBD5E1;
|
||||
}
|
||||
|
||||
.create-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
@@ -41,7 +48,8 @@
|
||||
position: relative;
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-lg);
|
||||
min-height: 100px;
|
||||
min-height: 110px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.create-field--a {
|
||||
@@ -65,7 +73,7 @@
|
||||
}
|
||||
|
||||
.create-textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.create-counter {
|
||||
@@ -74,15 +82,15 @@
|
||||
right: var(--space-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.create-submit {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
padding: 14px var(--space-lg);
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-muted);
|
||||
background: var(--glass-bg);
|
||||
@@ -91,15 +99,17 @@
|
||||
}
|
||||
|
||||
.create-submit--active {
|
||||
color: var(--color-text);
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
border-color: transparent;
|
||||
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4);
|
||||
}
|
||||
|
||||
.create-submit:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.create-submit--active:active {
|
||||
transform: scale(0.97);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { currentUser } from '../../services/auth';
|
||||
import { currentUser, hapticImpact, hapticNotification } from '../../services/auth';
|
||||
import { api } from '../../services/api';
|
||||
import './CreateCard.css';
|
||||
|
||||
const MAX_LENGTH = 150;
|
||||
|
||||
export default function CreateCard({ onBack }) {
|
||||
const { showToast, closeMenu } = useApp();
|
||||
const { showToast, closeMenu, handleApiResponse } = useApp();
|
||||
const [choiceA, setChoiceA] = useState('');
|
||||
const [choiceB, setChoiceB] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
@@ -17,47 +17,60 @@ export default function CreateCard({ onBack }) {
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
setIsSending(true);
|
||||
hapticImpact('light');
|
||||
|
||||
try {
|
||||
const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
|
||||
handleApiResponse(result);
|
||||
|
||||
if (!result.error) {
|
||||
hapticNotification('success');
|
||||
showToast('Карточка отправлена на модерацию!');
|
||||
setTimeout(() => {
|
||||
closeMenu();
|
||||
}, 2000);
|
||||
}, 1800);
|
||||
} else {
|
||||
showToast('Ошибка: ' + (result.result || 'неизвестная'));
|
||||
hapticNotification('error');
|
||||
showToast(typeof result.result === 'string' ? result.result : 'Ошибка модерации или отправки');
|
||||
setIsSending(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Add card error:', err);
|
||||
hapticNotification('error');
|
||||
showToast('Ошибка отправки');
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
hapticImpact('light');
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-card custom-scroll">
|
||||
<button className="create-back" onClick={onBack} aria-label="Назад">
|
||||
<button className="create-back" onClick={handleBack} aria-label="Назад">
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
<h2 className="create-title">Создать карточку</h2>
|
||||
|
||||
<p className="create-rules">
|
||||
При создании карточек запрещается использование мата и ссылок.
|
||||
Все карточки проходят процесс модерации перед публикацией.
|
||||
Лимит по длине текста: {MAX_LENGTH} символов.
|
||||
</p>
|
||||
<div className="create-rules-box">
|
||||
<p className="create-rules">
|
||||
💡 <strong>Правила публикации:</strong> Запрещены нецензурные выражения, оскорбления и спам-ссылки. Все карточки проверяются перед публикацией.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="create-fields">
|
||||
<div className="create-field create-field--a">
|
||||
<textarea
|
||||
className="create-textarea"
|
||||
placeholder="Первый вариант"
|
||||
placeholder="Вариант А"
|
||||
value={choiceA}
|
||||
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<span className="create-counter">
|
||||
{choiceA.length}/{MAX_LENGTH}
|
||||
@@ -67,11 +80,12 @@ export default function CreateCard({ onBack }) {
|
||||
<div className="create-field create-field--b">
|
||||
<textarea
|
||||
className="create-textarea"
|
||||
placeholder="Второй вариант"
|
||||
placeholder="Вариант Б"
|
||||
value={choiceB}
|
||||
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
disabled={isSending}
|
||||
/>
|
||||
<span className="create-counter">
|
||||
{choiceB.length}/{MAX_LENGTH}
|
||||
@@ -84,7 +98,7 @@ export default function CreateCard({ onBack }) {
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSending ? 'Отправка...' : 'Отправить!'}
|
||||
{isSending ? 'Отправка на модерацию...' : 'Отправить на модерацию'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { showBackButton } from '../../services/auth';
|
||||
import { showBackButton, hapticImpact, openExternalLink } from '../../services/auth';
|
||||
import Overlay from '../common/Overlay';
|
||||
import AboutPage from './AboutPage';
|
||||
import CreateCard from './CreateCard';
|
||||
@@ -12,24 +12,40 @@ export default function MenuPanel() {
|
||||
// Telegram BackButton for sub-screens
|
||||
useEffect(() => {
|
||||
if (isMenuOpen && menuScreen !== 'menu') {
|
||||
const cleanup = showBackButton(() => setMenuScreen('menu'));
|
||||
const cleanup = showBackButton(() => {
|
||||
hapticImpact('light');
|
||||
setMenuScreen('menu');
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
if (isMenuOpen && menuScreen === 'menu') {
|
||||
const cleanup = showBackButton(() => closeMenu());
|
||||
const cleanup = showBackButton(() => {
|
||||
hapticImpact('light');
|
||||
closeMenu();
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
}, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
|
||||
|
||||
if (!isMenuOpen) return null;
|
||||
|
||||
const handleNavigate = (screen) => {
|
||||
hapticImpact('light');
|
||||
setMenuScreen(screen);
|
||||
};
|
||||
|
||||
const handleExternalLink = (url) => {
|
||||
hapticImpact('light');
|
||||
openExternalLink(url);
|
||||
};
|
||||
|
||||
// Sub-screens
|
||||
if (menuScreen === 'about') {
|
||||
return (
|
||||
<>
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<AboutPage onBack={() => setMenuScreen('menu')} />
|
||||
<AboutPage onBack={() => handleNavigate('menu')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -40,7 +56,7 @@ export default function MenuPanel() {
|
||||
<>
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<CreateCard onBack={() => setMenuScreen('menu')} />
|
||||
<CreateCard onBack={() => handleNavigate('menu')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -51,7 +67,7 @@ export default function MenuPanel() {
|
||||
<Overlay visible={true} onClick={closeMenu} />
|
||||
<div className="menu-panel menu-panel--open">
|
||||
<nav className="menu-list">
|
||||
<button className="menu-item" onClick={() => setMenuScreen('about')}>
|
||||
<button className="menu-item" onClick={() => handleNavigate('about')}>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
|
||||
@@ -61,7 +77,7 @@ export default function MenuPanel() {
|
||||
<span className="menu-label">О проекте</span>
|
||||
</button>
|
||||
|
||||
<button className="menu-item" onClick={() => setMenuScreen('create')}>
|
||||
<button className="menu-item" onClick={() => handleNavigate('create')}>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
|
||||
@@ -73,7 +89,7 @@ export default function MenuPanel() {
|
||||
|
||||
<button
|
||||
className="menu-item"
|
||||
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}
|
||||
onClick={() => handleExternalLink('https://boosty.to/pseudodev/donate')}
|
||||
>
|
||||
<span className="menu-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
.banned-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
padding: var(--space-lg);
|
||||
z-index: 1000;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.banned-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow: 0 0 40px rgba(239, 68, 68, 0.15);
|
||||
border-radius: var(--radius-card);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
animation: bannedAppear 400ms var(--ease-spring);
|
||||
}
|
||||
|
||||
@keyframes bannedAppear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.92) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.banned-icon-wrapper {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
color: #EF4444;
|
||||
box-shadow: 0 0 24px rgba(239, 68, 68, 0.25);
|
||||
animation: pulseGlow 2.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 32px rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.banned-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.banned-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-sm);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.banned-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.banned-info-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: #CBD5E1;
|
||||
text-align: left;
|
||||
margin-bottom: var(--space-xl);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banned-info-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #EF4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.banned-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banned-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 12px var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.banned-btn--primary {
|
||||
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.banned-btn--primary:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.banned-btn--primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.banned-btn--secondary {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.banned-btn--secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import './BannedScreen.css';
|
||||
|
||||
export default function BannedScreen({ onRetry }) {
|
||||
const handleSupportClick = () => {
|
||||
window.open('https://t.me/IgorVolochay', '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="banned-screen">
|
||||
<div className="banned-card">
|
||||
<div className="banned-icon-wrapper">
|
||||
<svg className="banned-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="banned-title">Доступ ограничен</h1>
|
||||
|
||||
<p className="banned-subtitle">
|
||||
Система безопасности зафиксировала подозрительную активность с вашего IP-адреса.
|
||||
</p>
|
||||
|
||||
<div className="banned-info-box">
|
||||
<div className="banned-info-dot" />
|
||||
<span>Блокировка длится 1 час и снимается автоматически.</span>
|
||||
</div>
|
||||
|
||||
<div className="banned-actions">
|
||||
{onRetry && (
|
||||
<button className="banned-btn banned-btn--primary" onClick={onRetry}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
|
||||
</svg>
|
||||
<span>Повторить попытку</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="banned-btn banned-btn--secondary" onClick={handleSupportClick}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
|
||||
</svg>
|
||||
<span>Написать в поддержку</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import { currentUser, initTelegramApp } from '../services/auth';
|
||||
import { currentUser, initTelegramApp, hapticImpact, hapticNotification } from '../services/auth';
|
||||
|
||||
const AppContext = createContext(null);
|
||||
|
||||
@@ -9,11 +9,13 @@ export function AppProvider({ children }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isBanned, setIsBanned] = useState(false);
|
||||
|
||||
// Card queue
|
||||
const [cardQueue, setCardQueue] = useState([]);
|
||||
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||
const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
|
||||
const [isLoadingCards, setIsLoadingCards] = useState(false);
|
||||
|
||||
// Panels
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -22,84 +24,152 @@ export function AppProvider({ children }) {
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState(null);
|
||||
const toastTimeoutRef = useRef(null);
|
||||
|
||||
// User Profile Cache for comments
|
||||
const userProfileCacheRef = useRef(new Map());
|
||||
|
||||
// Initialization ref for React StrictMode
|
||||
const isInitializingRef = useRef(false);
|
||||
|
||||
// Current card helper
|
||||
const currentCard = cardQueue[currentCardIndex] || null;
|
||||
|
||||
// Initialize app
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
initTelegramApp();
|
||||
|
||||
// Check/register user
|
||||
const checkResult = await api.checkUser(currentUser.id);
|
||||
if (!checkResult.result) {
|
||||
await api.addUser({
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
});
|
||||
}
|
||||
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
|
||||
// Load first batch of cards
|
||||
await loadCards();
|
||||
} catch (err) {
|
||||
setError('Не удалось загрузить приложение');
|
||||
console.error('Init error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// Toast helper
|
||||
const showToast = useCallback((message, duration = 2500) => {
|
||||
if (toastTimeoutRef.current) {
|
||||
clearTimeout(toastTimeoutRef.current);
|
||||
}
|
||||
init();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
setToast(message);
|
||||
toastTimeoutRef.current = setTimeout(() => setToast(null), duration);
|
||||
}, []);
|
||||
|
||||
// Response interceptor helper
|
||||
const handleApiResponse = useCallback((res) => {
|
||||
if (res?.isBanned) {
|
||||
setIsBanned(true);
|
||||
}
|
||||
if (res?.status === 429) {
|
||||
showToast(typeof res.result === 'string' ? res.result : 'Слишком много запросов. Подождите немного.');
|
||||
}
|
||||
return res;
|
||||
}, [showToast]);
|
||||
|
||||
// Load cards batch
|
||||
const loadCards = useCallback(async () => {
|
||||
const loadCards = useCallback(async (isManualRefresh = false) => {
|
||||
setIsLoadingCards(true);
|
||||
if (isManualRefresh) {
|
||||
hapticImpact('light');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.getRandomCards(currentUser.id);
|
||||
handleApiResponse(result);
|
||||
|
||||
if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
|
||||
setCardQueue(result.result);
|
||||
setCurrentCardIndex(0);
|
||||
setChosenCard(null);
|
||||
if (isManualRefresh) {
|
||||
showToast('Карточки обновлены!');
|
||||
hapticNotification('success');
|
||||
}
|
||||
} else {
|
||||
// No more cards or error
|
||||
// Pool is empty or all cards seen
|
||||
setCardQueue([]);
|
||||
setCurrentCardIndex(0);
|
||||
setChosenCard(null);
|
||||
if (isManualRefresh) {
|
||||
showToast('Новых карточек пока нет');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load cards error:', err);
|
||||
setError('Ошибка загрузки карточек');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Choose a card (A or B)
|
||||
const chooseCard = useCallback((choice) => {
|
||||
if (chosenCard) {
|
||||
// Second tap on chosen card — go next
|
||||
if (choice === chosenCard) {
|
||||
goToNextCard();
|
||||
if (isManualRefresh) {
|
||||
showToast('Ошибка загрузки карточек');
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
setIsLoadingCards(false);
|
||||
}
|
||||
setChosenCard(choice);
|
||||
// Fire select_choice to backend
|
||||
if (currentCard) {
|
||||
api.selectChoice(currentUser.id, currentCard.card_id, choice).catch(console.error);
|
||||
}, [handleApiResponse, showToast]);
|
||||
|
||||
// Initialize app
|
||||
const initApp = useCallback(async () => {
|
||||
try {
|
||||
initTelegramApp();
|
||||
|
||||
// Check/register user
|
||||
const checkResult = await api.checkUser(currentUser.id);
|
||||
handleApiResponse(checkResult);
|
||||
if (checkResult?.isBanned) return;
|
||||
|
||||
if (!checkResult.error && !checkResult.result) {
|
||||
const addResult = await api.addUser({
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
});
|
||||
handleApiResponse(addResult);
|
||||
if (addResult?.isBanned) return;
|
||||
}
|
||||
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
handleApiResponse(userResult);
|
||||
if (userResult?.isBanned) return;
|
||||
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
|
||||
// Load first batch of cards
|
||||
await loadCards();
|
||||
} catch (err) {
|
||||
setError('Не удалось загрузить приложение');
|
||||
console.error('Init error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chosenCard, currentCard]);
|
||||
}, [handleApiResponse, loadCards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitializingRef.current) return;
|
||||
isInitializingRef.current = true;
|
||||
initApp();
|
||||
}, [initApp]);
|
||||
|
||||
// User Profile resolver for comments
|
||||
const getUserProfile = useCallback(async (userId) => {
|
||||
if (!userId) return null;
|
||||
if (userId === currentUser.id) {
|
||||
return {
|
||||
user_id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
first_name: currentUser.first_name,
|
||||
last_name: currentUser.last_name,
|
||||
photo_url: currentUser.photo_url,
|
||||
};
|
||||
}
|
||||
if (userProfileCacheRef.current.has(userId)) {
|
||||
return userProfileCacheRef.current.get(userId);
|
||||
}
|
||||
try {
|
||||
const res = await api.getUser(userId);
|
||||
handleApiResponse(res);
|
||||
if (!res.error && res.result) {
|
||||
userProfileCacheRef.current.set(userId, res.result);
|
||||
return res.result;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get user profile:', userId, err);
|
||||
}
|
||||
return null;
|
||||
}, [handleApiResponse]);
|
||||
|
||||
// Go to next card
|
||||
const goToNextCard = useCallback(async () => {
|
||||
hapticImpact('light');
|
||||
const nextIndex = currentCardIndex + 1;
|
||||
if (nextIndex < cardQueue.length) {
|
||||
setCurrentCardIndex(nextIndex);
|
||||
@@ -110,50 +180,155 @@ export function AppProvider({ children }) {
|
||||
}
|
||||
}, [currentCardIndex, cardQueue.length, loadCards]);
|
||||
|
||||
// Choose a card (A or B)
|
||||
const chooseCard = useCallback((choice) => {
|
||||
if (chosenCard) {
|
||||
// Second tap on chosen card — go next
|
||||
if (choice === chosenCard) {
|
||||
goToNextCard();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hapticImpact('medium');
|
||||
setChosenCard(choice);
|
||||
|
||||
if (currentCard) {
|
||||
api.selectChoice(currentUser.id, currentCard.card_id, choice)
|
||||
.then(handleApiResponse)
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [chosenCard, currentCard, goToNextCard, handleApiResponse]);
|
||||
|
||||
const syncCardComments = useCallback((cardId, commentsArrayOrIds) => {
|
||||
if (!Array.isArray(commentsArrayOrIds)) return;
|
||||
setCardQueue((prev) => {
|
||||
const targetCard = prev.find((c) => c.card_id === cardId);
|
||||
if (!targetCard) return prev;
|
||||
if (targetCard.comments && targetCard.comments.length === commentsArrayOrIds.length) {
|
||||
return prev; // No change, avoid re-render
|
||||
}
|
||||
return prev.map((c) =>
|
||||
c.card_id === cardId
|
||||
? { ...c, comments: commentsArrayOrIds }
|
||||
: c
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addCommentToCard = useCallback((cardId, commentId) => {
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === cardId
|
||||
? {
|
||||
...c,
|
||||
comments: c.comments ? [...c.comments, commentId] : [commentId],
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Auto-sync real comments count for current card from /get_comments
|
||||
useEffect(() => {
|
||||
const cardId = currentCard?.card_id;
|
||||
if (!cardId) return;
|
||||
|
||||
let isMounted = true;
|
||||
api.getComments(cardId)
|
||||
.then((res) => {
|
||||
if (isMounted && !res.error && Array.isArray(res.result)) {
|
||||
syncCardComments(cardId, res.result.map((c) => c.comment_id));
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentCard?.card_id, syncCardComments]);
|
||||
|
||||
// Reactions
|
||||
const likeCard = useCallback(async () => {
|
||||
if (!currentCard || !chosenCard) return;
|
||||
hapticImpact('light');
|
||||
|
||||
const result = await api.likeCard(currentUser.id, currentCard.card_id);
|
||||
if (!result.error) {
|
||||
// Refresh user data to get updated liked_card_ids
|
||||
handleApiResponse(result);
|
||||
|
||||
if (result && !result.error) {
|
||||
// Update local card counts in cardQueue
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === currentCard.card_id
|
||||
? { ...c, count_likes: (c.count_likes || 0) + 1 }
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
// Refresh user data for liked_card_ids
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) setUser(userResult.result);
|
||||
handleApiResponse(userResult);
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [currentCard, chosenCard]);
|
||||
}, [currentCard, chosenCard, handleApiResponse]);
|
||||
|
||||
const dislikeCard = useCallback(async () => {
|
||||
if (!currentCard || !chosenCard) return;
|
||||
hapticImpact('light');
|
||||
|
||||
const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
|
||||
if (!result.error) {
|
||||
handleApiResponse(result);
|
||||
|
||||
if (result && !result.error) {
|
||||
// Update local card counts in cardQueue
|
||||
setCardQueue((prev) =>
|
||||
prev.map((c) =>
|
||||
c.card_id === currentCard.card_id
|
||||
? { ...c, count_dislikes: (c.count_dislikes || 0) + 1 }
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
// Refresh user data for disliked_card_ids
|
||||
const userResult = await api.getUser(currentUser.id);
|
||||
if (!userResult.error) setUser(userResult.result);
|
||||
handleApiResponse(userResult);
|
||||
if (!userResult.error && userResult.result) {
|
||||
setUser(userResult.result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [currentCard, chosenCard]);
|
||||
|
||||
// Toast helper
|
||||
const showToast = useCallback((message, duration = 2500) => {
|
||||
setToast(message);
|
||||
setTimeout(() => setToast(null), duration);
|
||||
}, []);
|
||||
}, [currentCard, chosenCard, handleApiResponse]);
|
||||
|
||||
// Menu helpers
|
||||
const openMenu = useCallback(() => {
|
||||
hapticImpact('light');
|
||||
setIsMenuOpen(true);
|
||||
setMenuScreen('menu');
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
hapticImpact('light');
|
||||
setIsMenuOpen(false);
|
||||
setMenuScreen('menu');
|
||||
}, []);
|
||||
|
||||
const handleRetryAfterBan = useCallback(() => {
|
||||
setIsBanned(false);
|
||||
setIsLoading(true);
|
||||
initApp();
|
||||
}, [initApp]);
|
||||
|
||||
const value = {
|
||||
// State
|
||||
isLoading,
|
||||
isLoadingCards,
|
||||
user,
|
||||
error,
|
||||
isBanned,
|
||||
currentCard,
|
||||
chosenCard,
|
||||
cardQueue,
|
||||
@@ -169,12 +344,17 @@ export function AppProvider({ children }) {
|
||||
loadCards,
|
||||
likeCard,
|
||||
dislikeCard,
|
||||
getUserProfile,
|
||||
handleApiResponse,
|
||||
syncCardComments,
|
||||
addCommentToCard,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
setMenuScreen,
|
||||
setIsCommentsOpen,
|
||||
showToast,
|
||||
setError,
|
||||
handleRetryAfterBan,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
/**
|
||||
* API service — all backend requests for This OR That.
|
||||
* All endpoints return { result, error } (BaseResponse).
|
||||
* Includes X-Init-Data header injection, rate limit handling, and IP ban detection.
|
||||
* All endpoints return { result, error, status, isBanned }.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_API_URL || '/api';
|
||||
import { getTelegramInitData } from './auth';
|
||||
|
||||
const BASE_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
async function request(method, path, body = null) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const initData = getTelegramInitData();
|
||||
if (initData) {
|
||||
headers['X-Init-Data'] = initData;
|
||||
}
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = { result: response.statusText, error: !response.ok };
|
||||
}
|
||||
|
||||
// Check for IP ban by FastAPI-guard / penetration detection
|
||||
const isIpBanned = response.status === 403 && (
|
||||
(typeof data?.detail === 'string' && /banned|ip.*banned|suspicious/i.test(data.detail)) ||
|
||||
(typeof data?.result === 'string' && /banned|ip.*banned|suspicious/i.test(data.result))
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
result: data?.detail || 'Слишком много запросов. Подождите несколько секунд.',
|
||||
error: true,
|
||||
status: 429,
|
||||
isBanned: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: data?.result || data?.detail || `Ошибка сервера (${response.status})`,
|
||||
error: true,
|
||||
status: response.status,
|
||||
isBanned: isIpBanned,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: data?.result !== undefined ? data.result : data,
|
||||
error: data?.error || false,
|
||||
status: response.status,
|
||||
isBanned: false,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`API request error [${method} ${path}]:`, err);
|
||||
return {
|
||||
result: 'Ошибка соединения с сервером',
|
||||
error: true,
|
||||
status: 0,
|
||||
isBanned: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const GET = (path) => request('GET', path);
|
||||
@@ -58,9 +116,6 @@ export const api = {
|
||||
addComment: (authorId, cardId, commentText) =>
|
||||
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
|
||||
|
||||
// TODO: GET /get_comments — endpoint not yet implemented on backend
|
||||
getComments: (cardId) => {
|
||||
console.warn('GET /get_comments not implemented on backend yet');
|
||||
return Promise.resolve({ result: [], error: false });
|
||||
},
|
||||
getComments: (cardId) =>
|
||||
GET(`/get_comments?card_id=${cardId}`),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Auth service — detects Telegram WebApp user or falls back to mock.
|
||||
* Auto-registers user on backend if not yet registered.
|
||||
* Provides Telegram Mini App initialization, initData extraction, and Haptic Feedback.
|
||||
*/
|
||||
|
||||
const MOCK_USER = {
|
||||
@@ -11,7 +11,21 @@ const MOCK_USER = {
|
||||
photo_url: '',
|
||||
};
|
||||
|
||||
function getTelegramUser() {
|
||||
/**
|
||||
* Extracts raw Telegram initData query-string for backend HMAC validation.
|
||||
*/
|
||||
export function getTelegramInitData() {
|
||||
try {
|
||||
return window.Telegram?.WebApp?.initData || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts parsed user info from initDataUnsafe for UI display.
|
||||
*/
|
||||
export function getTelegramUser() {
|
||||
try {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const user = tg?.initDataUnsafe?.user;
|
||||
@@ -33,14 +47,47 @@ function getTelegramUser() {
|
||||
export const currentUser = getTelegramUser() ?? MOCK_USER;
|
||||
export const isTelegram = !!getTelegramUser();
|
||||
|
||||
/**
|
||||
* Initializes Telegram WebApp environment (theme, fullscreen expand, close confirmation).
|
||||
*/
|
||||
export function initTelegramApp() {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg) {
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
try {
|
||||
tg.setHeaderColor?.('#070711');
|
||||
tg.setBackgroundColor?.('#070711');
|
||||
tg.enableClosingConfirmation?.();
|
||||
} catch {
|
||||
// Ignored in unsupported client versions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers Telegram Haptic Feedback impact.
|
||||
* @param {'light' | 'medium' | 'heavy' | 'rigid' | 'soft'} style
|
||||
*/
|
||||
export function hapticImpact(style = 'light') {
|
||||
try {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(style);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers Telegram Haptic Feedback notification.
|
||||
* @param {'error' | 'success' | 'warning'} type
|
||||
*/
|
||||
export function hapticNotification(type = 'success') {
|
||||
try {
|
||||
window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred(type);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Telegram BackButton lifecycle with cleanup.
|
||||
*/
|
||||
export function showBackButton(onBack) {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg?.BackButton) {
|
||||
@@ -51,5 +98,18 @@ export function showBackButton(onBack) {
|
||||
tg.BackButton.hide();
|
||||
};
|
||||
}
|
||||
return () => { };
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely opens external link in Telegram WebApp or browser.
|
||||
*/
|
||||
export function openExternalLink(url) {
|
||||
try {
|
||||
if (window.Telegram?.WebApp?.openLink) {
|
||||
window.Telegram.WebApp.openLink(url);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
// setupTests.js
|
||||
|
||||
Reference in New Issue
Block a user