Refactor launcher and studio modules
This commit is contained in:
parent
a20d298be2
commit
ec3e0f5138
34 changed files with 10300 additions and 8600 deletions
974
src/launcher/WorldshaperLauncher.tsx
Normal file
974
src/launcher/WorldshaperLauncher.tsx
Normal file
|
|
@ -0,0 +1,974 @@
|
|||
// @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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue