2026-06-26 21:24:08 -04:00
|
|
|
import type { CSSProperties } from "react";
|
2026-06-26 20:54:48 -04:00
|
|
|
import { useEffect, useState } from "react";
|
2026-06-26 21:36:41 -04:00
|
|
|
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
|
2026-06-26 20:54:48 -04:00
|
|
|
import {
|
2026-06-26 21:36:41 -04:00
|
|
|
CHANGELOG_SECTIONS,
|
|
|
|
|
CHANGELOG_SPLASH_FOOTNOTE,
|
|
|
|
|
CHANGELOG_SPLASH_KICKER,
|
|
|
|
|
CHANGELOG_SPLASH_TITLE,
|
|
|
|
|
CHANGELOG_SPLASH_VERSION,
|
|
|
|
|
} from "./worldshaperStudio/changelogData";
|
|
|
|
|
import type { ChangelogItem } from "./worldshaperStudio/changelogData";
|
2026-06-26 21:24:08 -04:00
|
|
|
import launcherBackground from "../background.png";
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
type WorldDefaultPayload = {
|
|
|
|
|
worldId?: string;
|
|
|
|
|
world?: {
|
|
|
|
|
id?: string;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 21:36:41 -04:00
|
|
|
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
2026-06-26 22:55:50 -04:00
|
|
|
type BoardTab = "news" | "requests";
|
2026-06-27 01:44:11 -04:00
|
|
|
type LauncherWindowMode = "public" | "admin";
|
2026-06-27 02:03:43 -04:00
|
|
|
type LauncherRequestStatus = "pending" | "active" | "implemented";
|
2026-06-27 02:41:12 -04:00
|
|
|
type AdminDetailTab = "routing" | "analysis";
|
2026-06-27 01:44:11 -04:00
|
|
|
|
|
|
|
|
type LauncherRequestAnalysisRouting = {
|
|
|
|
|
summary?: string;
|
|
|
|
|
ambiguity?: "low" | "medium" | "high";
|
|
|
|
|
matchedTerms?: string[];
|
|
|
|
|
suggestedTags?: string[];
|
|
|
|
|
suggestedSystems?: string[];
|
|
|
|
|
suggestedModules?: string[];
|
|
|
|
|
rationale?: string;
|
|
|
|
|
possibleDirections?: string[];
|
|
|
|
|
kbSections?: string[];
|
|
|
|
|
};
|
2026-06-26 22:55:50 -04:00
|
|
|
|
|
|
|
|
type LauncherRequest = {
|
|
|
|
|
id: string;
|
2026-06-26 23:35:24 -04:00
|
|
|
sourceSubmissionId?: string;
|
|
|
|
|
title: string;
|
2026-06-27 02:03:43 -04:00
|
|
|
status: LauncherRequestStatus;
|
2026-06-26 23:35:24 -04:00
|
|
|
category: string;
|
|
|
|
|
tags: string[];
|
|
|
|
|
sourceText: string;
|
|
|
|
|
summary: string;
|
|
|
|
|
implementationNotes: string;
|
2026-06-27 00:38:21 -04:00
|
|
|
analysis?: {
|
|
|
|
|
state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error";
|
|
|
|
|
confidence?: number | null;
|
|
|
|
|
model?: string;
|
|
|
|
|
createdAt?: string;
|
|
|
|
|
updatedAt?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
submissionId?: string;
|
|
|
|
|
sourceTextSnapshot?: string;
|
2026-06-27 01:44:11 -04:00
|
|
|
routing?: LauncherRequestAnalysisRouting;
|
2026-06-27 00:38:21 -04:00
|
|
|
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;
|
2026-06-27 01:12:35 -04:00
|
|
|
reviewRationale?: string;
|
|
|
|
|
reviewOptions?: string[];
|
2026-06-27 00:38:21 -04:00
|
|
|
notes?: string;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
2026-06-26 22:55:50 -04:00
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
type LauncherRequestsPayload = {
|
|
|
|
|
requests?: LauncherRequest[];
|
|
|
|
|
};
|
2026-06-26 20:54:48 -04:00
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-27 01:44:11 -04:00
|
|
|
type RequeueAnalysisPayload = {
|
|
|
|
|
ok?: boolean;
|
|
|
|
|
launched?: boolean;
|
|
|
|
|
reason?: string;
|
|
|
|
|
request?: LauncherRequest;
|
|
|
|
|
requests?: LauncherRequest[];
|
|
|
|
|
requestId?: string;
|
|
|
|
|
queuedPendingCount?: number;
|
|
|
|
|
pid?: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
type LauncherRequestMetaPayload = {
|
|
|
|
|
allowedTags?: string[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-27 00:51:20 -04:00
|
|
|
type AdminAuthPayload = {
|
|
|
|
|
ok?: boolean;
|
|
|
|
|
accessGranted?: boolean;
|
|
|
|
|
adminConfigured?: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
|
|
|
|
|
2026-06-27 01:44:11 -04:00
|
|
|
function readLauncherWindowMode(): LauncherWindowMode {
|
|
|
|
|
if (typeof window === "undefined") {
|
|
|
|
|
return "public";
|
|
|
|
|
}
|
|
|
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
|
|
|
return searchParams.get("admin") === "requests" ? "admin" : "public";
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 02:41:12 -04:00
|
|
|
function normalizeStringList(values: string[]): string[] {
|
|
|
|
|
return Array.from(new Set(
|
|
|
|
|
values
|
|
|
|
|
.map((entry) => String(entry || "").trim())
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
)).sort((left, right) => left.localeCompare(right));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function appendUniqueString(values: string[], value: string): string[] {
|
|
|
|
|
const normalizedValue = String(value || "").trim();
|
|
|
|
|
if (!normalizedValue) {
|
|
|
|
|
return normalizeStringList(values);
|
|
|
|
|
}
|
|
|
|
|
return normalizeStringList([...values, normalizedValue]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeStringValue(values: string[], value: string): string[] {
|
|
|
|
|
const normalizedValue = String(value || "").trim().toLowerCase();
|
|
|
|
|
return normalizeStringList(values.filter((entry) => entry.trim().toLowerCase() !== normalizedValue));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleStringSelection(current: string[], value: string): string[] {
|
|
|
|
|
return current.includes(value)
|
|
|
|
|
? current.filter((entry) => entry !== value)
|
|
|
|
|
: [...current, value].sort((left, right) => left.localeCompare(right));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeSearchText(value: string): string {
|
|
|
|
|
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractRoutingTerms(requestEntry: LauncherRequest): string[] {
|
|
|
|
|
const tagTerms = requestEntry.tags
|
|
|
|
|
.map((entry) => String(entry || "").trim())
|
2026-06-27 02:03:43 -04:00
|
|
|
.filter(Boolean);
|
2026-06-27 02:41:12 -04:00
|
|
|
if (tagTerms.length > 0) {
|
|
|
|
|
return tagTerms.slice(0, 6);
|
|
|
|
|
}
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const stopWords = new Set([
|
|
|
|
|
"the", "and", "for", "with", "that", "this", "from", "into", "have", "need",
|
|
|
|
|
"want", "make", "more", "just", "like", "does", "dont", "cannot", "should",
|
|
|
|
|
"would", "could", "about", "because", "there", "their", "they", "them", "then",
|
|
|
|
|
"than", "over", "under", "your", "while", "where",
|
|
|
|
|
]);
|
|
|
|
|
const matches = `${requestEntry.title} ${requestEntry.sourceText}`.match(/[A-Za-z][A-Za-z0-9/-]{2,}/g) || [];
|
|
|
|
|
return matches
|
|
|
|
|
.map((entry) => entry.trim())
|
|
|
|
|
.filter((entry) => {
|
|
|
|
|
const normalized = entry.toLowerCase();
|
|
|
|
|
if (stopWords.has(normalized) || seen.has(normalized)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
seen.add(normalized);
|
|
|
|
|
return true;
|
|
|
|
|
})
|
|
|
|
|
.slice(0, 6);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildRoutingSummaryFallback(requestEntry: LauncherRequest, firstItem: LauncherRequestAnalysisItem | null): string {
|
|
|
|
|
const normalizedSummary = String(requestEntry.summary || "").trim();
|
|
|
|
|
if (normalizedSummary && normalizedSummary !== "Awaiting parsing and categorization.") {
|
|
|
|
|
return normalizedSummary;
|
|
|
|
|
}
|
|
|
|
|
if (firstItem?.parsedInterpretation) {
|
|
|
|
|
return String(firstItem.parsedInterpretation).trim();
|
|
|
|
|
}
|
|
|
|
|
const normalizedSource = String(requestEntry.sourceText || "").replace(/\s+/g, " ").trim();
|
|
|
|
|
if (normalizedSource) {
|
|
|
|
|
return normalizedSource.length > 220
|
|
|
|
|
? `${normalizedSource.slice(0, 217).trim()}...`
|
|
|
|
|
: normalizedSource;
|
|
|
|
|
}
|
|
|
|
|
return "No routing summary has been stored yet.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildFallbackRouting(requestEntry: LauncherRequest): LauncherRequestAnalysisRouting {
|
|
|
|
|
const firstItem = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items[0] : null;
|
|
|
|
|
const routedTags = Array.isArray(firstItem?.tags) && firstItem.tags.length > 0
|
|
|
|
|
? firstItem.tags
|
|
|
|
|
: requestEntry.tags.length > 0
|
|
|
|
|
? requestEntry.tags
|
|
|
|
|
: (requestEntry.category && requestEntry.category !== "Unsorted" ? [requestEntry.category] : []);
|
|
|
|
|
const likelySystems = Array.isArray(firstItem?.affectedSystems) && firstItem.affectedSystems.length > 0
|
|
|
|
|
? firstItem.affectedSystems
|
|
|
|
|
: (routedTags.length > 0 ? routedTags : []);
|
|
|
|
|
const possibleDirections = Array.isArray(firstItem?.reviewOptions) && firstItem.reviewOptions.length > 0
|
|
|
|
|
? firstItem.reviewOptions
|
|
|
|
|
: (requestEntry.implementationNotes.trim() ? [requestEntry.implementationNotes.trim()] : []);
|
|
|
|
|
return {
|
|
|
|
|
summary: buildRoutingSummaryFallback(requestEntry, firstItem),
|
|
|
|
|
ambiguity: requestEntry.status === "pending" ? "medium" : "low",
|
|
|
|
|
matchedTerms: extractRoutingTerms(requestEntry),
|
|
|
|
|
suggestedTags: Array.isArray(routedTags) ? routedTags : [],
|
|
|
|
|
suggestedSystems: likelySystems,
|
|
|
|
|
suggestedModules: [],
|
|
|
|
|
rationale: String(
|
|
|
|
|
firstItem?.reviewRationale
|
|
|
|
|
|| requestEntry.implementationNotes
|
|
|
|
|
|| `Routing was reconstructed from the saved request title, tags, and submission text for "${requestEntry.title}".`
|
|
|
|
|
).trim(),
|
|
|
|
|
possibleDirections,
|
|
|
|
|
kbSections: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeRoutingWithFallback(
|
|
|
|
|
requestEntry: LauncherRequest,
|
|
|
|
|
existingRouting: LauncherRequestAnalysisRouting | undefined,
|
|
|
|
|
): LauncherRequestAnalysisRouting {
|
|
|
|
|
const fallbackRouting = buildFallbackRouting(requestEntry);
|
|
|
|
|
const normalizedRouting = existingRouting || {};
|
|
|
|
|
return {
|
|
|
|
|
summary: String(normalizedRouting.summary || "").trim() || fallbackRouting.summary,
|
|
|
|
|
ambiguity: normalizedRouting.ambiguity || fallbackRouting.ambiguity,
|
|
|
|
|
matchedTerms: Array.isArray(normalizedRouting.matchedTerms) && normalizedRouting.matchedTerms.length > 0
|
|
|
|
|
? normalizedRouting.matchedTerms
|
|
|
|
|
: fallbackRouting.matchedTerms,
|
|
|
|
|
suggestedTags: Array.isArray(normalizedRouting.suggestedTags) && normalizedRouting.suggestedTags.length > 0
|
|
|
|
|
? normalizedRouting.suggestedTags
|
|
|
|
|
: fallbackRouting.suggestedTags,
|
|
|
|
|
suggestedSystems: Array.isArray(normalizedRouting.suggestedSystems) && normalizedRouting.suggestedSystems.length > 0
|
|
|
|
|
? normalizedRouting.suggestedSystems
|
|
|
|
|
: fallbackRouting.suggestedSystems,
|
|
|
|
|
suggestedModules: Array.isArray(normalizedRouting.suggestedModules) && normalizedRouting.suggestedModules.length > 0
|
|
|
|
|
? normalizedRouting.suggestedModules
|
|
|
|
|
: fallbackRouting.suggestedModules,
|
|
|
|
|
rationale: String(normalizedRouting.rationale || "").trim() || fallbackRouting.rationale,
|
|
|
|
|
possibleDirections: Array.isArray(normalizedRouting.possibleDirections) && normalizedRouting.possibleDirections.length > 0
|
|
|
|
|
? normalizedRouting.possibleDirections
|
|
|
|
|
: fallbackRouting.possibleDirections,
|
|
|
|
|
kbSections: Array.isArray(normalizedRouting.kbSections) && normalizedRouting.kbSections.length > 0
|
|
|
|
|
? normalizedRouting.kbSections
|
|
|
|
|
: fallbackRouting.kbSections,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hydrateLauncherRequestForUi(requestEntry: LauncherRequest): LauncherRequest {
|
|
|
|
|
const nextRequest = cloneLauncherRequest(requestEntry);
|
|
|
|
|
nextRequest.analysis = {
|
|
|
|
|
...(nextRequest.analysis || {}),
|
|
|
|
|
createdAt: nextRequest.analysis?.createdAt || nextRequest.createdAt,
|
|
|
|
|
updatedAt: nextRequest.analysis?.updatedAt || nextRequest.updatedAt,
|
|
|
|
|
itemCount: nextRequest.analysis?.itemCount ?? (Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis?.items.length : 0),
|
|
|
|
|
items: Array.isArray(nextRequest.analysis?.items) ? nextRequest.analysis.items : [],
|
|
|
|
|
routing: mergeRoutingWithFallback(nextRequest, nextRequest.analysis?.routing),
|
|
|
|
|
};
|
|
|
|
|
return nextRequest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildRequestSearchCorpus(requestEntry: LauncherRequest): string {
|
|
|
|
|
return normalizeSearchText([
|
|
|
|
|
requestEntry.title,
|
|
|
|
|
requestEntry.category,
|
|
|
|
|
requestEntry.tags.join(" "),
|
|
|
|
|
requestEntry.sourceText,
|
|
|
|
|
requestEntry.summary,
|
|
|
|
|
requestEntry.implementationNotes,
|
|
|
|
|
requestEntry.analysis?.routing?.summary,
|
|
|
|
|
requestEntry.analysis?.routing?.rationale,
|
|
|
|
|
...(Array.isArray(requestEntry.analysis?.routing?.matchedTerms) ? requestEntry.analysis?.routing?.matchedTerms : []),
|
|
|
|
|
...(Array.isArray(requestEntry.analysis?.items)
|
|
|
|
|
? requestEntry.analysis.items.flatMap((item) => [
|
|
|
|
|
item.title,
|
|
|
|
|
item.primaryCategory,
|
|
|
|
|
item.parsedInterpretation,
|
|
|
|
|
item.implementationApproach,
|
|
|
|
|
...(Array.isArray(item.tags) ? item.tags : []),
|
|
|
|
|
])
|
|
|
|
|
: []),
|
|
|
|
|
].filter(Boolean).join(" "));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function matchesRequestFilterToken(requestEntry: LauncherRequest, token: string): boolean {
|
|
|
|
|
if (token === "pending") {
|
|
|
|
|
return requestEntry.status === "pending";
|
|
|
|
|
}
|
|
|
|
|
if (token === "queued") {
|
|
|
|
|
return isQueuedPendingRequest(requestEntry);
|
|
|
|
|
}
|
|
|
|
|
if (token === "review") {
|
|
|
|
|
return isNeedsReviewRequest(requestEntry);
|
|
|
|
|
}
|
|
|
|
|
if (token === "active") {
|
|
|
|
|
return requestEntry.status === "active";
|
|
|
|
|
}
|
|
|
|
|
if (token === "implemented") {
|
|
|
|
|
return requestEntry.status === "implemented";
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requestMatchesFilters(
|
|
|
|
|
requestEntry: LauncherRequest,
|
|
|
|
|
searchText: string,
|
|
|
|
|
statusSelections: string[],
|
|
|
|
|
tagSelections: string[],
|
|
|
|
|
): boolean {
|
|
|
|
|
const normalizedSearchText = normalizeSearchText(searchText);
|
|
|
|
|
if (normalizedSearchText && !buildRequestSearchCorpus(requestEntry).includes(normalizedSearchText)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (statusSelections.length > 0 && !statusSelections.some((token) => matchesRequestFilterToken(requestEntry, token))) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (tagSelections.length > 0 && !tagSelections.some((tag) => requestEntry.tags.includes(tag))) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FilterIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path d="M4 6h16M7 12h10M10 18h4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function LogsIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path d="M5 5h14v14H5z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
|
|
|
|
<path d="M8 9h8M8 12h8M8 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
2026-06-27 02:03:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SaveIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path d="M5 4h11l3 3v13H5z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
|
|
|
|
<path d="M8 4h7v5H8zM8 14h8v5H8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CheckIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path d="M5 12.5l4.2 4.2L19 7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PlayIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path d="M8 6l10 6-10 6z" fill="currentColor" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 02:41:12 -04:00
|
|
|
type LauncherChipSelectorProps = {
|
|
|
|
|
label: string;
|
|
|
|
|
values: string[];
|
|
|
|
|
options: string[];
|
|
|
|
|
placeholder: string;
|
|
|
|
|
emptyLabel?: string;
|
|
|
|
|
onAdd: (value: string) => void;
|
|
|
|
|
onRemove: (value: string) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function LauncherChipSelector({
|
|
|
|
|
label,
|
|
|
|
|
values,
|
|
|
|
|
options,
|
|
|
|
|
placeholder,
|
|
|
|
|
emptyLabel = "No tags selected yet.",
|
|
|
|
|
onAdd,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: LauncherChipSelectorProps) {
|
|
|
|
|
const availableOptions = options.filter((option) => !values.includes(option));
|
|
|
|
|
return (
|
|
|
|
|
<div className="launcher-chip-field">
|
|
|
|
|
<div className="launcher-chip-field-head">
|
|
|
|
|
<span className="launcher-request-filter-label">{label}</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value=""
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
const nextValue = event.target.value;
|
|
|
|
|
if (!nextValue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
onAdd(nextValue);
|
|
|
|
|
}}
|
|
|
|
|
disabled={availableOptions.length === 0}
|
|
|
|
|
>
|
|
|
|
|
<option value="">{availableOptions.length > 0 ? placeholder : "Everything added"}</option>
|
|
|
|
|
{availableOptions.map((option) => (
|
|
|
|
|
<option key={`${label}-${option}`} value={option}>{option}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-chip-list">
|
|
|
|
|
{values.length > 0 ? values.map((value) => (
|
|
|
|
|
<span key={`${label}-${value}`} className="launcher-chip">
|
|
|
|
|
<span>{value}</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-chip-remove"
|
|
|
|
|
onClick={() => onRemove(value)}
|
|
|
|
|
aria-label={`Remove ${value}`}
|
|
|
|
|
title={`Remove ${value}`}
|
|
|
|
|
>
|
|
|
|
|
x
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
)) : (
|
|
|
|
|
<span className="launcher-chip-empty">{emptyLabel}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
async function resolveDefaultWorldId(): Promise<string> {
|
|
|
|
|
const response = await fetch("/api/world-default");
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Failed to load default world (${response.status}).`);
|
|
|
|
|
}
|
|
|
|
|
const payload = await response.json() as WorldDefaultPayload;
|
|
|
|
|
const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim();
|
|
|
|
|
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
async function fetchJsonOrThrow<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
|
|
|
|
const response = await fetch(input, init);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
let detail = `Request failed (${response.status}).`;
|
|
|
|
|
try {
|
|
|
|
|
const payload = await response.json() as { error?: string };
|
|
|
|
|
detail = String(payload?.error || detail);
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore JSON parse failures and fall back to status text.
|
|
|
|
|
}
|
|
|
|
|
throw new Error(detail);
|
|
|
|
|
}
|
|
|
|
|
return response.json() as Promise<T>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:51:20 -04:00
|
|
|
function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
|
|
|
|
|
const normalizedPassword = String(password || "").trim();
|
|
|
|
|
if (!normalizedPassword) {
|
|
|
|
|
return {
|
|
|
|
|
...(headers || {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...(headers || {}),
|
|
|
|
|
"x-worldshaper-admin-password": normalizedPassword,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isAdminAccessError(error: unknown): boolean {
|
|
|
|
|
const text = String(error || "").toLowerCase();
|
|
|
|
|
return text.includes("admin access denied")
|
|
|
|
|
|| text.includes("admin access is not configured");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
|
|
|
|
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
function formatRequestTimestamp(value: string): string {
|
|
|
|
|
const parsed = Date.parse(String(value || ""));
|
|
|
|
|
if (!Number.isFinite(parsed)) {
|
|
|
|
|
return "Saved recently";
|
|
|
|
|
}
|
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "numeric",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
}).format(parsed);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
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 {
|
2026-06-27 02:03:43 -04:00
|
|
|
if (requestEntry.status === "implemented") {
|
|
|
|
|
return "Implemented";
|
|
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
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 {
|
2026-06-27 02:03:43 -04:00
|
|
|
if (requestEntry.status === "implemented") {
|
|
|
|
|
return "implemented";
|
|
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
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";
|
2026-06-27 01:44:11 -04:00
|
|
|
case "launcher-request-analysis-requeue":
|
|
|
|
|
return "Request requeued for review";
|
2026-06-27 00:38:21 -04:00
|
|
|
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(" • ");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
function openStudioPopup(worldId: string): boolean {
|
|
|
|
|
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
|
|
|
|
return Boolean(popup);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 21:12:44 -04:00
|
|
|
function openRepo(): void {
|
|
|
|
|
window.location.assign("https://repo.andraxion.net/");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:44:11 -04:00
|
|
|
function openAdminPanelWindow(): boolean {
|
|
|
|
|
const nextUrl = new URL(window.location.href);
|
|
|
|
|
nextUrl.searchParams.set("admin", "requests");
|
|
|
|
|
nextUrl.searchParams.set("tab", "requests");
|
|
|
|
|
const popup = window.open(nextUrl.toString(), "worldshaper-admin-panel", "popup=yes,width=1620,height=980,resizable=yes,scrollbars=yes");
|
|
|
|
|
if (popup) {
|
|
|
|
|
popup.focus();
|
|
|
|
|
}
|
|
|
|
|
return Boolean(popup);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:54:48 -04:00
|
|
|
function WorldshaperLauncher() {
|
2026-06-27 01:44:11 -04:00
|
|
|
const launcherWindowMode = readLauncherWindowMode();
|
|
|
|
|
const adminWindowMode = launcherWindowMode === "admin";
|
2026-06-26 21:36:41 -04:00
|
|
|
const [launchState, setLaunchState] = useState<LaunchState>("ready");
|
2026-06-26 20:54:48 -04:00
|
|
|
const [error, setError] = useState("");
|
2026-06-26 21:36:41 -04:00
|
|
|
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
2026-06-27 01:44:11 -04:00
|
|
|
const [activeBoardTab, setActiveBoardTab] = useState<BoardTab>(adminWindowMode ? "requests" : "news");
|
2026-06-26 22:55:50 -04:00
|
|
|
const [requests, setRequests] = useState<LauncherRequest[]>([]);
|
|
|
|
|
const [requestsLoading, setRequestsLoading] = useState(true);
|
|
|
|
|
const [requestsError, setRequestsError] = useState("");
|
|
|
|
|
const [requestDraftOpen, setRequestDraftOpen] = useState(false);
|
|
|
|
|
const [requestDraft, setRequestDraft] = useState("");
|
|
|
|
|
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
|
|
|
|
const [requestMutatingId, setRequestMutatingId] = useState("");
|
2026-06-27 02:41:12 -04:00
|
|
|
const [requestSearchText, setRequestSearchText] = useState("");
|
|
|
|
|
const [requestFilterMenuOpen, setRequestFilterMenuOpen] = useState(false);
|
|
|
|
|
const [requestStatusFilters, setRequestStatusFilters] = useState<string[]>([]);
|
|
|
|
|
const [requestTagFilters, setRequestTagFilters] = useState<string[]>([]);
|
2026-06-27 01:12:35 -04:00
|
|
|
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
|
2026-06-26 23:35:24 -04:00
|
|
|
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
2026-06-27 00:51:20 -04:00
|
|
|
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
|
|
|
|
|
const [adminPassword, setAdminPassword] = useState("");
|
|
|
|
|
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
|
|
|
|
|
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
|
|
|
|
const [adminPasswordError, setAdminPasswordError] = useState("");
|
2026-06-27 01:12:35 -04:00
|
|
|
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
|
2026-06-27 02:03:43 -04:00
|
|
|
const [selectedAdminAnalysisIndex, setSelectedAdminAnalysisIndex] = useState(0);
|
2026-06-27 02:41:12 -04:00
|
|
|
const [adminSearchText, setAdminSearchText] = useState("");
|
|
|
|
|
const [adminFilterMenuOpen, setAdminFilterMenuOpen] = useState(false);
|
|
|
|
|
const [adminStatusFilters, setAdminStatusFilters] = useState<string[]>([]);
|
|
|
|
|
const [adminTagFilters, setAdminTagFilters] = useState<string[]>([]);
|
2026-06-27 01:12:35 -04:00
|
|
|
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
|
2026-06-27 02:41:12 -04:00
|
|
|
const [adminDetailTab, setAdminDetailTab] = useState<AdminDetailTab>("routing");
|
2026-06-27 01:12:35 -04:00
|
|
|
const [adminSaving, setAdminSaving] = useState(false);
|
2026-06-27 00:38:21 -04:00
|
|
|
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
|
|
|
|
|
const [logsLoading, setLogsLoading] = useState(false);
|
2026-06-27 02:41:12 -04:00
|
|
|
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
2026-06-27 00:38:21 -04:00
|
|
|
const [logsError, setLogsError] = useState("");
|
|
|
|
|
const [queueTriggering, setQueueTriggering] = useState(false);
|
2026-06-27 01:44:11 -04:00
|
|
|
const [requeueingMode, setRequeueingMode] = useState<"" | "saved" | "draft">("");
|
2026-06-27 00:38:21 -04:00
|
|
|
const [adminNotice, setAdminNotice] = useState("");
|
2026-06-27 01:44:11 -04:00
|
|
|
const adminPanelOpen = adminWindowMode;
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
2026-06-26 21:36:41 -04:00
|
|
|
void resolveDefaultWorldId()
|
|
|
|
|
.then((resolvedWorldId) => {
|
2026-06-26 20:54:48 -04:00
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setWorldId(resolvedWorldId);
|
2026-06-26 21:36:41 -04:00
|
|
|
})
|
|
|
|
|
.catch(() => {
|
2026-06-26 20:54:48 -04:00
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-26 21:36:41 -04:00
|
|
|
setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
|
|
|
|
});
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
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");
|
2026-06-27 02:41:12 -04:00
|
|
|
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
2026-06-27 00:38:21 -04:00
|
|
|
setRequestsError("");
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
setRequestsError(String(nextError || "Failed to load requests."));
|
|
|
|
|
} finally {
|
|
|
|
|
if (!silent) {
|
|
|
|
|
setRequestsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
async function loadRequestMeta(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<LauncherRequestMetaPayload>("/api/launcher-request-meta");
|
|
|
|
|
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
|
|
|
|
|
} catch {
|
|
|
|
|
setAllowedRequestTags([]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
async function loadRecentSaveEvents(): Promise<void> {
|
|
|
|
|
setLogsLoading(true);
|
|
|
|
|
try {
|
2026-06-27 00:51:20 -04:00
|
|
|
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
|
|
|
|
|
headers: buildAdminHeaders(adminPassword),
|
|
|
|
|
});
|
2026-06-27 00:38:21 -04:00
|
|
|
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
|
|
|
|
|
setLogsError("");
|
|
|
|
|
} catch (nextError: unknown) {
|
2026-06-27 00:51:20 -04:00
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
setLogsError(String(nextError || "Failed to load admin logs."));
|
|
|
|
|
} finally {
|
|
|
|
|
setLogsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:51:20 -04:00
|
|
|
async function verifyAdminPassword(password: string): Promise<void> {
|
|
|
|
|
const payload = await fetchJsonOrThrow<AdminAuthPayload>("/api/admin/auth-check", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ password }),
|
|
|
|
|
});
|
|
|
|
|
if (!payload.accessGranted) {
|
|
|
|
|
throw new Error(String(payload.error || "Admin access denied."));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
|
|
|
|
|
await loadRequests({ silent: options?.silentRequests === true });
|
2026-06-27 00:51:20 -04:00
|
|
|
if (options?.includeLogs && adminAccessGranted && adminPassword) {
|
2026-06-27 00:38:21 -04:00
|
|
|
await loadRecentSaveEvents();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
useEffect(() => {
|
2026-06-27 00:38:21 -04:00
|
|
|
void loadRequests();
|
2026-06-27 01:12:35 -04:00
|
|
|
void loadRequestMeta();
|
2026-06-27 00:38:21 -04:00
|
|
|
}, []);
|
2026-06-26 22:55:50 -04:00
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
useEffect(() => {
|
2026-06-27 00:51:20 -04:00
|
|
|
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
2026-06-27 00:38:21 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void loadRecentSaveEvents();
|
2026-06-27 00:51:20 -04:00
|
|
|
}, [adminPanelOpen, adminAccessGranted, adminPassword]);
|
2026-06-27 00:38:21 -04:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeBoardTab !== "requests") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const refreshBoard = async (): Promise<void> => {
|
2026-06-27 01:12:35 -04:00
|
|
|
if (!adminPanelOpen) {
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
|
|
|
|
if (!cancelled) {
|
2026-06-27 02:41:12 -04:00
|
|
|
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
2026-06-27 01:12:35 -04:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Keep the current list visible during background refresh failures.
|
2026-06-26 22:55:50 -04:00
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
}
|
2026-06-27 00:51:20 -04:00
|
|
|
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
2026-06-27 00:38:21 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-06-27 00:51:20 -04:00
|
|
|
const payload = await fetchJsonOrThrow<RecentSaveEventsPayload>("/api/debug/recent-saves", {
|
|
|
|
|
headers: buildAdminHeaders(adminPassword),
|
|
|
|
|
});
|
2026-06-26 22:55:50 -04:00
|
|
|
if (!cancelled) {
|
2026-06-27 00:38:21 -04:00
|
|
|
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
|
2026-06-26 22:55:50 -04:00
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
} catch {
|
|
|
|
|
// Avoid surfacing noisy polling failures in the admin panel.
|
2026-06-26 22:55:50 -04:00
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
};
|
|
|
|
|
const intervalId = window.setInterval(() => {
|
|
|
|
|
void refreshBoard();
|
|
|
|
|
}, 15000);
|
2026-06-26 22:55:50 -04:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
2026-06-27 00:38:21 -04:00
|
|
|
window.clearInterval(intervalId);
|
2026-06-26 22:55:50 -04:00
|
|
|
};
|
2026-06-27 00:51:20 -04:00
|
|
|
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
|
2026-06-26 22:55:50 -04:00
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!adminPanelOpen || !adminAccessGranted) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (requests.length === 0) {
|
|
|
|
|
setSelectedAdminRequestId("");
|
|
|
|
|
setAdminEditorDraft(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId);
|
|
|
|
|
if (selectedRequest) {
|
|
|
|
|
if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) {
|
2026-06-27 02:41:12 -04:00
|
|
|
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(selectedRequest)));
|
2026-06-27 01:12:35 -04:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSelectedAdminRequestId(requests[0].id);
|
2026-06-27 02:41:12 -04:00
|
|
|
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(requests[0])));
|
2026-06-27 01:12:35 -04:00
|
|
|
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
|
|
|
|
|
|
2026-06-27 02:03:43 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
setSelectedAdminAnalysisIndex(0);
|
|
|
|
|
}, [selectedAdminRequestId]);
|
|
|
|
|
|
2026-06-27 02:41:12 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
setAdminDetailTab("routing");
|
|
|
|
|
}, [selectedAdminRequestId]);
|
|
|
|
|
|
2026-06-26 21:36:41 -04:00
|
|
|
async function handleLaunch(): Promise<void> {
|
2026-06-26 20:54:48 -04:00
|
|
|
setError("");
|
2026-06-26 21:36:41 -04:00
|
|
|
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
|
|
|
|
setLaunchState("opening");
|
2026-06-26 20:54:48 -04:00
|
|
|
try {
|
2026-06-26 21:36:41 -04:00
|
|
|
const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
2026-06-26 20:54:48 -04:00
|
|
|
setWorldId(resolvedWorldId);
|
|
|
|
|
if (openStudioPopup(resolvedWorldId)) {
|
|
|
|
|
setLaunchState("opened");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLaunchState("blocked");
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
const nextErrorText = String(nextError || "Failed to prepare Worldshaper Studio.");
|
|
|
|
|
setLaunchState("error");
|
|
|
|
|
setError(nextErrorText);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
async function handleAddRequest(): Promise<void> {
|
|
|
|
|
const text = requestDraft.trim();
|
|
|
|
|
if (!text) {
|
|
|
|
|
setRequestsError("Write a request before saving it.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setRequestSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ text }),
|
|
|
|
|
});
|
2026-06-27 02:41:12 -04:00
|
|
|
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
2026-06-26 22:55:50 -04:00
|
|
|
setRequestDraft("");
|
|
|
|
|
setRequestDraftOpen(false);
|
|
|
|
|
setRequestsError("");
|
2026-06-27 00:38:21 -04:00
|
|
|
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
|
2026-06-27 00:51:20 -04:00
|
|
|
if (adminPanelOpen && adminAccessGranted) {
|
2026-06-27 00:38:21 -04:00
|
|
|
void loadRecentSaveEvents();
|
|
|
|
|
}
|
|
|
|
|
window.setTimeout(() => {
|
2026-06-27 00:51:20 -04:00
|
|
|
void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
|
2026-06-27 00:38:21 -04:00
|
|
|
}, 3500);
|
2026-06-26 22:55:50 -04:00
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
setRequestsError(String(nextError || "Failed to save request."));
|
|
|
|
|
} finally {
|
|
|
|
|
setRequestSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:51:20 -04:00
|
|
|
async function handleAdminPanelToggle(): Promise<void> {
|
|
|
|
|
setRequestDraftOpen(false);
|
|
|
|
|
setRequestsError("");
|
|
|
|
|
setLogsError("");
|
2026-06-27 01:44:11 -04:00
|
|
|
if (adminWindowMode) {
|
2026-06-27 00:51:20 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-27 01:44:11 -04:00
|
|
|
if (!openAdminPanelWindow()) {
|
|
|
|
|
setAdminNotice("Allow popups to open the admin review window.");
|
2026-06-27 00:51:20 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleAdminUnlock(): Promise<void> {
|
|
|
|
|
const submittedPassword = adminPasswordDraft.trim();
|
|
|
|
|
if (!submittedPassword) {
|
|
|
|
|
setAdminPasswordError("Enter the admin password to continue.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setAdminAuthSubmitting(true);
|
|
|
|
|
setAdminPasswordError("");
|
|
|
|
|
try {
|
|
|
|
|
await verifyAdminPassword(submittedPassword);
|
|
|
|
|
setAdminPassword(submittedPassword);
|
|
|
|
|
setAdminAccessGranted(true);
|
|
|
|
|
setAdminNotice("Admin access granted.");
|
|
|
|
|
await refreshAdminData({ includeLogs: true, silentRequests: true });
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError(String(nextError || "Failed to unlock the admin panel."));
|
|
|
|
|
} finally {
|
|
|
|
|
setAdminAuthSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:12:35 -04:00
|
|
|
function handleSelectAdminRequest(requestId: string): void {
|
|
|
|
|
const nextRequest = requests.find((entry) => entry.id === requestId);
|
|
|
|
|
setSelectedAdminRequestId(requestId);
|
2026-06-27 02:03:43 -04:00
|
|
|
setSelectedAdminAnalysisIndex(0);
|
2026-06-27 02:41:12 -04:00
|
|
|
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(hydrateLauncherRequestForUi(nextRequest)) : null);
|
2026-06-27 01:12:35 -04:00
|
|
|
setAdminNotice("");
|
|
|
|
|
setAdminPasswordError("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void {
|
|
|
|
|
setAdminEditorDraft((current) => (current ? updater(current) : current));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateAdminDraftItem(
|
|
|
|
|
itemIndex: number,
|
|
|
|
|
updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem,
|
|
|
|
|
): void {
|
|
|
|
|
updateAdminDraft((current) => {
|
|
|
|
|
const next = cloneLauncherRequest(current);
|
|
|
|
|
if (!next.analysis) {
|
|
|
|
|
next.analysis = {
|
|
|
|
|
state: "needs_review",
|
|
|
|
|
items: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : [];
|
|
|
|
|
const existingItem = items[itemIndex] || {};
|
|
|
|
|
items[itemIndex] = updater({
|
|
|
|
|
...existingItem,
|
|
|
|
|
tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [],
|
|
|
|
|
affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [],
|
|
|
|
|
affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [],
|
|
|
|
|
reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [],
|
|
|
|
|
});
|
|
|
|
|
next.analysis.items = items;
|
|
|
|
|
next.analysis.itemCount = items.length;
|
|
|
|
|
next.analysis.updatedAt = new Date().toISOString();
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit {
|
|
|
|
|
return {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: buildAdminHeaders(adminPassword, {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
}),
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
title: requestEntry.title,
|
|
|
|
|
status: requestEntry.status,
|
|
|
|
|
category: requestEntry.category,
|
|
|
|
|
tags: requestEntry.tags,
|
|
|
|
|
sourceText: requestEntry.sourceText,
|
|
|
|
|
summary: requestEntry.summary,
|
|
|
|
|
implementationNotes: requestEntry.implementationNotes,
|
|
|
|
|
analysis: requestEntry.analysis,
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleSaveAdminRequest(): Promise<void> {
|
|
|
|
|
if (!adminEditorDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setAdminSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
|
|
|
|
`/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`,
|
|
|
|
|
buildAdminSavePayload(adminEditorDraft),
|
|
|
|
|
);
|
2026-06-27 02:41:12 -04:00
|
|
|
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
|
2026-06-27 01:12:35 -04:00
|
|
|
setRequests(nextRequests);
|
2026-06-27 02:41:12 -04:00
|
|
|
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
|
|
|
|
|
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed)));
|
2026-06-27 01:12:35 -04:00
|
|
|
setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`);
|
|
|
|
|
if (adminPanelOpen) {
|
|
|
|
|
void loadRecentSaveEvents();
|
|
|
|
|
}
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
|
|
|
|
}
|
|
|
|
|
setLogsError(String(nextError || "Failed to save admin changes."));
|
|
|
|
|
} finally {
|
|
|
|
|
setAdminSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleApproveAdminRequest(): Promise<void> {
|
|
|
|
|
if (!adminEditorDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nextDraft = cloneLauncherRequest(adminEditorDraft);
|
|
|
|
|
if (!nextDraft.analysis) {
|
|
|
|
|
setLogsError("This request has no analysis to approve yet.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : [];
|
2026-06-27 02:41:12 -04:00
|
|
|
if (items.length === 0) {
|
|
|
|
|
setLogsError("This request does not have a structured analysis item to approve yet.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-27 01:12:35 -04:00
|
|
|
nextDraft.analysis.items = items.map((item) => ({
|
|
|
|
|
...item,
|
|
|
|
|
statusRecommendation: "active",
|
|
|
|
|
}));
|
|
|
|
|
nextDraft.analysis.state = "processed";
|
|
|
|
|
nextDraft.analysis.updatedAt = new Date().toISOString();
|
|
|
|
|
setAdminEditorDraft(nextDraft);
|
|
|
|
|
setAdminSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
|
|
|
|
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`,
|
|
|
|
|
buildAdminSavePayload(nextDraft),
|
|
|
|
|
);
|
|
|
|
|
const promotePayload = await fetchJsonOrThrow<LauncherRequestsPayload>(
|
|
|
|
|
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAdminHeaders(adminPassword, {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
}),
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
action: "promote",
|
|
|
|
|
analysis: nextDraft.analysis,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-27 02:41:12 -04:00
|
|
|
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests.map(hydrateLauncherRequestForUi) : [];
|
2026-06-27 01:12:35 -04:00
|
|
|
setRequests(nextRequests);
|
|
|
|
|
const fallbackSelection = nextRequests[0] || null;
|
|
|
|
|
setSelectedAdminRequestId(fallbackSelection?.id || "");
|
2026-06-27 02:41:12 -04:00
|
|
|
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(hydrateLauncherRequestForUi(fallbackSelection)) : null);
|
2026-06-27 01:12:35 -04:00
|
|
|
setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`);
|
|
|
|
|
if (adminPanelOpen) {
|
|
|
|
|
void loadRecentSaveEvents();
|
|
|
|
|
}
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
|
|
|
|
}
|
|
|
|
|
setLogsError(String(nextError || "Failed to approve this request."));
|
|
|
|
|
} finally {
|
|
|
|
|
setAdminSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 01:44:11 -04:00
|
|
|
async function handleRequeueAnalysis(mode: "saved" | "draft"): Promise<void> {
|
|
|
|
|
if (!adminEditorDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setRequeueingMode(mode);
|
|
|
|
|
setLogsError("");
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<RequeueAnalysisPayload>(
|
|
|
|
|
`/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}/requeue-analysis`,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: buildAdminHeaders(adminPassword, {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
}),
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
mode,
|
|
|
|
|
request: mode === "draft"
|
|
|
|
|
? {
|
|
|
|
|
title: adminEditorDraft.title,
|
|
|
|
|
category: adminEditorDraft.category,
|
|
|
|
|
tags: adminEditorDraft.tags,
|
|
|
|
|
sourceText: adminEditorDraft.sourceText,
|
|
|
|
|
summary: adminEditorDraft.summary,
|
|
|
|
|
implementationNotes: adminEditorDraft.implementationNotes,
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-06-27 02:41:12 -04:00
|
|
|
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
|
2026-06-27 01:44:11 -04:00
|
|
|
setRequests(nextRequests);
|
2026-06-27 02:41:12 -04:00
|
|
|
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
|
|
|
|
|
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed)));
|
2026-06-27 01:44:11 -04:00
|
|
|
if (payload.launched) {
|
|
|
|
|
setAdminNotice(mode === "draft"
|
|
|
|
|
? "Edited draft resubmitted to the analyzer."
|
|
|
|
|
: "Saved request resubmitted to the analyzer.");
|
|
|
|
|
} else {
|
|
|
|
|
const reason = String(payload.reason || "no-op");
|
|
|
|
|
if (reason === "request-analysis-already-running") {
|
|
|
|
|
setAdminNotice("The queue worker is already running. This request will be picked up on the next pass.");
|
|
|
|
|
} else if (reason === "request-not-queued") {
|
|
|
|
|
setAdminNotice("That request is not currently eligible for review reruns.");
|
|
|
|
|
} else {
|
|
|
|
|
setAdminNotice(`Review rerun returned: ${reason}.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await refreshAdminData({ includeLogs: true, silentRequests: true });
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
void refreshAdminData({ includeLogs: true, silentRequests: true });
|
|
|
|
|
}, 4200);
|
|
|
|
|
} catch (nextError: unknown) {
|
|
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
|
|
|
|
}
|
|
|
|
|
setLogsError(String(nextError || "Failed to requeue this request for review."));
|
|
|
|
|
} finally {
|
|
|
|
|
setRequeueingMode("");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 23:35:24 -04:00
|
|
|
function handleToggleExpandedRequest(requestId: string): void {
|
|
|
|
|
setExpandedRequestIds((current) => (
|
|
|
|
|
current.includes(requestId)
|
|
|
|
|
? current.filter((entry) => entry !== requestId)
|
|
|
|
|
: [...current, requestId]
|
|
|
|
|
));
|
2026-06-26 22:55:50 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
|
2026-06-26 23:35:24 -04:00
|
|
|
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`);
|
2026-06-26 22:55:50 -04:00
|
|
|
if (!confirmed) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setRequestMutatingId(requestEntry.id);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
|
|
|
|
|
method: "DELETE",
|
2026-06-27 00:51:20 -04:00
|
|
|
headers: buildAdminHeaders(adminPassword),
|
2026-06-26 22:55:50 -04:00
|
|
|
});
|
2026-06-27 02:41:12 -04:00
|
|
|
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
2026-06-26 22:55:50 -04:00
|
|
|
setRequestsError("");
|
2026-06-26 23:35:24 -04:00
|
|
|
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
|
2026-06-27 00:38:21 -04:00
|
|
|
setAdminNotice(`Deleted request "${requestEntry.title}".`);
|
|
|
|
|
if (adminPanelOpen) {
|
|
|
|
|
void loadRecentSaveEvents();
|
|
|
|
|
}
|
2026-06-26 22:55:50 -04:00
|
|
|
} catch (nextError: unknown) {
|
2026-06-27 00:51:20 -04:00
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
|
|
|
|
}
|
2026-06-26 22:55:50 -04:00
|
|
|
setRequestsError(String(nextError || "Failed to delete request."));
|
|
|
|
|
} finally {
|
|
|
|
|
setRequestMutatingId("");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 00:38:21 -04:00
|
|
|
async function handleProcessPendingQueue(): Promise<void> {
|
|
|
|
|
setQueueTriggering(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = await fetchJsonOrThrow<ProcessPendingPayload>("/api/launcher-requests/process-pending", {
|
|
|
|
|
method: "POST",
|
2026-06-27 00:51:20 -04:00
|
|
|
headers: buildAdminHeaders(adminPassword),
|
2026-06-27 00:38:21 -04:00
|
|
|
});
|
|
|
|
|
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) {
|
2026-06-27 00:51:20 -04:00
|
|
|
if (isAdminAccessError(nextError)) {
|
|
|
|
|
setAdminAccessGranted(false);
|
|
|
|
|
setAdminPassword("");
|
|
|
|
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
|
|
|
|
}
|
2026-06-27 00:38:21 -04:00
|
|
|
setLogsError(String(nextError || "Failed to trigger the queue worker."));
|
|
|
|
|
} finally {
|
|
|
|
|
setQueueTriggering(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 21:36:41 -04:00
|
|
|
const isBusy = launchState === "opening";
|
2026-06-26 22:55:50 -04:00
|
|
|
const requestCount = requests.length;
|
2026-06-26 23:35:24 -04:00
|
|
|
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length;
|
|
|
|
|
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
2026-06-27 02:03:43 -04:00
|
|
|
const implementedRequestCount = requests.filter((entry) => entry.status === "implemented").length;
|
2026-06-27 00:38:21 -04:00
|
|
|
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
|
|
|
|
|
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
|
2026-06-27 01:12:35 -04:00
|
|
|
const requestTags = (allowedRequestTags.length > 0
|
|
|
|
|
? allowedRequestTags
|
|
|
|
|
: Array.from(new Set(
|
|
|
|
|
requests
|
|
|
|
|
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
|
|
|
|
.map((entry) => String(entry || "").trim())
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
))).sort((a, b) => a.localeCompare(b));
|
2026-06-27 02:03:43 -04:00
|
|
|
const requestTagFilterOptions = requestTags
|
|
|
|
|
.map((tag) => ({
|
|
|
|
|
tag,
|
2026-06-27 02:41:12 -04:00
|
|
|
count: requests.filter((entry) => entry.tags.includes(tag)).length,
|
2026-06-27 02:03:43 -04:00
|
|
|
}))
|
|
|
|
|
.filter((entry) => entry.count > 0);
|
2026-06-27 02:41:12 -04:00
|
|
|
const requestStatusFilterOptions = [
|
|
|
|
|
{ id: "pending", label: "Pending", count: pendingRequestCount },
|
|
|
|
|
{ id: "queued", label: "Queued", count: queuedPendingRequestCount },
|
|
|
|
|
{ id: "review", label: "Needs Review", count: needsReviewRequestCount },
|
|
|
|
|
{ id: "active", label: "Active", count: activeRequestCount },
|
|
|
|
|
{ id: "implemented", label: "Implemented", count: implementedRequestCount },
|
|
|
|
|
].filter((entry) => entry.count > 0);
|
|
|
|
|
const filteredRequests = requests.filter((entry) => requestMatchesFilters(
|
|
|
|
|
entry,
|
|
|
|
|
requestSearchText,
|
|
|
|
|
requestStatusFilters,
|
|
|
|
|
requestTagFilters,
|
|
|
|
|
));
|
|
|
|
|
const adminFilteredRequests = requests.filter((entry) => requestMatchesFilters(
|
|
|
|
|
entry,
|
|
|
|
|
adminSearchText,
|
|
|
|
|
adminStatusFilters,
|
|
|
|
|
adminTagFilters,
|
|
|
|
|
));
|
|
|
|
|
const selectedAnalysisItem = adminEditorDraft?.analysis?.items?.[selectedAdminAnalysisIndex] || null;
|
|
|
|
|
const standardizedTagOptions = normalizeStringList([
|
|
|
|
|
...allowedRequestTags,
|
|
|
|
|
...requestTags,
|
|
|
|
|
...requests.flatMap((entry) => [
|
|
|
|
|
...entry.tags,
|
|
|
|
|
...(Array.isArray(entry.analysis?.routing?.suggestedTags) ? entry.analysis.routing.suggestedTags : []),
|
|
|
|
|
...(Array.isArray(entry.analysis?.items) ? entry.analysis.items.flatMap((item) => Array.isArray(item.tags) ? item.tags : []) : []),
|
|
|
|
|
]),
|
|
|
|
|
...(adminEditorDraft?.tags || []),
|
|
|
|
|
...(Array.isArray(adminEditorDraft?.analysis?.routing?.suggestedTags) ? adminEditorDraft.analysis.routing.suggestedTags : []),
|
|
|
|
|
...(Array.isArray(selectedAnalysisItem?.tags) ? selectedAnalysisItem.tags : []),
|
|
|
|
|
]);
|
|
|
|
|
const categoryOptions = normalizeStringList([
|
|
|
|
|
...standardizedTagOptions,
|
|
|
|
|
...requests.map((entry) => entry.category),
|
|
|
|
|
...requests.flatMap((entry) => Array.isArray(entry.analysis?.items) ? entry.analysis.items.map((item) => String(item.primaryCategory || "")) : []),
|
|
|
|
|
String(adminEditorDraft?.category || ""),
|
|
|
|
|
String(selectedAnalysisItem?.primaryCategory || ""),
|
|
|
|
|
]);
|
2026-06-27 01:44:11 -04:00
|
|
|
const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board";
|
|
|
|
|
const boardHint = adminWindowMode
|
2026-06-27 02:03:43 -04:00
|
|
|
? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`
|
2026-06-27 01:44:11 -04:00
|
|
|
: (activeBoardTab === "news"
|
|
|
|
|
? "Latest announcements"
|
2026-06-27 02:03:43 -04:00
|
|
|
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`);
|
2026-06-26 20:54:48 -04:00
|
|
|
|
|
|
|
|
return (
|
2026-06-26 21:24:08 -04:00
|
|
|
<main
|
2026-06-27 01:44:11 -04:00
|
|
|
className={`launcher-shell ${adminWindowMode ? "launcher-shell-admin" : ""}`}
|
2026-06-26 21:24:08 -04:00
|
|
|
style={{ "--launcher-background-image": `url(${launcherBackground})` } as CSSProperties}
|
|
|
|
|
>
|
2026-06-27 01:44:11 -04:00
|
|
|
<div className={`launcher-stack ${adminWindowMode ? "launcher-stack-admin" : ""}`}>
|
|
|
|
|
{!adminWindowMode ? (
|
2026-06-26 22:01:43 -04:00
|
|
|
<section className="launcher-hero-window" aria-labelledby="launcher-studio-title">
|
|
|
|
|
<div className="launcher-hero-body">
|
2026-06-27 01:20:44 -04:00
|
|
|
<div className="launcher-hero-stack">
|
|
|
|
|
<div className="launcher-title-bubble">
|
2026-06-26 22:01:43 -04:00
|
|
|
<h1 className="launcher-title" id="launcher-studio-title">Worldshaper Studio</h1>
|
|
|
|
|
</div>
|
2026-06-27 01:20:44 -04:00
|
|
|
<div className="launcher-actions launcher-actions-floating">
|
|
|
|
|
<button type="button" className="launcher-primary-btn" onClick={() => void handleLaunch()} disabled={isBusy}>
|
|
|
|
|
Launch
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="launcher-secondary-btn" onClick={openRepo} disabled={isBusy}>
|
|
|
|
|
Open Repo
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
{launchState === "blocked" ? <p className="launcher-error">Popup blocked. Allow popups, then press Launch again.</p> : null}
|
|
|
|
|
{error ? <p className="launcher-error">{error}</p> : null}
|
2026-06-26 22:01:43 -04:00
|
|
|
</div>
|
2026-06-26 20:54:48 -04:00
|
|
|
</div>
|
2026-06-26 21:36:41 -04:00
|
|
|
</section>
|
2026-06-27 01:44:11 -04:00
|
|
|
) : null}
|
2026-06-26 22:55:50 -04:00
|
|
|
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
|
2026-06-26 21:36:41 -04:00
|
|
|
<div className="launcher-changelog-titlebar">
|
2026-06-27 01:44:11 -04:00
|
|
|
<div className="launcher-changelog-title" id="launcher-board-title">{boardTitle}</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="launcher-changelog-hint">{boardHint}</div>
|
2026-06-26 21:19:33 -04:00
|
|
|
</div>
|
2026-06-26 21:36:41 -04:00
|
|
|
<div className="launcher-changelog-body">
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="launcher-board-content">
|
2026-06-27 01:44:11 -04:00
|
|
|
{!adminWindowMode ? (
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="launcher-board-tabs">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "news" ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setActiveBoardTab("news")}
|
|
|
|
|
>
|
|
|
|
|
News
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`launcher-secondary-btn launcher-board-tab ${activeBoardTab === "requests" ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setActiveBoardTab("requests")}
|
|
|
|
|
>
|
|
|
|
|
Requests
|
|
|
|
|
</button>
|
2026-06-26 21:36:41 -04:00
|
|
|
</div>
|
2026-06-27 01:44:11 -04:00
|
|
|
) : null}
|
2026-06-26 22:55:50 -04:00
|
|
|
{activeBoardTab === "news" ? (
|
|
|
|
|
<div className="changelog-splash-card">
|
|
|
|
|
<div className="changelog-splash-hero">
|
|
|
|
|
<div className="changelog-splash-kicker">{CHANGELOG_SPLASH_KICKER}</div>
|
|
|
|
|
<div className="changelog-splash-title" id="launcher-whats-new-title">{CHANGELOG_SPLASH_TITLE}</div>
|
|
|
|
|
<div className="changelog-splash-meta">Release {CHANGELOG_SPLASH_VERSION}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="changelog-splash-list">
|
|
|
|
|
{CHANGELOG_SECTIONS.map((section) => (
|
|
|
|
|
<section key={section.title} className="changelog-splash-section">
|
|
|
|
|
<h3 className="changelog-splash-section-title">{section.title}</h3>
|
|
|
|
|
<ul className="changelog-splash-bullets">
|
|
|
|
|
{section.items.map((item, index) => {
|
|
|
|
|
const key = `${section.title}-${index}`;
|
|
|
|
|
const normalizedItem: ChangelogItem = item;
|
|
|
|
|
if (typeof normalizedItem === "string") {
|
|
|
|
|
return <li key={key}>{normalizedItem}</li>;
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<li key={key}>
|
|
|
|
|
<div>{normalizedItem.text}</div>
|
|
|
|
|
{normalizedItem.note ? <div className="changelog-splash-bullet-note">{normalizedItem.note}</div> : null}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
</section>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="changelog-splash-footer">
|
|
|
|
|
<div className="changelog-splash-footnote">{CHANGELOG_SPLASH_FOOTNOTE}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="changelog-splash-card">
|
2026-06-27 02:41:12 -04:00
|
|
|
{!adminWindowMode ? (
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="changelog-splash-hero">
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="changelog-splash-kicker">Shared Request Board</div>
|
|
|
|
|
<div className="changelog-splash-title" id="launcher-requests-title">Requests</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="changelog-splash-meta">
|
2026-06-27 02:03:43 -04:00
|
|
|
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, {activeRequestCount} active, and {implementedRequestCount} implemented.
|
2026-06-26 22:55:50 -04:00
|
|
|
</div>
|
2026-06-27 00:51:20 -04:00
|
|
|
<div className="launcher-request-hero-actions">
|
2026-06-27 00:38:21 -04:00
|
|
|
<div className="launcher-request-toolbar-buttons">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-primary-btn"
|
2026-06-27 00:51:20 -04:00
|
|
|
onClick={() => {
|
|
|
|
|
setRequestDraftOpen((value) => !value);
|
|
|
|
|
}}
|
2026-06-27 00:38:21 -04:00
|
|
|
disabled={requestSubmitting}
|
|
|
|
|
>
|
2026-06-27 02:41:12 -04:00
|
|
|
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
|
2026-06-27 00:38:21 -04:00
|
|
|
</button>
|
2026-06-27 01:44:11 -04:00
|
|
|
{!adminPanelOpen ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-secondary-btn"
|
|
|
|
|
onClick={() => void handleAdminPanelToggle()}
|
|
|
|
|
>
|
|
|
|
|
Open Admin Window
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-06-27 00:38:21 -04:00
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-filter-bar">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="launcher-request-search-input"
|
|
|
|
|
value={requestSearchText}
|
|
|
|
|
onChange={(event) => setRequestSearchText(event.target.value)}
|
|
|
|
|
placeholder="Search requests..."
|
|
|
|
|
/>
|
|
|
|
|
<div className="launcher-request-filter-menu-wrap">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`launcher-secondary-btn launcher-request-filter-icon-btn ${requestFilterMenuOpen ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setRequestFilterMenuOpen((current) => !current)}
|
|
|
|
|
title="Open request filters"
|
|
|
|
|
aria-label="Open request filters"
|
|
|
|
|
>
|
|
|
|
|
<FilterIcon />
|
|
|
|
|
</button>
|
|
|
|
|
{requestFilterMenuOpen ? (
|
|
|
|
|
<div className="launcher-request-filter-menu">
|
|
|
|
|
{requestStatusFilterOptions.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-filter-group">
|
|
|
|
|
<div className="launcher-request-filter-group-title">Status</div>
|
|
|
|
|
{requestStatusFilterOptions.map((option) => (
|
|
|
|
|
<label key={`public-status-${option.id}`} className="launcher-request-filter-option">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={requestStatusFilters.includes(option.id)}
|
|
|
|
|
onChange={() => setRequestStatusFilters((current) => toggleStringSelection(current, option.id))}
|
|
|
|
|
/>
|
|
|
|
|
<span>({option.count}) {option.label}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{requestTagFilterOptions.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-filter-group">
|
|
|
|
|
<div className="launcher-request-filter-group-title">Tags</div>
|
|
|
|
|
{requestTagFilterOptions.map(({ tag, count }) => (
|
|
|
|
|
<label key={`public-tag-${tag}`} className="launcher-request-filter-option">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={requestTagFilters.includes(tag)}
|
|
|
|
|
onChange={() => setRequestTagFilters((current) => toggleStringSelection(current, tag))}
|
|
|
|
|
/>
|
|
|
|
|
<span>({count}) {tag}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-26 23:35:24 -04:00
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
) : null}
|
2026-06-27 00:38:21 -04:00
|
|
|
{adminPanelOpen ? (
|
|
|
|
|
<section className="launcher-request-admin-panel">
|
2026-06-27 00:51:20 -04:00
|
|
|
{!adminAccessGranted ? (
|
|
|
|
|
<div className="launcher-request-admin-unlock">
|
|
|
|
|
<div className="launcher-request-admin-kicker">Protected Tools</div>
|
|
|
|
|
<h3 className="launcher-request-admin-title">Admin Access Required</h3>
|
2026-06-27 00:38:21 -04:00
|
|
|
<p className="launcher-request-admin-copy">
|
2026-06-27 00:51:20 -04:00
|
|
|
Enter the admin password to manage deletions, run the queue worker, and read request logs.
|
2026-06-27 00:38:21 -04:00
|
|
|
</p>
|
2026-06-27 00:51:20 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Password</span>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={adminPasswordDraft}
|
|
|
|
|
onChange={(event) => setAdminPasswordDraft(event.target.value)}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === "Enter") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void handleAdminUnlock();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Enter admin password"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="launcher-request-admin-actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-primary-btn"
|
|
|
|
|
onClick={() => void handleAdminUnlock()}
|
|
|
|
|
disabled={adminAuthSubmitting}
|
|
|
|
|
>
|
|
|
|
|
{adminAuthSubmitting ? "Unlocking..." : "Unlock Admin Panel"}
|
|
|
|
|
</button>
|
2026-06-27 00:38:21 -04:00
|
|
|
</div>
|
2026-06-27 00:51:20 -04:00
|
|
|
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-admin-toolbar">
|
|
|
|
|
<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>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-secondary-btn"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setLogsModalOpen(true);
|
|
|
|
|
void loadRecentSaveEvents();
|
|
|
|
|
}}
|
|
|
|
|
disabled={logsLoading}
|
|
|
|
|
>
|
|
|
|
|
<LogsIcon /> View Logs
|
|
|
|
|
</button>
|
2026-06-27 00:51:20 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-admin-stats">
|
|
|
|
|
<span>{queuedPendingRequestCount} queued</span>
|
|
|
|
|
<span>{needsReviewRequestCount} review</span>
|
|
|
|
|
<span>{pendingRequestCount} pending</span>
|
|
|
|
|
<span>{activeRequestCount} active</span>
|
2026-06-27 02:03:43 -04:00
|
|
|
<span>{implementedRequestCount} implemented</span>
|
2026-06-27 00:51:20 -04:00
|
|
|
</div>
|
2026-06-27 00:38:21 -04:00
|
|
|
</div>
|
2026-06-27 00:51:20 -04:00
|
|
|
{adminNotice ? <p className="launcher-request-admin-notice">{adminNotice}</p> : null}
|
|
|
|
|
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
|
|
|
|
|
{logsError ? <p className="launcher-request-error">{logsError}</p> : null}
|
|
|
|
|
<div className="launcher-request-admin-grid">
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-sidebar">
|
2026-06-27 02:41:12 -04:00
|
|
|
<section className="launcher-request-admin-card launcher-request-admin-list-card">
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-card-head">
|
|
|
|
|
<h4 className="launcher-request-admin-card-title">Request Management</h4>
|
|
|
|
|
<div className="launcher-request-admin-card-hint">Select a request to load it on the right.</div>
|
|
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-filter-bar launcher-request-filter-bar-admin">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="launcher-request-search-input"
|
|
|
|
|
value={adminSearchText}
|
|
|
|
|
onChange={(event) => setAdminSearchText(event.target.value)}
|
|
|
|
|
placeholder="Search requests..."
|
|
|
|
|
/>
|
|
|
|
|
<div className="launcher-request-filter-menu-wrap">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`launcher-secondary-btn launcher-request-filter-icon-btn ${adminFilterMenuOpen ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setAdminFilterMenuOpen((current) => !current)}
|
|
|
|
|
title="Open request filters"
|
|
|
|
|
aria-label="Open request filters"
|
|
|
|
|
>
|
|
|
|
|
<FilterIcon />
|
|
|
|
|
</button>
|
|
|
|
|
{adminFilterMenuOpen ? (
|
|
|
|
|
<div className="launcher-request-filter-menu launcher-request-filter-menu-admin">
|
|
|
|
|
{requestStatusFilterOptions.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-filter-group">
|
|
|
|
|
<div className="launcher-request-filter-group-title">Status</div>
|
|
|
|
|
{requestStatusFilterOptions.map((option) => (
|
|
|
|
|
<label key={`admin-status-${option.id}`} className="launcher-request-filter-option">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={adminStatusFilters.includes(option.id)}
|
|
|
|
|
onChange={() => setAdminStatusFilters((current) => toggleStringSelection(current, option.id))}
|
|
|
|
|
/>
|
|
|
|
|
<span>({option.count}) {option.label}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{requestTagFilterOptions.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-filter-group">
|
|
|
|
|
<div className="launcher-request-filter-group-title">Tags</div>
|
|
|
|
|
{requestTagFilterOptions.map(({ tag, count }) => (
|
|
|
|
|
<label key={`admin-tag-${tag}`} className="launcher-request-filter-option">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={adminTagFilters.includes(tag)}
|
|
|
|
|
onChange={() => setAdminTagFilters((current) => toggleStringSelection(current, tag))}
|
|
|
|
|
/>
|
|
|
|
|
<span>({count}) {tag}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-request-list">
|
2026-06-27 02:41:12 -04:00
|
|
|
{!requestsLoading && adminFilteredRequests.length === 0 ? (
|
|
|
|
|
<div className="launcher-request-empty">No requests match the current search or filters.</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{adminFilteredRequests.map((requestEntry) => {
|
2026-06-27 02:03:43 -04:00
|
|
|
const isMutating = requestMutatingId === requestEntry.id;
|
|
|
|
|
const isSelected = requestEntry.id === selectedAdminRequestId;
|
2026-06-27 02:41:12 -04:00
|
|
|
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
|
|
|
|
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
2026-06-27 02:03:43 -04:00
|
|
|
return (
|
|
|
|
|
<article
|
|
|
|
|
key={`admin-${requestEntry.id}`}
|
|
|
|
|
className={`launcher-request-admin-request-row ${isSelected ? "is-selected" : ""}`}
|
|
|
|
|
onClick={() => handleSelectAdminRequest(requestEntry.id)}
|
|
|
|
|
>
|
|
|
|
|
<div className="launcher-request-admin-request-copy">
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-admin-request-headline">
|
|
|
|
|
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
|
|
|
|
|
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
|
|
|
|
|
{requestDisplayState}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-request-meta">
|
|
|
|
|
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{requestEntry.tags.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-tags">
|
|
|
|
|
{requestEntry.tags.slice(0, 3).map((tag) => (
|
|
|
|
|
<span key={`${requestEntry.id}-${tag}`} className="launcher-request-tag">{tag}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-27 00:51:20 -04:00
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-request-delete-btn"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
void handleDeleteRequest(requestEntry);
|
|
|
|
|
}}
|
|
|
|
|
disabled={isMutating}
|
|
|
|
|
aria-label={`Delete ${requestEntry.title}`}
|
|
|
|
|
title="Delete request"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</button>
|
|
|
|
|
</article>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
<section className="launcher-request-admin-card launcher-request-admin-detail-card">
|
2026-06-27 01:12:35 -04:00
|
|
|
{!adminEditorDraft ? (
|
|
|
|
|
<div className="launcher-request-empty">Select a request from the list to review it.</div>
|
|
|
|
|
) : (
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-detail">
|
|
|
|
|
<div className="launcher-request-admin-detail-top">
|
|
|
|
|
<div className="launcher-request-admin-detail-copy">
|
|
|
|
|
<div className="launcher-request-admin-kicker">Selected Request</div>
|
|
|
|
|
<h4 className="launcher-request-admin-title">{adminEditorDraft.title}</h4>
|
|
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-admin-detail-tabs" role="tablist" aria-label="Request detail views">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
role="tab"
|
|
|
|
|
aria-selected={adminDetailTab === "routing"}
|
|
|
|
|
className={`launcher-request-admin-tab ${adminDetailTab === "routing" ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setAdminDetailTab("routing")}
|
|
|
|
|
>
|
|
|
|
|
Routing
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
role="tab"
|
|
|
|
|
aria-selected={adminDetailTab === "analysis"}
|
|
|
|
|
className={`launcher-request-admin-tab ${adminDetailTab === "analysis" ? "is-active" : ""}`}
|
|
|
|
|
onClick={() => setAdminDetailTab("analysis")}
|
|
|
|
|
>
|
|
|
|
|
Analysis
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<div className="launcher-request-admin-detail-controls">
|
|
|
|
|
<label className="launcher-request-admin-field launcher-request-admin-field-inline">
|
|
|
|
|
<span className="launcher-request-filter-label">Request State</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={adminEditorDraft.status}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, status: event.target.value as LauncherRequestStatus }))}
|
|
|
|
|
>
|
|
|
|
|
<option value="pending">Pending</option>
|
|
|
|
|
<option value="active">Active</option>
|
|
|
|
|
<option value="implemented">Implemented</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
{(adminEditorDraft.analysis?.items?.length || 0) > 1 ? (
|
|
|
|
|
<label className="launcher-request-admin-field launcher-request-admin-field-inline">
|
|
|
|
|
<span className="launcher-request-filter-label">Analysis Item</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(selectedAdminAnalysisIndex)}
|
|
|
|
|
onChange={(event) => setSelectedAdminAnalysisIndex(Number(event.target.value) || 0)}
|
|
|
|
|
>
|
|
|
|
|
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
|
|
|
|
|
<option key={`analysis-tab-${itemIndex}`} value={itemIndex}>
|
|
|
|
|
{item.title || `Request ${itemIndex + 1}`}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="launcher-request-admin-icon-actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-request-admin-icon-btn"
|
|
|
|
|
onClick={() => void handleSaveAdminRequest()}
|
|
|
|
|
disabled={adminSaving || requeueingMode !== ""}
|
|
|
|
|
title="Save all edits to the selected request without changing its review state."
|
|
|
|
|
aria-label="Save request"
|
|
|
|
|
>
|
|
|
|
|
<SaveIcon />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-request-admin-icon-btn is-success"
|
|
|
|
|
onClick={() => void handleApproveAdminRequest()}
|
|
|
|
|
disabled={adminSaving || requeueingMode !== ""}
|
|
|
|
|
title="Approve the current reviewed item and promote it into the active request list."
|
|
|
|
|
aria-label="Approve request"
|
|
|
|
|
>
|
|
|
|
|
<CheckIcon />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-request-admin-icon-btn is-success"
|
|
|
|
|
onClick={() => void handleRequeueAnalysis("draft")}
|
|
|
|
|
disabled={adminSaving || requeueingMode !== ""}
|
|
|
|
|
title="Submit the current edited draft back through the analyzer for a fresh manual review pass."
|
|
|
|
|
aria-label="Manual submission"
|
|
|
|
|
>
|
|
|
|
|
<PlayIcon />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-27 01:12:35 -04:00
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
<section className="launcher-request-admin-analysis-item launcher-request-admin-tab-panel">
|
|
|
|
|
{adminDetailTab === "routing" ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="launcher-request-admin-analysis-head">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="launcher-request-admin-kicker">Routing Pass</div>
|
|
|
|
|
<div className="launcher-request-admin-request-title">KB Routing Summary</div>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Category</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(adminEditorDraft.category || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Select category</option>
|
|
|
|
|
{categoryOptions.map((option) => (
|
|
|
|
|
<option key={`request-category-${option}`} value={option}>{option}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Ambiguity</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(adminEditorDraft.analysis?.routing?.ambiguity || "medium")}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
|
|
|
|
ambiguity: event.target.value as "low" | "medium" | "high",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
>
|
|
|
|
|
<option value="low">Low</option>
|
|
|
|
|
<option value="medium">Medium</option>
|
|
|
|
|
<option value="high">High</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-admin-editor-grid">
|
|
|
|
|
<LauncherChipSelector
|
|
|
|
|
label="Request Tags"
|
|
|
|
|
values={adminEditorDraft.tags}
|
|
|
|
|
options={standardizedTagOptions}
|
|
|
|
|
placeholder="Add request tag"
|
|
|
|
|
onAdd={(value) => updateAdminDraft((current) => ({ ...current, tags: appendUniqueString(current.tags, value) }))}
|
|
|
|
|
onRemove={(value) => updateAdminDraft((current) => ({ ...current, tags: removeStringValue(current.tags, value) }))}
|
|
|
|
|
/>
|
|
|
|
|
<LauncherChipSelector
|
|
|
|
|
label="Suggested Tags"
|
|
|
|
|
values={Array.isArray(adminEditorDraft.analysis?.routing?.suggestedTags) ? adminEditorDraft.analysis.routing.suggestedTags : []}
|
|
|
|
|
options={standardizedTagOptions}
|
|
|
|
|
placeholder="Add suggested tag"
|
|
|
|
|
onAdd={(value) => updateAdminDraft((current) => ({
|
2026-06-27 01:12:35 -04:00
|
|
|
...current,
|
2026-06-27 02:03:43 -04:00
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
2026-06-27 02:41:12 -04:00
|
|
|
suggestedTags: appendUniqueString(
|
|
|
|
|
Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [],
|
|
|
|
|
value,
|
|
|
|
|
),
|
2026-06-27 02:03:43 -04:00
|
|
|
},
|
|
|
|
|
},
|
2026-06-27 01:12:35 -04:00
|
|
|
}))}
|
2026-06-27 02:41:12 -04:00
|
|
|
onRemove={(value) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
|
|
|
|
suggestedTags: removeStringValue(
|
|
|
|
|
Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [],
|
|
|
|
|
value,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Routing Summary</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={String(adminEditorDraft.analysis?.routing?.summary || "")}
|
2026-06-27 02:03:43 -04:00
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
2026-06-27 02:41:12 -04:00
|
|
|
summary: event.target.value,
|
2026-06-27 02:03:43 -04:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Routing Rationale</span>
|
2026-06-27 02:03:43 -04:00
|
|
|
<textarea
|
2026-06-27 02:41:12 -04:00
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={String(adminEditorDraft.analysis?.routing?.rationale || "")}
|
2026-06-27 02:03:43 -04:00
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
2026-06-27 02:41:12 -04:00
|
|
|
rationale: event.target.value,
|
2026-06-27 02:03:43 -04:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
2026-06-27 02:41:12 -04:00
|
|
|
<div className="launcher-request-admin-editor-grid">
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Matched Terms</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
|
|
|
|
value={Array.isArray(adminEditorDraft.analysis?.routing?.matchedTerms) ? adminEditorDraft.analysis.routing.matchedTerms.join("\n") : ""}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
|
|
|
|
matchedTerms: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Likely Systems</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
|
|
|
|
value={Array.isArray(adminEditorDraft.analysis?.routing?.suggestedSystems) ? adminEditorDraft.analysis.routing.suggestedSystems.join("\n") : ""}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
|
|
|
|
suggestedSystems: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
2026-06-27 02:03:43 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Possible Directions</span>
|
2026-06-27 02:03:43 -04:00
|
|
|
<textarea
|
2026-06-27 02:41:12 -04:00
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={Array.isArray(adminEditorDraft.analysis?.routing?.possibleDirections) ? adminEditorDraft.analysis.routing.possibleDirections.join("\n") : ""}
|
2026-06-27 02:03:43 -04:00
|
|
|
onChange={(event) => updateAdminDraft((current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
analysis: {
|
|
|
|
|
...(current.analysis || {}),
|
|
|
|
|
routing: {
|
|
|
|
|
...(current.analysis?.routing || {}),
|
2026-06-27 02:41:12 -04:00
|
|
|
possibleDirections: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
2026-06-27 02:03:43 -04:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
2026-06-27 02:41:12 -04:00
|
|
|
</>
|
|
|
|
|
) : selectedAnalysisItem ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="launcher-request-admin-analysis-head">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="launcher-request-admin-kicker">Analysis</div>
|
|
|
|
|
<div className="launcher-request-admin-request-title">{selectedAnalysisItem.title || "Structured analysis item"}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Request Title</span>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={adminEditorDraft.title}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, title: event.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Item Title</span>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(selectedAnalysisItem.title || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, title: event.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Primary Category</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(selectedAnalysisItem.primaryCategory || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Select category</option>
|
|
|
|
|
{categoryOptions.map((option) => (
|
|
|
|
|
<option key={`analysis-category-${option}`} value={option}>{option}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Problem Type</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(selectedAnalysisItem.problemType || "unknown")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, problemType: event.target.value }))}
|
|
|
|
|
>
|
|
|
|
|
<option value="feature">Feature</option>
|
|
|
|
|
<option value="bug">Bug</option>
|
|
|
|
|
<option value="workflow">Workflow</option>
|
|
|
|
|
<option value="performance">Performance</option>
|
|
|
|
|
<option value="ux">UX</option>
|
|
|
|
|
<option value="content">Content</option>
|
|
|
|
|
<option value="unknown">Unknown</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-admin-editor-grid launcher-request-admin-editor-grid-wide">
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Recommendation</span>
|
|
|
|
|
<select
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={String(selectedAnalysisItem.statusRecommendation || "needs_review")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
|
|
|
|
|
>
|
|
|
|
|
<option value="needs_review">Needs Review</option>
|
|
|
|
|
<option value="active">Active</option>
|
|
|
|
|
<option value="blocked">Blocked</option>
|
|
|
|
|
<option value="duplicate">Duplicate</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Confidence</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="1"
|
|
|
|
|
step="0.01"
|
|
|
|
|
className="launcher-request-filter-select"
|
|
|
|
|
value={Number.isFinite(Number(selectedAnalysisItem.confidence)) ? String(selectedAnalysisItem.confidence) : ""}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
confidence: event.target.value === "" ? null : Number(event.target.value),
|
|
|
|
|
}))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<LauncherChipSelector
|
|
|
|
|
label="Standardized Tags"
|
|
|
|
|
values={Array.isArray(selectedAnalysisItem.tags) ? selectedAnalysisItem.tags : []}
|
|
|
|
|
options={standardizedTagOptions}
|
|
|
|
|
placeholder="Add standardized tag"
|
|
|
|
|
onAdd={(value) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
2026-06-27 02:03:43 -04:00
|
|
|
...current,
|
2026-06-27 02:41:12 -04:00
|
|
|
tags: appendUniqueString(Array.isArray(current.tags) ? current.tags : [], value),
|
|
|
|
|
}))}
|
|
|
|
|
onRemove={(value) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
tags: removeStringValue(Array.isArray(current.tags) ? current.tags : [], value),
|
2026-06-27 02:03:43 -04:00
|
|
|
}))}
|
|
|
|
|
/>
|
2026-06-27 01:12:35 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Original Submission</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={adminEditorDraft.sourceText}
|
|
|
|
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, sourceText: event.target.value }))}
|
2026-06-27 01:12:35 -04:00
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Parsed Interpretation</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={String(selectedAnalysisItem.parsedInterpretation || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
|
2026-06-27 01:12:35 -04:00
|
|
|
/>
|
|
|
|
|
</label>
|
2026-06-27 02:41:12 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Implementation Approach</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
|
|
|
|
value={String(selectedAnalysisItem.implementationApproach || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="launcher-request-admin-editor-grid">
|
2026-06-27 02:03:43 -04:00
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Review Rationale</span>
|
2026-06-27 02:03:43 -04:00
|
|
|
<textarea
|
2026-06-27 02:41:12 -04:00
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
|
|
|
|
value={String(selectedAnalysisItem.reviewRationale || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
|
2026-06-27 02:03:43 -04:00
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
2026-06-27 02:41:12 -04:00
|
|
|
<span className="launcher-request-filter-label">Possible Options</span>
|
2026-06-27 02:03:43 -04:00
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
2026-06-27 02:41:12 -04:00
|
|
|
value={Array.isArray(selectedAnalysisItem.reviewOptions) ? selectedAnalysisItem.reviewOptions.join("\n") : ""}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
|
|
|
|
...current,
|
|
|
|
|
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
|
|
|
|
}))}
|
2026-06-27 02:03:43 -04:00
|
|
|
/>
|
|
|
|
|
</label>
|
2026-06-27 02:41:12 -04:00
|
|
|
</div>
|
|
|
|
|
<label className="launcher-request-admin-field">
|
|
|
|
|
<span className="launcher-request-filter-label">Notes</span>
|
|
|
|
|
<textarea
|
|
|
|
|
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
|
|
|
|
value={String(selectedAnalysisItem.notes || "")}
|
|
|
|
|
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, notes: event.target.value }))}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="launcher-request-empty">This request does not have a structured analysis item yet.</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
2026-06-27 01:12:35 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
2026-06-27 00:38:21 -04:00
|
|
|
</div>
|
2026-06-27 00:51:20 -04:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-27 00:38:21 -04:00
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-27 00:51:20 -04:00
|
|
|
{requestDraftOpen && !adminPanelOpen ? (
|
2026-06-26 22:55:50 -04:00
|
|
|
<section className="launcher-request-composer">
|
|
|
|
|
<label className="launcher-request-composer-label" htmlFor="launcher-request-draft">
|
|
|
|
|
What should be added or improved?
|
|
|
|
|
</label>
|
|
|
|
|
<textarea
|
|
|
|
|
id="launcher-request-draft"
|
|
|
|
|
className="launcher-request-textarea"
|
|
|
|
|
value={requestDraft}
|
|
|
|
|
onChange={(event) => setRequestDraft(event.target.value)}
|
|
|
|
|
placeholder="Type a request for the board..."
|
|
|
|
|
maxLength={1000}
|
|
|
|
|
/>
|
|
|
|
|
<div className="launcher-request-composer-actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-primary-btn"
|
|
|
|
|
onClick={() => void handleAddRequest()}
|
|
|
|
|
disabled={requestSubmitting}
|
|
|
|
|
>
|
|
|
|
|
{requestSubmitting ? "Saving Request..." : "Save Request"}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-secondary-btn"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRequestDraft("");
|
|
|
|
|
setRequestDraftOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
disabled={requestSubmitting}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-27 00:51:20 -04:00
|
|
|
{!adminPanelOpen ? (
|
|
|
|
|
<div className="launcher-request-list">
|
2026-06-26 22:55:50 -04:00
|
|
|
{requestsLoading ? (
|
|
|
|
|
<section className="launcher-request-entry">
|
|
|
|
|
<div className="launcher-request-empty">Loading saved requests...</div>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-26 23:35:24 -04:00
|
|
|
{!requestsLoading && filteredRequests.length === 0 ? (
|
2026-06-26 22:55:50 -04:00
|
|
|
<section className="launcher-request-entry">
|
2026-06-26 23:35:24 -04:00
|
|
|
<div className="launcher-request-empty">
|
|
|
|
|
{requests.length === 0
|
|
|
|
|
? "No requests yet. Add the first one to start the board."
|
|
|
|
|
: "No requests match the current filter."}
|
|
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
</section>
|
|
|
|
|
) : null}
|
2026-06-26 23:35:24 -04:00
|
|
|
{!requestsLoading ? filteredRequests.map((requestEntry) => {
|
|
|
|
|
const isExpanded = expandedRequestIds.includes(requestEntry.id);
|
2026-06-27 02:03:43 -04:00
|
|
|
const isActiveRequest = requestEntry.status === "active" || requestEntry.status === "implemented";
|
2026-06-27 00:38:21 -04:00
|
|
|
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
|
|
|
|
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
2026-06-26 22:55:50 -04:00
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
key={requestEntry.id}
|
2026-06-26 23:35:24 -04:00
|
|
|
className={`launcher-request-entry is-${requestEntry.status} ${isExpanded ? "is-expanded" : ""} ${isActiveRequest ? "is-clickable" : ""}`}
|
|
|
|
|
onClick={isActiveRequest ? () => handleToggleExpandedRequest(requestEntry.id) : undefined}
|
2026-06-26 22:55:50 -04:00
|
|
|
>
|
|
|
|
|
<div className="launcher-request-entry-head">
|
2026-06-26 23:35:24 -04:00
|
|
|
<div className="launcher-request-entry-head-main">
|
2026-06-27 00:38:21 -04:00
|
|
|
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
|
|
|
|
|
{requestDisplayState}
|
2026-06-26 23:35:24 -04:00
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-entry-title-block">
|
|
|
|
|
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-26 22:55:50 -04:00
|
|
|
</div>
|
2026-06-26 23:35:24 -04:00
|
|
|
{requestEntry.tags.length > 0 ? (
|
|
|
|
|
<div className="launcher-request-tags">
|
|
|
|
|
{requestEntry.tags.map((tag) => (
|
|
|
|
|
<span key={`${requestEntry.id}-${tag}`} className="launcher-request-tag">{tag}</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{isActiveRequest && isExpanded ? (
|
|
|
|
|
<div className="launcher-request-expanded">
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">Parsed interpretation</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.summary}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">How we could do that</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.implementationNotes}</p>
|
|
|
|
|
</div>
|
|
|
|
|
{requestEntry.sourceText ? (
|
|
|
|
|
<div className="launcher-request-expanded-block">
|
|
|
|
|
<div className="launcher-request-expanded-label">Original submission</div>
|
|
|
|
|
<p className="launcher-request-expanded-copy">{requestEntry.sourceText}</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-26 22:55:50 -04:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}) : null}
|
2026-06-27 00:51:20 -04:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-26 22:55:50 -04:00
|
|
|
<div className="changelog-splash-footer">
|
|
|
|
|
<div className="changelog-splash-footnote">
|
2026-06-27 00:51:20 -04:00
|
|
|
Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs stay behind protected admin access.
|
2026-06-26 22:55:50 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{requestsError ? (
|
|
|
|
|
<p className="launcher-request-error">{requestsError}</p>
|
|
|
|
|
) : null}
|
2026-06-26 21:36:41 -04:00
|
|
|
</div>
|
2026-06-26 21:19:33 -04:00
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-06-26 21:36:41 -04:00
|
|
|
</div>
|
2026-06-27 02:41:12 -04:00
|
|
|
{adminPanelOpen && logsModalOpen ? (
|
|
|
|
|
<div className="launcher-modal-backdrop" onClick={() => setLogsModalOpen(false)}>
|
|
|
|
|
<section
|
|
|
|
|
className="launcher-modal launcher-modal-logs"
|
|
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
aria-label="Request logs"
|
|
|
|
|
>
|
|
|
|
|
<div className="launcher-modal-head">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="launcher-request-admin-kicker">Console View</div>
|
|
|
|
|
<h3 className="launcher-request-admin-title">Request Logs</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="launcher-secondary-btn"
|
|
|
|
|
onClick={() => setLogsModalOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="launcher-modal-console">
|
|
|
|
|
{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={`modal-log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-modal-console-row">
|
|
|
|
|
<div className="launcher-modal-console-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
|
|
|
|
|
<div className="launcher-modal-console-copy">
|
|
|
|
|
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
|
|
|
|
|
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-06-26 20:54:48 -04:00
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default WorldshaperLauncher;
|