Compare commits

...

3 Commits

Author SHA1 Message Date
min
7cbcdce6f9 Merge pull request 'Fix Team Create: ����� �������� ������ � ������, ������ ����� ����, ���� �� �������' (#35) from restore/all-tasks into main
All checks were successful
CI / Lint (push) Successful in 1m7s
CI / Build (push) Successful in 1m56s
CI / Secret scan (push) Successful in 22s
CI / PR size check (push) Has been skipped
CI / Deploy to S1 + S2 (push) Successful in 3m25s
2026-06-08 03:07:24 +00:00
min
9c990bb80c merge main (синхрон перед PR фиксов Team Create)
All checks were successful
CI / Lint (pull_request) Successful in 1m4s
CI / Build (pull_request) Successful in 1m54s
CI / Secret scan (pull_request) Successful in 33s
CI / PR size check (pull_request) Successful in 15s
CI / Deploy to S1 + S2 (pull_request) Has been skipped
2026-06-08 05:58:06 +03:00
min
c0613853a2 fix(studio): Team Create — экран загрузки только в плеере, коллаб новой игры, вход по инвайту
1) Стартовый экран загрузки больше НЕ показывается в студии при тестовом
запуске (scene._editorMode), только в плеере на rublox.pro «Играть».
2) Новая игра: коллаб-сессия поднимается сразу после первого сохранения
(без перезагрузки) + кнопка «Пригласить» авто-сохраняет проект.
3) Незалогиненный по collab-ссылке → форма входа rublox.pro/login (origin без
/app, был 404) с ?return → возврат на инвайт-ссылку.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 05:57:55 +03:00
4 changed files with 107 additions and 11 deletions

View File

@ -71,6 +71,24 @@ export function AuthProvider({ children }) {
} catch {} } catch {}
}; };
// После успешного входа: если есть запомненная collab-ссылка и мы сейчас
// НЕ на ней переходим. Это и есть «возврат на приглашение» (минкин логин
// сам этого не делает). Чистим ключ, чтобы не зациклить.
const maybeResumePendingCollab = () => {
try {
const pending = localStorage.getItem('kbn_pending_collab_url');
if (!pending) return;
localStorage.removeItem('kbn_pending_collab_url');
// уже на нужной collab-странице? не дёргаем
if (window.location.href === pending) return;
const u = new URL(pending);
// безопасность: переходим только в пределах этого же origin (студии)
if (u.origin !== window.location.origin) return;
if (!/[?&]collab=/.test(pending)) return;
window.location.replace(pending);
} catch {}
};
const startWithJwt = (raw) => { const startWithJwt = (raw) => {
const payload = decodeJwtSafe(raw); const payload = decodeJwtSafe(raw);
if (!payload) { if (!payload) {
@ -96,6 +114,9 @@ export function AuthProvider({ children }) {
role: profile.role || 'user', role: profile.role || 'user',
jwt: raw, jwt: raw,
}); });
// Возврат на инвайт-ссылку после входа: минка вернула нас в студию
// (на главную/кабинет), но мы запомнили collab-URL идём на него.
maybeResumePendingCollab();
}) })
.catch(() => { .catch(() => {
if (cancelled) return; if (cancelled) return;
@ -149,3 +170,42 @@ export function AuthProvider({ children }) {
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
} }
/**
* Редирект незалогиненного пользователя на форму входа Рублокса (SSO).
* В студии НЕТ собственной страницы /login вход живёт на КОРНЕ домена
* Рублокса (`https://rublox.pro/login`, НЕ `/app/login` тот даёт 404).
* После входа лендинг возвращает в студию/плеер с #ticket=<hex>
* (его обменивает AuthProvider выше). `return`-параметр сохраняет ПОЛНЫЙ
* текущий URL (включая ?collab=<token>), чтобы человек попал ровно на ту
* инвайт-ссылку, с которой пришёл.
*/
// Ключ, под которым студия запоминает collab-ссылку на время входа.
// Основной путь возврата rublox.pro/login читает наш ?return и сам
// редиректит обратно с #ticket (см. rublox-site LoginPage.finishLogin).
// Этот pending-ключ РЕЗЕРВ на случай, если юзер вернулся в студию иным
// путём: AuthProvider после входа перекинет на запомненную ссылку.
export const PENDING_COLLAB_KEY = 'kbn_pending_collab_url';
export function redirectToLogin() {
const env = (typeof import.meta !== 'undefined' && import.meta.env) || {};
// VITE_RUBLOX_HOME = "https://rublox.pro/app" нам нужен ORIGIN (без /app),
// т.к. форма входа сидит на корне домена: https://rublox.pro/login.
const rawHome = env.VITE_RUBLOX_HOME || 'https://rublox.pro/app';
let origin;
try {
origin = new URL(rawHome).origin; // https://rublox.pro
} catch {
origin = rawHome.replace(/\/app\/?$/, '').replace(/\/$/, '');
}
// Если уходим со страницы с инвайт-токеном запомним её, чтобы вернуться
// после входа (минка сама вернёт только в кабинет, не на эту ссылку).
try {
const cur = window.location.href;
if (/[?&]collab=/.test(cur)) {
localStorage.setItem(PENDING_COLLAB_KEY, cur);
}
} catch {}
const back = encodeURIComponent(window.location.href);
window.location.href = `${origin}/login?return=${back}`;
}

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import { useAuth } from '../auth/AuthContext.jsx'; import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
import { useSanctions } from '../auth/SanctionsContext.jsx'; import { useSanctions } from '../auth/SanctionsContext.jsx';
import { BabylonScene } from './engine/BabylonScene'; import { BabylonScene } from './engine/BabylonScene';
import { StudioCollab } from './engine/StudioCollab'; import { StudioCollab } from './engine/StudioCollab';
@ -470,6 +470,9 @@ const KubikonEditor = () => {
// Team Create клиент совместного редактирования + presence-overlay. // Team Create клиент совместного редактирования + presence-overlay.
const collabRef = useRef(null); const collabRef = useRef(null);
const collabOverlayRef = useRef(null); const collabOverlayRef = useRef(null);
// ref на initCollab чтобы вызвать его из doSave (объявлен раньше initCollab)
// после создания нового проекта, не дожидаясь перезагрузки страницы.
const initCollabRef = useRef(null);
const [collabActive, setCollabActive] = useState(false); // подключены к комнате const [collabActive, setCollabActive] = useState(false); // подключены к комнате
const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов const [collabPeers, setCollabPeers] = useState(0); // сколько ДРУГИХ соавторов
// Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке). // Роль в коллабе: 'owner' (владелец) | 'collab' (приглашённый по ссылке).
@ -988,10 +991,12 @@ const KubikonEditor = () => {
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
// Доступ открыт всем авторизованным (раньше только admin) // Доступ открыт всем авторизованным (раньше только admin)
// Нет своей страницы /login уходим на вход минки (SSO) с возвратом
// на текущий URL (важно для инвайт-ссылок ?collab=<token>).
if (!isAuthenticated) { if (!isAuthenticated) {
navigate('/login', { replace: true }); redirectToLogin();
} }
}, [isAuthenticated, isLoading, navigate]); }, [isAuthenticated, isLoading]);
/** /**
* Реальное сохранение на сервер. * Реальное сохранение на сервер.
@ -1207,6 +1212,11 @@ const KubikonEditor = () => {
if (newId) { if (newId) {
currentProjectIdRef.current = newId; currentProjectIdRef.current = newId;
window.history.replaceState({}, '', `/edit/${newId}`); window.history.replaceState({}, '', `/edit/${newId}`);
// Team Create: проект только что получил реальный ID поднимаем
// совместную сессию сразу, без перезагрузки страницы. Иначе
// индикатор соавторов и инвайт не появлялись, пока юзер не
// поставит блок сохранит обновит (баг 2026-06-08).
try { initCollabRef.current?.(Number(newId)); } catch (_) {}
} }
} else { } else {
await Kubikon3DApi.updateProject(currentProjectIdRef.current, payload); await Kubikon3DApi.updateProject(currentProjectIdRef.current, payload);
@ -1318,6 +1328,8 @@ const KubikonEditor = () => {
console.warn('[collab] init skipped:', e?.message || e); console.warn('[collab] init skipped:', e?.message || e);
} }
}, []); }, []);
// Держим актуальную ссылку на initCollab для вызова из doSave (см. выше).
initCollabRef.current = initCollab;
/** /**
* Team Create: «Пригласить» запросить collab-токен у realtime, собрать * Team Create: «Пригласить» запросить collab-токен у realtime, собрать
@ -1325,10 +1337,25 @@ const KubikonEditor = () => {
*/ */
const handleInvite = useCallback(async () => { const handleInvite = useCallback(async () => {
try { try {
if (!/^\d+$/.test(id)) { alert('Сначала сохрани проект.'); return; } // Реальный ID берём из ref (он обновляется после сохранения нового
// проекта), а НЕ из URL-параметра id при создании нового проекта
// URL остаётся /edit/new даже после успешного сохранения, поэтому
// проверка по id давала вечное «Сначала сохрани» (баг 2026-06-08).
let pid = currentProjectIdRef.current;
// Не сохранён ещё? Попробуем сохранить прямо сейчас, затем пригласить.
if (pid == null) {
try {
await doSave?.();
} catch (_) { /* ignore — проверим pid ниже */ }
pid = currentProjectIdRef.current;
}
if (pid == null || !/^\d+$/.test(String(pid))) {
alert('Сначала сохрани проект — добавь хотя бы один объект на сцену и нажми «Сохранить».');
return;
}
const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || ''; const tokenRaw = localStorage.getItem('Authorization') || localStorage.getItem('jwt') || '';
const base = (REALTIME_HTTP || '').replace(/\/$/, ''); const base = (REALTIME_HTTP || '').replace(/\/$/, '');
const res = await fetch(`${base}/studio-invite/${id}`, { const res = await fetch(`${base}/studio-invite/${pid}`, {
method: 'POST', method: 'POST',
headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' }, headers: { Authorization: tokenRaw, 'Content-Type': 'application/json' },
}); });
@ -1338,7 +1365,7 @@ const KubikonEditor = () => {
return; return;
} }
const { token } = await res.json(); const { token } = await res.json();
const link = `${window.location.origin}/edit/${id}?collab=${token}`; const link = `${window.location.origin}/edit/${pid}?collab=${token}`;
try { try {
await navigator.clipboard.writeText(link); await navigator.clipboard.writeText(link);
collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.'); collabOverlayRef.current?.toast('Ссылка-приглашение скопирована! Отправь другу.');
@ -1349,7 +1376,7 @@ const KubikonEditor = () => {
console.warn('[collab] invite failed', e); console.warn('[collab] invite failed', e);
alert('Не удалось создать приглашение. Realtime недоступен?'); alert('Не удалось создать приглашение. Realtime недоступен?');
} }
}, [id]); }, [doSave]);
// Инициализация Babylon + загрузка проекта (если редактируем существующий) // Инициализация Babylon + загрузка проекта (если редактируем существующий)
useEffect(() => { useEffect(() => {
@ -1365,6 +1392,10 @@ const KubikonEditor = () => {
if (!canvasRef.current) return; if (!canvasRef.current) return;
const scene = new BabylonScene(canvasRef.current); const scene = new BabylonScene(canvasRef.current);
// Студия-редактор: тестовый «Запуск» НЕ должен показывать стартовый
// экран загрузки игры (он нужен только в плеере на rublox.pro, чтобы
// дать время подгрузить ассеты). В студии всё уже в памяти.
scene._editorMode = true;
scene.init(); scene.init();
sceneRef.current = scene; sceneRef.current = scene;

View File

@ -5972,6 +5972,10 @@ export class BabylonScene {
* Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены. * Зовётся из enterPlayMode; держится минимум `duration` сек либо до готовности сцены.
*/ */
showStartupLoadingScreen() { showStartupLoadingScreen() {
// В студии-редакторе тестовый «Запуск» НИКОГДА не показывает стартовый
// экран загрузки — он нужен только в плеере (rublox.pro «Играть»),
// чтобы дать время подгрузить ассеты. В редакторе всё уже в памяти.
if (this._editorMode) return;
const cfg = this._loadingConfig; const cfg = this._loadingConfig;
if (!cfg || cfg.enabled === false) return; if (!cfg || cfg.enabled === false) return;
if (!this.gameRuntime) return; if (!this.gameRuntime) return;

View File

@ -17,7 +17,7 @@ import ModalOverlay from '../editor/ModalOverlay';
import SkinShopOverlay from '../editor/SkinShopOverlay'; import SkinShopOverlay from '../editor/SkinShopOverlay';
import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton'; import KubikonBugReportButton from '../components/KubikonBugReport/KubikonBugReportButton';
import KubikonChatPanel from './KubikonChatPanel'; import KubikonChatPanel from './KubikonChatPanel';
import { useAuth } from '../auth/AuthContext.jsx'; import { useAuth, redirectToLogin } from '../auth/AuthContext.jsx';
import RublocsLogo from '../components/RublocsLogo/RublocsLogo'; import RublocsLogo from '../components/RublocsLogo/RublocsLogo';
import useDeviceType from '../hooks/useDeviceType'; import useDeviceType from '../hooks/useDeviceType';
import KubikonMobileControls from './KubikonMobileControls'; import KubikonMobileControls from './KubikonMobileControls';
@ -96,13 +96,14 @@ const KubikonPlayer = () => {
}; };
}, []); }, []);
// Только зарегистрированные. Если гость / без токена на логин. // Только зарегистрированные. Если гость / без токена на вход минки (SSO)
// с возвратом на текущий URL (своей страницы /login в студии нет).
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
if (!isAuthenticated) { if (!isAuthenticated) {
navigate('/login', { replace: true }); redirectToLogin();
} }
}, [isAuthenticated, authLoading, navigate]); }, [isAuthenticated, authLoading]);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const sceneRef = useRef(null); const sceneRef = useRef(null);