Add request board admin panel

This commit is contained in:
Andraxion 2026-06-27 00:38:21 -04:00
parent ca054581a4
commit 0c07ce073d
3 changed files with 695 additions and 49 deletions

View file

@ -682,6 +682,13 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
requestAnalysisRunState.child = child; requestAnalysisRunState.child = child;
recordSaveEvent({
type: "launcher-request-analysis-launch",
provider,
reason,
queuedPendingCount,
pid: child.pid,
});
child.stdout?.on("data", (chunk) => { child.stdout?.on("data", (chunk) => {
const text = String(chunk || "").trim(); const text = String(chunk || "").trim();
if (text) { if (text) {
@ -697,6 +704,14 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
requestAnalysisRunState.child = null; requestAnalysisRunState.child = null;
console.log(`[request-analysis] worker finished code=${String(code)} signal=${String(signal || "")} reason=${reason}`); console.log(`[request-analysis] worker finished code=${String(code)} signal=${String(signal || "")} reason=${reason}`);
recordSaveEvent({
type: "launcher-request-analysis-finish",
provider,
reason,
queuedPendingCount: getQueuedPendingLauncherRequestCount(),
code: Number.isFinite(Number(code)) ? Number(code) : null,
signal: signal || "",
});
if (getQueuedPendingLauncherRequestCount() > 0) { if (getQueuedPendingLauncherRequestCount() > 0) {
scheduleQueuedRequestAnalysis("drain-pending-requests", 1200); scheduleQueuedRequestAnalysis("drain-pending-requests", 1200);
} }
@ -704,6 +719,12 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
child.on("error", (error) => { child.on("error", (error) => {
requestAnalysisRunState.child = null; requestAnalysisRunState.child = null;
console.error(`[request-analysis] worker launch failed: ${String(error)}`); console.error(`[request-analysis] worker launch failed: ${String(error)}`);
recordSaveEvent({
type: "launcher-request-analysis-launch-error",
provider,
reason,
error: String(error),
});
}); });
console.log(`[request-analysis] launched provider=${provider} queuedPending=${queuedPendingCount} reason=${reason}`); console.log(`[request-analysis] launched provider=${provider} queuedPending=${queuedPendingCount} reason=${reason}`);
return { return {

View file

@ -31,6 +31,31 @@ type LauncherRequest = {
sourceText: string; sourceText: string;
summary: string; summary: string;
implementationNotes: string; implementationNotes: string;
analysis?: {
state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error";
confidence?: number | null;
model?: string;
createdAt?: string;
updatedAt?: string;
error?: string;
submissionId?: string;
sourceTextSnapshot?: string;
itemCount?: number;
items?: Array<{
title?: string;
primaryCategory?: string;
tags?: string[];
statusRecommendation?: string;
parsedInterpretation?: string;
implementationApproach?: string;
affectedSystems?: string[];
affectedFiles?: string[];
problemType?: string;
rawExcerpt?: string;
confidence?: number | null;
notes?: string;
}>;
};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@ -39,6 +64,37 @@ type LauncherRequestsPayload = {
requests?: LauncherRequest[]; requests?: LauncherRequest[];
}; };
type RecentSaveEvent = {
at?: string;
type?: string;
requestId?: string;
textPreview?: string;
status?: string;
category?: string;
itemCount?: number;
model?: string;
reason?: string;
provider?: string;
pid?: number;
code?: number | null;
signal?: string;
error?: string;
};
type RecentSaveEventsPayload = {
saves?: RecentSaveEvent[];
};
type ProcessPendingPayload = {
ok?: boolean;
launched?: boolean;
reason?: string;
autorunEnabled?: boolean;
configured?: boolean;
queuedPendingCount?: number;
pid?: number;
};
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
async function resolveDefaultWorldId(): Promise<string> { async function resolveDefaultWorldId(): Promise<string> {
@ -83,6 +139,123 @@ function formatRequestStatusLabel(status: "pending" | "active"): string {
return status === "active" ? "Active" : "Pending"; return status === "active" ? "Active" : "Pending";
} }
function normalizeAnalysisState(value: string | undefined): string {
return String(value || "").trim().toLowerCase();
}
function formatAnalysisStateLabel(value: string | undefined): string {
const normalized = normalizeAnalysisState(value);
if (normalized === "processing") {
return "Processing";
}
if (normalized === "processed") {
return "Processed";
}
if (normalized === "needs_review") {
return "Needs Review";
}
if (normalized === "error") {
return "Error";
}
return "Unprocessed";
}
function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
if (requestEntry.status === "active") {
return "Active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "Queued";
}
if (analysisState === "needs_review") {
return "Needs Review";
}
if (analysisState === "error") {
return "Analysis Error";
}
if (analysisState === "processed") {
return "Reviewed";
}
return formatAnalysisStateLabel(analysisState);
}
function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string {
if (requestEntry.status === "active") {
return "active";
}
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
if (!analysisState || analysisState === "unprocessed") {
return "queued";
}
if (analysisState === "needs_review") {
return "needs-review";
}
if (analysisState === "error") {
return "error";
}
if (analysisState === "processed") {
return "processed";
}
if (analysisState === "processing") {
return "processing";
}
return "pending";
}
function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing");
}
function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean {
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error");
}
function formatEventLabel(event: RecentSaveEvent): string {
switch (String(event.type || "").trim()) {
case "launcher-request-add":
return "Request submitted";
case "launcher-request-delete":
return "Request deleted";
case "launcher-request-update":
return "Request updated";
case "launcher-request-review":
return "Analysis saved for review";
case "launcher-request-promote":
return "Pending request promoted";
case "launcher-request-analysis-error":
return "Analysis failed";
case "launcher-request-analysis-launch":
return "Queue worker launched";
case "launcher-request-analysis-finish":
return "Queue worker finished";
case "launcher-request-analysis-launch-error":
return "Queue worker launch error";
default:
return String(event.type || "Event");
}
}
function formatEventDetail(event: RecentSaveEvent): string {
const parts = [
event.requestId ? `Request ${event.requestId}` : "",
event.category ? `Category ${event.category}` : "",
event.status ? `Status ${event.status}` : "",
event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "",
event.provider ? `Provider ${event.provider}` : "",
event.model ? `Model ${event.model}` : "",
event.reason ? `Reason ${event.reason}` : "",
event.pid ? `PID ${event.pid}` : "",
Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "",
event.signal ? `Signal ${event.signal}` : "",
event.error ? String(event.error) : "",
event.textPreview ? `Preview: ${event.textPreview}` : "",
].filter(Boolean);
return parts.join(" • ");
}
function openStudioPopup(worldId: string): boolean { function openStudioPopup(worldId: string): boolean {
const popup = openWorldshaperStudioWindow(worldId, window, { worldId }); const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
return Boolean(popup); return Boolean(popup);
@ -107,6 +280,12 @@ function WorldshaperLauncher() {
const [requestMutatingId, setRequestMutatingId] = useState(""); const [requestMutatingId, setRequestMutatingId] = useState("");
const [requestFilter, setRequestFilter] = useState("all"); const [requestFilter, setRequestFilter] = useState("all");
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]); const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
const [logsError, setLogsError] = useState("");
const [queueTriggering, setQueueTriggering] = useState(false);
const [adminNotice, setAdminNotice] = useState("");
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -129,36 +308,89 @@ function WorldshaperLauncher() {
}; };
}, []); }, []);
useEffect(() => { async function loadRequests(options?: { silent?: boolean }): Promise<void> {
let cancelled = false; const silent = options?.silent === true;
if (!silent) {
async function loadRequests() {
setRequestsLoading(true); setRequestsLoading(true);
try { }
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests"); try {
if (cancelled) { const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
return; setRequests(Array.isArray(payload.requests) ? payload.requests : []);
} setRequestsError("");
setRequests(Array.isArray(payload.requests) ? payload.requests : []); } catch (nextError: unknown) {
setRequestsError(""); setRequestsError(String(nextError || "Failed to load requests."));
} catch (nextError: unknown) { } finally {
if (cancelled) { if (!silent) {
return; setRequestsLoading(false);
}
setRequestsError(String(nextError || "Failed to load requests."));
} finally {
if (!cancelled) {
setRequestsLoading(false);
}
} }
} }
}
async function loadRecentSaveEvents(): Promise<void> {
setLogsLoading(true);
try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves");
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
setLogsError("");
} catch (nextError: unknown) {
setLogsError(String(nextError || "Failed to load admin logs."));
} finally {
setLogsLoading(false);
}
}
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
await loadRequests({ silent: options?.silentRequests === true });
if (options?.includeLogs) {
await loadRecentSaveEvents();
}
}
useEffect(() => {
void loadRequests(); void loadRequests();
}, []);
useEffect(() => {
if (!adminPanelOpen) {
return;
}
void loadRecentSaveEvents();
}, [adminPanelOpen]);
useEffect(() => {
if (activeBoardTab !== "requests") {
return;
}
let cancelled = false;
const refreshBoard = async (): Promise<void> => {
try {
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
if (!cancelled) {
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
}
} catch {
// Keep the current list visible during background refresh failures.
}
if (!adminPanelOpen) {
return;
}
try {
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves");
if (!cancelled) {
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
}
} catch {
// Avoid surfacing noisy polling failures in the admin panel.
}
};
const intervalId = window.setInterval(() => {
void refreshBoard();
}, 15000);
return () => { return () => {
cancelled = true; cancelled = true;
window.clearInterval(intervalId);
}; };
}, []); }, [activeBoardTab, adminPanelOpen]);
async function handleLaunch(): Promise<void> { async function handleLaunch(): Promise<void> {
setError(""); setError("");
@ -203,6 +435,13 @@ function WorldshaperLauncher() {
setRequestDraft(""); setRequestDraft("");
setRequestDraftOpen(false); setRequestDraftOpen(false);
setRequestsError(""); setRequestsError("");
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
if (adminPanelOpen) {
void loadRecentSaveEvents();
}
window.setTimeout(() => {
void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true });
}, 3500);
} catch (nextError: unknown) { } catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to save request.")); setRequestsError(String(nextError || "Failed to save request."));
} finally { } finally {
@ -231,6 +470,10 @@ function WorldshaperLauncher() {
setRequests(Array.isArray(payload.requests) ? payload.requests : []); setRequests(Array.isArray(payload.requests) ? payload.requests : []);
setRequestsError(""); setRequestsError("");
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id)); setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
setAdminNotice(`Deleted request "${requestEntry.title}".`);
if (adminPanelOpen) {
void loadRecentSaveEvents();
}
} catch (nextError: unknown) { } catch (nextError: unknown) {
setRequestsError(String(nextError || "Failed to delete request.")); setRequestsError(String(nextError || "Failed to delete request."));
} finally { } finally {
@ -238,10 +481,45 @@ function WorldshaperLauncher() {
} }
} }
async function handleProcessPendingQueue(): Promise<void> {
setQueueTriggering(true);
try {
const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", {
method: "POST",
});
if (payload.launched) {
setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`);
} else {
const reason = String(payload.reason || "no-op");
if (reason === "no-pending-requests") {
setAdminNotice("No unprocessed pending requests are waiting in the queue.");
} else if (reason === "request-analysis-already-running") {
setAdminNotice("The request analysis worker is already running on the VPS.");
} else if (reason === "request-analysis-not-configured") {
setAdminNotice("Request analysis is not configured on the server.");
} else {
setAdminNotice(`Queue trigger returned: ${reason}.`);
}
}
await refreshAdminData({ includeLogs: true, silentRequests: true });
if (payload.launched) {
window.setTimeout(() => {
void refreshAdminData({ includeLogs: true, silentRequests: true });
}, 4200);
}
} catch (nextError: unknown) {
setLogsError(String(nextError || "Failed to trigger the queue worker."));
} finally {
setQueueTriggering(false);
}
}
const isBusy = launchState === "opening"; const isBusy = launchState === "opening";
const requestCount = requests.length; const requestCount = requests.length;
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length; const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length;
const activeRequestCount = requests.filter((entry) => entry.status === "active").length; const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
const requestTags = Array.from(new Set( const requestTags = Array.from(new Set(
requests requests
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : []) .flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
@ -252,6 +530,12 @@ function WorldshaperLauncher() {
if (requestFilter === "status:pending") { if (requestFilter === "status:pending") {
return entry.status === "pending"; return entry.status === "pending";
} }
if (requestFilter === "status:queued") {
return isQueuedPendingRequest(entry);
}
if (requestFilter === "status:review") {
return isNeedsReviewRequest(entry);
}
if (requestFilter === "status:active") { if (requestFilter === "status:active") {
return entry.status === "active"; return entry.status === "active";
} }
@ -263,7 +547,7 @@ function WorldshaperLauncher() {
}); });
const boardHint = activeBoardTab === "news" const boardHint = activeBoardTab === "news"
? "Latest announcements" ? "Latest announcements"
: `${pendingRequestCount} pending, ${activeRequestCount} active`; : `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`;
return ( return (
<main <main
@ -374,19 +658,28 @@ function WorldshaperLauncher() {
<div className="changelog-splash-kicker">Shared Request Board</div> <div className="changelog-splash-kicker">Shared Request Board</div>
<div className="changelog-splash-title" id="launcher-requests-title">Requests</div> <div className="changelog-splash-title" id="launcher-requests-title">Requests</div>
<div className="changelog-splash-meta"> <div className="changelog-splash-meta">
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {pendingRequestCount} pending and {activeRequestCount} active. {requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
</div> </div>
</div> </div>
<div className="launcher-request-controls"> <div className="launcher-request-controls">
<div className="launcher-request-toolbar"> <div className="launcher-request-toolbar">
<button <div className="launcher-request-toolbar-buttons">
type="button" <button
className="launcher-primary-btn" type="button"
onClick={() => setRequestDraftOpen((value) => !value)} className="launcher-primary-btn"
disabled={requestSubmitting} onClick={() => setRequestDraftOpen((value) => !value)}
> disabled={requestSubmitting}
{requestDraftOpen ? "Hide Request Form" : "Add New Request"} >
</button> {requestDraftOpen ? "Hide Request Form" : "Add New Request"}
</button>
<button
type="button"
className={`launcher-secondary-btn ${adminPanelOpen ? "is-active" : ""}`}
onClick={() => setAdminPanelOpen((value) => !value)}
>
{adminPanelOpen ? "Hide Admin Panel" : "Admin Panel"}
</button>
</div>
</div> </div>
<label className="launcher-request-filter"> <label className="launcher-request-filter">
<span className="launcher-request-filter-label">Filter</span> <span className="launcher-request-filter-label">Filter</span>
@ -397,6 +690,8 @@ function WorldshaperLauncher() {
> >
<option value="all">All Requests</option> <option value="all">All Requests</option>
<option value="status:pending">Pending Requests</option> <option value="status:pending">Pending Requests</option>
<option value="status:queued">Queued For Analysis</option>
<option value="status:review">Needs Review</option>
<option value="status:active">Active Requests</option> <option value="status:active">Active Requests</option>
{requestTags.map((tag) => ( {requestTags.map((tag) => (
<option key={tag} value={`tag:${tag}`}>{tag}</option> <option key={tag} value={`tag:${tag}`}>{tag}</option>
@ -404,6 +699,104 @@ function WorldshaperLauncher() {
</select> </select>
</label> </label>
</div> </div>
{adminPanelOpen ? (
<section className="launcher-request-admin-panel">
<div className="launcher-request-admin-head">
<div>
<div className="launcher-request-admin-kicker">Moderation Tools</div>
<h3 className="launcher-request-admin-title">Admin Panel</h3>
<p className="launcher-request-admin-copy">
Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place.
</p>
</div>
<div className="launcher-request-admin-stats">
<span>{queuedPendingRequestCount} queued</span>
<span>{needsReviewRequestCount} review</span>
<span>{pendingRequestCount} pending</span>
<span>{activeRequestCount} active</span>
</div>
</div>
<div className="launcher-request-admin-actions">
<button
type="button"
className="launcher-primary-btn"
onClick={() => void handleProcessPendingQueue()}
disabled={queueTriggering}
>
{queueTriggering ? "Starting Queue..." : "Run Pending Queue"}
</button>
<button
type="button"
className="launcher-secondary-btn"
onClick={() => void refreshAdminData({ includeLogs: true, silentRequests: true })}
disabled={logsLoading}
>
{logsLoading ? "Refreshing..." : "Refresh Admin Data"}
</button>
</div>
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
<div className="launcher-request-admin-grid">
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Request Management</h4>
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
</div>
<div className="launcher-request-admin-request-list">
{requests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
return (
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row">
<div className="launcher-request-admin-request-copy">
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
<div className="launcher-request-admin-request-meta">
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
<span>{requestEntry.category}</span>
<span>{analysisState}</span>
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
</div>
</div>
<button
type="button"
className="launcher-request-delete-btn"
onClick={() => void handleDeleteRequest(requestEntry)}
disabled={isMutating}
aria-label={`Delete ${requestEntry.title}`}
>
X
</button>
</article>
);
})}
</div>
</section>
<section className="launcher-request-admin-card">
<div className="launcher-request-admin-card-head">
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
<div className="launcher-request-admin-card-hint">Newest events first.</div>
</div>
<div className="launcher-request-admin-log-list">
{logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">Loading admin logs...</div>
) : null}
{!logsLoading && recentSaveEvents.length === 0 ? (
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
) : null}
{recentSaveEvents.map((eventEntry, index) => (
<article key={`log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-request-admin-log-row">
<div className="launcher-request-admin-log-head">
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
<div className="launcher-request-admin-log-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
</div>
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
</article>
))}
</div>
</section>
</div>
</section>
) : null}
{requestDraftOpen ? ( {requestDraftOpen ? (
<section className="launcher-request-composer"> <section className="launcher-request-composer">
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft"> <label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
@ -456,9 +849,13 @@ function WorldshaperLauncher() {
</section> </section>
) : null} ) : null}
{!requestsLoading ? filteredRequests.map((requestEntry) => { {!requestsLoading ? filteredRequests.map((requestEntry) => {
const isMutating = requestMutatingId === requestEntry.id;
const isExpanded = expandedRequestIds.includes(requestEntry.id); const isExpanded = expandedRequestIds.includes(requestEntry.id);
const isActiveRequest = requestEntry.status === "active"; const isActiveRequest = requestEntry.status === "active";
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
const analysisStateLabel = requestEntry.status === "pending"
? formatAnalysisStateLabel(requestEntry.analysis?.state)
: "";
return ( return (
<section <section
key={requestEntry.id} key={requestEntry.id}
@ -467,26 +864,14 @@ function WorldshaperLauncher() {
> >
<div className="launcher-request-entry-head"> <div className="launcher-request-entry-head">
<div className="launcher-request-entry-head-main"> <div className="launcher-request-entry-head-main">
<div className={`launcher-request-status-pill is-${requestEntry.status}`}> <div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
{formatRequestStatusLabel(requestEntry.status)} {requestDisplayState}
</div> </div>
<div className="launcher-request-entry-title-block"> <div className="launcher-request-entry-title-block">
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3> <h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
<div className="launcher-request-entry-category">{requestEntry.category}</div> <div className="launcher-request-entry-category">{requestEntry.category}</div>
</div> </div>
</div> </div>
<button
type="button"
className="launcher-request-delete-btn"
onClick={(event) => {
event.stopPropagation();
void handleDeleteRequest(requestEntry);
}}
disabled={isMutating}
aria-label="Delete request"
>
X
</button>
</div> </div>
{requestEntry.tags.length > 0 ? ( {requestEntry.tags.length > 0 ? (
<div className="launcher-request-tags"> <div className="launcher-request-tags">
@ -498,7 +883,10 @@ function WorldshaperLauncher() {
<div className="launcher-request-entry-text"> <div className="launcher-request-entry-text">
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText} {requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
</div> </div>
<div className="launcher-request-entry-meta">{formatRequestTimestamp(requestEntry.createdAt)}</div> <div className="launcher-request-entry-meta">
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}
</div>
{isActiveRequest && isExpanded ? ( {isActiveRequest && isExpanded ? (
<div className="launcher-request-expanded"> <div className="launcher-request-expanded">
<div className="launcher-request-expanded-block"> <div className="launcher-request-expanded-block">
@ -523,7 +911,7 @@ function WorldshaperLauncher() {
</div> </div>
<div className="changelog-splash-footer"> <div className="changelog-splash-footer">
<div className="changelog-splash-footnote"> <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. Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs live in the admin panel.
</div> </div>
</div> </div>
</div> </div>

View file

@ -278,6 +278,12 @@ body {
justify-content: flex-start; justify-content: flex-start;
} }
.launcher-request-toolbar-buttons {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.launcher-request-filter { .launcher-request-filter {
display: grid; display: grid;
gap: 4px; gap: 4px;
@ -309,6 +315,195 @@ body {
box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14); box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14);
} }
.launcher-request-admin-panel {
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid #476d9d;
border-radius: 12px;
background:
radial-gradient(circle at top right, rgba(100, 170, 248, 0.18) 0%, transparent 48%),
linear-gradient(180deg, rgba(18, 33, 63, 0.9) 0%, rgba(10, 19, 38, 0.94) 100%);
box-shadow:
0 12px 28px rgba(3, 8, 18, 0.24),
inset 0 0 0 1px rgba(10, 16, 32, 0.14);
}
.launcher-request-admin-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.launcher-request-admin-kicker {
color: #ffd166;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.launcher-request-admin-title {
margin: 0 0 6px;
color: #eef6ff;
font-size: 18px;
line-height: 1.1;
}
.launcher-request-admin-copy {
margin: 0;
color: #b8cfee;
font-size: 12px;
line-height: 1.5;
max-width: 58ch;
}
.launcher-request-admin-stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.launcher-request-admin-stats span {
padding: 4px 8px;
border: 1px solid #365782;
border-radius: 999px;
background: rgba(8, 16, 31, 0.76);
color: #d7e7ff;
font-size: 11px;
line-height: 1;
}
.launcher-request-admin-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.launcher-request-admin-notice {
margin: 0;
color: #a8e6c2;
font-size: 12px;
line-height: 1.5;
}
.launcher-request-admin-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
min-height: 0;
}
.launcher-request-admin-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
min-height: 0;
padding: 12px;
border: 1px solid #365782;
border-radius: 12px;
background: rgba(8, 16, 31, 0.74);
}
.launcher-request-admin-card-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.launcher-request-admin-card-title {
margin: 0;
color: #eef6ff;
font-size: 13px;
font-weight: 800;
}
.launcher-request-admin-card-hint {
color: #9fb8e5;
font-size: 11px;
line-height: 1.4;
}
.launcher-request-admin-request-list,
.launcher-request-admin-log-list {
min-height: 0;
max-height: 320px;
overflow: auto;
display: grid;
gap: 8px;
padding-right: 4px;
}
.launcher-request-admin-request-row,
.launcher-request-admin-log-row {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid #365782;
border-radius: 10px;
background: rgba(17, 32, 63, 0.82);
}
.launcher-request-admin-request-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
}
.launcher-request-admin-request-copy {
min-width: 0;
display: grid;
gap: 5px;
}
.launcher-request-admin-request-title,
.launcher-request-admin-log-title {
color: #eef6ff;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.launcher-request-admin-request-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.launcher-request-admin-request-meta span {
padding: 3px 7px;
border: 1px solid #365782;
border-radius: 999px;
background: rgba(25, 48, 87, 0.72);
color: #d7e7ff;
font-size: 10px;
line-height: 1;
}
.launcher-request-admin-log-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.launcher-request-admin-log-time {
color: #9fb8e5;
font-size: 11px;
white-space: nowrap;
}
.launcher-request-admin-log-detail {
color: #d7e7ff;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
}
.launcher-request-composer-label { .launcher-request-composer-label {
color: #d7e7ff; color: #d7e7ff;
font-size: 12px; font-size: 12px;
@ -408,6 +603,31 @@ body {
color: #ffe7a9; color: #ffe7a9;
} }
.launcher-request-status-pill.is-queued,
.launcher-request-status-pill.is-processing {
border-color: #7d6d3b;
background: rgba(80, 62, 18, 0.82);
color: #ffe7a9;
}
.launcher-request-status-pill.is-needs-review {
border-color: #b96a32;
background: rgba(101, 42, 13, 0.84);
color: #ffd5b0;
}
.launcher-request-status-pill.is-error {
border-color: #9b4053;
background: rgba(87, 24, 36, 0.82);
color: #ffd4dc;
}
.launcher-request-status-pill.is-processed {
border-color: #466e99;
background: rgba(26, 53, 87, 0.82);
color: #d5e8ff;
}
.launcher-request-entry-title-block { .launcher-request-entry-title-block {
min-width: 0; min-width: 0;
display: grid; display: grid;
@ -1692,7 +1912,9 @@ button.danger:not(:disabled):hover {
} }
.launcher-board-tabs, .launcher-board-tabs,
.launcher-request-composer-actions { .launcher-request-composer-actions,
.launcher-request-admin-actions,
.launcher-request-toolbar-buttons {
flex-direction: column; flex-direction: column;
} }
@ -1700,6 +1922,21 @@ button.danger:not(:disabled):hover {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.launcher-request-admin-head,
.launcher-request-admin-card-head,
.launcher-request-admin-log-head {
grid-template-columns: 1fr;
display: grid;
}
.launcher-request-admin-grid {
grid-template-columns: 1fr;
}
.launcher-request-admin-request-row {
grid-template-columns: 1fr;
}
.launcher-request-filter { .launcher-request-filter {
min-width: 0; min-width: 0;
} }