diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx new file mode 100644 index 0000000..de891b2 --- /dev/null +++ b/src/WorldshaperLauncher.tsx @@ -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 { + 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("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 { + 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 ( +
+
+

New RPG

+

Worldshaper Studio

+

{status}

+ {launchState === "blocked" ? ( +

+ Click anywhere or use the button below if your browser asks for permission to open the studio window. +

+ ) : null} + {launchState === "opened" ? ( +

+ The editor is running in its own window. You can leave this tab open as a quick relaunch point, or close it. +

+ ) : null} + {error ?

{error}

: null} + {showFallbackActions ? ( +
+ + +
+ ) : null} +
+
+ ); +} + +export default WorldshaperLauncher; diff --git a/src/index.css b/src/index.css index 925eb2d..6b7f337 100644 --- a/src/index.css +++ b/src/index.css @@ -26,6 +26,102 @@ body { 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 { width: min(960px, 93vw); margin: 24px auto; @@ -1107,6 +1203,20 @@ button.danger:not(:disabled):hover { } @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 { width: 96vw; margin: 1rem auto; diff --git a/src/main.tsx b/src/main.tsx index bef5202..6a9aad3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import WorldshaperLauncher from './WorldshaperLauncher.tsx' createRoot(document.getElementById('root')!).render( - + , )