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"],
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
try {
|
||||||
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setRequestsError("");
|
setRequestsError("");
|
||||||
} catch (nextError: unknown) {
|
} catch (nextError: unknown) {
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setRequestsError(String(nextError || "Failed to load requests."));
|
setRequestsError(String(nextError || "Failed to load requests."));
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!silent) {
|
||||||
setRequestsLoading(false);
|
setRequestsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadRequests();
|
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 () => {
|
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,11 +658,12 @@ 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">
|
||||||
|
<div className="launcher-request-toolbar-buttons">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="launcher-primary-btn"
|
className="launcher-primary-btn"
|
||||||
|
|
@ -387,6 +672,14 @@ function WorldshaperLauncher() {
|
||||||
>
|
>
|
||||||
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
|
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
|
||||||
</button>
|
</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>
|
||||||
|
|
|
||||||
239
src/index.css
239
src/index.css
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue