Process launcher requests into active items
This commit is contained in:
parent
4eef0d4850
commit
9f9b13aa01
3 changed files with 546 additions and 81 deletions
|
|
@ -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}
|
||||
|
|
|
|||
173
src/index.css
173
src/index.css
|
|
@ -255,11 +255,50 @@ body {
|
|||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.launcher-request-controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(16, 29, 56, 0.96) 0%, rgba(12, 22, 43, 0.96) 100%);
|
||||
box-shadow:
|
||||
0 10px 24px rgba(3, 8, 18, 0.26),
|
||||
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
|
||||
}
|
||||
|
||||
.launcher-request-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.launcher-request-filter {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.launcher-request-filter-label {
|
||||
color: #9fb8e5;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.launcher-request-filter-select {
|
||||
min-height: 40px;
|
||||
border-color: #365782;
|
||||
background: rgba(8, 16, 31, 0.88);
|
||||
color: #eef6ff;
|
||||
}
|
||||
|
||||
.launcher-request-composer {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -313,9 +352,23 @@ body {
|
|||
box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14);
|
||||
}
|
||||
|
||||
.launcher-request-entry.is-done {
|
||||
border-color: #2f7e60;
|
||||
background: rgba(15, 40, 33, 0.84);
|
||||
.launcher-request-entry.is-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-request-entry.is-active {
|
||||
border-color: #4f79af;
|
||||
}
|
||||
|
||||
.launcher-request-entry.is-pending {
|
||||
border-color: #6d5f36;
|
||||
background: rgba(45, 34, 15, 0.64);
|
||||
}
|
||||
|
||||
.launcher-request-entry.is-expanded {
|
||||
box-shadow:
|
||||
0 14px 28px rgba(3, 8, 18, 0.28),
|
||||
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
|
||||
}
|
||||
|
||||
.launcher-request-entry-head {
|
||||
|
|
@ -325,19 +378,53 @@ body {
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.launcher-request-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #eef6ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
.launcher-request-entry-head-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launcher-request-check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.launcher-request-status-pill {
|
||||
padding: 4px 9px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.launcher-request-status-pill.is-active {
|
||||
border-color: #2f7e60;
|
||||
background: rgba(19, 73, 50, 0.88);
|
||||
color: #b7f0d5;
|
||||
}
|
||||
|
||||
.launcher-request-status-pill.is-pending {
|
||||
border-color: #9c8140;
|
||||
background: rgba(93, 70, 19, 0.78);
|
||||
color: #ffe7a9;
|
||||
}
|
||||
|
||||
.launcher-request-entry-title-block {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.launcher-request-entry-title {
|
||||
margin: 0;
|
||||
color: #eef6ff;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.launcher-request-entry-category {
|
||||
color: #9fb8e5;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.launcher-request-delete-btn {
|
||||
|
|
@ -354,6 +441,22 @@ body {
|
|||
background: #672536;
|
||||
}
|
||||
|
||||
.launcher-request-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.launcher-request-tag {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 999px;
|
||||
background: rgba(25, 48, 87, 0.72);
|
||||
color: #d7e7ff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.launcher-request-entry-text {
|
||||
color: #d7e7ff;
|
||||
font-size: 13px;
|
||||
|
|
@ -361,11 +464,6 @@ body {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.launcher-request-entry.is-done .launcher-request-entry-text {
|
||||
color: #9ec7b4;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.launcher-request-entry-meta,
|
||||
.launcher-request-empty {
|
||||
color: #9fb8e5;
|
||||
|
|
@ -373,6 +471,37 @@ body {
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.launcher-request-expanded {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.launcher-request-expanded-block {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 10px;
|
||||
background: rgba(8, 16, 31, 0.74);
|
||||
}
|
||||
|
||||
.launcher-request-expanded-label {
|
||||
color: #9fd8ff;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.launcher-request-expanded-copy {
|
||||
margin: 0;
|
||||
color: #d7e7ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.launcher-request-error {
|
||||
margin: 0;
|
||||
color: #ff9aa4;
|
||||
|
|
@ -1567,6 +1696,14 @@ button.danger:not(:disabled):hover {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.launcher-request-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.launcher-request-filter {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launcher-primary-btn,
|
||||
.launcher-secondary-btn {
|
||||
width: 100%;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue