studio/src/editor/PublishModal.jsx
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

258 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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, { useState, useEffect } from 'react';
import cl from './GameSettingsModal.module.css';
import Icon from './Icon';
/**
* PublishModal — публикация проекта в умную ленту Рублокса.
*
* Премодерации больше нет (RUBLOX_SMART_FEED_PLAN.md). Игра публикуется
* сразу: проходит автопроверку скриптов и попадает в ленту «Новое».
* Дальше её судьбу решает алгоритм — лайки, просмотры, время в игре.
*
* Поля:
* - Возрастной рейтинг: 6 / 12 / 16 / 18
* - Подтверждение «Я ознакомился с правилами публикации»
*
* Props:
* open
* project — { title, description, status, age_rating }
* onClose
* onSubmit({ age_rating }) → должен вернуть результат публикации
* (например { review: bool }), чтобы показать нужное сообщение.
*/
const AGE_OPTIONS = [
{ id: 6, label: '6+', desc: 'Для самых маленьких. Никакого насилия.' },
{ id: 12, label: '12+', desc: 'Лёгкая фантастика, без жестокости.' },
{ id: 16, label: '16+', desc: 'Подростковая. Лёгкое насилие допустимо.' },
{ id: 18, label: '18+', desc: 'Для взрослых.' },
];
// Самописная иконка «как работает алгоритм» — ступеньки роста.
const AlgoIcon = ({ size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"
strokeLinejoin="round">
<path d="M3 17l5-5 4 4 8-8" />
<path d="M16 8h5v5" />
</svg>
);
const PublishModal = ({ open, project, onClose, onSubmit }) => {
const [ageRating, setAgeRating] = useState(12);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setAgeRating(project?.age_rating || 12);
setConfirmed(false);
setError('');
setSubmitting(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (!open) return null;
const handleSubmit = async () => {
if (!confirmed) {
setError('Подтвердите что вы ознакомились с правилами');
return;
}
setSubmitting(true);
try {
await onSubmit?.({ age_rating: ageRating });
onClose?.();
} catch (e) {
// Приоритет — понятное message от бэка (фильтр названия/описания
// отдаёт его), затем код ошибки, затем общий текст.
setError(e?.response?.data?.message
|| e?.response?.data?.error
|| e?.message || 'Не удалось опубликовать');
setSubmitting(false);
}
};
// Игра уже опубликована (новый статус 'published') — это переиздание.
const isAlreadyPublished = project?.status === 'published';
return (
<div className={cl.overlay} onClick={onClose}>
<div className={cl.modal} onClick={(e) => e.stopPropagation()}>
<div className={cl.header}>
<h2 className={cl.title}>
<span><Icon name="upload" size={14} /></span>
<span>{isAlreadyPublished ? 'Обновить игру' : 'Опубликовать игру'}</span>
</h2>
<button className={cl.closeBtn} onClick={onClose}>×</button>
</div>
<div className={cl.body}>
{/* === Как работает лента === */}
<div style={{
padding: '14px 16px', marginBottom: 4,
background: 'linear-gradient(135deg, rgba(79,116,255,0.14) 0%, rgba(109,40,217,0.10) 100%)',
border: '1px solid rgba(79,116,255,0.32)',
borderRadius: 12, fontSize: 13, color: '#e8e8ea',
lineHeight: 1.55,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontWeight: 800, marginBottom: 8, color: '#6d8aff',
}}>
<AlgoIcon size={16} />
<span>Как игра попадает в ленту</span>
</div>
<ul style={{ margin: 0, paddingLeft: 18, fontWeight: 600 }}>
<li style={{ marginBottom: 4 }}>
После публикации игра <b>сразу появляется</b> во
вкладке «Новое» ждать одобрения не нужно.
</li>
<li style={{ marginBottom: 4 }}>
Первые дни ей дают <b>пробный показ</b>. Если
игроки ставят лайки и долго играют она
поднимается во вкладку «Рекомендуем».
</li>
<li style={{ marginBottom: 4 }}>
Если игру мало кто запускает или ставят много
дизлайков она тихо опускается в ленте. Но её
всё равно можно найти <b>через поиск</b>.
</li>
<li>
Хочешь в топ делай игру интересной: чтобы в неё
хотелось играть и возвращаться.
</li>
</ul>
</div>
{isAlreadyPublished && (
<div style={{
padding: '12px 14px',
background: 'linear-gradient(135deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.08) 100%)',
border: '1px solid rgba(16,185,129,0.40)',
borderRadius: 12, fontSize: 13, color: '#7ee0b8',
display: 'flex', alignItems: 'flex-start', gap: 10,
fontWeight: 600, lineHeight: 1.5,
}}>
<span style={{ fontSize: 16 }}><Icon name="check" size={14} /></span>
<span>
Игра уже опубликована. Обновление сохранит
изменения игра <b>останется в ленте</b>,
из неё не пропадёт.
</span>
</div>
)}
{/* Возрастной рейтинг */}
<div className={cl.field}>
<div className={cl.fieldLabel}>
<span>Возрастной рейтинг <span className={cl.required}>*</span></span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
{AGE_OPTIONS.map(opt => {
const active = ageRating === opt.id;
return (
<button
key={opt.id}
onClick={() => setAgeRating(opt.id)}
style={{
padding: '12px 14px',
background: active
? 'linear-gradient(135deg, #4f74ff 0%, #3a57d8 100%)'
: '#1b1b1b',
border: active
? '1px solid transparent'
: '1px solid #3a3a3a',
borderRadius: 12,
color: active ? '#fff' : '#e8e8ea',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 200ms cubic-bezier(0.34, 1.56, 0.64, 1)',
fontFamily: 'inherit',
boxShadow: active
? '0 8px 20px rgba(79, 116, 255, 0.4)'
: 'none',
transform: active ? 'translateY(-2px)' : 'translateY(0)',
}}
>
<div style={{
fontSize: 20, fontWeight: 900, letterSpacing: -0.3,
}}>{opt.label}</div>
<div style={{
fontSize: 12, fontWeight: 600, marginTop: 2,
color: active ? 'rgba(255,255,255,0.86)' : '#9a9a9e',
lineHeight: 1.35,
}}>{opt.desc}</div>
</button>
);
})}
</div>
</div>
{/* Подтверждение */}
<div className={cl.field}>
<label style={{
display: 'flex', gap: 12, alignItems: 'flex-start',
cursor: 'pointer', fontSize: 13,
padding: 14,
background: confirmed ? 'rgba(79,116,255,0.18)' : '#1b1b1b',
border: `1.5px solid ${confirmed ? '#4f74ff' : '#3a3a3a'}`,
borderRadius: 12,
transition: 'all 150ms ease',
color: '#e8e8ea',
}}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
style={{
marginTop: 2, width: 18, height: 18,
accentColor: '#4f74ff', cursor: 'pointer',
}}
/>
<span style={{ flex: 1, lineHeight: 1.5, fontWeight: 600 }}>
Я подтверждаю что игра не нарушает правила платформы:
нет матов, насилия не по возрасту, рекламы и контента,
нарушающего авторские права.
<br />
<span style={{
color: '#9a9a9e', fontWeight: 600, fontSize: 12,
display: 'inline-block', marginTop: 4,
}}>
Скрипты игры проходят быструю автопроверку.
Если всё в порядке игра появится в ленте
<b style={{ color: '#6d8aff' }}> сразу же</b>.
</span>
</span>
</label>
</div>
{error && (
<div className={cl.error}>
<span style={{ fontSize: 16 }}><Icon name="warning" size={14} /></span>
{error}
</div>
)}
</div>
<div className={cl.footer}>
<button className={cl.secondaryBtn} onClick={onClose} disabled={submitting}>
Отмена
</button>
<button
className={cl.primaryBtn}
onClick={handleSubmit}
disabled={submitting || !confirmed}
>
{submitting
? 'Публикация…'
: <><Icon name="upload" size={13} /> {isAlreadyPublished ? 'Обновить' : 'Опубликовать'}</>}
</button>
</div>
</div>
</div>
);
};
export default PublishModal;