Worldshaper/src/WorldshaperLauncher.tsx

467 lines
19 KiB
TypeScript
Raw Normal View History

2026-06-26 21:24:08 -04:00
import type { CSSProperties } from "react";
import { useEffect, useState } from "react";
2026-06-26 21:36:41 -04:00
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
import {
2026-06-26 21:36:41 -04:00
CHANGELOG_SECTIONS,
CHANGELOG_SPLASH_FOOTNOTE,
CHANGELOG_SPLASH_KICKER,
CHANGELOG_SPLASH_TITLE,
CHANGELOG_SPLASH_VERSION,
} from "./worldshaperStudio/changelogData";
import type { ChangelogItem } from "./worldshaperStudio/changelogData";
2026-06-26 21:24:08 -04:00
import launcherBackground from "../background.png";
type WorldDefaultPayload = {
worldId?: string;
world?: {
id?: string;
};
};
2026-06-26 21:36:41 -04:00
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
2026-06-26 22:55:50 -04:00
type BoardTab = "news" | "requests";
type LauncherRequest = {
id: string;
text: string;
done: boolean;
createdAt: string;
updatedAt: string;
};
type LauncherRequestsPayload = {
requests?: LauncherRequest[];
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
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;
}
2026-06-26 22:55:50 -04:00
async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
let detail = `Request failed (${response.status}).`;
try {
const payload = await response.json() as { error?: string };
detail = String(payload?.error || detail);
} catch {
// Ignore JSON parse failures and fall back to status text.
}
throw new Error(detail);
}
return response.json() as Promise<T>;
}
function formatRequestTimestamp(value: string): string {
const parsed = Date.parse(String(value || ""));
if (!Number.isFinite(parsed)) {
return "Saved recently";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(parsed);
}
function openStudioPopup(worldId: string): boolean {
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
return Boolean(popup);
}
2026-06-26 21:12:44 -04:00
function openRepo(): void {
window.location.assign("https://repo.andraxion.net/");
}
function WorldshaperLauncher() {
2026-06-26 21:36:41 -04:00
const [launchState, setLaunchState] = useState<LaunchState>("ready");
const [status, setStatus] = useState("Launch Worldshaper Studio in its floating window.");
const [error, setError] = useState("");
2026-06-26 21:36:41 -04:00
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
2026-06-26 22:55:50 -04:00
const [activeBoardTab, setActiveBoardTab] = useState<BoardTab>("news");
const [requests, setRequests] = useState<LauncherRequest[]>([]);
const [requestsLoading, setRequestsLoading] = useState(true);
const [requestsError, setRequestsError] = useState("");
const [requestDraftOpen, setRequestDraftOpen] = useState(false);
const [requestDraft, setRequestDraft] = useState("");
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [requestMutatingId, setRequestMutatingId] = useState("");
useEffect(() => {
let cancelled = false;
2026-06-26 21:36:41 -04:00
void resolveDefaultWorldId()
.then((resolvedWorldId) => {
if (cancelled) {
return;
}
setWorldId(resolvedWorldId);
2026-06-26 21:36:41 -04:00
})
.catch(() => {
if (cancelled) {
return;
}
2026-06-26 21:36:41 -04:00
setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
});
return () => {
cancelled = true;
};
}, []);
2026-06-26 22:55:50 -04:00
useEffect(() => {
let cancelled = false;
async function loadRequests() {
setRequestsLoading(true);
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
if (cancelled) {
return;
}
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
} catch (nextError: unknown) {
if (cancelled) {
return;
}
setRequestsError(String(nextError || "Failed to load requests."));
} finally {
if (!cancelled) {
setRequestsLoading(false);
}
}
}
void loadRequests();
return () => {
cancelled = true;
};
}, []);
2026-06-26 21:36:41 -04:00
async function handleLaunch(): Promise<void> {
setError("");
2026-06-26 21:36:41 -04:00
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
setLaunchState("opening");
setStatus(`Opening Worldshaper Studio for ${nextWorldId}...`);
try {
2026-06-26 21:36:41 -04:00
const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
setWorldId(resolvedWorldId);
setStatus(`Opening Worldshaper Studio for ${resolvedWorldId}...`);
if (openStudioPopup(resolvedWorldId)) {
setLaunchState("opened");
setStatus("Worldshaper Studio opened in a separate window.");
return;
}
setLaunchState("blocked");
2026-06-26 21:36:41 -04:00
setStatus("Your browser blocked the studio window. Use the launch button again after allowing popups.");
} catch (nextError: unknown) {
const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio.");
setLaunchState("error");
setError(nextErrorText);
setStatus("Worldshaper Studio unavailable.");
}
}
2026-06-26 22:55:50 -04:00
async function handleAddRequest(): Promise<void> {
const text = requestDraft.trim();
if (!text) {
setRequestsError("Write a request before saving it.");
return;
}
setRequestSubmitting(true);
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestDraft("");
setRequestDraftOpen(false);
setRequestsError("");
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to save request."));
} finally {
setRequestSubmitting(false);
}
}
async function handleToggleRequest(requestEntry: LauncherRequest): Promise<void> {
setRequestMutatingId(requestEntry.id);
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ done: !requestEntry.done }),
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to update request."));
} finally {
setRequestMutatingId("");
}
}
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.text}`);
if (!confirmed) {
return;
}
setRequestMutatingId(requestEntry.id);
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
method: "DELETE",
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to delete request."));
} finally {
setRequestMutatingId("");
}
}
2026-06-26 21:36:41 -04:00
const isBusy = launchState === "opening";
2026-06-26 22:55:50 -04:00
const requestCount = requests.length;
const pendingRequestCount = requests.filter((entry) => entry.done !== true).length;
const boardHint = activeBoardTab === "news"
? "Latest announcements"
: `${pendingRequestCount} open request${pendingRequestCount === 1 ? "" : "s"}`;
return (
2026-06-26 21:24:08 -04:00
<main
className="launcher-shell"
style={{ "--launcher-background-image": `url(${launcherBackground})` } as CSSProperties}
>
2026-06-26 21:36:41 -04:00
<div className="launcher-stack">
2026-06-26 22:01:43 -04:00
<section className="launcher-hero-window" aria-labelledby="launcher-studio-title">
<div className="launcher-changelog-titlebar">
<div className="launcher-changelog-title">Worldshaper Studio</div>
<div className="launcher-changelog-hint">Floating editor launch</div>
</div>
<div className="launcher-hero-body">
<div className="launcher-hero-card">
<div className="changelog-splash-hero launcher-hero-banner">
<p className="launcher-eyebrow">New RPG</p>
<h1 className="launcher-title" id="launcher-studio-title">Worldshaper Studio</h1>
<p className="launcher-status">{status}</p>
</div>
<div className="launcher-hero-support">
{launchState === "blocked" ? (
<p className="launcher-hint">
Allow the popup, then use the studio button again to launch the floating editor window.
</p>
) : null}
{launchState === "opened" ? (
<p className="launcher-hint">
The studio is open in its own slim window. This page stays behind as your release board and relaunch point.
</p>
) : null}
{launchState === "ready" ? (
<p className="launcher-hint">
The editor is designed to live in its own floating window, so the launcher keeps the first step clean.
</p>
) : null}
{error ? <p className="launcher-error">{error}</p> : null}
<div className="launcher-actions">
<button type="button" className="launcher-primary-btn" onClick={() => void handleLaunch()} disabled={isBusy}>
Open Floating Studio
</button>
<button type="button" className="launcher-secondary-btn" onClick={openRepo} disabled={isBusy}>
Open Repo
</button>
</div>
</div>
</div>
</div>
2026-06-26 21:36:41 -04:00
</section>
2026-06-26 22:55:50 -04:00
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
2026-06-26 21:36:41 -04:00
<div className="launcher-changelog-titlebar">
2026-06-26 22:55:50 -04:00
<div className="launcher-changelog-title" id="launcher-board-title">Worldshaper Board</div>
<div className="launcher-changelog-hint">{boardHint}</div>
2026-06-26 21:19:33 -04:00
</div>
2026-06-26 21:36:41 -04:00
<div className="launcher-changelog-body">
2026-06-26 22:55:50 -04:00
<div className="launcher-board-content">
<div className="launcher-board-tabs">
<button
type="button"
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "news" ? "is-active" : ""}`}
onClick={() => setActiveBoardTab("news")}
>
News
</button>
<button
type="button"
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "requests" ? "is-active" : ""}`}
onClick={() => setActiveBoardTab("requests")}
>
Requests
</button>
2026-06-26 21:36:41 -04:00
</div>
2026-06-26 22:55:50 -04:00
{activeBoardTab === "news" ? (
<div className="changelog-splash-card">
<div className="changelog-splash-hero">
<div className="changelog-splash-kicker">{CHANGELOG_SPLASH_KICKER}</div>
<div className="changelog-splash-title" id="launcher-whats-new-title">{CHANGELOG_SPLASH_TITLE}</div>
<div className="changelog-splash-meta">Release {CHANGELOG_SPLASH_VERSION}</div>
</div>
<div className="changelog-splash-list">
{CHANGELOG_SECTIONS.map((section) => (
<section key={section.title} className="changelog-splash-section">
<h3 className="changelog-splash-section-title">{section.title}</h3>
<ul className="changelog-splash-bullets">
{section.items.map((item, index) => {
const key = `${section.title}-${index}`;
const normalizedItem: ChangelogItem = item;
if (typeof normalizedItem === "string") {
return <li key={key}>{normalizedItem}</li>;
}
return (
<li key={key}>
<div>{normalizedItem.text}</div>
{normalizedItem.note ? <div className="changelog-splash-bullet-note">{normalizedItem.note}</div> : null}
</li>
);
})}
</ul>
</section>
))}
</div>
<div className="changelog-splash-footer">
<div className="changelog-splash-footnote">{CHANGELOG_SPLASH_FOOTNOTE}</div>
</div>
</div>
) : (
<div className="changelog-splash-card">
<div className="changelog-splash-hero">
<div className="changelog-splash-kicker">Shared Request Board</div>
<div className="changelog-splash-title" id="launcher-requests-title">Requests</div>
<div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"} with {pendingRequestCount} still open.
</div>
</div>
<div className="launcher-request-toolbar">
<button
type="button"
className="launcher-primary-btn"
onClick={() => setRequestDraftOpen((value) => !value)}
disabled={requestSubmitting}
>
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
</button>
</div>
{requestDraftOpen ? (
<section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
What should be added or improved?
</label>
<textarea
id="launcher-request-draft"
className="launcher-request-textarea"
value={requestDraft}
onChange={(event) => setRequestDraft(event.target.value)}
placeholder="Type a request for the board..."
maxLength={1000}
/>
<div className="launcher-request-composer-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleAddRequest()}
disabled={requestSubmitting}
>
{requestSubmitting ? "Saving Request..." : "Save Request"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => {
setRequestDraft("");
setRequestDraftOpen(false);
}}
disabled={requestSubmitting}
>
Cancel
</button>
</div>
</section>
) : null}
<div className="launcher-request-list">
{requestsLoading ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">Loading saved requests...</div>
</section>
) : null}
{!requestsLoading && requests.length === 0 ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">No requests yet. Add the first one to start the board.</div>
</section>
) : null}
{!requestsLoading ? requests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
return (
<section
key={requestEntry.id}
className={`launcher-request-entry ${requestEntry.done ? "is-done" : ""}`}
>
<div className="launcher-request-entry-head">
<label className="launcher-request-check">
<input
type="checkbox"
checked={requestEntry.done}
onChange={() => void handleToggleRequest(requestEntry)}
disabled={isMutating}
/>
<span>{requestEntry.done ? "Complete" : "Open"}</span>
</label>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
disabled={isMutating}
aria-label="Delete request"
>
X
</button>
</div>
<div className="launcher-request-entry-text">{requestEntry.text}</div>
<div className="launcher-request-entry-meta">{formatRequestTimestamp(requestEntry.createdAt)}</div>
</section>
);
}) : null}
</div>
<div className="changelog-splash-footer">
<div className="changelog-splash-footnote">
Requests are saved and shared from this launcher. Use the checkbox to mark progress and the X to remove an entry.
</div>
</div>
</div>
)}
{requestsError ? (
<p className="launcher-request-error">{requestsError}</p>
) : null}
2026-06-26 21:36:41 -04:00
</div>
2026-06-26 21:19:33 -04:00
</div>
</section>
2026-06-26 21:36:41 -04:00
</div>
</main>
);
}
export default WorldshaperLauncher;