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)
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
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;
|