studio/src/editor/ModelSaveModal.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

214 lines
10 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';
/**
* 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;