player/src/KubikonPlayer/KubikonComments.jsx
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

303 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import { formatRelative } from '../utils/kubikonTime';
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
/**
* KubikonComments — список комментариев под игрой + форма ответа.
*
* Используется в KubikonPlayer на отдельной панели (открывается по кнопке «💬»).
* В первой версии — без вложенных ответов, плоский список.
*
* Авторизация: гости не могут оставлять комменты — модалка регистрации.
*
* Props:
* projectId — id игры
* projectOwnerId — id автора игры (он может удалять чужие комменты)
* onClose — закрыть панель
* onRequestAuth — позвать модалку «нужна регистрация»
*/
const KubikonComments = ({ projectId, projectOwnerId, onClose, onRequestAuth }) => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [emailNotice, setEmailNotice] = useState(false); // окно «подтвердите email»
const userInfo = (() => {
try {
const t = localStorage.getItem('Authorization');
if (!t) return null;
const p = jwtDecode(t);
return {
id: p?.id || p?.user_id || null,
username: p?.firstName || p?.first_name || p?.email || '',
};
} catch (e) { return null; }
})();
const userId = userInfo?.id || null;
const isOwnerOfProject = userId && projectOwnerId && userId === projectOwnerId;
const load = useCallback(async () => {
setLoading(true);
try {
const res = await Kubikon3DApi.getProjectComments(projectId);
setItems(res.data?.comments || []);
} catch (e) {
setItems([]);
}
setLoading(false);
}, [projectId]);
useEffect(() => { load(); }, [load]);
const send = async () => {
if (!userId) { onRequestAuth?.('оставлять комментарии'); return; }
const t = text.trim();
if (!t) { setError('Напиши хотя бы что-нибудь.'); return; }
setSubmitting(true);
setError(null);
try {
const res = await Kubikon3DApi.createProjectComment(projectId, {
user_id: userId,
username: userInfo?.username || '',
text: t,
});
// Оптимистичное добавление в начало
const newComment = res.data?.comment;
if (newComment) setItems(prev => [newComment, ...prev]);
setText('');
} catch (e) {
const code = e?.response?.data?.error;
const msg = e?.response?.data?.message;
if (code === 'too_frequent') {
setError(msg || 'Слишком часто. Подожди пару секунд.');
} else if (code === 'rate_limit') {
setError(msg || 'Слишком много комментариев — притормози.');
} else if (code === 'login_required') {
onRequestAuth?.('оставлять комментарии');
} else if (code === 'email_not_confirmed') {
setEmailNotice(true);
} else {
setError(msg || code || 'Ошибка отправки');
}
}
setSubmitting(false);
};
const removeMine = async (cid) => {
try {
await Kubikon3DApi.deleteProjectComment(cid, userId);
setItems(prev => prev.filter(x => x.id !== cid));
} catch (e) { /* ignore */ }
};
return (
<div style={{
position: 'fixed',
top: 0, right: 0, bottom: 0,
width: 420, maxWidth: '95vw',
background: '#1a1410',
borderLeft: '1px solid #5a4a3a',
zIndex: 1600,
display: 'flex', flexDirection: 'column',
color: '#f0e6d8',
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
boxShadow: '-8px 0 24px rgba(0,0,0,0.5)',
}}>
<div style={{
padding: '12px 14px',
borderBottom: '1px solid #5a4a3a',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#c9a04e' }}>
💬 Комментарии
</div>
<div style={{ fontSize: 12, color: '#a59478', marginLeft: 'auto' }}>
{items.length}
</div>
<button onClick={onClose} style={iconBtn}></button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 14px' }}>
{loading ? (
<div style={{ padding: 30, textAlign: 'center', color: '#a59478' }}>
Загрузка
</div>
) : items.length === 0 ? (
<div style={{
padding: 30, textAlign: 'center', color: '#a59478',
background: 'rgba(0,0,0,0.25)', borderRadius: 6,
border: '1px dashed #5a4a3a',
}}>
💭 Пока нет комментариев. Будь первым!
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(c => (
<CommentRow
key={c.id}
comment={c}
userId={userId}
isOwnerOfProject={isOwnerOfProject}
onDelete={() => removeMine(c.id)}
/>
))}
</div>
)}
</div>
<div style={{
borderTop: '1px solid #5a4a3a',
padding: 10,
background: '#15100c',
flexShrink: 0,
}}>
{error && (
<div style={{
background: 'rgba(168,80,80,0.18)',
border: '1px solid #a85050',
borderRadius: 4,
padding: '6px 8px',
marginBottom: 6,
fontSize: 12, color: '#ff9a9a',
}}>
{error}
</div>
)}
{!userId ? (
<div style={{
padding: '10px 12px', textAlign: 'center',
background: '#2a2218', border: '1px solid #5a4a3a',
borderRadius: 6, fontSize: 13, color: '#a59478',
}}>
Войди в аккаунт, чтобы оставить комментарий
</div>
) : (
<>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, 1000))}
placeholder="Напиши свой комментарий…"
rows={2}
disabled={submitting}
style={{
width: '100%',
background: '#1a1410',
border: '1px solid #5a4a3a',
borderRadius: 5,
padding: '8px 10px',
color: '#f0e6d8',
fontSize: 13,
fontFamily: 'inherit',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
<span style={{ fontSize: 11, color: '#a59478' }}>
{text.length}/1000
</span>
<button
onClick={send}
disabled={submitting || !text.trim()}
style={{
marginLeft: 'auto',
padding: '7px 16px',
background: submitting || !text.trim() ? '#5a4a3a' : '#5a8c3e',
color: '#fff',
border: '1px solid ' + (submitting || !text.trim() ? '#3a2e22' : '#3d6029'),
borderRadius: 5,
fontSize: 13, fontWeight: 600,
cursor: submitting || !text.trim() ? 'not-allowed' : 'pointer',
fontFamily: 'inherit',
}}
>
{submitting ? 'Отправка…' : '📨 Отправить'}
</button>
</div>
</>
)}
</div>
<EmailConfirmNotice
open={emailNotice}
onClose={() => setEmailNotice(false)}
action="писать комментарии"
/>
</div>
);
};
const CommentRow = ({ comment, userId, isOwnerOfProject, onDelete }) => {
const isMine = userId && comment.user_id === userId;
const canDelete = isMine || isOwnerOfProject;
return (
<div style={{
background: '#2a2218',
border: '1px solid #5a4a3a',
borderRadius: 6,
padding: 10,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<a
href={`https://rublox.pro/app/profile/${comment.user_id}`}
style={{ fontSize: 12, fontWeight: 700, color: '#7bb84e', textDecoration: 'none' }}
>
{comment.username || `#${comment.user_id}`}
</a>
<span style={{ fontSize: 10, color: '#a59478' }}>
{formatRelative(comment.created_at)}
</span>
{comment.edited_at && (
<span style={{ fontSize: 10, color: '#a59478', fontStyle: 'italic' }}>
(изменено)
</span>
)}
{canDelete && (
<button
onClick={onDelete}
title={isOwnerOfProject && !isMine ? 'Удалить как автор игры' : 'Удалить свой комментарий'}
style={{
marginLeft: 'auto',
background: 'transparent',
border: '1px solid #5a4a3a',
color: '#a59478',
borderRadius: 4,
padding: '2px 6px',
fontSize: 10,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
🗑
</button>
)}
</div>
<div style={{
fontSize: 13, color: '#f0e6d8',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{comment.text}
</div>
</div>
);
};
const iconBtn = {
background: 'rgba(15,12,8,0.6)',
border: '1px solid #5a4a3a',
borderRadius: 4,
color: '#f0e6d8',
width: 28, height: 28,
cursor: 'pointer',
fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'inherit',
};
export default KubikonComments;