From 4eef0d4850c2cfd4826d08f0db179340373d03bc Mon Sep 17 00:00:00 2001 From: Andraxion Date: Fri, 26 Jun 2026 22:55:50 -0400 Subject: [PATCH] Add launcher request board --- server.js | 166 +++++++++++++++++ src/WorldshaperLauncher.tsx | 343 ++++++++++++++++++++++++++++++++---- src/index.css | 152 ++++++++++++++++ 3 files changed, 628 insertions(+), 33 deletions(-) diff --git a/server.js b/server.js index 6c488f4..25d5240 100644 --- a/server.js +++ b/server.js @@ -38,6 +38,7 @@ const dataRoot = path.resolve(__dirname, "data"); const catalogMetaPath = path.join(dataRoot, "catalog_meta.json"); const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json"); const editorSettingsPath = path.join(dataRoot, "editor_settings.json"); +const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json"); const imagesCatalogPath = path.join(contentRoot, "images.json"); const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json"); const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json"); @@ -218,6 +219,57 @@ function readEditorSettings() { return normalizeEditorSettings(readJsonSafe(editorSettingsPath, createDefaultEditorSettings())); } +function createLauncherRequestId() { + return `request_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +function normalizeLauncherRequestEntry(entry, index = 0) { + const source = entry && typeof entry === "object" && !Array.isArray(entry) + ? entry + : null; + if (!source) { + return null; + } + const text = String(source.text || "").trim(); + if (!text) { + return null; + } + const createdAt = String(source.createdAt || "").trim() || new Date().toISOString(); + const updatedAt = String(source.updatedAt || "").trim() || createdAt; + const fallbackId = `request_${index + 1}`; + return { + id: String(source.id || fallbackId).trim() || fallbackId, + text, + done: source.done === true, + createdAt, + updatedAt, + }; +} + +function readLauncherRequestsPayload() { + const fallback = { schemaVersion: 1, requests: [] }; + const payload = readJsonSafe(launcherRequestsPath, fallback); + const requests = Array.isArray(payload?.requests) + ? payload.requests + .map((entry, index) => normalizeLauncherRequestEntry(entry, index)) + .filter(Boolean) + : []; + return { + schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, + requests, + }; +} + +function writeLauncherRequestsPayload(payload) { + const requests = Array.isArray(payload?.requests) ? payload.requests : []; + writeJsonAtomic(launcherRequestsPath, { + schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, + requests: requests + .map((entry, index) => normalizeLauncherRequestEntry(entry, index)) + .filter(Boolean), + }); +} + function normalizeBackgroundTileId(value, idToSymbol = null) { const normalizedId = String(value || "").trim(); if (!normalizedId) { @@ -2043,6 +2095,120 @@ app.get("/api/debug/recent-saves", (_req, res) => { }); }); +app.get("/api/launcher-requests", (_req, res) => { + try { + res.json(readLauncherRequestsPayload()); + } catch (err) { + res.status(500).json({ error: `Failed to read launcher requests: ${String(err)}` }); + } +}); + +app.post("/api/launcher-requests", (req, res) => { + try { + const text = String(req.body?.text || "").trim(); + if (!text) { + res.status(400).json({ error: "Request text is required." }); + return; + } + if (text.length > 1000) { + res.status(400).json({ error: "Request text must be 1000 characters or fewer." }); + return; + } + const payload = readLauncherRequestsPayload(); + const now = new Date().toISOString(); + const requestEntry = normalizeLauncherRequestEntry({ + id: createLauncherRequestId(), + text, + done: false, + createdAt: now, + updatedAt: now, + }, payload.requests.length); + const nextPayload = { + schemaVersion: payload.schemaVersion, + requests: [...payload.requests, requestEntry], + }; + writeLauncherRequestsPayload(nextPayload); + recordSaveEvent({ + type: "launcher-request-add", + requestId: requestEntry.id, + textPreview: requestEntry.text.slice(0, 80), + }); + res.status(201).json({ + ok: true, + request: requestEntry, + requests: nextPayload.requests, + }); + } catch (err) { + res.status(500).json({ error: `Failed to save launcher request: ${String(err)}` }); + } +}); + +app.patch("/api/launcher-requests/:requestId", (req, res) => { + const requestId = String(req.params.requestId || "").trim(); + try { + const payload = readLauncherRequestsPayload(); + const index = payload.requests.findIndex((entry) => entry.id === requestId); + if (index < 0) { + res.status(404).json({ error: "Request not found." }); + return; + } + const existing = payload.requests[index]; + const nextDone = req.body?.done === true; + const updated = normalizeLauncherRequestEntry({ + ...existing, + done: nextDone, + updatedAt: new Date().toISOString(), + }, index); + const nextRequests = [...payload.requests]; + nextRequests[index] = updated; + writeLauncherRequestsPayload({ + schemaVersion: payload.schemaVersion, + requests: nextRequests, + }); + recordSaveEvent({ + type: "launcher-request-update", + requestId, + done: updated.done, + }); + res.json({ + ok: true, + request: updated, + requests: nextRequests, + }); + } catch (err) { + res.status(500).json({ error: `Failed to update launcher request: ${String(err)}` }); + } +}); + +app.delete("/api/launcher-requests/:requestId", (req, res) => { + const requestId = String(req.params.requestId || "").trim(); + try { + const payload = readLauncherRequestsPayload(); + const existing = payload.requests.find((entry) => entry.id === requestId); + if (!existing) { + res.status(404).json({ error: "Request not found." }); + return; + } + const nextRequests = payload.requests.filter((entry) => entry.id !== requestId); + writeLauncherRequestsPayload({ + schemaVersion: payload.schemaVersion, + requests: nextRequests, + }); + recordSaveEvent({ + type: "launcher-request-delete", + requestId, + textPreview: existing.text.slice(0, 80), + }); + res.json({ + ok: true, + deletedRequestId: requestId, + requests: nextRequests, + }); + } catch (err) { + res.status(500).json({ error: `Failed to delete launcher request: ${String(err)}` }); + } +}); + app.get("/api/world-default", (_req, res) => { try { const indexPayload = readWorldIndexPayload(); diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index c0f446b..f99c961 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -19,6 +19,19 @@ type WorldDefaultPayload = { }; type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error"; +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"; @@ -32,6 +45,34 @@ async function resolveDefaultWorldId(): Promise { return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; } +async function fetchJsonOrThrow(input: RequestInfo | URL, init?: RequestInit): Promise { + 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; +} + +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); @@ -46,6 +87,14 @@ function WorldshaperLauncher() { const [status, setStatus] = useState("Launch Worldshaper Studio in its floating window."); const [error, setError] = useState(""); const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK); + const [activeBoardTab, setActiveBoardTab] = useState("news"); + const [requests, setRequests] = useState([]); + 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; @@ -68,6 +117,37 @@ function WorldshaperLauncher() { }; }, []); + useEffect(() => { + let cancelled = false; + + async function loadRequests() { + setRequestsLoading(true); + try { + const payload = await fetchJsonOrThrow("/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; + }; + }, []); + async function handleLaunch(): Promise { setError(""); const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; @@ -92,7 +172,76 @@ function WorldshaperLauncher() { } } + async function handleAddRequest(): Promise { + const text = requestDraft.trim(); + if (!text) { + setRequestsError("Write a request before saving it."); + return; + } + setRequestSubmitting(true); + try { + const payload = await fetchJsonOrThrow("/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 { + setRequestMutatingId(requestEntry.id); + try { + const payload = await fetchJsonOrThrow(`/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 { + const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.text}`); + if (!confirmed) { + return; + } + setRequestMutatingId(requestEntry.id); + try { + const payload = await fetchJsonOrThrow(`/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(""); + } + } + const isBusy = launchState === "opening"; + 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 (
-
+
-
What's New
-
Current release highlights
+
Worldshaper Board
+
{boardHint}
-
-
-
{CHANGELOG_SPLASH_KICKER}
-
{CHANGELOG_SPLASH_TITLE}
-
Release {CHANGELOG_SPLASH_VERSION}
-
-
- {CHANGELOG_SECTIONS.map((section) => ( -
-

{section.title}

-
    - {section.items.map((item, index) => { - const key = `${section.title}-${index}`; - const normalizedItem: ChangelogItem = item; - if (typeof normalizedItem === "string") { - return
  • {normalizedItem}
  • ; - } - return ( -
  • -
    {normalizedItem.text}
    - {normalizedItem.note ?
    {normalizedItem.note}
    : null} -
  • - ); - })} -
-
- ))} -
-
-
{CHANGELOG_SPLASH_FOOTNOTE}
+
+
+ +
+ {activeBoardTab === "news" ? ( +
+
+
{CHANGELOG_SPLASH_KICKER}
+
{CHANGELOG_SPLASH_TITLE}
+
Release {CHANGELOG_SPLASH_VERSION}
+
+
+ {CHANGELOG_SECTIONS.map((section) => ( +
+

{section.title}

+
    + {section.items.map((item, index) => { + const key = `${section.title}-${index}`; + const normalizedItem: ChangelogItem = item; + if (typeof normalizedItem === "string") { + return
  • {normalizedItem}
  • ; + } + return ( +
  • +
    {normalizedItem.text}
    + {normalizedItem.note ?
    {normalizedItem.note}
    : null} +
  • + ); + })} +
+
+ ))} +
+
+
{CHANGELOG_SPLASH_FOOTNOTE}
+
+
+ ) : ( +
+
+
Shared Request Board
+
Requests
+
+ {requestCount} saved request{requestCount === 1 ? "" : "s"} with {pendingRequestCount} still open. +
+
+
+ +
+ {requestDraftOpen ? ( +
+ +