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
@@ -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>
);
}