Add request board admin panel
This commit is contained in:
parent
ca054581a4
commit
0c07ce073d
3 changed files with 695 additions and 49 deletions
|
|
@ -31,6 +31,31 @@ type LauncherRequest = {
|
|||
sourceText: string;
|
||||
summary: 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;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
|
@ -39,6 +64,37 @@ type LauncherRequestsPayload = {
|
|||
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";
|
||||
|
||||
async function resolveDefaultWorldId(): Promise<string> {
|
||||
|
|
@ -83,6 +139,123 @@ function formatRequestStatusLabel(status: "pending" | "active"): string {
|
|||
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 {
|
||||
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
||||
return Boolean(popup);
|
||||
|
|
@ -107,6 +280,12 @@ function WorldshaperLauncher() {
|
|||
const [requestMutatingId, setRequestMutatingId] = useState("");
|
||||
const [requestFilter, setRequestFilter] = useState("all");
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -129,36 +308,89 @@ function WorldshaperLauncher() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadRequests() {
|
||||
async function loadRequests(options?: { silent?: boolean }): Promise<void> {
|
||||
const silent = options?.silent === true;
|
||||
if (!silent) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setRequestsError("");
|
||||
} catch (nextError: unknown) {
|
||||
setRequestsError(String(nextError || "Failed to load requests."));
|
||||
} finally {
|
||||
if (!silent) {
|
||||
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();
|
||||
}, []);
|
||||
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
}, [activeBoardTab, adminPanelOpen]);
|
||||
|
||||
async function handleLaunch(): Promise<void> {
|
||||
setError("");
|
||||
|
|
@ -203,6 +435,13 @@ function WorldshaperLauncher() {
|
|||
setRequestDraft("");
|
||||
setRequestDraftOpen(false);
|
||||
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) {
|
||||
setRequestsError(String(nextError || "Failed to save request."));
|
||||
} finally {
|
||||
|
|
@ -231,6 +470,10 @@ function WorldshaperLauncher() {
|
|||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setRequestsError("");
|
||||
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
|
||||
setAdminNotice(`Deleted request "${requestEntry.title}".`);
|
||||
if (adminPanelOpen) {
|
||||
void loadRecentSaveEvents();
|
||||
}
|
||||
} catch (nextError: unknown) {
|
||||
setRequestsError(String(nextError || "Failed to delete request."));
|
||||
} 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 requestCount = requests.length;
|
||||
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").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(
|
||||
requests
|
||||
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
||||
|
|
@ -252,6 +530,12 @@ function WorldshaperLauncher() {
|
|||
if (requestFilter === "status:pending") {
|
||||
return entry.status === "pending";
|
||||
}
|
||||
if (requestFilter === "status:queued") {
|
||||
return isQueuedPendingRequest(entry);
|
||||
}
|
||||
if (requestFilter === "status:review") {
|
||||
return isNeedsReviewRequest(entry);
|
||||
}
|
||||
if (requestFilter === "status:active") {
|
||||
return entry.status === "active";
|
||||
}
|
||||
|
|
@ -263,7 +547,7 @@ function WorldshaperLauncher() {
|
|||
});
|
||||
const boardHint = activeBoardTab === "news"
|
||||
? "Latest announcements"
|
||||
: `${pendingRequestCount} pending, ${activeRequestCount} active`;
|
||||
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`;
|
||||
|
||||
return (
|
||||
<main
|
||||
|
|
@ -374,19 +658,28 @@ 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"}: {pendingRequestCount} pending and {activeRequestCount} active.
|
||||
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
|
||||
</div>
|
||||
</div>
|
||||
<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 className="launcher-request-toolbar-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-primary-btn"
|
||||
onClick={() => setRequestDraftOpen((value) => !value)}
|
||||
disabled={requestSubmitting}
|
||||
>
|
||||
{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>
|
||||
<label className="launcher-request-filter">
|
||||
<span className="launcher-request-filter-label">Filter</span>
|
||||
|
|
@ -397,6 +690,8 @@ function WorldshaperLauncher() {
|
|||
>
|
||||
<option value="all">All 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>
|
||||
{requestTags.map((tag) => (
|
||||
<option key={tag} value={`tag:${tag}`}>{tag}</option>
|
||||
|
|
@ -404,6 +699,104 @@ function WorldshaperLauncher() {
|
|||
</select>
|
||||
</label>
|
||||
</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 ? (
|
||||
<section className="launcher-request-composer">
|
||||
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
|
||||
|
|
@ -456,9 +849,13 @@ function WorldshaperLauncher() {
|
|||
</section>
|
||||
) : null}
|
||||
{!requestsLoading ? filteredRequests.map((requestEntry) => {
|
||||
const isMutating = requestMutatingId === requestEntry.id;
|
||||
const isExpanded = expandedRequestIds.includes(requestEntry.id);
|
||||
const isActiveRequest = requestEntry.status === "active";
|
||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||
const analysisStateLabel = requestEntry.status === "pending"
|
||||
? formatAnalysisStateLabel(requestEntry.analysis?.state)
|
||||
: "";
|
||||
return (
|
||||
<section
|
||||
key={requestEntry.id}
|
||||
|
|
@ -467,26 +864,14 @@ function WorldshaperLauncher() {
|
|||
>
|
||||
<div className="launcher-request-entry-head">
|
||||
<div className="launcher-request-entry-head-main">
|
||||
<div className={`launcher-request-status-pill is-${requestEntry.status}`}>
|
||||
{formatRequestStatusLabel(requestEntry.status)}
|
||||
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
|
||||
{requestDisplayState}
|
||||
</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={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteRequest(requestEntry);
|
||||
}}
|
||||
disabled={isMutating}
|
||||
aria-label="Delete request"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
{requestEntry.tags.length > 0 ? (
|
||||
<div className="launcher-request-tags">
|
||||
|
|
@ -498,7 +883,10 @@ function WorldshaperLauncher() {
|
|||
<div className="launcher-request-entry-text">
|
||||
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
||||
</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 ? (
|
||||
<div className="launcher-request-expanded">
|
||||
<div className="launcher-request-expanded-block">
|
||||
|
|
@ -523,7 +911,7 @@ function WorldshaperLauncher() {
|
|||
</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.
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue