Add request board admin panel
This commit is contained in:
parent
ca054581a4
commit
0c07ce073d
3 changed files with 695 additions and 49 deletions
21
server.js
21
server.js
|
|
@ -682,6 +682,13 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
|
|||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
requestAnalysisRunState.child = child;
|
||||
recordSaveEvent({
|
||||
type: "launcher-request-analysis-launch",
|
||||
provider,
|
||||
reason,
|
||||
queuedPendingCount,
|
||||
pid: child.pid,
|
||||
});
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk || "").trim();
|
||||
if (text) {
|
||||
|
|
@ -697,6 +704,14 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
|
|||
child.on("exit", (code, signal) => {
|
||||
requestAnalysisRunState.child = null;
|
||||
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) {
|
||||
scheduleQueuedRequestAnalysis("drain-pending-requests", 1200);
|
||||
}
|
||||
|
|
@ -704,6 +719,12 @@ function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
|
|||
child.on("error", (error) => {
|
||||
requestAnalysisRunState.child = null;
|
||||
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}`);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
239
src/index.css
239
src/index.css
|
|
@ -278,6 +278,12 @@ body {
|
|||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.launcher-request-toolbar-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-request-filter {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
|
@ -309,6 +315,195 @@ body {
|
|||
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 {
|
||||
color: #d7e7ff;
|
||||
font-size: 12px;
|
||||
|
|
@ -408,6 +603,31 @@ body {
|
|||
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 {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
|
|
@ -1692,7 +1912,9 @@ button.danger:not(:disabled):hover {
|
|||
}
|
||||
|
||||
.launcher-board-tabs,
|
||||
.launcher-request-composer-actions {
|
||||
.launcher-request-composer-actions,
|
||||
.launcher-request-admin-actions,
|
||||
.launcher-request-toolbar-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
|
@ -1700,6 +1922,21 @@ button.danger:not(:disabled):hover {
|
|||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue