2026-06-26 21:24:08 -04:00
|
|
|
import type { CSSProperties } from "react";
|
2026-06-26 20:54:48 -04:00
|
|
|
import { useEffect, useState } from "react";
|
2026-06-26 21:36:41 -04:00
|
|
|
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
|
2026-06-26 20:54:48 -04:00
|
|
|
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";
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
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;
|
2026-06-26 23:35:24 -04:00
|
|
|
sourceSubmissionId?: string;
|
|
|
|
|
title: string;
|
|
|
|
|
status: "pending" | "active";
|
|
|
|
|
category: string;
|
|
|
|
|
tags: string[];
|
|
|
|
|
sourceText: string;
|
|
|
|
|
summary: string;
|
|
|
|
|
implementationNotes: string;
|
2026-06-26 22:55:50 -04:00
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type LauncherRequestsPayload = {
|
|
|
|
|
requests?: LauncherRequest[];
|
|
|
|
|
};
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 23:35:24 -04:00
|
|
|
function formatRequestStatusLabel(status: "pending" | "active"): string {
|
|
|
|
|
return status === "active" ? "Active" : "Pending";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
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/");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
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.");
|
2026-06-26 20:54:48 -04:00
|
|
|
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("");
|
2026-06-26 23:35:24 -04:00
|
|
|
const [requestFilter, setRequestFilter] = useState("all");
|
|
|
|
|
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
2026-06-26 21:36:41 -04:00
|
|
|
void resolveDefaultWorldId()
|
|
|
|
|
.then((resolvedWorldId) => {
|
2026-06-26 20:54:48 -04:00
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setWorldId(resolvedWorldId);
|
2026-06-26 21:36:41 -04:00
|
|
|
})
|
|
|
|
|
.catch(() => {
|
2026-06-26 20:54:48 -04:00
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-26 21:36:41 -04:00
|
|
|
setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
|
|
|
|
});
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
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> {
|
2026-06-26 20:54:48 -04:00
|
|
|
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}...`);
|
2026-06-26 20:54:48 -04:00
|
|
|
try {
|
2026-06-26 21:36:41 -04:00
|
|
|
const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
2026-06-26 20:54:48 -04:00
|
|
|
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.");
|
2026-06-26 20:54:48 -04:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 23:35:24 -04:00
|
|
|
function handleToggleExpandedRequest(requestId: string): void {
|
|
|
|
|
setExpandedRequestIds((current) => (
|
|
|
|
|
current.includes(requestId)
|
|
|
|
|
? current.filter((entry) => entry !== requestId)
|
|
|
|
|
: [...current, requestId]
|
|
|
|
|
));
|
2026-06-26 22:55:50 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
|
2026-06-26 23:35:24 -04:00
|
|
|
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`);
|
2026-06-26 22:55:50 -04:00
|
|
|
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("");
|
2026-06-26 23:35:24 -04:00
|
|
|
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
|
2026-06-26 22:55:50 -04:00
|
|
|
} 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;
|
2026-06-26 23:35:24 -04:00
|
|
|
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length;
|
|
|
|
|
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
|
|
|
|
const requestTags = Array.from(new Set(
|
|
|
|
|
requests
|
|
|
|
|
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
|
|
|
|
.map((entry) => String(entry || "").trim())
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
)).sort((a, b) => a.localeCompare(b));
|
|
|
|
|
const filteredRequests = requests.filter((entry) => {
|
|
|
|
|
if (requestFilter === "status:pending") {
|
|
|
|
|
return entry.status === "pending";
|
|
|
|
|
}
|
|
|
|
|
if (requestFilter === "status:active") {
|
|
|
|
|
return entry.status === "active";
|
|
|
|
|
}
|
|
|
|
|
if (requestFilter.startsWith("tag:")) {
|
|
|
|
|
const tag = requestFilter.slice(4);
|
|
|
|
|
return entry.status === "active" && entry.tags.includes(tag);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2026-06-26 22:55:50 -04:00
|
|
|
const boardHint = activeBoardTab === "news"
|
|
|
|
|
? "Latest announcements"
|
2026-06-26 23:35:24 -04:00
|
|
|
: `${pendingRequestCount} pending, ${activeRequestCount} active`;
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
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>
|
2026-06-26 20:54:48 -04:00
|
|
|
</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">
|
2026-06-26 23:35:24 -04:00
|
|
|
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {pendingRequestCount} pending and {activeRequestCount} active.
|
2026-06-26 22:55:50 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-26 23:35:24 -04:00
|
|
|
<div className="launcher-request-controls">
|
|
|
|
|
<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>
|
|
|
|
|
<label className="launcher-request-filter">
|
|
|
|
|
<span className="launcher-request-filter-label">Filter</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={requestFilter}
|
|
|
|
|
onChange={(event) => setRequestFilter(event.target.value)}
|
|
|
|
|
>
|
|
|
|
|
<option value="all">All Requests</option>
|
|
|
|
|
<option value="status:pending">Pending Requests</option>
|
|
|
|
|
<option value="status:active">Active Requests</option>
|
|
|
|
|
{requestTags.map((tag) => (
|
|
|
|
|
<option key={tag} value={`tag:${tag}`}>{tag}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
2026-06-26 22:55:50 -04:00
|
|
|
</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}
|
2026-06-26 23:35:24 -04:00
|
|
|
{!requestsLoading && filteredRequests.length === 0 ? (
|
2026-06-26 22:55:50 -04:00
|
|
|
<section className="launcher-request-entry">
|
2026-06-26 23:35:24 -04:00
|
|
|
<div className="launcher-request-empty">
|
|
|
|
|
{requests.length === 0
|
|
|
|
|
? "No requests yet. Add the first one to start the board."
|
|
|
|
|
: "No requests match the current filter."}
|
|
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-26 23:35:24 -04:00
|
|
|
{!requestsLoading ? filteredRequests.map((requestEntry) => {
|
2026-06-26 22:55:50 -04:00
|
|
|
const isMutating = requestMutatingId === requestEntry.id;
|
2026-06-26 23:35:24 -04:00
|
|
|
const isExpanded = expandedRequestIds.includes(requestEntry.id);
|
|
|
|
|
const isActiveRequest = requestEntry.status === "active";
|
2026-06-26 22:55:50 -04:00
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
key={requestEntry.id}
|
2026-06-26 23:35:24 -04:00
|
|
|
className={`launcher-request-entry is-${requestEntry.status} ${isExpanded ? "is-expanded" : ""} ${isActiveRequest ? "is-clickable" : ""}`}
|
|
|
|
|
onClick={isActiveRequest ? () => handleToggleExpandedRequest(requestEntry.id) : undefined}
|
2026-06-26 22:55:50 -04:00
|
|
|
>
|
|
|
|
|
<div className="launcher-request-entry-head">
|
2026-06-26 23:35:24 -04:00
|
|
|
<div className="launcher-request-entry-head-main">
|
|
|
|
|
<div className={`launcher-request-status-pill is-${requestEntry.status}`}>
|
|
|
|
|
{formatRequestStatusLabel(requestEntry.status)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-entry-title-block">
|
|
|
|
|
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
|
|
|
|
|
<div className="launcher-request-entry-category">{requestEntry.category}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-request-delete-btn"
|
2026-06-26 23:35:24 -04:00
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
void handleDeleteRequest(requestEntry);
|
|
|
|
|
}}
|
2026-06-26 22:55:50 -04:00
|
|
|
disabled={isMutating}
|
|
|
|
|
aria-label="Delete request"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-26 23:35:24 -04:00
|
|
|
{requestEntry.tags.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-tags">
|
|
|
|
|
{requestEntry.tags.map((tag) => (
|
|
|
|
|
<span key={`${requestEntry.id}-${tag}`} className="launcher-request-tag">{tag}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="launcher-request-entry-text">
|
|
|
|
|
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
|
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="launcher-request-entry-meta">{formatRequestTimestamp(requestEntry.createdAt)}</div>
|
2026-06-26 23:35:24 -04:00
|
|
|
{isActiveRequest && isExpanded ? (
|
|
|
|
|
<div className="launcher-request-expanded">
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">Parsed interpretation</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.summary}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">How we could do that</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.implementationNotes}</p>
|
|
|
|
|
</div>
|
|
|
|
|
{requestEntry.sourceText ? (
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">Original submission</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.sourceText}</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-26 22:55:50 -04:00
|
|
|
</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>
|
2026-06-26 20:54:48 -04:00
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default WorldshaperLauncher;
|