diff --git a/.gitignore b/.gitignore
index e07ccd3..0e31d07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -172,3 +172,17 @@ cython_debug/
# PyPI configuration file
.pypirc
app/.env
+
+# Node.js and React
+node_modules/
+build/
+dist/
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.eslintcache
+.DS_Store
diff --git a/app/mongo_worker.py b/app/mongo_worker.py
index 12352ae..3ee39f5 100644
--- a/app/mongo_worker.py
+++ b/app/mongo_worker.py
@@ -319,4 +319,4 @@ class MongoWorker:
return BaseResponse(result="Card doesn't exist", error=True)
comments = await self.comments_data.find({"card_id": card_id}).sort("creation_date", -1).to_list(length=None)
comments = [Comment.model_validate(comment) for comment in comments]
- return BaseResponse(result=comments)
\ No newline at end of file
+ return BaseResponse(result=comments)
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
- You need to enable JavaScript to run this app.
+ Для работы приложения необходим JavaScript.
-
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 (
-
-
-
-
- Edit src/App.js and save to reload.
-
-
- Learn React
-
+
+ {/* Header with menu button */}
+
+
+ thisOR that
+
+
+
+
+
+
+
+ {/* 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 (
+
+
+
+
+
+ {formatCount(dislikes)}
+
+
+
+
+
+
+ {formatCount(currentCard?.comments?.length || 0)}
+
+
+
+
+
+
+ {formatCount(likes)}
+
+
+ );
+}
+
+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 (
+
+ {label}
+ {isRevealed && (
+
+ {percent}%
+ {count}
+
+ )}
+ {isChosen && isRevealed && (
+
+ нажми чтобы продолжить
+
+ )}
+
+ );
+}
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 (
+
+
+
Карточки закончились!
+
+ { openMenu(); setMenuScreen('create'); }}>
+ Создать карточку
+
+ { openMenu(); setMenuScreen('about'); }}>
+ О проекте
+
+ window.open('https://boosty.to/pseudodev/donate', '_blank')}>
+ Поддержать проект
+
+
+
+
+ );
+ }
+
+ // 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 (
+
+
+ setIsCommentsOpen(false)}
+ aria-label="Закрыть"
+ >
+ ←
+
+
Комментарии
+
+
+
+ {isLoading ? (
+
Загрузка...
+ ) : comments.length === 0 ? (
+
+
Комментариев пока нет
+
Будь первым!
+
+ ) : (
+ comments.map((comment, i) => (
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Menu/AboutPage.css b/frontend/src/components/Menu/AboutPage.css
new file mode 100644
index 0000000..eebfd69
--- /dev/null
+++ b/frontend/src/components/Menu/AboutPage.css
@@ -0,0 +1,61 @@
+.about-page {
+ padding: var(--space-lg);
+ overflow-y: auto;
+ max-height: 80vh;
+}
+
+.about-back {
+ font-size: 14px;
+ color: var(--color-muted);
+ margin-bottom: var(--space-lg);
+ padding: var(--space-xs) 0;
+ transition: color var(--duration-fast) var(--ease-smooth);
+}
+
+.about-back:hover {
+ color: var(--color-text);
+}
+
+.about-title {
+ font-family: var(--font-display);
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: var(--space-lg);
+}
+
+.about-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ margin-bottom: var(--space-xl);
+ line-height: 1.65;
+ font-size: 14px;
+ color: var(--color-muted);
+}
+
+.about-content strong {
+ color: var(--color-text);
+}
+
+.about-links {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+}
+
+.about-link {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md);
+ border-radius: var(--radius-sm);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ font-size: 14px;
+ color: var(--color-stat-a);
+ transition: background var(--duration-fast) var(--ease-smooth);
+}
+
+.about-link:active {
+ background: rgba(255, 255, 255, 0.12);
+}
diff --git a/frontend/src/components/Menu/AboutPage.jsx b/frontend/src/components/Menu/AboutPage.jsx
new file mode 100644
index 0000000..4687549
--- /dev/null
+++ b/frontend/src/components/Menu/AboutPage.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import './AboutPage.css';
+
+export default function AboutPage({ onBack }) {
+ function handleCopyEmail() {
+ navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
+ // Visual feedback handled by CSS :active
+ }).catch(() => {
+ // Fallback — select text
+ });
+ }
+
+ return (
+
+
+ ← Назад
+
+
+
О проекте
+
+
+
+ This OR That — это open-source Telegram Mini App,
+ в которой ты выбираешь один из двух вариантов и смотришь,
+ что выбрали другие.
+
+
+ Проект создан для развлечения и исследования интересных
+ дилемм. Все карточки проходят модерацию перед публикацией.
+
+
+ Поддержи проект или загляни в исходный код —
+ мы открыты для идей и контрибьюций!
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/Menu/CreateCard.css b/frontend/src/components/Menu/CreateCard.css
new file mode 100644
index 0000000..3f25517
--- /dev/null
+++ b/frontend/src/components/Menu/CreateCard.css
@@ -0,0 +1,105 @@
+.create-card {
+ padding: var(--space-lg);
+ overflow-y: auto;
+ max-height: 80vh;
+}
+
+.create-back {
+ font-size: 14px;
+ color: var(--color-muted);
+ margin-bottom: var(--space-lg);
+ padding: var(--space-xs) 0;
+ transition: color var(--duration-fast) var(--ease-smooth);
+}
+
+.create-back:hover {
+ color: var(--color-text);
+}
+
+.create-title {
+ font-family: var(--font-display);
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: var(--space-md);
+}
+
+.create-rules {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--color-muted);
+ margin-bottom: var(--space-lg);
+}
+
+.create-fields {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: var(--space-lg);
+}
+
+.create-field {
+ position: relative;
+ border-radius: var(--radius-card);
+ padding: var(--space-lg);
+ min-height: 100px;
+}
+
+.create-field--a {
+ background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to));
+}
+
+.create-field--b {
+ background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to));
+}
+
+.create-textarea {
+ width: 100%;
+ background: transparent;
+ color: var(--color-text);
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 600;
+ text-align: center;
+ resize: none;
+ line-height: 1.4;
+}
+
+.create-textarea::placeholder {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.create-counter {
+ position: absolute;
+ bottom: var(--space-sm);
+ right: var(--space-md);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+.create-submit {
+ width: 100%;
+ padding: var(--space-md) var(--space-lg);
+ border-radius: var(--radius-full);
+ font-family: var(--font-display);
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--color-muted);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ transition: all var(--duration-normal) var(--ease-smooth);
+}
+
+.create-submit--active {
+ color: var(--color-text);
+ background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
+ border-color: transparent;
+}
+
+.create-submit:disabled {
+ cursor: default;
+}
+
+.create-submit--active:active {
+ transform: scale(0.97);
+}
diff --git a/frontend/src/components/Menu/CreateCard.jsx b/frontend/src/components/Menu/CreateCard.jsx
new file mode 100644
index 0000000..3960a7f
--- /dev/null
+++ b/frontend/src/components/Menu/CreateCard.jsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import { useApp } from '../../context/AppContext';
+import { currentUser } 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 [choiceA, setChoiceA] = useState('');
+ const [choiceB, setChoiceB] = useState('');
+ const [isSending, setIsSending] = useState(false);
+
+ const canSubmit = choiceA.trim().length > 0 && choiceB.trim().length > 0 && !isSending;
+
+ async function handleSubmit() {
+ if (!canSubmit) return;
+ setIsSending(true);
+ try {
+ const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
+ if (!result.error) {
+ showToast('Карточка отправлена на модерацию!');
+ setTimeout(() => {
+ closeMenu();
+ }, 2000);
+ } else {
+ showToast('Ошибка: ' + (result.result || 'неизвестная'));
+ setIsSending(false);
+ }
+ } catch (err) {
+ console.error('Add card error:', err);
+ showToast('Ошибка отправки');
+ setIsSending(false);
+ }
+ }
+
+ return (
+
+
+ ← Назад
+
+
+
Создать карточку
+
+
+ При создании карточек запрещается использование мата и ссылок.
+ Все карточки проходят процесс модерации перед публикацией.
+ Лимит по длине текста: {MAX_LENGTH} символов.
+
+
+
+
+
+ {isSending ? 'Отправка...' : 'Отправить!'}
+
+
+ );
+}
diff --git a/frontend/src/components/Menu/MenuPanel.css b/frontend/src/components/Menu/MenuPanel.css
new file mode 100644
index 0000000..75d4a5b
--- /dev/null
+++ b/frontend/src/components/Menu/MenuPanel.css
@@ -0,0 +1,59 @@
+.menu-panel {
+ position: fixed;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%) translateY(100%);
+ width: 100%;
+ max-width: 480px;
+ max-height: 85vh;
+ z-index: 60;
+ background: var(--color-bg-elevated);
+ border-radius: var(--radius-card) var(--radius-card) 0 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition: transform var(--duration-normal) var(--ease-spring);
+}
+
+.menu-panel--open {
+ transform: translateX(-50%) translateY(0);
+}
+
+/* Menu list */
+.menu-list {
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-xl) var(--space-lg);
+ gap: var(--space-xs);
+}
+
+.menu-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-md) var(--space-md);
+ border-radius: var(--radius-sm);
+ transition: background var(--duration-fast) var(--ease-smooth);
+ color: var(--color-text);
+}
+
+.menu-item:active {
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.menu-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ background: var(--glass-bg);
+ color: var(--color-muted);
+ flex-shrink: 0;
+}
+
+.menu-label {
+ font-size: 16px;
+ font-weight: 500;
+}
diff --git a/frontend/src/components/Menu/MenuPanel.jsx b/frontend/src/components/Menu/MenuPanel.jsx
new file mode 100644
index 0000000..8c97950
--- /dev/null
+++ b/frontend/src/components/Menu/MenuPanel.jsx
@@ -0,0 +1,89 @@
+import React, { useEffect } from 'react';
+import { useApp } from '../../context/AppContext';
+import { showBackButton } from '../../services/auth';
+import Overlay from '../common/Overlay';
+import AboutPage from './AboutPage';
+import CreateCard from './CreateCard';
+import './MenuPanel.css';
+
+export default function MenuPanel() {
+ const { isMenuOpen, closeMenu, menuScreen, setMenuScreen } = useApp();
+
+ // Telegram BackButton for sub-screens
+ useEffect(() => {
+ if (isMenuOpen && menuScreen !== 'menu') {
+ const cleanup = showBackButton(() => setMenuScreen('menu'));
+ return cleanup;
+ }
+ if (isMenuOpen && menuScreen === 'menu') {
+ const cleanup = showBackButton(() => closeMenu());
+ return cleanup;
+ }
+ }, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
+
+ if (!isMenuOpen) return null;
+
+ // Sub-screens
+ if (menuScreen === 'about') {
+ return (
+ <>
+
+
+
setMenuScreen('menu')} />
+
+ >
+ );
+ }
+
+ if (menuScreen === 'create') {
+ return (
+ <>
+
+
+ setMenuScreen('menu')} />
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ setMenuScreen('about')}>
+
+
+
+
+
+
+ О проекте
+
+
+ setMenuScreen('create')}>
+
+
+
+
+
+
+ Создать карточку
+
+
+ window.open('https://boosty.to/pseudodev/donate', '_blank')}
+ >
+
+
+
+
+
+ Поддержать проект
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/common/LoadingScreen.css b/frontend/src/components/common/LoadingScreen.css
new file mode 100644
index 0000000..8231711
--- /dev/null
+++ b/frontend/src/components/common/LoadingScreen.css
@@ -0,0 +1,58 @@
+.loading-screen {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--color-bg);
+}
+
+.loading-content {
+ text-align: center;
+}
+
+.loading-title {
+ font-family: var(--font-display);
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--color-text);
+ letter-spacing: -0.5px;
+}
+
+.loading-or {
+ display: inline-block;
+ margin: 0 4px;
+ padding: 2px 10px;
+ background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
+ border-radius: var(--radius-sm);
+ font-size: 20px;
+ vertical-align: middle;
+}
+
+.loading-error {
+ margin-top: var(--space-lg);
+ color: var(--color-dislike);
+ font-size: 14px;
+}
+
+.loading-dots {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ margin-top: var(--space-lg);
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-muted);
+ animation: dotPulse 1.4s infinite ease-in-out both;
+}
+
+.dot:nth-child(2) { animation-delay: 0.16s; }
+.dot:nth-child(3) { animation-delay: 0.32s; }
+
+@keyframes dotPulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
diff --git a/frontend/src/components/common/LoadingScreen.jsx b/frontend/src/components/common/LoadingScreen.jsx
new file mode 100644
index 0000000..7b126e8
--- /dev/null
+++ b/frontend/src/components/common/LoadingScreen.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import './LoadingScreen.css';
+
+export default function LoadingScreen({ error }) {
+ return (
+
+
+
+ thisOR that
+
+ {error ? (
+
{error}
+ ) : (
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/common/Overlay.css b/frontend/src/components/common/Overlay.css
new file mode 100644
index 0000000..d93b620
--- /dev/null
+++ b/frontend/src/components/common/Overlay.css
@@ -0,0 +1,16 @@
+.overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--overlay-bg);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ z-index: 50;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--duration-normal) var(--ease-smooth);
+}
+
+.overlay--visible {
+ opacity: 1;
+ pointer-events: auto;
+}
diff --git a/frontend/src/components/common/Overlay.jsx b/frontend/src/components/common/Overlay.jsx
new file mode 100644
index 0000000..b1f8b96
--- /dev/null
+++ b/frontend/src/components/common/Overlay.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import './Overlay.css';
+
+export default function Overlay({ visible, onClick }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/common/Toast.css b/frontend/src/components/common/Toast.css
new file mode 100644
index 0000000..b2df782
--- /dev/null
+++ b/frontend/src/components/common/Toast.css
@@ -0,0 +1,43 @@
+.toast {
+ position: fixed;
+ top: var(--space-lg);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+ animation: toastIn 300ms var(--ease-spring) forwards;
+}
+
+.toast-content {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--glass-bg);
+ backdrop-filter: blur(var(--glass-blur));
+ -webkit-backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-full);
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.toast-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--color-like);
+ font-size: 11px;
+}
+
+.toast-text {
+ color: var(--color-text);
+}
+
+@keyframes toastIn {
+ from { opacity: 0; transform: translateX(-50%) translateY(-12px); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
+}
diff --git a/frontend/src/components/common/Toast.jsx b/frontend/src/components/common/Toast.jsx
new file mode 100644
index 0000000..5830cbb
--- /dev/null
+++ b/frontend/src/components/common/Toast.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import './Toast.css';
+
+export default function Toast({ message }) {
+ if (!message) return null;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/context/AppContext.jsx b/frontend/src/context/AppContext.jsx
new file mode 100644
index 0000000..8feabab
--- /dev/null
+++ b/frontend/src/context/AppContext.jsx
@@ -0,0 +1,189 @@
+import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
+import { api } from '../services/api';
+import { currentUser, initTelegramApp } from '../services/auth';
+
+const AppContext = createContext(null);
+
+export function AppProvider({ children }) {
+ // App state
+ const [isLoading, setIsLoading] = useState(true);
+ const [user, setUser] = useState(null);
+ const [error, setError] = useState(null);
+
+ // Card queue
+ const [cardQueue, setCardQueue] = useState([]);
+ const [currentCardIndex, setCurrentCardIndex] = useState(0);
+ const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
+
+ // Panels
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isCommentsOpen, setIsCommentsOpen] = useState(false);
+ const [menuScreen, setMenuScreen] = useState('menu'); // 'menu' | 'about' | 'create'
+
+ // Toast
+ const [toast, setToast] = useState(null);
+
+ // 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);
+ }
+ }
+ init();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Load cards batch
+ const loadCards = useCallback(async () => {
+ try {
+ const result = await api.getRandomCards(currentUser.id);
+ if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
+ setCardQueue(result.result);
+ setCurrentCardIndex(0);
+ setChosenCard(null);
+ } else {
+ // No more cards or error
+ setCardQueue([]);
+ setCurrentCardIndex(0);
+ }
+ } 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();
+ }
+ return;
+ }
+ setChosenCard(choice);
+ // Fire select_choice to backend
+ if (currentCard) {
+ api.selectChoice(currentUser.id, currentCard.card_id, choice).catch(console.error);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [chosenCard, currentCard]);
+
+ // Go to next card
+ const goToNextCard = useCallback(async () => {
+ const nextIndex = currentCardIndex + 1;
+ if (nextIndex < cardQueue.length) {
+ setCurrentCardIndex(nextIndex);
+ setChosenCard(null);
+ } else {
+ // Load next batch
+ await loadCards();
+ }
+ }, [currentCardIndex, cardQueue.length, loadCards]);
+
+ // Reactions
+ const likeCard = useCallback(async () => {
+ if (!currentCard || !chosenCard) return;
+ const result = await api.likeCard(currentUser.id, currentCard.card_id);
+ if (!result.error) {
+ // Refresh user data to get updated liked_card_ids
+ const userResult = await api.getUser(currentUser.id);
+ if (!userResult.error) setUser(userResult.result);
+ }
+ return result;
+ }, [currentCard, chosenCard]);
+
+ const dislikeCard = useCallback(async () => {
+ if (!currentCard || !chosenCard) return;
+ const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
+ if (!result.error) {
+ const userResult = await api.getUser(currentUser.id);
+ if (!userResult.error) setUser(userResult.result);
+ }
+ return result;
+ }, [currentCard, chosenCard]);
+
+ // Toast helper
+ const showToast = useCallback((message, duration = 2500) => {
+ setToast(message);
+ setTimeout(() => setToast(null), duration);
+ }, []);
+
+ // Menu helpers
+ const openMenu = useCallback(() => {
+ setIsMenuOpen(true);
+ setMenuScreen('menu');
+ }, []);
+
+ const closeMenu = useCallback(() => {
+ setIsMenuOpen(false);
+ setMenuScreen('menu');
+ }, []);
+
+ const value = {
+ // State
+ isLoading,
+ user,
+ error,
+ currentCard,
+ chosenCard,
+ cardQueue,
+ currentCardIndex,
+ isMenuOpen,
+ isCommentsOpen,
+ menuScreen,
+ toast,
+
+ // Actions
+ chooseCard,
+ goToNextCard,
+ loadCards,
+ likeCard,
+ dislikeCard,
+ openMenu,
+ closeMenu,
+ setMenuScreen,
+ setIsCommentsOpen,
+ showToast,
+ setError,
+ };
+
+ return {children} ;
+}
+
+export function useApp() {
+ const context = useContext(AppContext);
+ if (!context) {
+ throw new Error('useApp must be used within AppProvider');
+ }
+ return context;
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index ec2585e..8985378 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,13 +1,150 @@
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
- sans-serif;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
+/* ============================================
+ This OR That — Design System
+ Palette: "Abyss Neon"
+ ============================================ */
+
+@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
+
+/* ---------- Design Tokens ---------- */
+:root {
+ /* Background */
+ --color-bg: #070711;
+ --color-bg-elevated: #0D0C1E;
+
+ /* Card A — Deep Violet */
+ --color-card-a-from: #1D0658;
+ --color-card-a-to: #7C3AED;
+
+ /* Card B — Electric Blue */
+ --color-card-b-from: #0F2027;
+ --color-card-b-to: #0284C7;
+
+ /* Text */
+ --color-text: #F8FAFC;
+ --color-muted: #94A3B8;
+
+ /* Stats accent per card */
+ --color-stat-a: #A78BFA;
+ --color-stat-b: #38BDF8;
+
+ /* Reactions */
+ --color-like: #7C3AED;
+ --color-dislike: #EF4444;
+
+ /* Glassmorphism surfaces */
+ --glass-bg: rgba(255, 255, 255, 0.07);
+ --glass-border: rgba(255, 255, 255, 0.12);
+ --glass-blur: 12px;
+
+ /* Overlay */
+ --overlay-bg: rgba(7, 7, 17, 0.75);
+
+ /* Typography */
+ --font-display: 'Unbounded', sans-serif;
+ --font-body: 'Inter', sans-serif;
+ --font-mono: 'JetBrains Mono', monospace;
+
+ /* Spacing */
+ --space-xs: 4px;
+ --space-sm: 8px;
+ --space-md: 16px;
+ --space-lg: 24px;
+ --space-xl: 32px;
+
+ /* Radius */
+ --radius-card: 20px;
+ --radius-sm: 10px;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
+ --duration-card: 600ms;
+ --duration-fast: 200ms;
+ --duration-normal: 300ms;
+
+ /* Bottom bar height */
+ --bar-height: 64px;
}
-code {
- font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
- monospace;
+/* ---------- Global Reset ---------- */
+*, *::before, *::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body, #root {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+body {
+ font-family: var(--font-body);
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--color-text);
+ background: var(--color-bg);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ overscroll-behavior: none;
+}
+
+button {
+ font-family: inherit;
+ border: none;
+ background: none;
+ color: inherit;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+
+input, textarea {
+ font-family: inherit;
+ border: none;
+ outline: none;
+ background: none;
+ color: inherit;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+/* ---------- Scrollbar (for panels) ---------- */
+.custom-scroll::-webkit-scrollbar {
+ width: 4px;
+}
+
+.custom-scroll::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scroll::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: var(--radius-full);
+}
+
+/* ---------- App Shell ---------- */
+.app {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ max-width: 480px;
+ margin: 0 auto;
+}
+
+/* ---------- Reduced Motion ---------- */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
}
diff --git a/frontend/src/index.js b/frontend/src/index.js
index d563c0f..2cb1087 100644
--- a/frontend/src/index.js
+++ b/frontend/src/index.js
@@ -2,7 +2,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
-import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
@@ -10,8 +9,3 @@ root.render(
);
-
-// If you want to start measuring performance in your app, pass a function
-// to log results (for example: reportWebVitals(console.log))
-// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
-reportWebVitals();
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
new file mode 100644
index 0000000..a4a7292
--- /dev/null
+++ b/frontend/src/services/api.js
@@ -0,0 +1,66 @@
+/**
+ * API service — all backend requests for This OR That.
+ * All endpoints return { result, error } (BaseResponse).
+ */
+
+const BASE_URL = process.env.REACT_APP_API_URL || '/api';
+
+async function request(method, path, body = null) {
+ const options = {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ };
+ if (body) {
+ options.body = JSON.stringify(body);
+ }
+
+ const response = await fetch(`${BASE_URL}${path}`, options);
+ const data = await response.json();
+ return data;
+}
+
+const GET = (path) => request('GET', path);
+const POST = (path, body) => request('POST', path, body);
+const PATCH = (path, body) => request('PATCH', path, body);
+
+export const api = {
+ // Users
+ checkUser: (userId) =>
+ GET(`/check_user?user_id=${userId}`),
+
+ getUser: (userId) =>
+ GET(`/get_user?user_id=${userId}`),
+
+ addUser: ({ user_id, username, first_name, last_name, photo_url }) =>
+ POST('/add_user', { user_id, username, first_name, last_name, photo_url }),
+
+ // Cards
+ getCard: (cardId) =>
+ GET(`/get_card?card_id=${cardId}`),
+
+ getRandomCards: (userId) =>
+ GET(`/get_random_cards?user_id=${userId}`),
+
+ selectChoice: (userId, cardId, choice) =>
+ PATCH('/select_choice', { user_id: userId, card_id: cardId, choice }),
+
+ addCard: (choiceA, choiceB, authorId) =>
+ POST('/add_card', { choice_A: choiceA, choice_B: choiceB, author_id: authorId }),
+
+ // Reactions
+ likeCard: (userId, cardId) =>
+ PATCH('/like_card', { user_id: userId, card_id: cardId }),
+
+ dislikeCard: (userId, cardId) =>
+ PATCH('/dislike_card', { user_id: userId, card_id: cardId }),
+
+ // Comments
+ 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 });
+ },
+};
diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js
new file mode 100644
index 0000000..b166dfb
--- /dev/null
+++ b/frontend/src/services/auth.js
@@ -0,0 +1,55 @@
+/**
+ * Auth service — detects Telegram WebApp user or falls back to mock.
+ * Auto-registers user on backend if not yet registered.
+ */
+
+const MOCK_USER = {
+ id: 999995,
+ first_name: 'Dev',
+ last_name: 'User',
+ username: 'devuser',
+ photo_url: '',
+};
+
+function getTelegramUser() {
+ try {
+ const tg = window.Telegram?.WebApp;
+ const user = tg?.initDataUnsafe?.user;
+ if (user && user.id) {
+ return {
+ id: user.id,
+ first_name: user.first_name || '',
+ last_name: user.last_name || '',
+ username: user.username || '',
+ photo_url: user.photo_url || '',
+ };
+ }
+ } catch {
+ // Telegram SDK not available
+ }
+ return null;
+}
+
+export const currentUser = getTelegramUser() ?? MOCK_USER;
+export const isTelegram = !!getTelegramUser();
+
+export function initTelegramApp() {
+ const tg = window.Telegram?.WebApp;
+ if (tg) {
+ tg.ready();
+ tg.expand();
+ }
+}
+
+export function showBackButton(onBack) {
+ const tg = window.Telegram?.WebApp;
+ if (tg?.BackButton) {
+ tg.BackButton.show();
+ tg.BackButton.onClick(onBack);
+ return () => {
+ tg.BackButton.offClick(onBack);
+ tg.BackButton.hide();
+ };
+ }
+ return () => { };
+}