- {author?.photo_url ? (
-

+ {photoUrl && !imageError ? (
+

setImageError(true)}
+ />
) : (
-
- {displayName[0]?.toUpperCase() || '?'}
+
+ {initial}
)}
+
-
{displayName}
-
{date}
+
+ {displayName}
+ {isMe && Вы}
+
+
{dateStr}
{comment.comment_text || comment.commet_text}
diff --git a/frontend/src/components/Comments/CommentsPanel.css b/frontend/src/components/Comments/CommentsPanel.css
index c8f5c61..4cdaaa0 100644
--- a/frontend/src/components/Comments/CommentsPanel.css
+++ b/frontend/src/components/Comments/CommentsPanel.css
@@ -7,6 +7,8 @@
background: var(--color-bg);
transform: translateY(100%);
transition: transform var(--duration-normal) var(--ease-smooth);
+ padding-top: max(0px, env(safe-area-inset-top));
+ padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.comments-panel--open {
@@ -40,6 +42,15 @@
font-weight: 600;
}
+.comments-count {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--color-muted);
+ background: rgba(255, 255, 255, 0.08);
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+}
+
/* List */
.comments-list {
flex: 1;
@@ -47,8 +58,31 @@
padding: 0 var(--space-lg);
}
-.comments-placeholder {
+.comments-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
padding: var(--space-xl);
+ gap: var(--space-md);
+}
+
+.comments-spinner {
+ width: 28px;
+ height: 28px;
+ border: 3px solid rgba(255, 255, 255, 0.1);
+ border-top-color: var(--color-card-a-to);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.comments-placeholder {
text-align: center;
color: var(--color-muted);
font-size: 14px;
@@ -60,9 +94,15 @@
align-items: center;
justify-content: center;
height: 100%;
+ min-height: 200px;
gap: var(--space-sm);
}
+.comments-empty-icon {
+ font-size: 32px;
+ margin-bottom: var(--space-xs);
+}
+
.comments-empty-text {
font-family: var(--font-display);
font-size: 16px;
@@ -89,15 +129,20 @@
.comments-input {
flex: 1;
resize: none;
- padding: var(--space-sm) var(--space-md);
+ padding: 10px var(--space-md);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 14px;
line-height: 1.4;
- min-height: 40px;
+ min-height: 42px;
max-height: 100px;
+ transition: border-color var(--duration-fast) var(--ease-smooth);
+}
+
+.comments-input:focus {
+ border-color: var(--color-card-a-to);
}
.comments-input::placeholder {
@@ -108,8 +153,8 @@
display: flex;
align-items: center;
justify-content: center;
- width: 40px;
- height: 40px;
+ width: 42px;
+ height: 42px;
border-radius: 50%;
background: var(--glass-bg);
color: var(--color-muted);
@@ -118,10 +163,21 @@
}
.comments-send--active {
- background: var(--color-like);
- color: var(--color-text);
+ background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
+ color: #FFFFFF;
+ box-shadow: 0 0 12px rgba(124, 58, 237, 0.3);
}
.comments-send:disabled {
cursor: default;
+ opacity: 0.6;
+}
+
+.comments-btn-spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-top-color: #FFFFFF;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
}
diff --git a/frontend/src/components/Comments/CommentsPanel.jsx b/frontend/src/components/Comments/CommentsPanel.jsx
index fb24621..428efe2 100644
--- a/frontend/src/components/Comments/CommentsPanel.jsx
+++ b/frontend/src/components/Comments/CommentsPanel.jsx
@@ -1,14 +1,23 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../../context/AppContext';
-import { showBackButton } from '../../services/auth';
+import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth';
import { api } from '../../services/api';
-import { currentUser } from '../../services/auth';
import CommentItem from './CommentItem';
import './CommentsPanel.css';
export default function CommentsPanel() {
- const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp();
+ const {
+ isCommentsOpen,
+ setIsCommentsOpen,
+ currentCard,
+ showToast,
+ getUserProfile,
+ handleApiResponse,
+ syncCardComments,
+ addCommentToCard,
+ } = useApp();
const [comments, setComments] = useState([]);
+ const [authors, setAuthors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const [newComment, setNewComment] = useState('');
const [isSending, setIsSending] = useState(false);
@@ -17,59 +26,100 @@ export default function CommentsPanel() {
// Telegram BackButton
useEffect(() => {
if (isCommentsOpen) {
- const cleanup = showBackButton(() => setIsCommentsOpen(false));
+ const cleanup = showBackButton(() => {
+ hapticImpact('light');
+ setIsCommentsOpen(false);
+ });
return cleanup;
}
}, [isCommentsOpen, setIsCommentsOpen]);
- // Load comments when panel opens
- useEffect(() => {
- if (isCommentsOpen && currentCard) {
- loadComments();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isCommentsOpen, currentCard?.card_id]);
+ const cardId = currentCard?.card_id;
- async function loadComments() {
+ const loadComments = useCallback(async () => {
+ if (!cardId) return;
setIsLoading(true);
+
try {
- // TODO: Replace with real GET /get_comments when backend implements it
- const result = await api.getComments(currentCard.card_id);
- if (!result.error) {
- setComments(result.result || []);
+ const result = await api.getComments(cardId);
+ handleApiResponse(result);
+
+ if (!result.error && Array.isArray(result.result)) {
+ const loadedComments = result.result;
+ setComments(loadedComments);
+ syncCardComments(cardId, loadedComments.map((c) => c.comment_id));
+
+ // Fetch author profiles for all unique authors
+ const uniqueAuthorIds = Array.from(new Set(loadedComments.map((c) => c.author_id)));
+ const authorsData = {};
+
+ await Promise.all(
+ uniqueAuthorIds.map(async (authorId) => {
+ const profile = await getUserProfile(authorId);
+ if (profile) {
+ authorsData[authorId] = profile;
+ }
+ })
+ );
+
+ setAuthors(authorsData);
+ } else {
+ setComments([]);
}
} catch (err) {
console.error('Load comments error:', err);
} finally {
setIsLoading(false);
}
- }
+ }, [cardId, getUserProfile, handleApiResponse, syncCardComments]);
+
+ // Load comments only when panel opens or active card ID changes
+ useEffect(() => {
+ if (isCommentsOpen && cardId) {
+ loadComments();
+ }
+ }, [isCommentsOpen, cardId, loadComments]);
async function handleSend() {
- if (!newComment.trim() || isSending) return;
+ const text = newComment.trim();
+ if (!text || isSending || !currentCard) return;
+
setIsSending(true);
+ hapticImpact('light');
+
try {
- const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim());
- if (!result.error) {
+ const result = await api.addComment(currentUser.id, currentCard.card_id, text);
+ handleApiResponse(result);
+
+ if (!result.error && result.result) {
setNewComment('');
- showToast('Комментарий отправлен');
-
- // Optimistically add the new comment to the list
- if (result.result) {
- setComments(prev => [...prev, result.result]);
-
- // Scroll to bottom after adding
- setTimeout(() => {
- if (listRef.current) {
- listRef.current.scrollTop = listRef.current.scrollHeight;
- }
- }, 100);
- }
+ showToast('Комментарий опубликован');
+ hapticNotification('success');
+
+ const createdComment = result.result;
+ setComments((prev) => [...prev, createdComment]);
+ addCommentToCard(currentCard.card_id, createdComment.comment_id);
+
+ // Add current user to authors map
+ setAuthors((prev) => ({
+ ...prev,
+ [currentUser.id]: currentUser,
+ }));
+
+ // Scroll to bottom
+ setTimeout(() => {
+ if (listRef.current) {
+ listRef.current.scrollTop = listRef.current.scrollHeight;
+ }
+ }, 100);
} else {
- showToast('Ошибка: ' + (result.result || 'неизвестная'));
+ showToast(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария');
+ hapticNotification('error');
}
} catch (err) {
console.error('Send comment error:', err);
+ showToast('Ошибка отправки');
+ hapticNotification('error');
} finally {
setIsSending(false);
}
@@ -82,30 +132,44 @@ export default function CommentsPanel() {
}
}
+ const handleClose = () => {
+ hapticImpact('light');
+ setIsCommentsOpen(false);
+ };
+
return (
Комментарии
+ {comments.length}
{isLoading ? (
-
Загрузка...
+
+
+
Загрузка комментариев...
+
) : comments.length === 0 ? (
+
💬
Комментариев пока нет
-
Будь первым!
+
Будь первым, кто поделится мнением!
) : (
comments.map((comment, i) => (
-
+
))
)}
@@ -113,22 +177,27 @@ export default function CommentsPanel() {
diff --git a/frontend/src/components/Menu/AboutPage.jsx b/frontend/src/components/Menu/AboutPage.jsx
index 4687549..a5f778d 100644
--- a/frontend/src/components/Menu/AboutPage.jsx
+++ b/frontend/src/components/Menu/AboutPage.jsx
@@ -1,18 +1,38 @@
import React from 'react';
+import { hapticImpact, hapticNotification, openExternalLink } from '../../services/auth';
+import { useApp } from '../../context/AppContext';
import './AboutPage.css';
export default function AboutPage({ onBack }) {
+ const { showToast } = useApp();
+
function handleCopyEmail() {
- navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
- // Visual feedback handled by CSS :active
- }).catch(() => {
- // Fallback — select text
- });
+ hapticImpact('light');
+ navigator.clipboard
+ .writeText('pseudo.developer.ru@gmail.com')
+ .then(() => {
+ hapticNotification('success');
+ showToast('Email скопирован в буфер обмена');
+ })
+ .catch(() => {
+ showToast('pseudo.developer.ru@gmail.com');
+ });
}
+ const handleBack = () => {
+ hapticImpact('light');
+ onBack();
+ };
+
+ const handleOpenGithub = (e) => {
+ e.preventDefault();
+ hapticImpact('light');
+ openExternalLink('https://github.com/IgorVolochay/thisORthat');
+ };
+
return (
-
-
+
Исходники проекта
-
+