Create frontend

This commit is contained in:
IgorVolochay
2026-08-13 11:35:00 +03:00
parent 343c108e51
commit 9327ac53eb
33 changed files with 2083 additions and 87 deletions
+1
View File
@@ -32,6 +32,7 @@
"last 1 safari version" "last 1 safari version"
] ]
}, },
"proxy": "http://localhost:5000",
"devDependencies": { "devDependencies": {
"web-vitals": "^4.2.4" "web-vitals": "^4.2.4"
} }
+9 -31
View File
@@ -1,43 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#070711" />
<meta <meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will <!-- Telegram WebApp SDK -->
work correctly both with client-side routing and a non-root public URL. <script src="https://telegram.org/js/telegram-web-app.js"></script>
Learn how to configure a non-root public URL by running `npm run build`.
--> <title>This OR That</title>
<title>React App</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>Для работы приложения необходим JavaScript.</noscript>
<div id="root"></div> <div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body> </body>
</html> </html>
+40 -24
View File
@@ -1,38 +1,54 @@
.App { /* ---------- App Layout ---------- */
text-align: center;
.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 { .app-logo {
height: 40vmin; font-family: var(--font-display);
pointer-events: none; font-size: 16px;
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.3px;
} }
@media (prefers-reduced-motion: no-preference) { .app-logo-or {
.App-logo { display: inline-block;
animation: App-logo-spin infinite 20s linear; 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 { /* Hamburger menu */
background-color: #282c34; .menu-toggle {
min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
font-size: calc(10px + 2vmin); gap: 4px;
color: white; width: 36px;
height: 36px;
padding: 8px;
border-radius: var(--radius-sm);
transition: background var(--duration-fast) var(--ease-smooth);
} }
.App-link { .menu-toggle:active {
color: #61dafb; background: rgba(255, 255, 255, 0.06);
} }
@keyframes App-logo-spin { .menu-toggle-line {
from { display: block;
transform: rotate(0deg); width: 100%;
} height: 2px;
to { background: var(--color-text);
transform: rotate(360deg); border-radius: 1px;
} transition: transform var(--duration-normal) var(--ease-smooth);
} }
+51 -16
View File
@@ -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'; import './App.css';
function App() { function AppContent() {
const { isLoading, error, openMenu, toast } = useApp();
if (isLoading) {
return <LoadingScreen />;
}
if (error && !isLoading) {
return <LoadingScreen error={error} />;
}
return ( return (
<div className="App"> <div className="app">
<header className="App-header"> {/* Header with menu button */}
<img src={logo} className="App-logo" alt="logo" /> <header className="app-header">
<p> <div className="app-logo">
Edit <code>src/App.js</code> and save to reload. this<span className="app-logo-or">OR</span>that
</p> </div>
<a <button className="menu-toggle" onClick={openMenu} aria-label="Меню">
className="App-link" <span className="menu-toggle-line" />
href="https://reactjs.org" <span className="menu-toggle-line" />
target="_blank" <span className="menu-toggle-line" />
rel="noopener noreferrer" </button>
>
Learn React
</a>
</header> </header>
{/* Main content — card pair */}
<CardPair />
{/* Bottom bar — reactions */}
<BottomBar />
{/* Panels */}
<CommentsPanel />
<MenuPanel />
{/* Toast notifications */}
<Toast message={toast} />
</div> </div>
); );
} }
function App() {
return (
<AppProvider>
<AppContent />
</AppProvider>
);
}
export default App; export default App;
@@ -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); }
}
@@ -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 (
<div className="bottom-bar">
<button
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
onClick={handleDislike}
aria-label="Дизлайк"
>
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
</svg>
<span className="bar-count">{formatCount(dislikes)}</span>
</button>
<button
className="bar-btn bar-btn--comments"
onClick={handleComments}
aria-label="Комментарии"
>
<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>
</button>
<button
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
onClick={handleLike}
aria-label="Лайк"
>
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
</svg>
<span className="bar-count">{formatCount(likes)}</span>
</button>
</div>
);
}
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);
}
+112
View File
@@ -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; }
}
+34
View File
@@ -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 (
<button className={cardClass} onClick={onClick} aria-label={label}>
<span className="card-text">{label}</span>
{isRevealed && (
<div className="card-stats">
<span className="card-percent">{percent}%</span>
<span className="card-count">{count}</span>
</div>
)}
{isChosen && isRevealed && (
<div className="card-hint">
нажми чтобы продолжить
</div>
)}
</button>
);
}
@@ -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);
}
@@ -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 (
<div className="card-pair card-pair--empty">
<div className="empty-content">
<p className="card-pair-empty-text">Карточки закончились!</p>
<div className="empty-actions">
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}>
Создать карточку
</button>
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
О проекте
</button>
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
Поддержать проект
</button>
</div>
</div>
</div>
);
}
// 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 (
<div className="card-pair">
<div className="card-pair-inner">
<div className="card-wrapper" style={{ flexGrow: growA }}>
<Card
label={currentCard.choice_A}
type="A"
chosen={chosenCard}
isChosen={chosenCard === 'A'}
percent={displayPercentA}
count={currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0)}
onClick={() => chooseCard('A')}
/>
</div>
<OrBadge visible={!chosenCard} />
<div className="card-wrapper" style={{ flexGrow: growB }}>
<Card
label={currentCard.choice_B}
type="B"
chosen={chosenCard}
isChosen={chosenCard === 'B'}
percent={displayPercentB}
count={currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0)}
onClick={() => chooseCard('B')}
/>
</div>
</div>
</div>
);
}
@@ -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;
}
@@ -0,0 +1,10 @@
import React from 'react';
import './OrBadge.css';
export default function OrBadge({ visible }) {
return (
<div className={`or-badge ${visible ? '' : 'or-badge--hidden'}`}>
<span className="or-badge-text">ИЛИ</span>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className="comment-item">
<div className="comment-avatar">
{author?.photo_url ? (
<img src={author.photo_url} alt="" className="comment-avatar-img" />
) : (
<span className="comment-avatar-fallback">
{displayName[0]?.toUpperCase() || '?'}
</span>
)}
</div>
<div className="comment-body">
<div className="comment-header">
<span className="comment-author">{displayName}</span>
<span className="comment-date">{date}</span>
</div>
<p className="comment-text">{comment.comment_text || comment.commet_text}</p>
</div>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
<div className="comments-header">
<button
className="comments-close"
onClick={() => setIsCommentsOpen(false)}
aria-label="Закрыть"
>
</button>
<h2 className="comments-title">Комментарии</h2>
</div>
<div className="comments-list custom-scroll" ref={listRef}>
{isLoading ? (
<p className="comments-placeholder">Загрузка...</p>
) : comments.length === 0 ? (
<div className="comments-empty">
<p className="comments-empty-text">Комментариев пока нет</p>
<p className="comments-empty-sub">Будь первым!</p>
</div>
) : (
comments.map((comment, i) => (
<CommentItem key={comment.comment_id || i} comment={comment} author={null} />
))
)}
</div>
<div className="comments-input-area">
<textarea
className="comments-input"
placeholder="Ваш комментарий..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={300}
rows={1}
/>
<button
className={`comments-send ${newComment.trim() ? '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>
</button>
</div>
</div>
);
}
@@ -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);
}
@@ -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 (
<div className="about-page custom-scroll">
<button className="about-back" onClick={onBack} aria-label="Назад">
Назад
</button>
<h2 className="about-title">О проекте</h2>
<div className="about-content">
<p>
<strong>This OR That</strong> это open-source Telegram Mini App,
в которой ты выбираешь один из двух вариантов и смотришь,
что выбрали другие.
</p>
<p>
Проект создан для развлечения и исследования интересных
дилемм. Все карточки проходят модерацию перед публикацией.
</p>
<p>
Поддержи проект или загляни в исходный код
мы открыты для идей и контрибьюций!
</p>
</div>
<div className="about-links">
<a
href="https://github.com/IgorVolochay/thisORthat"
target="_blank"
rel="noopener noreferrer"
className="about-link"
>
<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 className="about-link" onClick={handleCopyEmail}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
<span>pseudo.developer.ru@gmail.com</span>
</button>
</div>
</div>
);
}
+105
View File
@@ -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);
}
@@ -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 (
<div className="create-card custom-scroll">
<button className="create-back" onClick={onBack} aria-label="Назад">
Назад
</button>
<h2 className="create-title">Создать карточку</h2>
<p className="create-rules">
При создании карточек запрещается использование мата и ссылок.
Все карточки проходят процесс модерации перед публикацией.
Лимит по длине текста: {MAX_LENGTH} символов.
</p>
<div className="create-fields">
<div className="create-field create-field--a">
<textarea
className="create-textarea"
placeholder="Первый вариант"
value={choiceA}
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH}
rows={3}
/>
<span className="create-counter">
{choiceA.length}/{MAX_LENGTH}
</span>
</div>
<div className="create-field create-field--b">
<textarea
className="create-textarea"
placeholder="Второй вариант"
value={choiceB}
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH}
rows={3}
/>
<span className="create-counter">
{choiceB.length}/{MAX_LENGTH}
</span>
</div>
</div>
<button
className={`create-submit ${canSubmit ? 'create-submit--active' : ''}`}
onClick={handleSubmit}
disabled={!canSubmit}
>
{isSending ? 'Отправка...' : 'Отправить!'}
</button>
</div>
);
}
@@ -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;
}
@@ -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 (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<AboutPage onBack={() => setMenuScreen('menu')} />
</div>
</>
);
}
if (menuScreen === 'create') {
return (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<CreateCard onBack={() => setMenuScreen('menu')} />
</div>
</>
);
}
return (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<nav className="menu-list">
<button className="menu-item" onClick={() => setMenuScreen('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" />
<path d="M12 16v-4M12 8h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</span>
<span className="menu-label">О проекте</span>
</button>
<button className="menu-item" onClick={() => setMenuScreen('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" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
<span className="menu-label">Создать карточку</span>
</button>
<button
className="menu-item"
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}
>
<span className="menu-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" />
</svg>
</span>
<span className="menu-label">Поддержать проект</span>
</button>
</nav>
</div>
</>
);
}
@@ -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); }
}
@@ -0,0 +1,23 @@
import React from 'react';
import './LoadingScreen.css';
export default function LoadingScreen({ error }) {
return (
<div className="loading-screen">
<div className="loading-content">
<h1 className="loading-title">
this<span className="loading-or">OR</span>that
</h1>
{error ? (
<p className="loading-error">{error}</p>
) : (
<div className="loading-dots">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
)}
</div>
</div>
);
}
@@ -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;
}
@@ -0,0 +1,12 @@
import React from 'react';
import './Overlay.css';
export default function Overlay({ visible, onClick }) {
return (
<div
className={`overlay ${visible ? 'overlay--visible' : ''}`}
onClick={onClick}
aria-hidden="true"
/>
);
}
+43
View File
@@ -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); }
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import './Toast.css';
export default function Toast({ message }) {
if (!message) return null;
return (
<div className="toast" role="status" aria-live="polite">
<div className="toast-content">
<span className="toast-icon"></span>
<span className="toast-text">{message}</span>
</div>
</div>
);
}
+189
View File
@@ -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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}
+147 -10
View File
@@ -1,13 +1,150 @@
body { /* ============================================
margin: 0; This OR That — Design System
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', Palette: "Abyss Neon"
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', ============================================ */
sans-serif;
-webkit-font-smoothing: antialiased; @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');
-moz-osx-font-smoothing: grayscale;
/* ---------- 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 { /* ---------- Global Reset ---------- */
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', *, *::before, *::after {
monospace; 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;
}
} }
-6
View File
@@ -2,7 +2,6 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
@@ -10,8 +9,3 @@ root.render(
<App /> <App />
</React.StrictMode> </React.StrictMode>
); );
// 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();
+66
View File
@@ -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 || '';
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 });
},
};
+55
View File
@@ -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 () => { };
}