975 lines
34 KiB
TypeScript
975 lines
34 KiB
TypeScript
|
|
// @ts-nocheck
|
||
|
|
import type { CSSProperties } from "react";
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { openWorldshaperStudioWindow } from "../shared/windowing";
|
||
|
|
import launcherBackground from "../../background.png";
|
||
|
|
import { LauncherAdminPanel } from "./components/LauncherAdminPanel";
|
||
|
|
import { LauncherLogsModal } from "./components/LauncherLogsModal";
|
||
|
|
import { LauncherNewsPanel } from "./components/LauncherNewsPanel";
|
||
|
|
import { LauncherPublicRequestBoard } from "./components/LauncherPublicRequestBoard";
|
||
|
|
import { useLauncherRequestBoard } from "./useLauncherRequestBoard";
|
||
|
|
|
||
|
|
declare const __APP_BUILD__: string;
|
||
|
|
|
||
|
|
type WorldDefaultPayload = {
|
||
|
|
worldId?: string;
|
||
|
|
world?: {
|
||
|
|
id?: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
||
|
|
type BoardTab = "news" | "requests";
|
||
|
|
type LauncherWindowMode = "public" | "admin";
|
||
|
|
type LauncherRequestStatus = "pending" | "active" | "implemented";
|
||
|
|
type AdminDetailTab = "routing" | "analysis";
|
||
|
|
|
||
|
|
type LauncherRequestAnalysisRouting = {
|
||
|
|
summary?: string;
|
||
|
|
ambiguity?: "low" | "medium" | "high";
|
||
|
|
matchedTerms?: string[];
|
||
|
|
suggestedTags?: string[];
|
||
|
|
suggestedSystems?: string[];
|
||
|
|
suggestedModules?: string[];
|
||
|
|
rationale?: string;
|
||
|
|
possibleDirections?: string[];
|
||
|
|
kbSections?: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
type LauncherRequest = {
|
||
|
|
id: string;
|
||
|
|
sourceSubmissionId?: string;
|
||
|
|
title: string;
|
||
|
|
status: LauncherRequestStatus;
|
||
|
|
category: string;
|
||
|
|
tags: string[];
|
||
|
|
sourceText: string;
|
||
|
|
summary: string;
|
||
|
|
implementationNotes: string;
|
||
|
|
analysis?: {
|
||
|
|
state?: "unprocessed" | "processing" | "processed" | "needs_review" | "error";
|
||
|
|
confidence?: number | null;
|
||
|
|
model?: string;
|
||
|
|
createdAt?: string;
|
||
|
|
updatedAt?: string;
|
||
|
|
error?: string;
|
||
|
|
submissionId?: string;
|
||
|
|
sourceTextSnapshot?: string;
|
||
|
|
routing?: LauncherRequestAnalysisRouting;
|
||
|
|
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;
|
||
|
|
reviewRationale?: string;
|
||
|
|
reviewOptions?: string[];
|
||
|
|
notes?: string;
|
||
|
|
}>;
|
||
|
|
};
|
||
|
|
createdAt: string;
|
||
|
|
updatedAt: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
|
||
|
|
|
||
|
|
type LauncherRequestsPayload = {
|
||
|
|
requests?: LauncherRequest[];
|
||
|
|
};
|
||
|
|
|
||
|
|
type RecentSaveEvent = {
|
||
|
|
at?: string;
|
||
|
|
type?: string;
|
||
|
|
requestId?: string;
|
||
|
|
textPreview?: string;
|
||
|
|
status?: string;
|
||
|
|
category?: string;
|
||
|
|
itemCount?: number;
|
||
|
|
model?: string;
|
||
|
|
reason?: string;
|
||
|
|
provider?: string;
|
||
|
|
pid?: number;
|
||
|
|
code?: number | null;
|
||
|
|
signal?: string;
|
||
|
|
error?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
type RecentSaveEventsPayload = {
|
||
|
|
saves?: RecentSaveEvent[];
|
||
|
|
};
|
||
|
|
|
||
|
|
type ProcessPendingPayload = {
|
||
|
|
ok?: boolean;
|
||
|
|
launched?: boolean;
|
||
|
|
reason?: string;
|
||
|
|
autorunEnabled?: boolean;
|
||
|
|
configured?: boolean;
|
||
|
|
queuedPendingCount?: number;
|
||
|
|
pid?: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
type RequeueAnalysisPayload = {
|
||
|
|
ok?: boolean;
|
||
|
|
launched?: boolean;
|
||
|
|
reason?: string;
|
||
|
|
request?: LauncherRequest;
|
||
|
|
requests?: LauncherRequest[];
|
||
|
|
requestId?: string;
|
||
|
|
queuedPendingCount?: number;
|
||
|
|
pid?: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
type LauncherRequestMetaPayload = {
|
||
|
|
allowedTags?: string[];
|
||
|
|
};
|
||
|
|
|
||
|
|
type AdminAuthPayload = {
|
||
|
|
ok?: boolean;
|
||
|
|
accessGranted?: boolean;
|
||
|
|
adminConfigured?: boolean;
|
||
|
|
error?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
||
|
|
|
||
|
|
function readLauncherWindowMode(): LauncherWindowMode {
|
||
|
|
if (typeof window === "undefined") {
|
||
|
|
return "public";
|
||
|
|
}
|
||
|
|
const searchParams = new URLSearchParams(window.location.search);
|
||
|
|
return searchParams.get("admin") === "requests" ? "admin" : "public";
|
||
|
|
}
|
||
|
|
|
||
|
|
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())
|
||
|
|
.filter(Boolean);
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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");
|
||
|
|
}
|
||
|
|
|
||
|
|
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
||
|
|
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatRequestSubmittedDate(value: string): string {
|
||
|
|
const parsed = Date.parse(String(value || ""));
|
||
|
|
if (!Number.isFinite(parsed)) {
|
||
|
|
return "Recently";
|
||
|
|
}
|
||
|
|
return new Intl.DateTimeFormat(undefined, {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
year: "numeric",
|
||
|
|
}).format(parsed);
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeAnalysisState(value: string | undefined): string {
|
||
|
|
return String(value || "").trim().toLowerCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatAnalysisStateLabel(value: string | undefined): string {
|
||
|
|
const normalized = normalizeAnalysisState(value);
|
||
|
|
if (normalized === "processing") {
|
||
|
|
return "Processing";
|
||
|
|
}
|
||
|
|
if (normalized === "processed") {
|
||
|
|
return "Processed";
|
||
|
|
}
|
||
|
|
if (normalized === "needs_review") {
|
||
|
|
return "Needs Review";
|
||
|
|
}
|
||
|
|
if (normalized === "error") {
|
||
|
|
return "Error";
|
||
|
|
}
|
||
|
|
return "Unprocessed";
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
|
||
|
|
if (requestEntry.status === "implemented") {
|
||
|
|
return "Implemented";
|
||
|
|
}
|
||
|
|
if (requestEntry.status === "active") {
|
||
|
|
return "Active";
|
||
|
|
}
|
||
|
|
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
|
||
|
|
if (!analysisState || analysisState === "unprocessed") {
|
||
|
|
return "Queued";
|
||
|
|
}
|
||
|
|
if (analysisState === "needs_review") {
|
||
|
|
return "Needs Review";
|
||
|
|
}
|
||
|
|
if (analysisState === "error") {
|
||
|
|
return "Analysis Error";
|
||
|
|
}
|
||
|
|
if (analysisState === "processed") {
|
||
|
|
return "Reviewed";
|
||
|
|
}
|
||
|
|
return formatAnalysisStateLabel(analysisState);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string {
|
||
|
|
if (requestEntry.status === "implemented") {
|
||
|
|
return "implemented";
|
||
|
|
}
|
||
|
|
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";
|
||
|
|
case "launcher-request-analysis-requeue":
|
||
|
|
return "Request requeued for review";
|
||
|
|
default:
|
||
|
|
return String(event.type || "Event");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatEventDetail(event: RecentSaveEvent): string {
|
||
|
|
const parts = [
|
||
|
|
event.requestId ? `Request ${event.requestId}` : "",
|
||
|
|
event.category ? `Category ${event.category}` : "",
|
||
|
|
event.status ? `Status ${event.status}` : "",
|
||
|
|
event.itemCount ? `${event.itemCount} item${event.itemCount === 1 ? "" : "s"}` : "",
|
||
|
|
event.provider ? `Provider ${event.provider}` : "",
|
||
|
|
event.model ? `Model ${event.model}` : "",
|
||
|
|
event.reason ? `Reason ${event.reason}` : "",
|
||
|
|
event.pid ? `PID ${event.pid}` : "",
|
||
|
|
Number.isFinite(Number(event.code)) ? `Exit ${event.code}` : "",
|
||
|
|
event.signal ? `Signal ${event.signal}` : "",
|
||
|
|
event.error ? String(event.error) : "",
|
||
|
|
event.textPreview ? `Preview: ${event.textPreview}` : "",
|
||
|
|
].filter(Boolean);
|
||
|
|
return parts.join(" • ");
|
||
|
|
}
|
||
|
|
|
||
|
|
function openStudioPopup(worldId: string): boolean {
|
||
|
|
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
||
|
|
return Boolean(popup);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openRepo(): void {
|
||
|
|
window.location.assign("https://repo.andraxion.net/");
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
function WorldshaperLauncher() {
|
||
|
|
const launcherWindowMode = readLauncherWindowMode();
|
||
|
|
const adminWindowMode = launcherWindowMode === "admin";
|
||
|
|
const [launchState, setLaunchState] = useState<LaunchState>("ready");
|
||
|
|
const [error, setError] = useState("");
|
||
|
|
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||
|
|
const {
|
||
|
|
adminPanelOpen,
|
||
|
|
activeBoardTab,
|
||
|
|
setActiveBoardTab,
|
||
|
|
requests,
|
||
|
|
requestsLoading,
|
||
|
|
requestsError,
|
||
|
|
requestDraftOpen,
|
||
|
|
setRequestDraftOpen,
|
||
|
|
requestDraft,
|
||
|
|
setRequestDraft,
|
||
|
|
requestSubmitting,
|
||
|
|
requestMutatingId,
|
||
|
|
requestSearchText,
|
||
|
|
setRequestSearchText,
|
||
|
|
requestFilterMenuOpen,
|
||
|
|
setRequestFilterMenuOpen,
|
||
|
|
requestStatusFilters,
|
||
|
|
setRequestStatusFilters,
|
||
|
|
requestTagFilters,
|
||
|
|
setRequestTagFilters,
|
||
|
|
expandedRequestIds,
|
||
|
|
adminAccessGranted,
|
||
|
|
adminPasswordDraft,
|
||
|
|
setAdminPasswordDraft,
|
||
|
|
adminAuthSubmitting,
|
||
|
|
adminPasswordError,
|
||
|
|
selectedAdminRequestId,
|
||
|
|
selectedAdminAnalysisIndex,
|
||
|
|
setSelectedAdminAnalysisIndex,
|
||
|
|
adminSearchText,
|
||
|
|
setAdminSearchText,
|
||
|
|
adminFilterMenuOpen,
|
||
|
|
setAdminFilterMenuOpen,
|
||
|
|
adminStatusFilters,
|
||
|
|
setAdminStatusFilters,
|
||
|
|
adminTagFilters,
|
||
|
|
setAdminTagFilters,
|
||
|
|
adminEditorDraft,
|
||
|
|
adminDetailTab,
|
||
|
|
setAdminDetailTab,
|
||
|
|
adminSaving,
|
||
|
|
recentSaveEvents,
|
||
|
|
logsLoading,
|
||
|
|
logsModalOpen,
|
||
|
|
setLogsModalOpen,
|
||
|
|
logsError,
|
||
|
|
queueTriggering,
|
||
|
|
requeueingMode,
|
||
|
|
adminNotice,
|
||
|
|
refreshAdminData,
|
||
|
|
loadRecentSaveEvents,
|
||
|
|
handleAddRequest,
|
||
|
|
handleAdminPanelToggle,
|
||
|
|
handleAdminUnlock,
|
||
|
|
handleSelectAdminRequest,
|
||
|
|
updateAdminDraft,
|
||
|
|
updateAdminDraftItem,
|
||
|
|
handleSaveAdminRequest,
|
||
|
|
handleApproveAdminRequest,
|
||
|
|
handleRequeueAnalysis,
|
||
|
|
handleToggleExpandedRequest,
|
||
|
|
handleDeleteRequest,
|
||
|
|
handleProcessPendingQueue,
|
||
|
|
requestCount,
|
||
|
|
pendingRequestCount,
|
||
|
|
activeRequestCount,
|
||
|
|
implementedRequestCount,
|
||
|
|
queuedPendingRequestCount,
|
||
|
|
needsReviewRequestCount,
|
||
|
|
requestTagFilterOptions,
|
||
|
|
requestStatusFilterOptions,
|
||
|
|
filteredRequests,
|
||
|
|
adminFilteredRequests,
|
||
|
|
selectedAnalysisItem,
|
||
|
|
standardizedTagOptions,
|
||
|
|
categoryOptions,
|
||
|
|
boardTitle,
|
||
|
|
boardHint,
|
||
|
|
} = useLauncherRequestBoard({ adminWindowMode });
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false;
|
||
|
|
void resolveDefaultWorldId()
|
||
|
|
.then((resolvedWorldId) => {
|
||
|
|
if (cancelled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setWorldId(resolvedWorldId);
|
||
|
|
})
|
||
|
|
.catch(() => {
|
||
|
|
if (cancelled) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setWorldId(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||
|
|
});
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
async function handleLaunch(): Promise<void> {
|
||
|
|
setError("");
|
||
|
|
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||
|
|
setLaunchState("opening");
|
||
|
|
try {
|
||
|
|
const resolvedWorldId = nextWorldId || await resolveDefaultWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const isBusy = launchState === "opening";
|
||
|
|
|
||
|
|
return (
|
||
|
|
<main
|
||
|
|
className={`launcher-shell ${adminWindowMode ? "launcher-shell-admin" : ""}`}
|
||
|
|
style={{ "--launcher-background-image": `url(${launcherBackground})` } as CSSProperties}
|
||
|
|
>
|
||
|
|
<div className={`launcher-stack ${adminWindowMode ? "launcher-stack-admin" : ""}`}>
|
||
|
|
{!adminWindowMode ? (
|
||
|
|
<section className="launcher-hero-window" aria-labelledby="launcher-studio-title">
|
||
|
|
<div className="launcher-hero-body">
|
||
|
|
<div className="launcher-hero-stack">
|
||
|
|
<div className="launcher-title-bubble">
|
||
|
|
<h1 className="launcher-title" id="launcher-studio-title">Worldshaper Studio</h1>
|
||
|
|
</div>
|
||
|
|
<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>
|
||
|
|
{launchState === "blocked" ? <p className="launcher-error">Popup blocked. Allow popups, then press Launch again.</p> : null}
|
||
|
|
{error ? <p className="launcher-error">{error}</p> : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
) : null}
|
||
|
|
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
|
||
|
|
<div className="launcher-changelog-titlebar">
|
||
|
|
<div className="launcher-changelog-title" id="launcher-board-title">{boardTitle}</div>
|
||
|
|
<div className="launcher-changelog-hint">{boardHint}</div>
|
||
|
|
</div>
|
||
|
|
<div className="launcher-changelog-body">
|
||
|
|
<div className="launcher-board-content">
|
||
|
|
{!adminWindowMode ? (
|
||
|
|
<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>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
{activeBoardTab === "news" ? (
|
||
|
|
<LauncherNewsPanel />
|
||
|
|
) : (
|
||
|
|
adminPanelOpen ? (
|
||
|
|
<LauncherAdminPanel
|
||
|
|
adminAccessGranted={adminAccessGranted}
|
||
|
|
adminPasswordDraft={adminPasswordDraft}
|
||
|
|
setAdminPasswordDraft={setAdminPasswordDraft}
|
||
|
|
handleAdminUnlock={handleAdminUnlock}
|
||
|
|
adminAuthSubmitting={adminAuthSubmitting}
|
||
|
|
adminPasswordError={adminPasswordError}
|
||
|
|
handleProcessPendingQueue={handleProcessPendingQueue}
|
||
|
|
queueTriggering={queueTriggering}
|
||
|
|
refreshAdminData={refreshAdminData}
|
||
|
|
logsLoading={logsLoading}
|
||
|
|
setLogsModalOpen={setLogsModalOpen}
|
||
|
|
loadRecentSaveEvents={loadRecentSaveEvents}
|
||
|
|
queuedPendingRequestCount={queuedPendingRequestCount}
|
||
|
|
needsReviewRequestCount={needsReviewRequestCount}
|
||
|
|
pendingRequestCount={pendingRequestCount}
|
||
|
|
activeRequestCount={activeRequestCount}
|
||
|
|
implementedRequestCount={implementedRequestCount}
|
||
|
|
adminNotice={adminNotice}
|
||
|
|
logsError={logsError}
|
||
|
|
adminSearchText={adminSearchText}
|
||
|
|
setAdminSearchText={setAdminSearchText}
|
||
|
|
adminFilterMenuOpen={adminFilterMenuOpen}
|
||
|
|
setAdminFilterMenuOpen={setAdminFilterMenuOpen}
|
||
|
|
requestStatusFilterOptions={requestStatusFilterOptions}
|
||
|
|
adminStatusFilters={adminStatusFilters}
|
||
|
|
setAdminStatusFilters={setAdminStatusFilters}
|
||
|
|
requestTagFilterOptions={requestTagFilterOptions}
|
||
|
|
adminTagFilters={adminTagFilters}
|
||
|
|
setAdminTagFilters={setAdminTagFilters}
|
||
|
|
toggleStringSelection={toggleStringSelection}
|
||
|
|
requestsLoading={requestsLoading}
|
||
|
|
adminFilteredRequests={adminFilteredRequests}
|
||
|
|
requestMutatingId={requestMutatingId}
|
||
|
|
selectedAdminRequestId={selectedAdminRequestId}
|
||
|
|
handleSelectAdminRequest={handleSelectAdminRequest}
|
||
|
|
handleDeleteRequest={handleDeleteRequest}
|
||
|
|
adminEditorDraft={adminEditorDraft}
|
||
|
|
adminDetailTab={adminDetailTab}
|
||
|
|
setAdminDetailTab={setAdminDetailTab}
|
||
|
|
selectedAnalysisItem={selectedAnalysisItem}
|
||
|
|
selectedAdminAnalysisIndex={selectedAdminAnalysisIndex}
|
||
|
|
setSelectedAdminAnalysisIndex={setSelectedAdminAnalysisIndex}
|
||
|
|
updateAdminDraft={updateAdminDraft}
|
||
|
|
updateAdminDraftItem={updateAdminDraftItem}
|
||
|
|
standardizedTagOptions={standardizedTagOptions}
|
||
|
|
categoryOptions={categoryOptions}
|
||
|
|
handleSaveAdminRequest={handleSaveAdminRequest}
|
||
|
|
adminSaving={adminSaving}
|
||
|
|
requeueingMode={requeueingMode}
|
||
|
|
handleApproveAdminRequest={handleApproveAdminRequest}
|
||
|
|
handleRequeueAnalysis={handleRequeueAnalysis}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<LauncherPublicRequestBoard
|
||
|
|
requestCount={requestCount}
|
||
|
|
queuedPendingRequestCount={queuedPendingRequestCount}
|
||
|
|
needsReviewRequestCount={needsReviewRequestCount}
|
||
|
|
activeRequestCount={activeRequestCount}
|
||
|
|
implementedRequestCount={implementedRequestCount}
|
||
|
|
requestDraftOpen={requestDraftOpen}
|
||
|
|
requestSubmitting={requestSubmitting}
|
||
|
|
requestSearchText={requestSearchText}
|
||
|
|
requestFilterMenuOpen={requestFilterMenuOpen}
|
||
|
|
requestStatusFilterOptions={requestStatusFilterOptions}
|
||
|
|
requestTagFilterOptions={requestTagFilterOptions}
|
||
|
|
requestStatusFilters={requestStatusFilters}
|
||
|
|
requestTagFilters={requestTagFilters}
|
||
|
|
requestsLoading={requestsLoading}
|
||
|
|
requests={requests}
|
||
|
|
filteredRequests={filteredRequests}
|
||
|
|
expandedRequestIds={expandedRequestIds}
|
||
|
|
setRequestDraftOpen={setRequestDraftOpen}
|
||
|
|
handleAdminPanelToggle={handleAdminPanelToggle}
|
||
|
|
setRequestSearchText={setRequestSearchText}
|
||
|
|
setRequestFilterMenuOpen={setRequestFilterMenuOpen}
|
||
|
|
setRequestStatusFilters={setRequestStatusFilters}
|
||
|
|
setRequestTagFilters={setRequestTagFilters}
|
||
|
|
toggleStringSelection={toggleStringSelection}
|
||
|
|
handleToggleExpandedRequest={handleToggleExpandedRequest}
|
||
|
|
requestDraft={requestDraft}
|
||
|
|
setRequestDraft={setRequestDraft}
|
||
|
|
handleAddRequest={handleAddRequest}
|
||
|
|
/>
|
||
|
|
)
|
||
|
|
)}
|
||
|
|
{requestsError ? (
|
||
|
|
<p className="launcher-request-error">{requestsError}</p>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
<div className="launcher-build-stamp">Build {__APP_BUILD__}</div>
|
||
|
|
</div>
|
||
|
|
{adminPanelOpen && logsModalOpen ? (
|
||
|
|
<LauncherLogsModal
|
||
|
|
logsLoading={logsLoading}
|
||
|
|
recentSaveEvents={recentSaveEvents}
|
||
|
|
setLogsModalOpen={setLogsModalOpen}
|
||
|
|
/>
|
||
|
|
) : null}
|
||
|
|
</main>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export default WorldshaperLauncher;
|