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)
214 lines
10 KiB
JavaScript
214 lines
10 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import cl from './GameSettingsModal.module.css';
|
||
import Icon from './Icon';
|
||
|
||
/**
|
||
* ModelSaveModal — модалка сохранения пользовательской модели.
|
||
*
|
||
* Открывается из ModelEditorScreen при клике "Сохранить" (не автосейв).
|
||
* Поля:
|
||
* - Название (обязательное, до 100)
|
||
* - Описание (до 500)
|
||
* - Превью (берётся из текущей camera через captureThumbnail)
|
||
* - Опубликовать (флаг is_public)
|
||
*
|
||
* Стиль повторяет GameSettingsModal (синий header, светлая тема, scrollable
|
||
* body + sticky footer). Файл .module.css переиспользован полностью.
|
||
*
|
||
* Props:
|
||
* open
|
||
* initial — { title, description, is_public, thumbnail }
|
||
* Если initial.thumbnail задан — используется как превью
|
||
* (создан через интерактивное выделение), автоснимок НЕ делается.
|
||
* onClose
|
||
* onSave(data) — { title, description, is_public, thumbnail }
|
||
* onCaptureThumbnail() — async () → Promise<dataURL> (автоснимок при открытии)
|
||
* onCreatePreview() — открыть режим интерактивного выделения превью
|
||
* mode — 'create' | 'edit'
|
||
*/
|
||
|
||
const MAX_TITLE_LEN = 100;
|
||
const MAX_DESC_LEN = 500;
|
||
|
||
const ModelSaveModal = ({
|
||
open, initial, onClose, onSave,
|
||
onCaptureThumbnail, onCreatePreview, mode = 'create',
|
||
}) => {
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [isPublic, setIsPublic] = useState(false);
|
||
const [thumbnail, setThumbnail] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [capturing, setCapturing] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setTitle(initial?.title || '');
|
||
setDescription(initial?.description || '');
|
||
setIsPublic(!!initial?.is_public);
|
||
setError('');
|
||
// Если превью пришло из интерактивного выделения (initial.thumbnail) —
|
||
// используем его как есть, автоснимок НЕ делаем (иначе перезатрём
|
||
// то, что пользователь только что закадрировал).
|
||
if (initial?.thumbnail) {
|
||
setThumbnail(initial.thumbnail);
|
||
} else {
|
||
setThumbnail('');
|
||
// Иначе при открытии автоматически снимаем актуальное превью.
|
||
if (onCaptureThumbnail) {
|
||
setCapturing(true);
|
||
onCaptureThumbnail()
|
||
.then((data) => { if (data) setThumbnail(data); })
|
||
.catch(() => {})
|
||
.finally(() => setCapturing(false));
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
if (!open) return null;
|
||
|
||
const handleSubmit = (e) => {
|
||
e.preventDefault();
|
||
const trimmed = title.trim();
|
||
if (!trimmed) {
|
||
setError('Название обязательно');
|
||
return;
|
||
}
|
||
if (trimmed.length > MAX_TITLE_LEN) {
|
||
setError(`Название не длиннее ${MAX_TITLE_LEN} символов`);
|
||
return;
|
||
}
|
||
if (description.length > MAX_DESC_LEN) {
|
||
setError(`Описание не длиннее ${MAX_DESC_LEN} символов`);
|
||
return;
|
||
}
|
||
onSave({
|
||
title: trimmed,
|
||
description: description.trim(),
|
||
is_public: isPublic,
|
||
thumbnail,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className={cl.overlay} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||
<div className={cl.modal}>
|
||
<header className={cl.header}>
|
||
<h2 className={cl.title}>
|
||
{mode === 'edit'
|
||
? <><Icon name="save" size={13} /> Сохранить модель</>
|
||
: <><Icon name="sparkles" size={13} /> Сохранение модели</>}
|
||
</h2>
|
||
<button className={cl.closeBtn} onClick={onClose}>
|
||
<Icon name="close" size={14} />
|
||
</button>
|
||
</header>
|
||
|
||
<form onSubmit={handleSubmit} className={cl.form}>
|
||
<div className={cl.body}>
|
||
{/* Превью */}
|
||
<div className={cl.iconRow}>
|
||
<div className={cl.iconPreview}>
|
||
{thumbnail ? (
|
||
<img src={thumbnail} alt="Превью" />
|
||
) : (
|
||
<div className={cl.iconPlaceholder}>
|
||
{capturing ? <Icon name="loading" size={14} /> : <Icon name="cube" size={14} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className={cl.iconActions}>
|
||
<div className={cl.fieldLabel}>Превью</div>
|
||
{/* Кнопка "Создать превью" — открывает режим
|
||
интерактивного выделения области на канвасе.
|
||
Только если onCreatePreview передан (из
|
||
ModelEditorScreen). В режиме настроек из
|
||
Toolbox её нет — превью не редактируется. */}
|
||
{onCreatePreview && (
|
||
<div className={cl.iconButtons}>
|
||
<button
|
||
type="button"
|
||
className={cl.actionBtn}
|
||
onClick={onCreatePreview}
|
||
disabled={capturing}
|
||
>
|
||
<Icon name="camera" size={14} /> Создать превью
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className={cl.fieldHint}>
|
||
{onCreatePreview
|
||
? 'Нажми «Создать превью» — затем выдели на модели квадратную область для обложки.'
|
||
: 'Чтобы изменить превью — открой модель в редакторе и пересохрани.'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Название */}
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
Название <span className={cl.required}>*</span>
|
||
<span className={cl.counter}>{title.length} / {MAX_TITLE_LEN}</span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
className={cl.input}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value.slice(0, MAX_TITLE_LEN))}
|
||
placeholder="Например, Каменный стул"
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
|
||
{/* Описание */}
|
||
<label className={cl.field}>
|
||
<div className={cl.fieldLabel}>
|
||
Описание
|
||
<span className={cl.counter}>{description.length} / {MAX_DESC_LEN}</span>
|
||
</div>
|
||
<textarea
|
||
className={cl.textarea}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value.slice(0, MAX_DESC_LEN))}
|
||
placeholder="Что это за модель, где использовать..."
|
||
rows={3}
|
||
/>
|
||
</label>
|
||
|
||
{/* Публичность */}
|
||
<label className={cl.toggleRow}>
|
||
<input
|
||
type="checkbox"
|
||
className={cl.toggle}
|
||
checked={isPublic}
|
||
onChange={(e) => setIsPublic(e.target.checked)}
|
||
/>
|
||
<div className={cl.toggleText}>
|
||
<div className={cl.toggleTitle}>Опубликовать</div>
|
||
<div className={cl.toggleHint}>
|
||
<Icon name={isPublic ? 'globe' : 'locked'} size={12} />
|
||
<span>{isPublic ? 'Появится в разделе «Сообщество» Toolbox' : 'Видна только тебе в «Мои модели»'}</span>
|
||
</div>
|
||
</div>
|
||
</label>
|
||
|
||
{error && <div className={cl.error}><Icon name="warning" size={13} /> {error}</div>}
|
||
</div>
|
||
|
||
<div className={cl.footer}>
|
||
<button type="button" className={cl.secondaryBtn} onClick={onClose}>
|
||
Отмена
|
||
</button>
|
||
<button type="submit" className={cl.primaryBtn}>
|
||
<Icon name="save" size={13} /> Сохранить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ModelSaveModal;
|