Launch directly into Worldshaper Studio
This commit is contained in:
parent
b4dbd4ee8e
commit
932a638daf
3 changed files with 337 additions and 2 deletions
225
src/WorldshaperLauncher.tsx
Normal file
225
src/WorldshaperLauncher.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
buildWorldshaperStudioUrl,
|
||||||
|
openWorldshaperStudioWindow,
|
||||||
|
} from "./worldshaperStudio/windowing";
|
||||||
|
|
||||||
|
type WorldDefaultPayload = {
|
||||||
|
worldId?: string;
|
||||||
|
world?: {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchState = "resolving" | "opening" | "opened" | "blocked" | "error";
|
||||||
|
|
||||||
|
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
||||||
|
|
||||||
|
let didAttemptInitialLaunch = false;
|
||||||
|
let rememberedLaunchState: LaunchState | "" = "";
|
||||||
|
let rememberedStatus = "";
|
||||||
|
let rememberedError = "";
|
||||||
|
let rememberedWorldId = "";
|
||||||
|
|
||||||
|
async function resolveDefaultWorldId(): Promise<string> {
|
||||||
|
const response = await fetch("/api/world-default");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load default world (${response.status}).`);
|
||||||
|
}
|
||||||
|
const payload = await response.json() as WorldDefaultPayload;
|
||||||
|
const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim();
|
||||||
|
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStudioPopup(worldId: string): boolean {
|
||||||
|
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
||||||
|
return Boolean(popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStudioInCurrentTab(worldId: string): void {
|
||||||
|
window.location.assign(buildWorldshaperStudioUrl(worldId, window, { worldId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorldshaperLauncher() {
|
||||||
|
const [launchState, setLaunchState] = useState<LaunchState>("resolving");
|
||||||
|
const [status, setStatus] = useState("Preparing Worldshaper Studio...");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [worldId, setWorldId] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function attemptInitialLaunch() {
|
||||||
|
if (didAttemptInitialLaunch) {
|
||||||
|
setWorldId(rememberedWorldId);
|
||||||
|
setLaunchState(rememberedLaunchState || "blocked");
|
||||||
|
setStatus(rememberedStatus || "Open Worldshaper Studio.");
|
||||||
|
setError(rememberedError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
didAttemptInitialLaunch = true;
|
||||||
|
setLaunchState("resolving");
|
||||||
|
setError("");
|
||||||
|
setStatus("Preparing Worldshaper Studio...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolvedWorldId = await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorldId(resolvedWorldId);
|
||||||
|
rememberedWorldId = resolvedWorldId;
|
||||||
|
setLaunchState("opening");
|
||||||
|
setStatus(`Opening Worldshaper Studio for ${resolvedWorldId}...`);
|
||||||
|
if (openStudioPopup(resolvedWorldId)) {
|
||||||
|
setLaunchState("opened");
|
||||||
|
setStatus("Worldshaper Studio opened in a separate window.");
|
||||||
|
rememberedLaunchState = "opened";
|
||||||
|
rememberedStatus = "Worldshaper Studio opened in a separate window.";
|
||||||
|
rememberedError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaunchState("blocked");
|
||||||
|
setStatus("Your browser blocked the studio window.");
|
||||||
|
rememberedLaunchState = "blocked";
|
||||||
|
rememberedStatus = "Your browser blocked the studio window.";
|
||||||
|
rememberedError = "";
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio.");
|
||||||
|
setLaunchState("error");
|
||||||
|
setError(nextErrorText);
|
||||||
|
setStatus("Worldshaper Studio unavailable.");
|
||||||
|
rememberedLaunchState = "error";
|
||||||
|
rememberedStatus = "Worldshaper Studio unavailable.";
|
||||||
|
rememberedError = nextErrorText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void attemptInitialLaunch();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (launchState !== "blocked" || !worldId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLaunch() {
|
||||||
|
if (!worldId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaunchState("opening");
|
||||||
|
setStatus(`Opening Worldshaper Studio for ${worldId}...`);
|
||||||
|
if (openStudioPopup(worldId)) {
|
||||||
|
setLaunchState("opened");
|
||||||
|
setStatus("Worldshaper Studio opened in a separate window.");
|
||||||
|
rememberedLaunchState = "opened";
|
||||||
|
rememberedStatus = "Worldshaper Studio opened in a separate window.";
|
||||||
|
rememberedError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaunchState("blocked");
|
||||||
|
setStatus("Your browser blocked the studio window.");
|
||||||
|
rememberedLaunchState = "blocked";
|
||||||
|
rememberedStatus = "Your browser blocked the studio window.";
|
||||||
|
rememberedError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown() {
|
||||||
|
retryLaunch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown() {
|
||||||
|
retryLaunch();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointerdown", handlePointerDown, { once: true });
|
||||||
|
window.addEventListener("keydown", handleKeyDown, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", handlePointerDown);
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [launchState, worldId]);
|
||||||
|
|
||||||
|
async function handleRetry(): Promise<void> {
|
||||||
|
setError("");
|
||||||
|
setLaunchState("resolving");
|
||||||
|
setStatus("Preparing Worldshaper Studio...");
|
||||||
|
try {
|
||||||
|
const resolvedWorldId = worldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||||||
|
setWorldId(resolvedWorldId);
|
||||||
|
rememberedWorldId = resolvedWorldId;
|
||||||
|
setLaunchState("opening");
|
||||||
|
setStatus(`Opening Worldshaper Studio for ${resolvedWorldId}...`);
|
||||||
|
if (openStudioPopup(resolvedWorldId)) {
|
||||||
|
setLaunchState("opened");
|
||||||
|
setStatus("Worldshaper Studio opened in a separate window.");
|
||||||
|
rememberedLaunchState = "opened";
|
||||||
|
rememberedStatus = "Worldshaper Studio opened in a separate window.";
|
||||||
|
rememberedError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLaunchState("blocked");
|
||||||
|
setStatus("Your browser blocked the studio window.");
|
||||||
|
rememberedLaunchState = "blocked";
|
||||||
|
rememberedStatus = "Your browser blocked the studio window.";
|
||||||
|
rememberedError = "";
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio.");
|
||||||
|
setLaunchState("error");
|
||||||
|
setError(nextErrorText);
|
||||||
|
setStatus("Worldshaper Studio unavailable.");
|
||||||
|
rememberedLaunchState = "error";
|
||||||
|
rememberedStatus = "Worldshaper Studio unavailable.";
|
||||||
|
rememberedError = nextErrorText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenHere(): void {
|
||||||
|
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||||
|
openStudioInCurrentTab(nextWorldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = launchState === "resolving" || launchState === "opening";
|
||||||
|
const showFallbackActions = launchState === "blocked" || launchState === "opened" || launchState === "error";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="launcher-shell">
|
||||||
|
<section className="launcher-card">
|
||||||
|
<p className="launcher-eyebrow">New RPG</p>
|
||||||
|
<h1 className="launcher-title">Worldshaper Studio</h1>
|
||||||
|
<p className="launcher-status">{status}</p>
|
||||||
|
{launchState === "blocked" ? (
|
||||||
|
<p className="launcher-hint">
|
||||||
|
Click anywhere or use the button below if your browser asks for permission to open the studio window.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{launchState === "opened" ? (
|
||||||
|
<p className="launcher-hint">
|
||||||
|
The editor is running in its own window. You can leave this tab open as a quick relaunch point, or close it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{error ? <p className="launcher-error">{error}</p> : null}
|
||||||
|
{showFallbackActions ? (
|
||||||
|
<div className="launcher-actions">
|
||||||
|
<button type="button" className="launcher-primary-btn" onClick={() => void handleRetry()} disabled={isBusy}>
|
||||||
|
Open Floating Studio
|
||||||
|
</button>
|
||||||
|
<button type="button" className="launcher-secondary-btn" onClick={handleOpenHere} disabled={isBusy}>
|
||||||
|
Open In This Tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorldshaperLauncher;
|
||||||
110
src/index.css
110
src/index.css
|
|
@ -26,6 +26,102 @@ body {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-shell {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-card {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
padding: 28px 30px;
|
||||||
|
border: 1px solid rgba(120, 170, 230, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(92, 200, 255, 0.12), transparent 38%),
|
||||||
|
linear-gradient(180deg, rgba(24, 31, 44, 0.96) 0%, rgba(18, 24, 35, 0.96) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 60px rgba(0, 0, 0, 0.34),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9fd8ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-status {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #ebf3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-primary-btn,
|
||||||
|
.launcher-secondary-btn {
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-primary-btn {
|
||||||
|
border: 1px solid rgba(110, 255, 173, 0.5);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(33, 146, 86, 0.92) 0%, rgba(24, 108, 123, 0.92) 100%);
|
||||||
|
color: #f3fff8;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(161, 255, 206, 0.14),
|
||||||
|
0 0 18px rgba(52, 211, 153, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-primary-btn:not(:disabled):hover {
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(38, 163, 96, 0.96) 0%, rgba(27, 122, 139, 0.96) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-secondary-btn {
|
||||||
|
border: 1px solid #364154;
|
||||||
|
background: #1d2432;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-secondary-btn:not(:disabled):hover {
|
||||||
|
background: #263149;
|
||||||
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
width: min(960px, 93vw);
|
width: min(960px, 93vw);
|
||||||
margin: 24px auto;
|
margin: 24px auto;
|
||||||
|
|
@ -1107,6 +1203,20 @@ button.danger:not(:disabled):hover {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.launcher-card {
|
||||||
|
padding: 22px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-primary-btn,
|
||||||
|
.launcher-secondary-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
width: 96vw;
|
width: 96vw;
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import WorldshaperLauncher from './WorldshaperLauncher.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<WorldshaperLauncher />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue