RELEASE 1.0! #5
+14
@@ -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
|
||||
|
||||
+1
-1
@@ -335,4 +335,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)
|
||||
return BaseResponse(result=comments)
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:5000",
|
||||
"devDependencies": {
|
||||
"web-vitals": "^4.2.4"
|
||||
}
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#070711" />
|
||||
<meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
|
||||
<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" />
|
||||
<!--
|
||||
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
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<!-- Telegram WebApp SDK -->
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
|
||||
<title>This OR That</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<noscript>Для работы приложения необходим JavaScript.</noscript>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
+40
-24
@@ -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);
|
||||
}
|
||||
|
||||
+51
-16
@@ -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 <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (error && !isLoading) {
|
||||
return <LoadingScreen error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
<div className="app">
|
||||
{/* Header with menu button */}
|
||||
<header className="app-header">
|
||||
<div className="app-logo">
|
||||
this<span className="app-logo-or">OR</span>that
|
||||
</div>
|
||||
<button className="menu-toggle" onClick={openMenu} aria-label="Меню">
|
||||
<span className="menu-toggle-line" />
|
||||
<span className="menu-toggle-line" />
|
||||
<span className="menu-toggle-line" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Main content — card pair */}
|
||||
<CardPair />
|
||||
|
||||
{/* Bottom bar — reactions */}
|
||||
<BottomBar />
|
||||
|
||||
{/* Panels */}
|
||||
<CommentsPanel />
|
||||
<MenuPanel />
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toast message={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<AppContent />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<App />
|
||||
</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();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 () => { };
|
||||
}
|
||||
Reference in New Issue
Block a user