Add launcher request board
This commit is contained in:
parent
6c6b1295a3
commit
4eef0d4850
3 changed files with 628 additions and 33 deletions
|
|
@ -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<string> {
|
|||
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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<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;
|
||||
|
|
@ -68,6 +117,37 @@ function WorldshaperLauncher() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleLaunch(): Promise<void> {
|
||||
setError("");
|
||||
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||
|
|
@ -92,7 +172,76 @@ function WorldshaperLauncher() {
|
|||
}
|
||||
}
|
||||
|
||||
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("");
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<main
|
||||
|
|
@ -141,43 +290,171 @@ function WorldshaperLauncher() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="launcher-changelog-window" aria-labelledby="launcher-whats-new-title">
|
||||
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
|
||||
<div className="launcher-changelog-titlebar">
|
||||
<div className="launcher-changelog-title">What's New</div>
|
||||
<div className="launcher-changelog-hint">Current release highlights</div>
|
||||
<div className="launcher-changelog-title" id="launcher-board-title">Worldshaper Board</div>
|
||||
<div className="launcher-changelog-hint">{boardHint}</div>
|
||||
</div>
|
||||
<div className="launcher-changelog-body">
|
||||
<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 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>
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue