Process launcher requests into active items

This commit is contained in:
Andraxion 2026-06-26 23:35:24 -04:00
parent 4eef0d4850
commit 9f9b13aa01
3 changed files with 546 additions and 81 deletions

View file

@ -23,8 +23,14 @@ type BoardTab = "news" | "requests";
type LauncherRequest = {
id: string;
text: string;
done: boolean;
sourceSubmissionId?: string;
title: string;
status: "pending" | "active";
category: string;
tags: string[];
sourceText: string;
summary: string;
implementationNotes: string;
createdAt: string;
updatedAt: string;
};
@ -73,6 +79,10 @@ function formatRequestTimestamp(value: string): string {
}).format(parsed);
}
function formatRequestStatusLabel(status: "pending" | "active"): string {
return status === "active" ? "Active" : "Pending";
}
function openStudioPopup(worldId: string): boolean {
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
return Boolean(popup);
@ -95,6 +105,8 @@ function WorldshaperLauncher() {
const [requestDraft, setRequestDraft] = useState("");
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [requestMutatingId, setRequestMutatingId] = useState("");
const [requestFilter, setRequestFilter] = useState("all");
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
@ -198,27 +210,16 @@ function WorldshaperLauncher() {
}
}
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("");
}
function handleToggleExpandedRequest(requestId: string): void {
setExpandedRequestIds((current) => (
current.includes(requestId)
? current.filter((entry) => entry !== requestId)
: [...current, requestId]
));
}
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.text}`);
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`);
if (!confirmed) {
return;
}
@ -229,6 +230,7 @@ function WorldshaperLauncher() {
});
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError("");
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
} catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to delete request."));
} finally {
@ -238,10 +240,30 @@ function WorldshaperLauncher() {
const isBusy = launchState === "opening";
const requestCount = requests.length;
const pendingRequestCount = requests.filter((entry) => entry.done !== true).length;
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;
});
const boardHint = activeBoardTab === "news"
? "Latest announcements"
: `${pendingRequestCount} open request${pendingRequestCount === 1 ? "" : "s"}`;
: `${pendingRequestCount} pending, ${activeRequestCount} active`;
return (
<main
@ -352,18 +374,35 @@ function WorldshaperLauncher() {
<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.
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {pendingRequestCount} pending and {activeRequestCount} active.
</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 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>
</div>
{requestDraftOpen ? (
<section className="launcher-request-composer">
@ -407,40 +446,77 @@ function WorldshaperLauncher() {
<div className="launcher-request-empty">Loading saved requests...</div>
</section>
) : null}
{!requestsLoading && requests.length === 0 ? (
{!requestsLoading && filteredRequests.length === 0 ? (
<section className="launcher-request-entry">
<div className="launcher-request-empty">No requests yet. Add the first one to start the board.</div>
<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>
</section>
) : null}
{!requestsLoading ? requests.map((requestEntry) => {
{!requestsLoading ? filteredRequests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const isExpanded = expandedRequestIds.includes(requestEntry.id);
const isActiveRequest = requestEntry.status === "active";
return (
<section
key={requestEntry.id}
className={`launcher-request-entry ${requestEntry.done ? "is-done" : ""}`}
className={`launcher-request-entry is-${requestEntry.status} ${isExpanded ? "is-expanded" : ""} ${isActiveRequest ? "is-clickable" : ""}`}
onClick={isActiveRequest ? () => handleToggleExpandedRequest(requestEntry.id) : undefined}
>
<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>
<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>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
onClick={(event) => {
event.stopPropagation();
void handleDeleteRequest(requestEntry);
}}
disabled={isMutating}
aria-label="Delete request"
>
X
</button>
</div>
<div className="launcher-request-entry-text">{requestEntry.text}</div>
{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>
<div className="launcher-request-entry-meta">{formatRequestTimestamp(requestEntry.createdAt)}</div>
{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}
</section>
);
}) : null}