Revert "Refactor launcher and studio modules"
This reverts commit ec3e0f5138.
This commit is contained in:
parent
2a427688c6
commit
3b7876dc07
34 changed files with 8600 additions and 10300 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,4 +16,3 @@ backups/
|
|||
# OS/editor noise
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
# Request System Flowchart
|
||||
|
||||
This flow shows the current Worldshaper request pipeline from public submission through promotion into the public Active request list.
|
||||
|
||||

|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User submits request from Launcher Requests tab] --> B[POST /api/launcher-requests]
|
||||
B --> C[Server normalizes request<br/>status = pending<br/>stores sourceText + fallback title]
|
||||
C --> D[Request saved in launcher request store]
|
||||
|
||||
D --> E{How does analysis start?}
|
||||
E -->|Auto / queued worker| F[Request analysis worker selects pending unprocessed requests]
|
||||
E -->|Admin clicks Run Pending Queue| F
|
||||
E -->|Admin manual resubmission| F
|
||||
|
||||
F --> G[Mark request analysis.state = processing]
|
||||
G --> H[Routing pass]
|
||||
H --> H1[Map slang / loose wording to Worldshaper terminology]
|
||||
H1 --> H2[Suggest standardized tags, likely systems, likely modules]
|
||||
H2 --> I[Load only relevant KB docs]
|
||||
I --> J[Deep analysis pass]
|
||||
J --> K[Split submission into one or more atomic request items]
|
||||
K --> L[Generate title, category, standardized tags,<br/>parsed interpretation, implementation approach,<br/>review rationale, and confidence]
|
||||
|
||||
L --> M{Can it auto-promote?}
|
||||
M -->|Yes| N[Requirements:<br/>all items statusRecommendation = active<br/>every item confidence >= promote threshold<br/>routing ambiguity is not high]
|
||||
N --> O[POST /api/launcher-requests/:id/process-analysis<br/>action = promote]
|
||||
O --> P[Server replaces pending submission with one or more active request rows]
|
||||
P --> Q[Public Requests tab shows those rows as Active]
|
||||
|
||||
M -->|No| R[POST /api/launcher-requests/:id/process-analysis<br/>action = review]
|
||||
R --> S[Original request stays pending]
|
||||
S --> T[analysis.state = needs_review or error<br/>routing + analysis metadata saved on the request]
|
||||
T --> U[Admin reviews request in Admin window]
|
||||
U --> V{Admin outcome}
|
||||
V -->|Edit + approve| O
|
||||
V -->|Edit + resubmit| F
|
||||
V -->|Leave pending| S
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Public submission starts as a single `pending` request record, even if the worker later splits it into multiple active items.
|
||||
- The original `sourceText` is preserved through the workflow.
|
||||
- Auto-promotion is intentionally strict:
|
||||
- every analyzed item must recommend `active`
|
||||
- every item must meet the confidence threshold
|
||||
- routing ambiguity cannot be `high`
|
||||
- If the analyzer is unsure, the request is still interpreted and stored with review guidance instead of being dropped.
|
||||
- Promotion replaces the pending submission with one or more normalized active request rows that appear on the public board.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 269 KiB |
|
|
@ -1,148 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1620" viewBox="0 0 1600 1620" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Worldshaper Request System Flowchart</title>
|
||||
<desc id="desc">Flowchart showing the Worldshaper request system from public submission through automated analysis, review, and promotion into active requests.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#07111f"/>
|
||||
<stop offset="100%" stop-color="#0b1627"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="panel" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#173258"/>
|
||||
<stop offset="100%" stop-color="#112544"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="decision" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#2a466d"/>
|
||||
<stop offset="100%" stop-color="#19304f"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="success" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1c7a53"/>
|
||||
<stop offset="100%" stop-color="#146b78"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="review" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7f4a19"/>
|
||||
<stop offset="100%" stop-color="#6c2f13"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="14" flood-color="#000000" flood-opacity="0.28"/>
|
||||
</filter>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#89b9ff"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 800 36px "Segoe UI", "Trebuchet MS", sans-serif; fill: #eef6ff; }
|
||||
.subtitle { font: 500 16px "Segoe UI", "Trebuchet MS", sans-serif; fill: #9fb8e5; }
|
||||
.box { fill: url(#panel); stroke: #5f96d6; stroke-width: 2; rx: 22; filter: url(#shadow); }
|
||||
.box-success { fill: url(#success); stroke: #8ce7bb; stroke-width: 2; rx: 22; filter: url(#shadow); }
|
||||
.box-review { fill: url(#review); stroke: #f0b06f; stroke-width: 2; rx: 22; filter: url(#shadow); }
|
||||
.decision { fill: url(#decision); stroke: #7ab5ff; stroke-width: 2; filter: url(#shadow); }
|
||||
.label { font: 700 17px "Segoe UI", "Trebuchet MS", sans-serif; fill: #eef6ff; }
|
||||
.copy { font: 500 14px "Segoe UI", "Trebuchet MS", sans-serif; fill: #d7e7ff; }
|
||||
.small { font: 700 13px "Segoe UI", "Trebuchet MS", sans-serif; fill: #9fd8ff; }
|
||||
.line { fill: none; stroke: #89b9ff; stroke-width: 3; marker-end: url(#arrow); }
|
||||
.line-soft { fill: none; stroke: #89b9ff; stroke-width: 2.5; marker-end: url(#arrow); }
|
||||
.tag { font: 800 12px "Segoe UI", "Trebuchet MS", sans-serif; fill: #ffd166; letter-spacing: 0.08em; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width="1600" height="1620" fill="url(#bg)"/>
|
||||
|
||||
<text x="800" y="62" text-anchor="middle" class="title">Worldshaper Request System</text>
|
||||
<text x="800" y="92" text-anchor="middle" class="subtitle">From public submission through review and promotion into the Active request list</text>
|
||||
|
||||
<rect class="box" x="560" y="130" width="480" height="86" rx="22"/>
|
||||
<text x="800" y="162" text-anchor="middle" class="tag">PUBLIC ENTRY</text>
|
||||
<text x="800" y="186" text-anchor="middle" class="label">1. User submits request</text>
|
||||
<text x="800" y="206" text-anchor="middle" class="copy">Launcher Requests tab posts raw submission text.</text>
|
||||
|
||||
<path class="line" d="M800 216 L800 258"/>
|
||||
|
||||
<rect class="box" x="560" y="258" width="480" height="94" rx="22"/>
|
||||
<text x="800" y="290" text-anchor="middle" class="tag">API WRITE</text>
|
||||
<text x="800" y="314" text-anchor="middle" class="label">2. Server stores pending request</text>
|
||||
<text x="800" y="334" text-anchor="middle" class="copy">`POST /api/launcher-requests` normalizes the record,</text>
|
||||
<text x="800" y="352" text-anchor="middle" class="copy">sets `status = pending`, and preserves `sourceText`.</text>
|
||||
|
||||
<path class="line" d="M800 352 L800 394"/>
|
||||
|
||||
<rect class="box" x="560" y="394" width="480" height="94" rx="22"/>
|
||||
<text x="800" y="426" text-anchor="middle" class="tag">QUEUE STATE</text>
|
||||
<text x="800" y="450" text-anchor="middle" class="label">3. Request sits in the pending queue</text>
|
||||
<text x="800" y="470" text-anchor="middle" class="copy">It keeps fallback title, timestamps, and raw source text</text>
|
||||
<text x="800" y="488" text-anchor="middle" class="copy">until automated or manual analysis begins.</text>
|
||||
|
||||
<path class="line" d="M800 488 L800 542"/>
|
||||
|
||||
<polygon class="decision" points="800,542 950,632 800,722 650,632"/>
|
||||
<text x="800" y="620" text-anchor="middle" class="label">4. What starts analysis?</text>
|
||||
<text x="800" y="642" text-anchor="middle" class="copy">Autorun, admin queue trigger,</text>
|
||||
<text x="800" y="660" text-anchor="middle" class="copy">or manual resubmission</text>
|
||||
|
||||
<path class="line" d="M800 722 L800 770"/>
|
||||
|
||||
<rect class="box" x="560" y="770" width="480" height="94" rx="22"/>
|
||||
<text x="800" y="802" text-anchor="middle" class="tag">WORKER PICKUP</text>
|
||||
<text x="800" y="826" text-anchor="middle" class="label">5. Worker selects eligible pending requests</text>
|
||||
<text x="800" y="846" text-anchor="middle" class="copy">It targets pending records whose analysis state is still</text>
|
||||
<text x="800" y="864" text-anchor="middle" class="copy">unprocessed, then marks them as `processing`.</text>
|
||||
|
||||
<path class="line" d="M800 864 L800 912"/>
|
||||
|
||||
<rect class="box" x="560" y="912" width="480" height="94" rx="22"/>
|
||||
<text x="800" y="944" text-anchor="middle" class="tag">ROUTING PASS</text>
|
||||
<text x="800" y="968" text-anchor="middle" class="label">6. Route the request into Worldshaper terms</text>
|
||||
<text x="800" y="988" text-anchor="middle" class="copy">Map loose wording onto standardized tags, likely systems,</text>
|
||||
<text x="800" y="1006" text-anchor="middle" class="copy">and likely modules before the deeper analysis call.</text>
|
||||
|
||||
<path class="line" d="M800 1006 L800 1054"/>
|
||||
|
||||
<rect class="box" x="560" y="1054" width="480" height="94" rx="22"/>
|
||||
<text x="800" y="1086" text-anchor="middle" class="tag">KB RETRIEVAL</text>
|
||||
<text x="800" y="1110" text-anchor="middle" class="label">7. Load only relevant KB sections</text>
|
||||
<text x="800" y="1130" text-anchor="middle" class="copy">The worker pulls matching systems, focused modules,</text>
|
||||
<text x="800" y="1148" text-anchor="middle" class="copy">terminology hints, and standardized tag definitions.</text>
|
||||
|
||||
<path class="line" d="M800 1148 L800 1196"/>
|
||||
|
||||
<rect class="box" x="560" y="1196" width="480" height="112" rx="22"/>
|
||||
<text x="800" y="1228" text-anchor="middle" class="tag">ANALYSIS PASS</text>
|
||||
<text x="800" y="1252" text-anchor="middle" class="label">8. Produce structured request items</text>
|
||||
<text x="800" y="1272" text-anchor="middle" class="copy">Split the submission into atomic items, then generate</text>
|
||||
<text x="800" y="1290" text-anchor="middle" class="copy">title, category, tags, interpretation, implementation path,</text>
|
||||
<text x="800" y="1308" text-anchor="middle" class="copy">review rationale, options, and confidence.</text>
|
||||
|
||||
<path class="line" d="M800 1308 L800 1362"/>
|
||||
|
||||
<polygon class="decision" points="800,1362 960,1452 800,1542 640,1452"/>
|
||||
<text x="800" y="1430" text-anchor="middle" class="label">9. Can it auto-promote?</text>
|
||||
<text x="800" y="1452" text-anchor="middle" class="copy">Every item must be `active`,</text>
|
||||
<text x="800" y="1470" text-anchor="middle" class="copy">meet confidence threshold,</text>
|
||||
<text x="800" y="1488" text-anchor="middle" class="copy">and avoid high ambiguity</text>
|
||||
|
||||
<text x="595" y="1458" text-anchor="end" class="small">NO</text>
|
||||
<text x="1005" y="1458" class="small">YES</text>
|
||||
|
||||
<path class="line-soft" d="M960 1452 L1160 1452"/>
|
||||
<rect class="box-success" x="1110" y="1386" width="390" height="98" rx="22"/>
|
||||
<text x="1305" y="1418" text-anchor="middle" class="tag">PROMOTION</text>
|
||||
<text x="1305" y="1442" text-anchor="middle" class="label">10A. Promote analyzed items</text>
|
||||
<text x="1305" y="1462" text-anchor="middle" class="copy">`process-analysis` runs with `action = promote`</text>
|
||||
<text x="1305" y="1480" text-anchor="middle" class="copy">and replaces the pending request with active rows.</text>
|
||||
|
||||
<path class="line-soft" d="M1305 1484 L1305 1542"/>
|
||||
<rect class="box-success" x="1110" y="1542" width="390" height="62" rx="22"/>
|
||||
<text x="1305" y="1578" text-anchor="middle" class="label">11A. Public board lists them as Active</text>
|
||||
|
||||
<path class="line-soft" d="M640 1452 L430 1452"/>
|
||||
<rect class="box-review" x="90" y="1386" width="420" height="98" rx="22"/>
|
||||
<text x="300" y="1418" text-anchor="middle" class="tag">REVIEW HOLD</text>
|
||||
<text x="300" y="1442" text-anchor="middle" class="label">10B. Save review metadata on the pending request</text>
|
||||
<text x="300" y="1462" text-anchor="middle" class="copy">The original request stays pending with routing,</text>
|
||||
<text x="300" y="1480" text-anchor="middle" class="copy">analysis items, rationale, and possible options attached.</text>
|
||||
|
||||
<path class="line-soft" d="M300 1484 L300 1542"/>
|
||||
<rect class="box-review" x="90" y="1542" width="420" height="62" rx="22"/>
|
||||
<text x="300" y="1578" text-anchor="middle" class="label">11B. Admin reviews, edits, or resubmits</text>
|
||||
|
||||
<path class="line-soft" d="M510 1573 L620 1573 L620 1516"/>
|
||||
<text x="560" y="1560" text-anchor="middle" class="small">Approve or rerun</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -41,7 +41,6 @@ Request records can now contain:
|
|||
- Depends on `Launcher Home` for the public site presentation.
|
||||
- Depends on the KB under `docs/kb/` for model-grounded request parsing.
|
||||
- Depends on the request-analysis worker for automated triage.
|
||||
- Flow reference: `docs/kb/request-system-flowchart.md`
|
||||
|
||||
## Triage Hints
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import ConfigSection from "./components/ConfigSection";
|
|||
import EditorToolbar from "./components/EditorToolbar";
|
||||
import StatusFooter from "./components/StatusFooter";
|
||||
import TopNavTabs from "./components/TopNavTabs";
|
||||
import { openWorldshaperStudioWindow } from "./shared/windowing";
|
||||
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
|
||||
import {
|
||||
CONFIG_TAB_TO_KEY,
|
||||
DIALOGUE_NODE_FIELD_ORDER,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,974 +0,0 @@
|
|||
// @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;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
export type ChangelogItem = string | {
|
||||
text: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ChangelogSection = {
|
||||
title: string;
|
||||
items: ReadonlyArray<ChangelogItem>;
|
||||
};
|
||||
|
||||
export const CHANGELOG_SPLASH_VERSION = "2026-06-26-launcher-presentation-update";
|
||||
export const CHANGELOG_SPLASH_KICKER = "Launch Experience Update";
|
||||
export const CHANGELOG_SPLASH_TITLE = "What's New";
|
||||
export const CHANGELOG_SPLASH_FOOTNOTE = "This release focuses on presentation, access, and a cleaner studio handoff.";
|
||||
|
||||
export const CHANGELOG_SECTIONS: ReadonlyArray<ChangelogSection> = [
|
||||
{
|
||||
title: "Studio Launch Experience",
|
||||
items: [
|
||||
"Worldshaper now opens from a dedicated launch page built to frame the studio instead of burying it behind a utility screen.",
|
||||
"The editor now launches only in its slim floating window, keeping the first impression focused on the intended workspace.",
|
||||
"The launch page now opens with an editor showcase backdrop that sets the tone before you step inside.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Project Access",
|
||||
items: [
|
||||
"Added a direct Repo destination from the launcher, making project browsing and source access part of the front door.",
|
||||
"Release highlights now live on the main page, so returning creators can catch up before jumping back into the world.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Presentation & Structure",
|
||||
items: [
|
||||
"The launcher now gives the studio controls and release notes their own stage, mirroring the feel of the in-editor update window.",
|
||||
"The entry flow has been tightened into a cleaner, more cinematic handoff from main page to creation space.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,687 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { LauncherChipSelector, CheckIcon, FilterIcon, LogsIcon, PlayIcon, SaveIcon, appendUniqueString, formatRequestTimestamp, getRequestDisplayStateClassName, getRequestDisplayStateLabel, removeStringValue } from "../utils";
|
||||
|
||||
export function LauncherAdminPanel({
|
||||
adminAccessGranted,
|
||||
adminPasswordDraft,
|
||||
setAdminPasswordDraft,
|
||||
handleAdminUnlock,
|
||||
adminAuthSubmitting,
|
||||
adminPasswordError,
|
||||
handleProcessPendingQueue,
|
||||
queueTriggering,
|
||||
refreshAdminData,
|
||||
logsLoading,
|
||||
setLogsModalOpen,
|
||||
loadRecentSaveEvents,
|
||||
queuedPendingRequestCount,
|
||||
needsReviewRequestCount,
|
||||
pendingRequestCount,
|
||||
activeRequestCount,
|
||||
implementedRequestCount,
|
||||
adminNotice,
|
||||
logsError,
|
||||
adminSearchText,
|
||||
setAdminSearchText,
|
||||
adminFilterMenuOpen,
|
||||
setAdminFilterMenuOpen,
|
||||
requestStatusFilterOptions,
|
||||
adminStatusFilters,
|
||||
setAdminStatusFilters,
|
||||
requestTagFilterOptions,
|
||||
adminTagFilters,
|
||||
setAdminTagFilters,
|
||||
toggleStringSelection,
|
||||
requestsLoading,
|
||||
adminFilteredRequests,
|
||||
requestMutatingId,
|
||||
selectedAdminRequestId,
|
||||
handleSelectAdminRequest,
|
||||
handleDeleteRequest,
|
||||
adminEditorDraft,
|
||||
adminDetailTab,
|
||||
setAdminDetailTab,
|
||||
selectedAnalysisItem,
|
||||
selectedAdminAnalysisIndex,
|
||||
setSelectedAdminAnalysisIndex,
|
||||
updateAdminDraft,
|
||||
updateAdminDraftItem,
|
||||
standardizedTagOptions,
|
||||
categoryOptions,
|
||||
handleSaveAdminRequest,
|
||||
adminSaving,
|
||||
requeueingMode,
|
||||
handleApproveAdminRequest,
|
||||
handleRequeueAnalysis,
|
||||
}) {
|
||||
return (
|
||||
<section className="launcher-request-admin-panel">
|
||||
{!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>
|
||||
<p className="launcher-request-admin-copy">
|
||||
Enter the admin password to manage deletions, run the queue worker, and read request logs.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
{adminPasswordError ? <p className="launcher-request-error">{adminPasswordError}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<div className="launcher-request-admin-stats">
|
||||
<span>{queuedPendingRequestCount} queued</span>
|
||||
<span>{needsReviewRequestCount} review</span>
|
||||
<span>{pendingRequestCount} pending</span>
|
||||
<span>{activeRequestCount} active</span>
|
||||
<span>{implementedRequestCount} implemented</span>
|
||||
</div>
|
||||
</div>
|
||||
{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">
|
||||
<div className="launcher-request-admin-sidebar">
|
||||
<section className="launcher-request-admin-card launcher-request-admin-list-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Request Management</h4>
|
||||
<div className="launcher-request-admin-card-hint">Select a request to load it on the right.</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="launcher-request-admin-request-list">
|
||||
{!requestsLoading && adminFilteredRequests.length === 0 ? (
|
||||
<div className="launcher-request-empty">No requests match the current search or filters.</div>
|
||||
) : null}
|
||||
{adminFilteredRequests.map((requestEntry) => {
|
||||
const isMutating = requestMutatingId === requestEntry.id;
|
||||
const isSelected = requestEntry.id === selectedAdminRequestId;
|
||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||
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">
|
||||
<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>
|
||||
<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}
|
||||
</div>
|
||||
<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">
|
||||
{!adminEditorDraft ? (
|
||||
<div className="launcher-request-empty">Select a request from the list to review it.</div>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
<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 }))}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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">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,
|
||||
},
|
||||
},
|
||||
}))}
|
||||
>
|
||||
<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) => ({
|
||||
...current,
|
||||
analysis: {
|
||||
...(current.analysis || {}),
|
||||
routing: {
|
||||
...(current.analysis?.routing || {}),
|
||||
suggestedTags: appendUniqueString(
|
||||
Array.isArray(current.analysis?.routing?.suggestedTags) ? current.analysis.routing.suggestedTags : [],
|
||||
value,
|
||||
),
|
||||
},
|
||||
},
|
||||
}))}
|
||||
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>
|
||||
<label className="launcher-request-admin-field">
|
||||
<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 || "")}
|
||||
onChange={(event) => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
analysis: {
|
||||
...(current.analysis || {}),
|
||||
routing: {
|
||||
...(current.analysis?.routing || {}),
|
||||
summary: event.target.value,
|
||||
},
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Routing Rationale</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={String(adminEditorDraft.analysis?.routing?.rationale || "")}
|
||||
onChange={(event) => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
analysis: {
|
||||
...(current.analysis || {}),
|
||||
routing: {
|
||||
...(current.analysis?.routing || {}),
|
||||
rationale: event.target.value,
|
||||
},
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Possible Directions</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={Array.isArray(adminEditorDraft.analysis?.routing?.possibleDirections) ? adminEditorDraft.analysis.routing.possibleDirections.join("\n") : ""}
|
||||
onChange={(event) => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
analysis: {
|
||||
...(current.analysis || {}),
|
||||
routing: {
|
||||
...(current.analysis?.routing || {}),
|
||||
possibleDirections: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
||||
},
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : 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) => ({
|
||||
...current,
|
||||
tags: appendUniqueString(Array.isArray(current.tags) ? current.tags : [], value),
|
||||
}))}
|
||||
onRemove={(value) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
||||
...current,
|
||||
tags: removeStringValue(Array.isArray(current.tags) ? current.tags : [], value),
|
||||
}))}
|
||||
/>
|
||||
<label className="launcher-request-admin-field">
|
||||
<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 }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<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 }))}
|
||||
/>
|
||||
</label>
|
||||
<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">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Review Rationale</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
||||
value={String(selectedAnalysisItem.reviewRationale || "")}
|
||||
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Possible Options</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-xs"
|
||||
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),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { formatEventDetail, formatEventLabel, formatRequestTimestamp } from "../utils";
|
||||
|
||||
export function LauncherLogsModal({
|
||||
logsLoading,
|
||||
recentSaveEvents,
|
||||
setLogsModalOpen,
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import {
|
||||
CHANGELOG_SECTIONS,
|
||||
CHANGELOG_SPLASH_FOOTNOTE,
|
||||
CHANGELOG_SPLASH_KICKER,
|
||||
CHANGELOG_SPLASH_TITLE,
|
||||
CHANGELOG_SPLASH_VERSION,
|
||||
type ChangelogItem,
|
||||
} from "../changelogData";
|
||||
|
||||
export function LauncherNewsPanel() {
|
||||
return (
|
||||
<div className="changelog-splash-card launcher-request-board-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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,234 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import {
|
||||
FilterIcon,
|
||||
formatRequestSubmittedDate,
|
||||
getRequestDisplayStateClassName,
|
||||
getRequestDisplayStateLabel,
|
||||
} from "../utils";
|
||||
|
||||
export function LauncherPublicRequestBoard({
|
||||
requestCount,
|
||||
queuedPendingRequestCount,
|
||||
needsReviewRequestCount,
|
||||
activeRequestCount,
|
||||
implementedRequestCount,
|
||||
requestDraftOpen,
|
||||
requestSubmitting,
|
||||
requestSearchText,
|
||||
requestFilterMenuOpen,
|
||||
requestStatusFilterOptions,
|
||||
requestTagFilterOptions,
|
||||
requestStatusFilters,
|
||||
requestTagFilters,
|
||||
requestsLoading,
|
||||
requests,
|
||||
filteredRequests,
|
||||
expandedRequestIds,
|
||||
setRequestDraftOpen,
|
||||
handleAdminPanelToggle,
|
||||
setRequestSearchText,
|
||||
setRequestFilterMenuOpen,
|
||||
setRequestStatusFilters,
|
||||
setRequestTagFilters,
|
||||
toggleStringSelection,
|
||||
handleToggleExpandedRequest,
|
||||
requestDraft,
|
||||
setRequestDraft,
|
||||
handleAddRequest,
|
||||
}) {
|
||||
return (
|
||||
<div className="changelog-splash-card">
|
||||
<div className="changelog-splash-hero">
|
||||
<div className="changelog-splash-kicker">Shared Request Board</div>
|
||||
<div className="changelog-splash-title" id="launcher-requests-title">Requests</div>
|
||||
<div className="changelog-splash-meta">
|
||||
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, {activeRequestCount} active, and {implementedRequestCount} implemented.
|
||||
</div>
|
||||
<div className="launcher-request-hero-actions">
|
||||
<div className="launcher-request-toolbar-buttons">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-primary-btn"
|
||||
onClick={() => {
|
||||
setRequestDraftOpen((value) => !value);
|
||||
}}
|
||||
disabled={requestSubmitting}
|
||||
>
|
||||
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => void handleAdminPanelToggle()}
|
||||
>
|
||||
Open Admin Window
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{requestDraftOpen ? (
|
||||
<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}
|
||||
<div className="launcher-request-list">
|
||||
{requestsLoading ? (
|
||||
<section className="launcher-request-entry">
|
||||
<div className="launcher-request-empty">Loading saved requests...</div>
|
||||
</section>
|
||||
) : null}
|
||||
{!requestsLoading && filteredRequests.length === 0 ? (
|
||||
<section className="launcher-request-entry">
|
||||
<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>
|
||||
</section>
|
||||
) : null}
|
||||
{!requestsLoading ? filteredRequests.map((requestEntry) => {
|
||||
const isExpanded = expandedRequestIds.includes(requestEntry.id);
|
||||
const isActiveRequest = requestEntry.status === "active" || requestEntry.status === "implemented";
|
||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||
return (
|
||||
<section
|
||||
key={requestEntry.id}
|
||||
className={`launcher-request-entry is-${requestEntry.status} ${isExpanded ? "is-expanded" : ""} ${isActiveRequest ? "is-clickable" : ""}`}
|
||||
onClick={isActiveRequest ? () => handleToggleExpandedRequest(requestEntry.id) : undefined}
|
||||
>
|
||||
<div className="launcher-request-entry-head">
|
||||
<div className="launcher-request-entry-head-main">
|
||||
<div className={`launcher-request-status-pill is-${requestDisplayStateClassName}`}>
|
||||
{requestDisplayState}
|
||||
</div>
|
||||
<div className="launcher-request-entry-title-block">
|
||||
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="launcher-request-entry-date" title={`Submitted ${formatRequestSubmittedDate(requestEntry.createdAt)}`}>
|
||||
{formatRequestSubmittedDate(requestEntry.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
{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}
|
||||
</section>
|
||||
);
|
||||
}) : null}
|
||||
</div>
|
||||
<div className="changelog-splash-footer">
|
||||
<div className="changelog-splash-footnote">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import { fetchJsonOrThrow, buildAdminHeaders } from "./utils";
|
||||
|
||||
export async function loadLauncherRequests() {
|
||||
return fetchJsonOrThrow("/api/launcher-requests");
|
||||
}
|
||||
|
||||
export async function loadLauncherRequestMeta() {
|
||||
return fetchJsonOrThrow("/api/launcher-request-meta");
|
||||
}
|
||||
|
||||
export async function createLauncherRequest(text) {
|
||||
return fetchJsonOrThrow("/api/launcher-requests", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadLauncherRecentSaveEvents(adminPassword) {
|
||||
return fetchJsonOrThrow("/api/debug/recent-saves", {
|
||||
headers: buildAdminHeaders(adminPassword),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyLauncherAdminPassword(password) {
|
||||
const payload = await fetchJsonOrThrow("/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."));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildLauncherAdminSavePayload(adminPassword, requestEntry) {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveLauncherAdminRequest(adminPassword, requestEntry) {
|
||||
return fetchJsonOrThrow(
|
||||
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`,
|
||||
buildLauncherAdminSavePayload(adminPassword, requestEntry),
|
||||
);
|
||||
}
|
||||
|
||||
export async function promoteLauncherAdminRequest(adminPassword, requestEntry) {
|
||||
return fetchJsonOrThrow(
|
||||
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}/process-analysis`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: buildAdminHeaders(adminPassword, {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
action: "promote",
|
||||
analysis: requestEntry.analysis,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function requeueLauncherAdminRequest(adminPassword, requestEntry, mode) {
|
||||
return fetchJsonOrThrow(
|
||||
`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}/requeue-analysis`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: buildAdminHeaders(adminPassword, {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
mode,
|
||||
request: mode === "draft"
|
||||
? {
|
||||
title: requestEntry.title,
|
||||
category: requestEntry.category,
|
||||
tags: requestEntry.tags,
|
||||
sourceText: requestEntry.sourceText,
|
||||
summary: requestEntry.summary,
|
||||
implementationNotes: requestEntry.implementationNotes,
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLauncherRequest(adminPassword, requestId) {
|
||||
return fetchJsonOrThrow(`/api/launcher-requests/${encodeURIComponent(requestId)}`, {
|
||||
method: "DELETE",
|
||||
headers: buildAdminHeaders(adminPassword),
|
||||
});
|
||||
}
|
||||
|
||||
export async function triggerLauncherPendingQueue(adminPassword) {
|
||||
return fetchJsonOrThrow("/api/launcher-requests/process-pending", {
|
||||
method: "POST",
|
||||
headers: buildAdminHeaders(adminPassword),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
export type WorldDefaultPayload = {
|
||||
worldId?: string;
|
||||
world?: {
|
||||
id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
||||
export type BoardTab = "news" | "requests";
|
||||
export type LauncherWindowMode = "public" | "admin";
|
||||
export type LauncherRequestStatus = "pending" | "active" | "implemented";
|
||||
export type AdminDetailTab = "routing" | "analysis";
|
||||
|
||||
export type LauncherRequestAnalysisRouting = {
|
||||
summary?: string;
|
||||
ambiguity?: "low" | "medium" | "high";
|
||||
matchedTerms?: string[];
|
||||
suggestedTags?: string[];
|
||||
suggestedSystems?: string[];
|
||||
suggestedModules?: string[];
|
||||
rationale?: string;
|
||||
possibleDirections?: string[];
|
||||
kbSections?: string[];
|
||||
};
|
||||
|
||||
export type LauncherRequestAnalysisItem = {
|
||||
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;
|
||||
};
|
||||
|
||||
export 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?: LauncherRequestAnalysisItem[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type LauncherRequestsPayload = {
|
||||
requests?: LauncherRequest[];
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export type RecentSaveEventsPayload = {
|
||||
saves?: RecentSaveEvent[];
|
||||
};
|
||||
|
||||
export type ProcessPendingPayload = {
|
||||
ok?: boolean;
|
||||
launched?: boolean;
|
||||
reason?: string;
|
||||
autorunEnabled?: boolean;
|
||||
configured?: boolean;
|
||||
queuedPendingCount?: number;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export type RequeueAnalysisPayload = {
|
||||
ok?: boolean;
|
||||
launched?: boolean;
|
||||
reason?: string;
|
||||
request?: LauncherRequest;
|
||||
requests?: LauncherRequest[];
|
||||
requestId?: string;
|
||||
queuedPendingCount?: number;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export type LauncherRequestMetaPayload = {
|
||||
allowedTags?: string[];
|
||||
};
|
||||
|
||||
export type AdminAuthPayload = {
|
||||
ok?: boolean;
|
||||
accessGranted?: boolean;
|
||||
adminConfigured?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
AdminDetailTab,
|
||||
BoardTab,
|
||||
LauncherRequest,
|
||||
LauncherRequestAnalysisItem,
|
||||
} from "./types";
|
||||
import {
|
||||
createLauncherRequest,
|
||||
deleteLauncherRequest,
|
||||
loadLauncherRecentSaveEvents,
|
||||
loadLauncherRequestMeta,
|
||||
loadLauncherRequests,
|
||||
promoteLauncherAdminRequest,
|
||||
requeueLauncherAdminRequest,
|
||||
saveLauncherAdminRequest,
|
||||
triggerLauncherPendingQueue,
|
||||
verifyLauncherAdminPassword,
|
||||
} from "./requestApi";
|
||||
import {
|
||||
appendUniqueString,
|
||||
cloneLauncherRequest,
|
||||
hydrateLauncherRequestForUi,
|
||||
isAdminAccessError,
|
||||
isNeedsReviewRequest,
|
||||
isQueuedPendingRequest,
|
||||
normalizeStringList,
|
||||
openAdminPanelWindow,
|
||||
removeStringValue,
|
||||
requestMatchesFilters,
|
||||
} from "./utils";
|
||||
|
||||
export function useLauncherRequestBoard({ adminWindowMode }) {
|
||||
const adminPanelOpen = adminWindowMode;
|
||||
const [activeBoardTab, setActiveBoardTab] = useState<BoardTab>(adminWindowMode ? "requests" : "news");
|
||||
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("");
|
||||
const [requestSearchText, setRequestSearchText] = useState("");
|
||||
const [requestFilterMenuOpen, setRequestFilterMenuOpen] = useState(false);
|
||||
const [requestStatusFilters, setRequestStatusFilters] = useState<string[]>([]);
|
||||
const [requestTagFilters, setRequestTagFilters] = useState<string[]>([]);
|
||||
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
|
||||
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
||||
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
|
||||
const [adminPassword, setAdminPassword] = useState("");
|
||||
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
|
||||
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
||||
const [adminPasswordError, setAdminPasswordError] = useState("");
|
||||
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
|
||||
const [selectedAdminAnalysisIndex, setSelectedAdminAnalysisIndex] = useState(0);
|
||||
const [adminSearchText, setAdminSearchText] = useState("");
|
||||
const [adminFilterMenuOpen, setAdminFilterMenuOpen] = useState(false);
|
||||
const [adminStatusFilters, setAdminStatusFilters] = useState<string[]>([]);
|
||||
const [adminTagFilters, setAdminTagFilters] = useState<string[]>([]);
|
||||
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
|
||||
const [adminDetailTab, setAdminDetailTab] = useState<AdminDetailTab>("routing");
|
||||
const [adminSaving, setAdminSaving] = useState(false);
|
||||
const [recentSaveEvents, setRecentSaveEvents] = useState([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
||||
const [logsError, setLogsError] = useState("");
|
||||
const [queueTriggering, setQueueTriggering] = useState(false);
|
||||
const [requeueingMode, setRequeueingMode] = useState<"" | "saved" | "draft">("");
|
||||
const [adminNotice, setAdminNotice] = useState("");
|
||||
|
||||
async function loadRequests(options?: { silent?: boolean }): Promise<void> {
|
||||
const silent = options?.silent === true;
|
||||
if (!silent) {
|
||||
setRequestsLoading(true);
|
||||
}
|
||||
try {
|
||||
const payload = await loadLauncherRequests();
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
||||
setRequestsError("");
|
||||
} catch (nextError: unknown) {
|
||||
setRequestsError(String(nextError || "Failed to load requests."));
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setRequestsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRequestMeta(): Promise<void> {
|
||||
try {
|
||||
const payload = await loadLauncherRequestMeta();
|
||||
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
|
||||
} catch {
|
||||
setAllowedRequestTags([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentSaveEvents(): Promise<void> {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const payload = await loadLauncherRecentSaveEvents(adminPassword);
|
||||
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
|
||||
setLogsError("");
|
||||
} catch (nextError: unknown) {
|
||||
if (isAdminAccessError(nextError)) {
|
||||
setAdminAccessGranted(false);
|
||||
}
|
||||
setLogsError(String(nextError || "Failed to load admin logs."));
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAdminPassword(password: string): Promise<void> {
|
||||
await verifyLauncherAdminPassword(password);
|
||||
}
|
||||
|
||||
async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise<void> {
|
||||
await loadRequests({ silent: options?.silentRequests === true });
|
||||
if (options?.includeLogs && adminAccessGranted && adminPassword) {
|
||||
await loadRecentSaveEvents();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRequests();
|
||||
void loadRequestMeta();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
||||
return;
|
||||
}
|
||||
void loadRecentSaveEvents();
|
||||
}, [adminPanelOpen, adminAccessGranted, adminPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeBoardTab !== "requests") {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refreshBoard = async (): Promise<void> => {
|
||||
if (!adminPanelOpen) {
|
||||
try {
|
||||
const payload = await loadLauncherRequests();
|
||||
if (!cancelled) {
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
||||
}
|
||||
} catch {
|
||||
// Keep the current list visible during background refresh failures.
|
||||
}
|
||||
}
|
||||
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await loadLauncherRecentSaveEvents(adminPassword);
|
||||
if (!cancelled) {
|
||||
setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []);
|
||||
}
|
||||
} catch {
|
||||
// Avoid surfacing noisy polling failures in the admin panel.
|
||||
}
|
||||
};
|
||||
const intervalId = window.setInterval(() => {
|
||||
void refreshBoard();
|
||||
}, 15000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
|
||||
|
||||
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) {
|
||||
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(selectedRequest)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedAdminRequestId(requests[0].id);
|
||||
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(requests[0])));
|
||||
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAdminAnalysisIndex(0);
|
||||
}, [selectedAdminRequestId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAdminDetailTab("routing");
|
||||
}, [selectedAdminRequestId]);
|
||||
|
||||
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 createLauncherRequest(text);
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
||||
setRequestDraft("");
|
||||
setRequestDraftOpen(false);
|
||||
setRequestsError("");
|
||||
setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled.");
|
||||
if (adminPanelOpen && adminAccessGranted) {
|
||||
void loadRecentSaveEvents();
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true });
|
||||
}, 3500);
|
||||
} catch (nextError: unknown) {
|
||||
setRequestsError(String(nextError || "Failed to save request."));
|
||||
} finally {
|
||||
setRequestSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdminPanelToggle(): Promise<void> {
|
||||
setRequestDraftOpen(false);
|
||||
setRequestsError("");
|
||||
setLogsError("");
|
||||
if (adminWindowMode) {
|
||||
return;
|
||||
}
|
||||
if (!openAdminPanelWindow()) {
|
||||
setAdminNotice("Allow popups to open the admin review window.");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAdminRequest(requestId: string): void {
|
||||
const nextRequest = requests.find((entry) => entry.id === requestId);
|
||||
setSelectedAdminRequestId(requestId);
|
||||
setSelectedAdminAnalysisIndex(0);
|
||||
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(hydrateLauncherRequestForUi(nextRequest)) : null);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveAdminRequest(): Promise<void> {
|
||||
if (!adminEditorDraft) {
|
||||
return;
|
||||
}
|
||||
setAdminSaving(true);
|
||||
try {
|
||||
const payload = await saveLauncherAdminRequest(adminPassword, adminEditorDraft);
|
||||
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
|
||||
setRequests(nextRequests);
|
||||
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
|
||||
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed)));
|
||||
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 : [];
|
||||
if (items.length === 0) {
|
||||
setLogsError("This request does not have a structured analysis item to approve yet.");
|
||||
return;
|
||||
}
|
||||
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 saveLauncherAdminRequest(adminPassword, nextDraft);
|
||||
const promotePayload = await promoteLauncherAdminRequest(adminPassword, nextDraft);
|
||||
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests.map(hydrateLauncherRequestForUi) : [];
|
||||
setRequests(nextRequests);
|
||||
const fallbackSelection = nextRequests[0] || null;
|
||||
setSelectedAdminRequestId(fallbackSelection?.id || "");
|
||||
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(hydrateLauncherRequestForUi(fallbackSelection)) : null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequeueAnalysis(mode: "saved" | "draft"): Promise<void> {
|
||||
if (!adminEditorDraft) {
|
||||
return;
|
||||
}
|
||||
setRequeueingMode(mode);
|
||||
setLogsError("");
|
||||
try {
|
||||
const payload = await requeueLauncherAdminRequest(adminPassword, adminEditorDraft, mode);
|
||||
const nextRequests = Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : requests;
|
||||
setRequests(nextRequests);
|
||||
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || hydrateLauncherRequestForUi(payload.request || adminEditorDraft);
|
||||
setAdminEditorDraft(cloneLauncherRequest(hydrateLauncherRequestForUi(refreshed)));
|
||||
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("");
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleExpandedRequest(requestId: string): void {
|
||||
setExpandedRequestIds((current) => (
|
||||
current.includes(requestId)
|
||||
? current.filter((entry) => entry !== requestId)
|
||||
: [...current, requestId]
|
||||
));
|
||||
}
|
||||
|
||||
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
|
||||
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.title}`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setRequestMutatingId(requestEntry.id);
|
||||
try {
|
||||
const payload = await deleteLauncherRequest(adminPassword, requestEntry.id);
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests.map(hydrateLauncherRequestForUi) : []);
|
||||
setRequestsError("");
|
||||
setExpandedRequestIds((current) => current.filter((entry) => entry !== requestEntry.id));
|
||||
setAdminNotice(`Deleted request "${requestEntry.title}".`);
|
||||
if (adminPanelOpen) {
|
||||
void loadRecentSaveEvents();
|
||||
}
|
||||
} catch (nextError: unknown) {
|
||||
if (isAdminAccessError(nextError)) {
|
||||
setAdminAccessGranted(false);
|
||||
setAdminPassword("");
|
||||
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||
}
|
||||
setRequestsError(String(nextError || "Failed to delete request."));
|
||||
} finally {
|
||||
setRequestMutatingId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProcessPendingQueue(): Promise<void> {
|
||||
setQueueTriggering(true);
|
||||
try {
|
||||
const payload = await triggerLauncherPendingQueue(adminPassword);
|
||||
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) {
|
||||
if (isAdminAccessError(nextError)) {
|
||||
setAdminAccessGranted(false);
|
||||
setAdminPassword("");
|
||||
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||
}
|
||||
setLogsError(String(nextError || "Failed to trigger the queue worker."));
|
||||
} finally {
|
||||
setQueueTriggering(false);
|
||||
}
|
||||
}
|
||||
|
||||
const requestCount = requests.length;
|
||||
const pendingRequestCount = requests.filter((entry) => entry.status === "pending").length;
|
||||
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
||||
const implementedRequestCount = requests.filter((entry) => entry.status === "implemented").length;
|
||||
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
|
||||
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
|
||||
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));
|
||||
const requestTagFilterOptions = requestTags
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
count: requests.filter((entry) => entry.tags.includes(tag)).length,
|
||||
}))
|
||||
.filter((entry) => entry.count > 0);
|
||||
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 || ""),
|
||||
]);
|
||||
const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board";
|
||||
const boardHint = adminWindowMode
|
||||
? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`
|
||||
: (activeBoardTab === "news"
|
||||
? "Latest announcements"
|
||||
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`);
|
||||
|
||||
return {
|
||||
adminPanelOpen,
|
||||
activeBoardTab,
|
||||
setActiveBoardTab,
|
||||
requests,
|
||||
requestsLoading,
|
||||
requestsError,
|
||||
requestDraftOpen,
|
||||
setRequestDraftOpen,
|
||||
requestDraft,
|
||||
setRequestDraft,
|
||||
requestSubmitting,
|
||||
requestMutatingId,
|
||||
requestSearchText,
|
||||
setRequestSearchText,
|
||||
requestFilterMenuOpen,
|
||||
setRequestFilterMenuOpen,
|
||||
requestStatusFilters,
|
||||
setRequestStatusFilters,
|
||||
requestTagFilters,
|
||||
setRequestTagFilters,
|
||||
allowedRequestTags,
|
||||
expandedRequestIds,
|
||||
adminAccessGranted,
|
||||
adminPassword,
|
||||
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,
|
||||
requestTags,
|
||||
requestTagFilterOptions,
|
||||
requestStatusFilterOptions,
|
||||
filteredRequests,
|
||||
adminFilteredRequests,
|
||||
selectedAnalysisItem,
|
||||
standardizedTagOptions,
|
||||
categoryOptions,
|
||||
boardTitle,
|
||||
boardHint,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
import { openWorldshaperStudioWindow } from "../shared/windowing";
|
||||
import type {
|
||||
AdminAuthPayload,
|
||||
LauncherRequest,
|
||||
LauncherRequestAnalysisItem,
|
||||
LauncherRequestAnalysisRouting,
|
||||
LauncherRequestStatus,
|
||||
LauncherWindowMode,
|
||||
RecentSaveEvent,
|
||||
WorldDefaultPayload,
|
||||
} from "./types";
|
||||
import { DEFAULT_EDITOR_WORLD_ID_FALLBACK } from "./types";
|
||||
|
||||
export function readLauncherWindowMode(): LauncherWindowMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "public";
|
||||
}
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
return searchParams.get("admin") === "requests" ? "admin" : "public";
|
||||
}
|
||||
|
||||
export function normalizeStringList(values: string[]): string[] {
|
||||
return Array.from(new Set(
|
||||
values
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean),
|
||||
)).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function appendUniqueString(values: string[], value: string): string[] {
|
||||
const normalizedValue = String(value || "").trim();
|
||||
if (!normalizedValue) {
|
||||
return normalizeStringList(values);
|
||||
}
|
||||
return normalizeStringList([...values, normalizedValue]);
|
||||
}
|
||||
|
||||
export function removeStringValue(values: string[], value: string): string[] {
|
||||
const normalizedValue = String(value || "").trim().toLowerCase();
|
||||
return normalizeStringList(values.filter((entry) => entry.trim().toLowerCase() !== normalizedValue));
|
||||
}
|
||||
|
||||
export function toggleStringSelection(current: string[], value: string): string[] {
|
||||
return current.includes(value)
|
||||
? current.filter((entry) => entry !== value)
|
||||
: [...current, value].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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>;
|
||||
}
|
||||
|
||||
export function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit {
|
||||
const normalizedPassword = String(password || "").trim();
|
||||
if (!normalizedPassword) {
|
||||
return {
|
||||
...(headers || {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...(headers || {}),
|
||||
"x-worldshaper-admin-password": normalizedPassword,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAdminAccessError(error: unknown): boolean {
|
||||
const text = String(error || "").toLowerCase();
|
||||
return text.includes("admin access denied")
|
||||
|| text.includes("admin access is not configured");
|
||||
}
|
||||
|
||||
export function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
||||
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function normalizeAnalysisState(value: string | undefined): string {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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";
|
||||
}
|
||||
|
||||
export function isQueuedPendingRequest(requestEntry: LauncherRequest): boolean {
|
||||
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
|
||||
return requestEntry.status === "pending" && (!analysisState || analysisState === "unprocessed" || analysisState === "processing");
|
||||
}
|
||||
|
||||
export function isNeedsReviewRequest(requestEntry: LauncherRequest): boolean {
|
||||
const analysisState = normalizeAnalysisState(requestEntry.analysis?.state);
|
||||
return requestEntry.status === "pending" && (analysisState === "needs_review" || analysisState === "error");
|
||||
}
|
||||
|
||||
export 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");
|
||||
}
|
||||
}
|
||||
|
||||
export 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(" | ");
|
||||
}
|
||||
|
||||
export function openStudioPopup(worldId: string): boolean {
|
||||
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
||||
return Boolean(popup);
|
||||
}
|
||||
|
||||
export function openRepo(): void {
|
||||
window.location.assign("https://repo.andraxion.net/");
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export type { AdminAuthPayload, LauncherRequestStatus };
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import WorldshaperLauncher from './launcher/WorldshaperLauncher.tsx'
|
||||
import WorldshaperLauncher from './WorldshaperLauncher.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
export type PopupBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio";
|
||||
export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds";
|
||||
export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer";
|
||||
export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds";
|
||||
|
||||
export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
|
||||
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin);
|
||||
const normalizedMapId = String(mapId || "").trim();
|
||||
const normalizedWorldId = String(options?.worldId || "").trim();
|
||||
if (normalizedMapId) {
|
||||
popupUrl.searchParams.set("mapId", normalizedMapId);
|
||||
}
|
||||
if (normalizedWorldId) {
|
||||
popupUrl.searchParams.set("worldId", normalizedWorldId);
|
||||
}
|
||||
return popupUrl.toString();
|
||||
}
|
||||
|
||||
export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
|
||||
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin);
|
||||
const normalizedMapId = String(mapId || "").trim();
|
||||
const normalizedToken = String(token || "").trim();
|
||||
if (normalizedMapId) {
|
||||
popupUrl.searchParams.set("mapId", normalizedMapId);
|
||||
}
|
||||
if (normalizedToken) {
|
||||
popupUrl.searchParams.set("token", normalizedToken);
|
||||
}
|
||||
return popupUrl.toString();
|
||||
}
|
||||
|
||||
export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
|
||||
const width = 1360;
|
||||
const height = 900;
|
||||
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
|
||||
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
|
||||
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
|
||||
? hostWindow.outerWidth
|
||||
: hostWindow.innerWidth;
|
||||
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
|
||||
? hostWindow.outerHeight
|
||||
: hostWindow.innerHeight;
|
||||
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
|
||||
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
|
||||
const width = 1280;
|
||||
const height = 820;
|
||||
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
|
||||
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
|
||||
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
|
||||
? hostWindow.outerWidth
|
||||
: hostWindow.innerWidth;
|
||||
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
|
||||
? hostWindow.outerHeight
|
||||
: hostWindow.innerHeight;
|
||||
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
|
||||
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
|
||||
try {
|
||||
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
|
||||
const width = Math.max(640, Number(parsed.width) || 0);
|
||||
const height = Math.max(480, Number(parsed.height) || 0);
|
||||
const left = Math.max(0, Number(parsed.left) || 0);
|
||||
const top = Math.max(0, Number(parsed.top) || 0);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
return { left, top, width, height };
|
||||
} catch {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
|
||||
try {
|
||||
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
|
||||
const width = Math.max(640, Number(parsed.width) || 0);
|
||||
const height = Math.max(480, Number(parsed.height) || 0);
|
||||
const left = Math.max(0, Number(parsed.left) || 0);
|
||||
const top = Math.max(0, Number(parsed.top) || 0);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
return { left, top, width, height };
|
||||
} catch {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void {
|
||||
if (sourceWindow.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
|
||||
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
|
||||
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
|
||||
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
|
||||
sourceWindow.localStorage.setItem(
|
||||
WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify({ left, top, width, height }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage and same-origin failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void {
|
||||
if (sourceWindow.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
|
||||
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
|
||||
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
|
||||
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
|
||||
sourceWindow.localStorage.setItem(
|
||||
WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify({ left, top, width, height }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage and same-origin failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function openWorldshaperStudioWindow(
|
||||
mapId: string,
|
||||
hostWindow: Window = window,
|
||||
options?: { worldId?: string },
|
||||
): Window | null {
|
||||
const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options);
|
||||
const initialBounds = readWorldshaperStudioBounds(hostWindow);
|
||||
const popupFeatures = [
|
||||
"popup=yes",
|
||||
"resizable=yes",
|
||||
"scrollbars=no",
|
||||
"width=" + initialBounds.width,
|
||||
"height=" + initialBounds.height,
|
||||
"left=" + initialBounds.left,
|
||||
"top=" + initialBounds.top,
|
||||
].join(",");
|
||||
|
||||
const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures);
|
||||
if (!popup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
popup.moveTo(initialBounds.left, initialBounds.top);
|
||||
popup.resizeTo(initialBounds.width, initialBounds.height);
|
||||
} catch {
|
||||
// Ignore browser restrictions.
|
||||
}
|
||||
|
||||
popup.location.href = popupUrl;
|
||||
popup.focus();
|
||||
return popup;
|
||||
}
|
||||
|
||||
export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null {
|
||||
const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow);
|
||||
const initialBounds = readWorldshaperHeightViewerBounds(hostWindow);
|
||||
const popupFeatures = [
|
||||
"popup=yes",
|
||||
"resizable=yes",
|
||||
"scrollbars=no",
|
||||
"width=" + initialBounds.width,
|
||||
"height=" + initialBounds.height,
|
||||
"left=" + initialBounds.left,
|
||||
"top=" + initialBounds.top,
|
||||
].join(",");
|
||||
|
||||
const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures);
|
||||
if (!popup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
popup.moveTo(initialBounds.left, initialBounds.top);
|
||||
popup.resizeTo(initialBounds.width, initialBounds.height);
|
||||
} catch {
|
||||
// Ignore browser restrictions.
|
||||
}
|
||||
|
||||
popup.location.href = popupUrl;
|
||||
popup.focus();
|
||||
return popup;
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
loadWorldshaperStudioBootstrap,
|
||||
loadStandaloneWorldshaperBootstrap,
|
||||
} from "../worldshaperStudio/bootstrap";
|
||||
import { persistWorldshaperHeightViewerBounds } from "../shared/windowing";
|
||||
import { persistWorldshaperHeightViewerBounds } from "../worldshaperStudio/windowing";
|
||||
import { createDebouncedCallback } from "../worldshaperStudio/debounce";
|
||||
|
||||
const VIEWER_STYLE_ID = "worldshaper-height-viewer-styles";
|
||||
|
|
|
|||
|
|
@ -1 +1,39 @@
|
|||
export * from "../launcher/changelogData";
|
||||
export type ChangelogItem = string | {
|
||||
text: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ChangelogSection = {
|
||||
title: string;
|
||||
items: ReadonlyArray<ChangelogItem>;
|
||||
};
|
||||
|
||||
export const CHANGELOG_SPLASH_VERSION = "2026-06-26-launcher-presentation-update";
|
||||
export const CHANGELOG_SPLASH_KICKER = "Launch Experience Update";
|
||||
export const CHANGELOG_SPLASH_TITLE = "What's New";
|
||||
export const CHANGELOG_SPLASH_FOOTNOTE = "This release focuses on presentation, access, and a cleaner studio handoff.";
|
||||
|
||||
export const CHANGELOG_SECTIONS: ReadonlyArray<ChangelogSection> = [
|
||||
{
|
||||
title: "Studio Launch Experience",
|
||||
items: [
|
||||
"Worldshaper now opens from a dedicated launch page built to frame the studio instead of burying it behind a utility screen.",
|
||||
"The editor now launches only in its slim floating window, keeping the first impression focused on the intended workspace.",
|
||||
"The launch page now opens with an editor showcase backdrop that sets the tone before you step inside.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Project Access",
|
||||
items: [
|
||||
"Added a direct Repo destination from the launcher, making project browsing and source access part of the front door.",
|
||||
"Release highlights now live on the main page, so returning creators can catch up before jumping back into the world.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Presentation & Structure",
|
||||
items: [
|
||||
"The launcher now gives the studio controls and release notes their own stage, mirroring the feel of the in-editor update window.",
|
||||
"The entry flow has been tightened into a cleaner, more cinematic handoff from main page to creation space.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,34 +0,0 @@
|
|||
import { WORLDSHAPER_THEME_PRESETS } from "./themePresets";
|
||||
import {
|
||||
WORLDSHAPER_STUDIO_MARKUP_SHELL,
|
||||
WORLDSHAPER_STUDIO_MARKUP_SIDEBAR,
|
||||
WORLDSHAPER_STUDIO_MARKUP_STAGE,
|
||||
} from "./domMarkupSections";
|
||||
|
||||
export function buildWorldshaperStudioPopupMarkup(): string {
|
||||
const themePresetButtons = WORLDSHAPER_THEME_PRESETS.map((preset) => `
|
||||
<button
|
||||
class="theme-preset-btn"
|
||||
data-theme-preset="${preset.id}"
|
||||
type="button"
|
||||
title="${preset.label} theme"
|
||||
aria-label="Switch to ${preset.label} theme"
|
||||
>
|
||||
<span
|
||||
class="theme-preset-swatch"
|
||||
style="--theme-swatch-a:${preset.swatch[0]}; --theme-swatch-b:${preset.swatch[1]}; --theme-swatch-c:${preset.swatch[2]}; --theme-swatch-d:${preset.swatch[3]};"
|
||||
></span>
|
||||
</button>
|
||||
`).join("");
|
||||
return (
|
||||
WORLDSHAPER_STUDIO_MARKUP_SHELL.replace("__THEME_PRESET_BUTTONS__", themePresetButtons)
|
||||
+ WORLDSHAPER_STUDIO_MARKUP_SIDEBAR
|
||||
+ WORLDSHAPER_STUDIO_MARKUP_STAGE
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorldshaperStudioBodyMarkup(): string {
|
||||
return buildWorldshaperStudioPopupMarkup()
|
||||
.replace(/^<body>/i, "")
|
||||
.replace(/<\/body>\s*$/i, "");
|
||||
}
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
export const WORLDSHAPER_STUDIO_MARKUP_SHELL = `
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="menu-bar" id="menuBar">
|
||||
<button class="menu-btn" id="undoBtn" type="button">Undo</button>
|
||||
<button class="menu-btn" id="redoBtn" type="button">Redo</button>
|
||||
<button class="menu-btn" id="saveBtn" type="button">Save</button>
|
||||
<button class="menu-btn" id="testHeightBtn" type="button">Test Height</button>
|
||||
<div class="menu-bar-center">
|
||||
<label class="menu-layer-label" for="menuLayerSelect">Change Layer:</label>
|
||||
<select class="menu-layer-select" id="menuLayerSelect"></select>
|
||||
</div>
|
||||
<div class="menu-bar-right">
|
||||
<div class="theme-preset-bar" role="group" aria-label="Editor theme presets">
|
||||
__THEME_PRESET_BUTTONS__
|
||||
</div>
|
||||
<span id="saveStatus" class="save-status" title="Ready" aria-label="Ready">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body" id="editorBody">
|
||||
|
||||
`;
|
||||
|
||||
export const WORLDSHAPER_STUDIO_MARKUP_SIDEBAR = `
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-tabs" id="sidebarTabs">
|
||||
<div class="sidebar-tab-row">
|
||||
<button class="sidebar-tab-btn" id="informationTabBtn" type="button">Settings</button>
|
||||
<button class="sidebar-tab-btn" id="historyTabBtn" type="button">History</button>
|
||||
<button class="sidebar-tab-btn" id="newsTabBtn" type="button">News</button>
|
||||
</div>
|
||||
<div class="sidebar-tab-row">
|
||||
<button class="sidebar-tab-btn" id="instancesTabBtn" type="button">Entities</button>
|
||||
<button class="sidebar-tab-btn" id="tilesTabBtn" type="button">Graphics</button>
|
||||
<button class="sidebar-tab-btn active" id="layersTabBtn" type="button">Layers</button>
|
||||
</div>
|
||||
<div class="sidebar-tab-row">
|
||||
<button class="sidebar-tab-btn" id="triggersTabBtn" type="button">Triggers</button>
|
||||
<button class="sidebar-tab-btn" id="pathsTabBtn" type="button">Paths</button>
|
||||
<button class="sidebar-tab-btn" id="transitionsTabBtn" type="button">Transitions</button>
|
||||
</div>
|
||||
<div class="sidebar-tab-row">
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panels-host" id="sidebarPanelsHost">
|
||||
<div class="sidebar-panel hidden" id="informationPanel">
|
||||
<h3>Settings</h3>
|
||||
<div class="information-panel-layout">
|
||||
<div class="selector-section">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleInformationSettingsSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>World Info</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="selector-section-body" id="informationSettingsSectionBody">
|
||||
<div class="map-manager">
|
||||
<div class="field-row">
|
||||
<label for="mapIdLocked">World Id</label>
|
||||
<input id="mapIdLocked" class="info-readonly" type="text" readonly />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="mapNameInput">World Name</label>
|
||||
<input id="mapNameInput" type="text" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="mapWidthInput">Loaded Width</label>
|
||||
<div class="info-cell-value info-dim-value" id="mapWidthValue">
|
||||
<input id="mapWidthInput" class="info-dim-input info-readonly" type="number" min="1" max="512" readonly />
|
||||
<div class="info-dim-controls" id="mapWidthControls">
|
||||
<button class="icon-action-btn" id="confirmWidthBtn" type="button" title="Apply width change">✓</button>
|
||||
<button class="icon-action-btn danger" id="cancelWidthBtn" type="button" title="Discard width change">Ãâ€â€</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="mapHeightInput">Loaded Height</label>
|
||||
<div class="info-cell-value info-dim-value" id="mapHeightValue">
|
||||
<input id="mapHeightInput" class="info-dim-input info-readonly" type="number" min="1" max="512" readonly />
|
||||
<div class="info-dim-controls" id="mapHeightControls">
|
||||
<button class="icon-action-btn" id="confirmHeightBtn" type="button" title="Apply height change">✓</button>
|
||||
<button class="icon-action-btn danger" id="cancelHeightBtn" type="button" title="Discard height change">Ãâ€â€</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="mapBackgroundColorInput">Background Color</label>
|
||||
<input id="mapBackgroundColorInput" type="color" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-section">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleInformationConfigurationSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Configuration</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="selector-section-body" id="informationConfigurationSectionBody">
|
||||
<div class="map-manager">
|
||||
<div class="field-row">
|
||||
<label for="engineOverridesBtn">Engine Overrides</label>
|
||||
<div>
|
||||
<button id="engineOverridesBtn" class="mini-btn engine-overrides-launch-btn" type="button">Open Override Manager</button>
|
||||
<div class="engine-overrides-summary" id="engineOverridesSummary">No engine overrides active.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label for="backgroundModeBtn">Background Brush</label>
|
||||
<button id="backgroundModeBtn" class="background-mode-btn" type="button">
|
||||
<span class="background-mode-preview" id="backgroundModePreview"></span>
|
||||
<span class="background-mode-copy">
|
||||
<span class="background-mode-title" id="backgroundModeTitle">Inherit</span>
|
||||
<span class="background-mode-meta" id="backgroundModeMeta">Click to cycle tile, hole, inherit.</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="information-utility-actions">
|
||||
<button class="mini-btn" id="restoreToolWindowsBtn" type="button">Restore all windows</button>
|
||||
<button class="mini-btn" id="resetWorkspaceLayoutBtn" type="button">Reset workspace layout</button>
|
||||
</div>
|
||||
<div class="experimental-import-panel">
|
||||
<button id="experimentalImportToggleBtn" class="experimental-import-toggle" type="button" aria-expanded="false">
|
||||
<span class="experimental-import-check" id="experimentalImportCheck">[ ]</span>
|
||||
<span class="experimental-import-copy">
|
||||
<span class="experimental-import-title">Experimental Imports</span>
|
||||
<span class="experimental-import-meta">Import compatible sprite or tile JSON from other editor builds.</span>
|
||||
</span>
|
||||
<span class="experimental-import-chevron">▾</span>
|
||||
</button>
|
||||
<div class="experimental-import-body hidden" id="experimentalImportBody">
|
||||
<div class="experimental-import-warning">Warning: supports a single entry or a full <code>sprites.json</code> / <code>tiles.json</code> gallery. Matching art is deduped and only new images are kept.</div>
|
||||
<div class="experimental-import-actions">
|
||||
<button class="mini-btn" id="importSpritesBtn" type="button">Import Sprites</button>
|
||||
<button class="mini-btn" id="importTilesBtn" type="button">Import Tiles</button>
|
||||
<button class="mini-btn experimental-import-icon-btn" id="importJsonBtn" type="button" title="Paste JSON import" aria-label="Paste JSON import">📝</button>
|
||||
</div>
|
||||
<input id="importSpritesInput" class="hidden" type="file" accept=".json,application/json" />
|
||||
<input id="importTilesInput" class="hidden" type="file" accept=".json,application/json" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="experimental-import-modal hidden" id="importJsonModal">
|
||||
<div class="experimental-import-modal-card" role="dialog" aria-modal="true" aria-labelledby="importJsonModalTitle">
|
||||
<div class="experimental-import-modal-head">
|
||||
<h4 class="experimental-import-modal-title" id="importJsonModalTitle">Paste Import JSON</h4>
|
||||
<select id="importJsonTypeSelect">
|
||||
<option value="sprites">Sprites</option>
|
||||
<option value="tiles">Tiles</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="experimental-import-modal-copy">Paste a single entry or a full compatible gallery payload, then confirm to import only new art.</div>
|
||||
<div class="experimental-import-modal-body">
|
||||
<textarea id="importJsonTextarea" rows="12" spellcheck="false" placeholder="{ "tiles": [ ... ] }"></textarea>
|
||||
</div>
|
||||
<div class="experimental-import-modal-actions">
|
||||
<button class="mini-btn" id="importJsonConfirmBtn" type="button" title="Import JSON">✓</button>
|
||||
<button class="mini-btn danger" id="importJsonCancelBtn" type="button" title="Cancel import">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="information-bottom-stack">
|
||||
<div class="selector-section">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleInformationHotkeysSectionBtn" type="button" aria-expanded="false">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Hotkeys</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="selector-section-body hidden" id="informationHotkeysSectionBody">
|
||||
<div class="info-help-panel" aria-label="Worldshaper Studio keyboard help">
|
||||
<div class="info-help-title">Editor Controls</div>
|
||||
<div class="info-help-list">
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Ctrl</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Wheel</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Zoom canvas</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">MMB</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Drag</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Pan the room view</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">L Shift</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Drag</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Straight line draw</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Alt</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Drag</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Erase on active layer</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">L Ctrl</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Drag</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action"><span class="shortcut-shape-icon square" aria-hidden="true"></span><span>Rectangle outline</span></div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">R Ctrl</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-mouse-shell">
|
||||
<span class="shortcut-mouse-dot"></span>
|
||||
<span class="shortcut-mouse-label">Drag</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="shortcut-action"><span class="shortcut-shape-icon circle" aria-hidden="true"></span><span>Circle outline</span></div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">R Shift</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Hold to hide the grid</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">M</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Open world overview</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">O</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Toggle chunk bounds</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Ctrl</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-keycap">Z</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Undo</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Ctrl</span>
|
||||
<span class="shortcut-plus">+</span>
|
||||
<span class="shortcut-keycap">Y</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Redo</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Escape</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Clear canvas selection / collapse cells</div>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<div class="shortcut-keys">
|
||||
<span class="shortcut-keycap">Delete</span>
|
||||
</div>
|
||||
<div class="shortcut-action">Delete focused tile or entity</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel" id="layersPanel">
|
||||
<h3>Layers</h3>
|
||||
<div class="selector-section">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleDrawLayerSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Draw Layers</span>
|
||||
</button>
|
||||
<button class="mini-btn" id="addLayerBtn" type="button">Add Layer</button>
|
||||
</div>
|
||||
<div class="selector-section-body" id="drawLayerSectionBody">
|
||||
<div class="layer-list" id="layerList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-section">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleHeightLayerSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Height Layers</span>
|
||||
</button>
|
||||
<button class="mini-btn" id="addHeightLayerBtn" type="button">Add Height Layer</button>
|
||||
</div>
|
||||
<div class="selector-section-body" id="heightLayerSectionBody">
|
||||
<div class="layer-list" id="heightLayerList"></div>
|
||||
<p class="muted selector-hint">Select a height layer, then paint tiles to grow its sparse patch footprint. The list order is the height stack: top entry is Z1, next is Z2, and so on.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden" id="tilesPanel">
|
||||
<h3>Graphics</h3>
|
||||
<div class="entity-filter-tabs" role="tablist" aria-label="Graphics categories">
|
||||
<button class="entity-filter-tab active" id="graphicsTilesBtn" type="button" aria-pressed="true">Tiles</button>
|
||||
<button class="entity-filter-tab" id="graphicsSpritesBtn" type="button" aria-pressed="false">Sprites</button>
|
||||
<button class="entity-filter-tab" id="graphicsOtherBtn" type="button" aria-pressed="false">Other</button>
|
||||
</div>
|
||||
<div class="selector-toolbar">
|
||||
<button class="panel-square-btn" id="newTileFolderBtn" type="button" title="Create graphics folder">📁</button>
|
||||
<button class="panel-square-btn" id="newTileBtn" type="button" title="Create new graphic" aria-label="Create new graphic">
|
||||
<span class="panel-icon-image-plus" aria-hidden="true">
|
||||
<span class="panel-icon-image-frame"></span>
|
||||
<span class="panel-icon-image-plus-mark">+</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="panel-square-btn" id="tileSearchModeBtn" type="button" title="Search graphics" aria-label="Search graphics" aria-pressed="false">
|
||||
<span class="panel-icon-search" aria-hidden="true">🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="npc-list" id="paintPalette"></div>
|
||||
<p class="muted selector-hint">Tiles paint the world. Sprites power entities. Other graphics stay stored without entering the entity picker.</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden" id="instancesPanel">
|
||||
<h3>Entities</h3>
|
||||
<div class="entity-filter-tabs" role="tablist" aria-label="Entity categories">
|
||||
<button class="entity-filter-tab active" id="entityTypeFriendlyBtn" type="button" aria-pressed="true">Friendly</button>
|
||||
<button class="entity-filter-tab" id="entityTypeHostileBtn" type="button" aria-pressed="false">Hostile</button>
|
||||
<button class="entity-filter-tab" id="entityTypePropBtn" type="button" aria-pressed="false">Props</button>
|
||||
</div>
|
||||
<div class="hidden" id="entitySearchModeHost"></div>
|
||||
<div class="selector-section" id="entityCatalogSection">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="toggleTemplateSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Catalog</span>
|
||||
</button>
|
||||
<div class="selector-section-actions">
|
||||
<button class="panel-square-btn" id="newTemplateFolderBtn" type="button" title="Create entity catalog folder">📁</button>
|
||||
<button class="panel-square-btn" id="entitySearchModeBtn" type="button" title="Search entities" aria-label="Search entities" aria-pressed="false">
|
||||
<span class="panel-icon-search" aria-hidden="true">🔍</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-section-body" id="instanceTemplateSectionBody">
|
||||
<div class="npc-list" id="instancePalette"></div>
|
||||
<p class="muted selector-hint">Select a catalog entity, then click the canvas to place it. The selection stays active for multi-click placement.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-section" id="placedEntitiesSection">
|
||||
<div class="selector-section-header">
|
||||
<button class="selector-section-toggle" id="togglePlacedSectionBtn" type="button" aria-expanded="true">
|
||||
<span class="selector-section-chevron">▾</span>
|
||||
<span>Placed Entities</span>
|
||||
</button>
|
||||
<div class="selector-section-actions">
|
||||
<button class="mini-btn" id="newNpcBtn" type="button">New Entity</button>
|
||||
<button class="panel-square-btn" id="newPlacedFolderBtn" type="button" title="Create placed entity folder">📁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selector-section-body" id="placedInstanceSectionBody">
|
||||
<div class="npc-list" id="npcList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden" id="triggersPanel">
|
||||
<h3>Triggers</h3>
|
||||
<div class="selector-toolbar">
|
||||
<button class="panel-square-btn" id="newTriggerFolderBtn" type="button" title="Create trigger folder">📁</button>
|
||||
</div>
|
||||
<div class="npc-list" id="triggerList"></div>
|
||||
<p class="muted selector-hint">Trigger placement UI will land here next.</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden" id="pathsPanel">
|
||||
<h3>Paths</h3>
|
||||
<div class="selector-toolbar">
|
||||
<button class="panel-square-btn" id="newPathFolderBtn" type="button" title="Create path folder">📁</button>
|
||||
</div>
|
||||
<div class="npc-list" id="pathList"></div>
|
||||
<p class="muted selector-hint">Path placement UI will land here next.</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden" id="transitionsPanel">
|
||||
<h3>Transitions</h3>
|
||||
<div class="selector-toolbar">
|
||||
<button class="panel-square-btn" id="newTransitionFolderBtn" type="button" title="Create transition folder">📁</button>
|
||||
</div>
|
||||
<div class="npc-list" id="transitionList"></div>
|
||||
<p class="muted selector-hint">Transition placement UI will land here next.</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-panel hidden history-panel-layout" id="historyPanel">
|
||||
<h3>History</h3>
|
||||
<div class="history-stack">
|
||||
<div class="history-list-scroll">
|
||||
<div class="history-list" id="historyList"></div>
|
||||
</div>
|
||||
<div class="history-current" id="historyCurrent">
|
||||
<div class="history-current-label">Current State</div>
|
||||
<div class="history-current-empty">No history yet.</div>
|
||||
</div>
|
||||
<div class="history-preview" id="historyPreview">
|
||||
<h4>Change Preview</h4>
|
||||
<div class="history-preview-empty">Select a history entry to inspect it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-static-footer" aria-label="Tool pane links">
|
||||
<div class="sidebar-footer-links">
|
||||
<div class="sidebar-footer-linkbar">
|
||||
<a class="sidebar-footer-link" href="http://www.andraxion.net" target="_blank" rel="noreferrer">Andraxion Studios</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
`;
|
||||
|
||||
export const WORLDSHAPER_STUDIO_MARKUP_STAGE = `
|
||||
<section class="stage" id="stage">
|
||||
<div class="meta" id="meta">
|
||||
<div class="meta-main" id="metaMain"></div>
|
||||
<div class="meta-stats" id="metaStats"></div>
|
||||
</div>
|
||||
<button
|
||||
class="canvas-tool-btn"
|
||||
id="canvasSelectToolBtn"
|
||||
type="button"
|
||||
title="Tile selector: off"
|
||||
aria-label="Toggle tile selector"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<span class="canvas-tool-btn-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="viewport" id="viewport">
|
||||
<div class="viewport-layer"><div class="pixi-host" id="pixiHost" aria-hidden="true"></div><canvas id="roomCanvas"></canvas></div>
|
||||
<div class="viewport-spacer" id="viewportSpacer" aria-hidden="true"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="tool-window-layer" id="toolWindowLayer" aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
`;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +0,0 @@
|
|||
import { buildWorldshaperStudioThemeOverrideCss } from "./themePresets";
|
||||
import {
|
||||
WORLDSHAPER_STUDIO_STYLE_SHELL,
|
||||
WORLDSHAPER_STUDIO_STYLE_SIDEBAR,
|
||||
WORLDSHAPER_STUDIO_STYLE_STAGE,
|
||||
} from "./domStyleSections";
|
||||
|
||||
export function buildWorldshaperStudioStyles(): string {
|
||||
return (
|
||||
WORLDSHAPER_STUDIO_STYLE_SHELL
|
||||
+ WORLDSHAPER_STUDIO_STYLE_SIDEBAR
|
||||
+ WORLDSHAPER_STUDIO_STYLE_STAGE
|
||||
+ buildWorldshaperStudioThemeOverrideCss()
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,141 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-unused-vars, no-empty, no-useless-escape */
|
||||
// @ts-nocheck
|
||||
import { buildSpritePreviewDataUrl } from "../editorCore";
|
||||
import {
|
||||
buildSpriteCatalog,
|
||||
buildTileCatalogById,
|
||||
DEFAULT_MAP_BACKGROUND_COLOR,
|
||||
} from "../components/worldshaperShared";
|
||||
import type { WorldshaperStudioBootstrap } from "./bootstrap";
|
||||
|
||||
function getContentRecords(payload: unknown, key: string) {
|
||||
const records = payload && Array.isArray(payload[key]) ? payload[key] : [];
|
||||
return records.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry));
|
||||
}
|
||||
|
||||
export function cloneRuntimeValue<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function buildSpriteCatalogFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
|
||||
const spriteRecords = getContentRecords(bootstrap?.contentByType?.sprites, "sprites");
|
||||
if (spriteRecords.length > 0) {
|
||||
return buildSpriteCatalog(spriteRecords, buildSpritePreviewDataUrl);
|
||||
}
|
||||
return cloneRuntimeValue(bootstrap?.spriteCatalog) || {};
|
||||
}
|
||||
|
||||
export function buildTileCatalogByIdFromBootstrap(bootstrap: WorldshaperStudioBootstrap) {
|
||||
const tileRecords = getContentRecords(bootstrap?.contentByType?.tiles, "tiles");
|
||||
if (tileRecords.length > 0) {
|
||||
return buildTileCatalogById(tileRecords, buildSpritePreviewDataUrl);
|
||||
}
|
||||
return cloneRuntimeValue(bootstrap?.tileCatalogById) || {};
|
||||
}
|
||||
|
||||
export function buildNpcOverlaysFromWorldChunks(
|
||||
chunks: unknown[],
|
||||
spriteCatalog: Record<string, unknown>,
|
||||
chunkWidth: number,
|
||||
chunkHeight: number,
|
||||
originChunkX: number,
|
||||
originChunkY: number,
|
||||
) {
|
||||
return (Array.isArray(chunks) ? chunks : []).flatMap((chunk) => {
|
||||
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||||
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||||
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||||
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||||
const instances = Array.isArray(chunk?.instances) ? chunk.instances : [];
|
||||
return instances
|
||||
.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry))
|
||||
.map((entry) => {
|
||||
const record = entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
|
||||
? cloneRuntimeValue(entry.record)
|
||||
: {};
|
||||
const spriteId = String(record.spriteId || entry.spriteId || "").trim();
|
||||
const spriteEntry = spriteCatalog[spriteId] || null;
|
||||
const overlayX = offsetX + Math.max(0, Number(entry.x) || 0);
|
||||
const overlayY = offsetY + Math.max(0, Number(entry.y) || 0);
|
||||
record.position = {
|
||||
x: overlayX,
|
||||
y: overlayY,
|
||||
};
|
||||
return {
|
||||
id: String(entry.id || "").trim(),
|
||||
layer: Number(entry.layer) || 0,
|
||||
name: String(record.name || entry.id || "NPC"),
|
||||
spriteId,
|
||||
x: overlayX,
|
||||
y: overlayY,
|
||||
dataUrl: spriteEntry ? spriteEntry.dataUrl : null,
|
||||
spriteWidth: spriteEntry ? spriteEntry.spriteWidth : 28,
|
||||
spriteHeight: spriteEntry ? spriteEntry.spriteHeight : 28,
|
||||
opacity: spriteEntry ? spriteEntry.opacity : 1,
|
||||
record,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeMapBackgroundColor(value: unknown, fallback?: string) {
|
||||
const safeFallback = fallback || DEFAULT_MAP_BACKGROUND_COLOR;
|
||||
const raw = String(value || "").trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(raw) ? raw.toUpperCase() : safeFallback;
|
||||
}
|
||||
|
||||
export function createInitialWorldRuntimeState(bootstrap: WorldshaperStudioBootstrap) {
|
||||
const worldId = String(bootstrap.worldId || bootstrap.mapId || "").trim();
|
||||
const chunkRadius = Math.max(0, Math.floor(Number(bootstrap.worldChunkRadius) || 0));
|
||||
const originChunkX = Math.floor(Number(bootstrap.worldOriginChunkX) || 0);
|
||||
const originChunkY = Math.floor(Number(bootstrap.worldOriginChunkY) || 0);
|
||||
|
||||
return {
|
||||
enabled: !!worldId,
|
||||
worldId,
|
||||
worldName: String(bootstrap.worldName || bootstrap.mapName || bootstrap.worldId || bootstrap.mapId || "World").trim() || "World",
|
||||
defaultBackgroundTileId: String(bootstrap.backgroundTileId || "").trim(),
|
||||
heightBlurStep: Math.max(0, Math.min(1, Number(bootstrap.heightBlurStep ?? bootstrap.heightDetailStep) || 0.1)),
|
||||
chunkWidth: Math.max(1, Number(bootstrap.worldChunkWidth) || 32),
|
||||
chunkHeight: Math.max(1, Number(bootstrap.worldChunkHeight) || 32),
|
||||
chunkRadius,
|
||||
originChunkX,
|
||||
originChunkY,
|
||||
tileOffsetX: Math.floor(Number(bootstrap.worldTileOffsetX) || 0),
|
||||
tileOffsetY: Math.floor(Number(bootstrap.worldTileOffsetY) || 0),
|
||||
spawnX: Math.floor(Number(bootstrap.worldSpawnX) || 0),
|
||||
spawnY: Math.floor(Number(bootstrap.worldSpawnY) || 0),
|
||||
centerChunkX: originChunkX + chunkRadius,
|
||||
centerChunkY: originChunkY + chunkRadius,
|
||||
sourceChunks: Array.isArray(bootstrap.sourceChunks)
|
||||
? bootstrap.sourceChunks.map((entry) => ({
|
||||
chunkX: Math.floor(Number(entry?.chunkX) || 0),
|
||||
chunkY: Math.floor(Number(entry?.chunkY) || 0),
|
||||
}))
|
||||
: [],
|
||||
bookmarks: Array.isArray(bootstrap.worldBookmarks)
|
||||
? bootstrap.worldBookmarks.map((entry, index) => ({
|
||||
id: String(entry?.id || `poi_${index + 1}`).trim() || `poi_${index + 1}`,
|
||||
label: String(entry?.label || entry?.id || `POI ${index + 1}`).trim() || `POI ${index + 1}`,
|
||||
x: Math.floor(Number(entry?.x) || 0),
|
||||
y: Math.floor(Number(entry?.y) || 0),
|
||||
}))
|
||||
: [],
|
||||
chunkCache: new Map(),
|
||||
dirtyChunkKeys: new Set(),
|
||||
pendingNeighborhoodFetches: new Map(),
|
||||
prefetchedNeighborhoodKeys: new Set(),
|
||||
pendingLoadKey: "",
|
||||
pendingLoadPromise: null,
|
||||
requestSerial: 0,
|
||||
documentDirty: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_WORLD_CHUNK_CACHE_ENTRIES = 256;
|
||||
export const MAX_DYNAMIC_WORLD_CHUNK_RADIUS = 4;
|
||||
export const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
import { createHistoryController } from "./historyController";
|
||||
import { createInteractionController } from "./interactionController";
|
||||
import { createImportController } from "./importController";
|
||||
import { createNpcController } from "./npcController";
|
||||
import { createChangelogSplashWindowController } from "./changelogSplashWindowController";
|
||||
import { createEntityEditorWindowController } from "./entityEditorWindowController";
|
||||
import { createEngineOverrideWindowController } from "./engineOverrideWindowController";
|
||||
import { createPersistenceController } from "./persistenceController";
|
||||
import { createRenderController } from "./renderController";
|
||||
import { createSidebarController } from "./sidebarController";
|
||||
import { createStatusLogWindowController } from "./statusLogWindowController";
|
||||
import { createTileArtEditorWindowController } from "./tileArtEditorWindowController";
|
||||
import { createToolWindowController } from "./toolWindowController";
|
||||
import { createWorldOverviewWindowController } from "./worldOverviewWindowController";
|
||||
import { createDebouncedCallback } from "./debounce";
|
||||
|
||||
export function initializeRuntimeControllers(config) {
|
||||
const {
|
||||
scope,
|
||||
uiScope,
|
||||
resetWorkspaceLayout,
|
||||
setStatus,
|
||||
createNewTile,
|
||||
createNewSpriteGraphic,
|
||||
duplicateGraphicRecord,
|
||||
openTilePaletteContextMenu,
|
||||
openPlacedEntityContextMenu,
|
||||
applyNpcEditorChange,
|
||||
getEditorEngineOverrides,
|
||||
saveEditorEngineOverrides,
|
||||
getEffectiveHeightBlurStep,
|
||||
isRendererDebugEnabled,
|
||||
reloadGraphicsContentFromApi,
|
||||
syncDocumentTitle,
|
||||
syncCanvasDimensionsToTileSize,
|
||||
refreshEditorEngineOverridesUi,
|
||||
cacheStandaloneMapBootstrap,
|
||||
currentMapId,
|
||||
persistPopupBounds,
|
||||
popupSessionStore,
|
||||
windowRef,
|
||||
isWorldModeActive,
|
||||
getInitialWorldViewTile,
|
||||
centerViewportOnWorldTile,
|
||||
prefetchAdjacentWorldNeighborhoods,
|
||||
worldRuntimeState,
|
||||
syncWorldNeighborhoodForViewport,
|
||||
drawNow,
|
||||
} = config;
|
||||
|
||||
const toolWindowController = createToolWindowController(scope);
|
||||
const tileArtEditorWindowController = createTileArtEditorWindowController(scope);
|
||||
const entityEditorWindowController = createEntityEditorWindowController(scope);
|
||||
const engineOverrideWindowController = createEngineOverrideWindowController(scope);
|
||||
const worldOverviewWindowController = createWorldOverviewWindowController(scope);
|
||||
const changelogSplashWindowController = createChangelogSplashWindowController(scope);
|
||||
const statusLogWindowController = createStatusLogWindowController(scope);
|
||||
|
||||
const syncToolPanels = () => toolWindowController.syncPanels();
|
||||
const handleSidebarTabButtonClick = (tab) => toolWindowController.handleTabButtonClick(tab);
|
||||
const restoreAllToolWindows = () => toolWindowController.restoreAllWindows();
|
||||
const openTileArtEditorWindow = (recordTypeOrId, maybeRecordId) => tileArtEditorWindowController.open(recordTypeOrId, maybeRecordId);
|
||||
const closeTileArtEditorWindow = () => tileArtEditorWindowController.close();
|
||||
const openEntityEditorWindow = (entityId) => entityEditorWindowController.open(entityId);
|
||||
const closeEntityEditorWindow = () => entityEditorWindowController.close();
|
||||
const openEngineOverrideWindow = () => engineOverrideWindowController.open();
|
||||
const closeEngineOverrideWindow = () => engineOverrideWindowController.close();
|
||||
const refreshEngineOverrideWindow = () => engineOverrideWindowController.refresh();
|
||||
const refreshEngineOverrideSummary = () => engineOverrideWindowController.updateSummary();
|
||||
const openWorldOverviewWindow = () => worldOverviewWindowController.open();
|
||||
const closeWorldOverviewWindow = () => worldOverviewWindowController.close();
|
||||
const refreshWorldOverviewWindow = () => worldOverviewWindowController.refresh();
|
||||
const invalidateWorldOverviewChunkSurfaces = (chunkKeys, options) => worldOverviewWindowController.invalidateChunkSurfaces?.(chunkKeys, options);
|
||||
const openStatusLogWindow = () => statusLogWindowController.open();
|
||||
const closeStatusLogWindow = () => statusLogWindowController.close();
|
||||
const openNewsWindow = (options = {}) => changelogSplashWindowController.open({ markSeen: false, ...options });
|
||||
const resetWorkspaceLayoutFlow = () => {
|
||||
resetWorkspaceLayout();
|
||||
toolWindowController.restoreAllWindows();
|
||||
setStatus("Workspace layout reset.", false);
|
||||
};
|
||||
|
||||
scope.syncToolPanels = syncToolPanels;
|
||||
scope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
|
||||
scope.restoreAllToolWindows = restoreAllToolWindows;
|
||||
scope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
|
||||
scope.createNewTile = createNewTile;
|
||||
scope.createNewSpriteGraphic = createNewSpriteGraphic;
|
||||
scope.duplicateGraphicRecord = duplicateGraphicRecord;
|
||||
scope.openTileArtEditorWindow = openTileArtEditorWindow;
|
||||
scope.closeTileArtEditorWindow = closeTileArtEditorWindow;
|
||||
scope.openEntityEditorWindow = openEntityEditorWindow;
|
||||
scope.closeEntityEditorWindow = closeEntityEditorWindow;
|
||||
scope.openEngineOverrideWindow = openEngineOverrideWindow;
|
||||
scope.closeEngineOverrideWindow = closeEngineOverrideWindow;
|
||||
scope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
|
||||
scope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
|
||||
scope.openWorldOverviewWindow = openWorldOverviewWindow;
|
||||
scope.closeWorldOverviewWindow = closeWorldOverviewWindow;
|
||||
scope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
|
||||
scope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
|
||||
scope.openStatusLogWindow = openStatusLogWindow;
|
||||
scope.closeStatusLogWindow = closeStatusLogWindow;
|
||||
scope.openNewsWindow = openNewsWindow;
|
||||
scope.openTilePaletteContextMenu = openTilePaletteContextMenu;
|
||||
scope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
|
||||
scope.applyNpcEditorChange = applyNpcEditorChange;
|
||||
scope.getEditorEngineOverrides = getEditorEngineOverrides;
|
||||
scope.saveEditorEngineOverrides = saveEditorEngineOverrides;
|
||||
scope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
|
||||
scope.isRendererDebugEnabled = isRendererDebugEnabled;
|
||||
scope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
|
||||
|
||||
uiScope.syncToolPanels = syncToolPanels;
|
||||
uiScope.handleSidebarTabButtonClick = handleSidebarTabButtonClick;
|
||||
uiScope.restoreAllToolWindows = restoreAllToolWindows;
|
||||
uiScope.resetWorkspaceLayout = resetWorkspaceLayoutFlow;
|
||||
uiScope.createNewTile = createNewTile;
|
||||
uiScope.createNewSpriteGraphic = createNewSpriteGraphic;
|
||||
uiScope.duplicateGraphicRecord = duplicateGraphicRecord;
|
||||
uiScope.openEntityEditorWindow = openEntityEditorWindow;
|
||||
uiScope.closeEntityEditorWindow = closeEntityEditorWindow;
|
||||
uiScope.openEngineOverrideWindow = openEngineOverrideWindow;
|
||||
uiScope.closeEngineOverrideWindow = closeEngineOverrideWindow;
|
||||
uiScope.refreshEngineOverrideWindow = refreshEngineOverrideWindow;
|
||||
uiScope.refreshEngineOverrideSummary = refreshEngineOverrideSummary;
|
||||
uiScope.openWorldOverviewWindow = openWorldOverviewWindow;
|
||||
uiScope.closeWorldOverviewWindow = closeWorldOverviewWindow;
|
||||
uiScope.refreshWorldOverviewWindow = refreshWorldOverviewWindow;
|
||||
uiScope.invalidateWorldOverviewChunkSurfaces = invalidateWorldOverviewChunkSurfaces;
|
||||
uiScope.openStatusLogWindow = openStatusLogWindow;
|
||||
uiScope.closeStatusLogWindow = closeStatusLogWindow;
|
||||
uiScope.openNewsWindow = openNewsWindow;
|
||||
uiScope.openTilePaletteContextMenu = openTilePaletteContextMenu;
|
||||
uiScope.openPlacedEntityContextMenu = openPlacedEntityContextMenu;
|
||||
uiScope.applyNpcEditorChange = applyNpcEditorChange;
|
||||
uiScope.getEditorEngineOverrides = getEditorEngineOverrides;
|
||||
uiScope.saveEditorEngineOverrides = saveEditorEngineOverrides;
|
||||
uiScope.getEffectiveHeightBlurStep = getEffectiveHeightBlurStep;
|
||||
uiScope.isRendererDebugEnabled = isRendererDebugEnabled;
|
||||
uiScope.reloadGraphicsContentFromApi = reloadGraphicsContentFromApi;
|
||||
|
||||
syncDocumentTitle();
|
||||
createHistoryController(scope);
|
||||
createNpcController(scope);
|
||||
createSidebarController(scope);
|
||||
const renderController = createRenderController(scope);
|
||||
createPersistenceController(scope);
|
||||
createImportController(scope);
|
||||
const interactionController = createInteractionController(scope);
|
||||
|
||||
const persistPopupBoundsDeferred = createDebouncedCallback(() => {
|
||||
persistPopupBounds();
|
||||
}, 160);
|
||||
|
||||
syncCanvasDimensionsToTileSize();
|
||||
toolWindowController.initialize();
|
||||
tileArtEditorWindowController.initialize();
|
||||
entityEditorWindowController.initialize();
|
||||
engineOverrideWindowController.initialize();
|
||||
worldOverviewWindowController.initialize();
|
||||
changelogSplashWindowController.initialize();
|
||||
statusLogWindowController.initialize();
|
||||
renderController.initializeRenderAssets();
|
||||
interactionController.initializeEditorState();
|
||||
interactionController.bindDomEvents();
|
||||
interactionController.initializeUi();
|
||||
refreshEditorEngineOverridesUi();
|
||||
cacheStandaloneMapBootstrap(currentMapId);
|
||||
|
||||
if (isWorldModeActive()) {
|
||||
windowRef.requestAnimationFrame(() => {
|
||||
const initialWorldView = getInitialWorldViewTile();
|
||||
centerViewportOnWorldTile(initialWorldView.worldTileX, initialWorldView.worldTileY);
|
||||
prefetchAdjacentWorldNeighborhoods(worldRuntimeState.centerChunkX, worldRuntimeState.centerChunkY);
|
||||
syncWorldNeighborhoodForViewport();
|
||||
drawNow();
|
||||
setStatus("World mode loaded. Endless navigation is active.", false);
|
||||
});
|
||||
}
|
||||
|
||||
windowRef.requestAnimationFrame(() => {
|
||||
changelogSplashWindowController.maybeOpenForCurrentVersion();
|
||||
});
|
||||
windowRef.addEventListener("resize", () => {
|
||||
persistPopupBoundsDeferred();
|
||||
});
|
||||
windowRef.addEventListener("beforeunload", () => {
|
||||
popupSessionStore.flushPersistedLayout(windowRef);
|
||||
persistPopupBounds();
|
||||
});
|
||||
|
||||
return {
|
||||
renderController,
|
||||
statusLogWindowController,
|
||||
changelogSplashWindowController,
|
||||
persistPopupBoundsDeferred,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
export function createRuntimeLogging({ windowRef, runtimeUniqueId }) {
|
||||
const editorLogEntries = [];
|
||||
const EDITOR_LOG_LIMIT = 500;
|
||||
let statusLogWindowController = null;
|
||||
|
||||
function formatEditorLogTimestamp(timestamp) {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch {
|
||||
return String(timestamp || "");
|
||||
}
|
||||
}
|
||||
|
||||
function appendEditorLogEntry(level, message) {
|
||||
const normalizedMessage = String(message || "").trim();
|
||||
if (!normalizedMessage) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const entry = {
|
||||
id: runtimeUniqueId(),
|
||||
timestamp,
|
||||
timestampLabel: formatEditorLogTimestamp(timestamp),
|
||||
level: String(level || "Information").trim() || "Information",
|
||||
message: normalizedMessage,
|
||||
};
|
||||
editorLogEntries.push(entry);
|
||||
while (editorLogEntries.length > EDITOR_LOG_LIMIT) {
|
||||
editorLogEntries.shift();
|
||||
}
|
||||
statusLogWindowController?.refresh?.();
|
||||
return entry;
|
||||
}
|
||||
|
||||
function getEditorLogEntries() {
|
||||
return editorLogEntries.slice();
|
||||
}
|
||||
|
||||
function clearEditorLogEntries() {
|
||||
editorLogEntries.splice(0, editorLogEntries.length);
|
||||
statusLogWindowController?.refresh?.();
|
||||
}
|
||||
|
||||
windowRef.addEventListener("error", (event) => {
|
||||
const message = String(event?.message || event?.error?.message || "Unknown runtime error");
|
||||
appendEditorLogEntry("Error", message);
|
||||
});
|
||||
|
||||
windowRef.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
const message = typeof reason === "string"
|
||||
? reason
|
||||
: String(reason?.message || reason || "Unhandled promise rejection");
|
||||
appendEditorLogEntry("Error", message);
|
||||
});
|
||||
|
||||
return {
|
||||
appendEditorLogEntry,
|
||||
getEditorLogEntries,
|
||||
clearEditorLogEntries,
|
||||
setStatusLogWindowController(nextController) {
|
||||
statusLogWindowController = nextController;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
|
||||
import {
|
||||
buildSpritePreviewDataUrl,
|
||||
getSpritePalette,
|
||||
normalizeImagePlayback,
|
||||
} from "../editorCore";
|
||||
import { normalizeEditorTags } from "./tagUtils";
|
||||
|
||||
export const TILE_ART_SIZE = 16;
|
||||
|
||||
export const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path d="M20.8 4.2c1.6-1.6 4.3-1.6 5.9 0s1.6 4.3 0 5.9l-3.1 3.1-5.9-5.9 3.1-3.1z" fill="#eef6ff" stroke="#08111d" stroke-width="1.5"/>
|
||||
<path d="M10.8 14.1l6.9-6.9 6.1 6.1-6.9 6.9-2.7.9-.9 2.7-3 3a2.2 2.2 0 0 1-3.1 0l-1-1a2.2 2.2 0 0 1 0-3.1l3-3 2.7-.9.9-2.7z" fill="#7ee8c6" stroke="#08111d" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="8.4" cy="23.6" r="2" fill="#ff5f6d"/>
|
||||
</svg>`,
|
||||
)}") 4 28, crosshair`;
|
||||
|
||||
export function cloneValue(value) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeRoleList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((entry) => String(entry || "").trim().toLowerCase())
|
||||
.filter((entry) => entry === "tile" || entry === "sprite"),
|
||||
));
|
||||
}
|
||||
|
||||
export function normalizeTimelineRows(rows) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
|
||||
return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkingFrames(record) {
|
||||
const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
|
||||
const normalizedFrames = rawFrames.map((entry, index) => ({
|
||||
...cloneValue(entry),
|
||||
id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
|
||||
enabled: entry.enabled !== false,
|
||||
index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
|
||||
rows: normalizeTimelineRows(entry.rows),
|
||||
}));
|
||||
if (normalizedFrames.length > 0) {
|
||||
return normalizedFrames;
|
||||
}
|
||||
return [{
|
||||
id: "frame_0",
|
||||
enabled: true,
|
||||
index: 0,
|
||||
rows: normalizeTimelineRows(record?.rows),
|
||||
}];
|
||||
}
|
||||
|
||||
export function sortWorkingFrames(frames) {
|
||||
return frames
|
||||
.map((frame, sourceIndex) => ({
|
||||
frame,
|
||||
sourceIndex,
|
||||
sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.sortIndex !== right.sortIndex
|
||||
? left.sortIndex - right.sortIndex
|
||||
: left.sourceIndex - right.sourceIndex
|
||||
))
|
||||
.map((entry) => entry.frame);
|
||||
}
|
||||
|
||||
export function normalizeWorkingGraphicRecord(recordType, record) {
|
||||
const source = cloneValue(record) || {};
|
||||
const roles = normalizeRoleList(source.roles);
|
||||
const nextRoles = recordType === "tile"
|
||||
? Array.from(new Set([...roles, "tile"]))
|
||||
: (
|
||||
recordType === "sprite"
|
||||
? Array.from(new Set([...roles, "sprite"]))
|
||||
: roles.filter((entry) => entry !== "sprite")
|
||||
);
|
||||
const frames = normalizeWorkingFrames(source).map((frame, index) => ({
|
||||
...frame,
|
||||
index,
|
||||
}));
|
||||
const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
|
||||
const defaultFrameId = String(
|
||||
frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
|
||||
|| frames[0]?.id
|
||||
|| "frame_0",
|
||||
).trim() || "frame_0";
|
||||
const workingRows = normalizeTimelineRows(
|
||||
Array.isArray(source.rows) && source.rows.length > 0
|
||||
? source.rows
|
||||
: (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
|
||||
name: typeof source.name === "string" ? source.name : "",
|
||||
description: typeof source.description === "string" ? source.description : "",
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
pixelScale: Math.max(1, Number(source.pixelScale) || 2),
|
||||
opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
|
||||
tags: normalizeEditorTags(source.tags),
|
||||
roles: nextRoles,
|
||||
tileSymbol: nextRoles.includes("tile")
|
||||
? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
|
||||
: "",
|
||||
defaultFrame: defaultFrameId,
|
||||
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
|
||||
playback: normalizeImagePlayback(source.playback),
|
||||
frames,
|
||||
rows: workingRows,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpacityValue(value, fallback = 1) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(0, Math.min(1, parsed));
|
||||
}
|
||||
|
||||
export function formatOpacityValue(value) {
|
||||
const normalized = normalizeOpacityValue(value, 1);
|
||||
return normalized.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
export function cloneRows(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
|
||||
: Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
|
||||
}
|
||||
|
||||
export function buildRowsPreviewRecord(rows) {
|
||||
return {
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
rows: cloneRows(rows),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatPlaybackLabel(value) {
|
||||
const normalized = normalizeImagePlayback(value);
|
||||
if (normalized === "rewind") {
|
||||
return "Rewind";
|
||||
}
|
||||
if (normalized === "stop") {
|
||||
return "Stop";
|
||||
}
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
export function getWorkingCellSymbol(record, x, y) {
|
||||
const rows = Array.isArray(record?.rows) ? record.rows : [];
|
||||
const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
return String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
|
||||
export function paintWorkingRowsCell(rows, x, y, symbol) {
|
||||
const nextRows = cloneRows(rows);
|
||||
const nextSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
|
||||
return nextRows;
|
||||
}
|
||||
|
||||
export function getRowsMatrix(rows) {
|
||||
return cloneRows(rows).map((row) => Array.from(row));
|
||||
}
|
||||
|
||||
export function buildRowsFromMatrix(matrix) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
|
||||
return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAlternatePaintSymbol(record, preferredSymbol) {
|
||||
const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
|
||||
const palette = getSpritePalette(record || undefined);
|
||||
const nextSymbol = Object.keys(palette)
|
||||
.map((symbol) => String(symbol || "").charAt(0))
|
||||
.find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
|
||||
return nextSymbol || ".";
|
||||
}
|
||||
|
||||
export function shiftRows(rows, offsetX, offsetY) {
|
||||
const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
|
||||
const sourceRows = cloneRows(rows);
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
|
||||
continue;
|
||||
}
|
||||
nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextRows);
|
||||
}
|
||||
|
||||
export function flipRowsHorizontally(rows) {
|
||||
return cloneRows(rows).map((row) => row.split("").reverse().join(""));
|
||||
}
|
||||
|
||||
export function flipRowsVertically(rows) {
|
||||
return cloneRows(rows).slice().reverse();
|
||||
}
|
||||
|
||||
export function rotateRowsClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
export function rotateRowsCounterClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
export function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
|
||||
const minX = Math.max(0, Math.min(startX, endX));
|
||||
const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
|
||||
const minY = Math.max(0, Math.min(startY, endY));
|
||||
const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
|
||||
const fillMask = new Set();
|
||||
const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const width = Math.max(1, (maxX - minX) + 1);
|
||||
const height = Math.max(1, (maxY - minY) + 1);
|
||||
const centerX = minX + (width / 2);
|
||||
const centerY = minY + (height / 2);
|
||||
const denomX = Math.max(0.5, width / 2);
|
||||
const denomY = Math.max(0.5, height / 2);
|
||||
const triangleAx = minX + (width - 1) / 2;
|
||||
const triangleAy = minY;
|
||||
const triangleBx = minX;
|
||||
const triangleBy = maxY;
|
||||
const triangleCx = maxX;
|
||||
const triangleCy = maxY;
|
||||
const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
let include;
|
||||
const sampleX = x + 0.5;
|
||||
const sampleY = y + 0.5;
|
||||
if (shape === "rectangle") {
|
||||
include = true;
|
||||
} else if (shape === "circle") {
|
||||
const normX = (sampleX - centerX) / denomX;
|
||||
const normY = (sampleY - centerY) / denomY;
|
||||
include = (normX * normX) + (normY * normY) <= 1;
|
||||
} else if (triangleDenominator !== 0) {
|
||||
const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const c = 1 - a - b;
|
||||
include = a >= 0 && b >= 0 && c >= 0;
|
||||
} else {
|
||||
include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
|
||||
}
|
||||
if (include === true) {
|
||||
fillMask.add(`${x}:${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fillMask;
|
||||
}
|
||||
|
||||
export function buildOutlineMask(fillMask) {
|
||||
const outlineMask = new Set();
|
||||
fillMask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
const neighbors = [
|
||||
`${x - 1}:${y}`,
|
||||
`${x + 1}:${y}`,
|
||||
`${x}:${y - 1}`,
|
||||
`${x}:${y + 1}`,
|
||||
];
|
||||
if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
|
||||
outlineMask.add(key);
|
||||
}
|
||||
});
|
||||
return outlineMask;
|
||||
}
|
||||
|
||||
export function applyMaskToRows(baseRows, mask, symbol) {
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
mask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
|
||||
return;
|
||||
}
|
||||
matrix[y][x] = String(symbol || ".").charAt(0) || ".";
|
||||
});
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
export function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
|
||||
const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
|
||||
let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
|
||||
const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
|
||||
const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
|
||||
const deltaX = Math.abs(x1 - x0);
|
||||
const deltaY = Math.abs(y1 - y0);
|
||||
const stepX = x0 < x1 ? 1 : -1;
|
||||
const stepY = y0 < y1 ? 1 : -1;
|
||||
let error = deltaX - deltaY;
|
||||
while (true) {
|
||||
matrix[y0][x0] = normalizedSymbol;
|
||||
if (x0 === x1 && y0 === y1) {
|
||||
break;
|
||||
}
|
||||
const nextError = error * 2;
|
||||
if (nextError > -deltaY) {
|
||||
error -= deltaY;
|
||||
x0 += stepX;
|
||||
}
|
||||
if (nextError < deltaX) {
|
||||
error += deltaX;
|
||||
y0 += stepY;
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
export function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
|
||||
const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-shape-icon is-${normalizedShape} is-${normalizedVariant} is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-shape-outline\"></span>"
|
||||
+ "<span class=\"tile-art-menu-shape-fill\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildLineOptionIconMarkup(tone = "draw") {
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-line-icon is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-line-stroke\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildCurrentShapeToolIconMarkup(state) {
|
||||
if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
|
||||
return buildLineOptionIconMarkup("draw");
|
||||
}
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeShapeKind || "rectangle",
|
||||
state?.activeShapeVariant || "outline",
|
||||
"draw",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCurrentEraseToolIconMarkup(state) {
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeEraseKind || "rectangle",
|
||||
"fill",
|
||||
"erase",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTransformCategoryIconMarkup(kind) {
|
||||
const normalizedKind = kind === "flip" ? "flip" : "rotate";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildTransformOptionIconMarkup(kind) {
|
||||
const normalizedKind = [
|
||||
"rotate-cw",
|
||||
"rotate-ccw",
|
||||
"flip-h",
|
||||
"flip-v",
|
||||
].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function buildFramePreviewDataUrl(rows, scale = 10) {
|
||||
return buildSpritePreviewDataUrl(buildRowsPreviewRecord(rows), scale);
|
||||
}
|
||||
|
|
@ -22,40 +22,9 @@ import {
|
|||
} from "./textTransferUtils";
|
||||
import { clampFloatingWindowRect } from "./floatingWindowUtils";
|
||||
import { appendContextMenuItems, menuItem, menuSubmenu, openContextMenuAtPoint } from "./contextMenuSchema";
|
||||
import {
|
||||
applyMaskToRows,
|
||||
buildCurrentEraseToolIconMarkup,
|
||||
buildCurrentShapeToolIconMarkup,
|
||||
buildFramePreviewDataUrl,
|
||||
buildLineOptionIconMarkup,
|
||||
buildOutlineMask,
|
||||
buildShapeFillMask,
|
||||
buildShapeOptionIconMarkup,
|
||||
buildTransformCategoryIconMarkup,
|
||||
buildTransformOptionIconMarkup,
|
||||
cloneRows,
|
||||
cloneValue,
|
||||
EYEDROPPER_CURSOR,
|
||||
flipRowsHorizontally,
|
||||
flipRowsVertically,
|
||||
formatOpacityValue,
|
||||
formatPlaybackLabel,
|
||||
getAlternatePaintSymbol,
|
||||
getLineRows,
|
||||
getWorkingCellSymbol,
|
||||
normalizeOpacityValue,
|
||||
normalizeTimelineRows,
|
||||
normalizeWorkingFrames,
|
||||
normalizeWorkingGraphicRecord,
|
||||
paintWorkingRowsCell,
|
||||
rotateRowsClockwise,
|
||||
rotateRowsCounterClockwise,
|
||||
shiftRows,
|
||||
sortWorkingFrames,
|
||||
TILE_ART_SIZE,
|
||||
} from "./tileArtEditorHelpers";
|
||||
|
||||
const TILE_ART_WINDOW_KEY = "tileArtEditor";
|
||||
const TILE_ART_SIZE = 16;
|
||||
const GRID_CELL_SIZE = 21;
|
||||
const MIN_WIDTH = 452;
|
||||
const MIN_HEIGHT = 628;
|
||||
|
|
@ -69,11 +38,415 @@ const TOOL_MENU_TAG_PREFIX = "tile-art-tool-menu:";
|
|||
const SHORTCUT_HELP_TOOLTIP_TAG = "tile-art-shortcut-help";
|
||||
const ANIMATION_SPEED_TOOLTIP_TAG = "tile-art-animation-speed";
|
||||
const ANIMATION_PLAYBACK_TOOLTIP_TAG = "tile-art-animation-playback";
|
||||
const EYEDROPPER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path d="M20.8 4.2c1.6-1.6 4.3-1.6 5.9 0s1.6 4.3 0 5.9l-3.1 3.1-5.9-5.9 3.1-3.1z" fill="#eef6ff" stroke="#08111d" stroke-width="1.5"/>
|
||||
<path d="M10.8 14.1l6.9-6.9 6.1 6.1-6.9 6.9-2.7.9-.9 2.7-3 3a2.2 2.2 0 0 1-3.1 0l-1-1a2.2 2.2 0 0 1 0-3.1l3-3 2.7-.9.9-2.7z" fill="#7ee8c6" stroke="#08111d" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="8.4" cy="23.6" r="2" fill="#ff5f6d"/>
|
||||
</svg>`,
|
||||
)}") 4 28, crosshair`;
|
||||
|
||||
function cloneValue(value) {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clampWindowRect(layerRect, left, top, width, height) {
|
||||
return clampFloatingWindowRect(layerRect, left, top, width, height, MIN_WIDTH, MIN_HEIGHT, DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||
}
|
||||
|
||||
function normalizeRoleList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.map((entry) => String(entry || "").trim().toLowerCase())
|
||||
.filter((entry) => entry === "tile" || entry === "sprite"),
|
||||
));
|
||||
}
|
||||
|
||||
function normalizeTimelineRows(rows) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const row = Array.isArray(rows) ? String(rows[rowIndex] || "") : "";
|
||||
return row.padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeWorkingFrames(record) {
|
||||
const rawFrames = Array.isArray(record?.frames) ? record.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : [];
|
||||
const normalizedFrames = rawFrames.map((entry, index) => ({
|
||||
...cloneValue(entry),
|
||||
id: String(entry.id || `frame_${index}`).trim() || `frame_${index}`,
|
||||
enabled: entry.enabled !== false,
|
||||
index: Number.isFinite(Number(entry.index)) ? Math.max(0, Math.floor(Number(entry.index))) : index,
|
||||
rows: normalizeTimelineRows(entry.rows),
|
||||
}));
|
||||
if (normalizedFrames.length > 0) {
|
||||
return normalizedFrames;
|
||||
}
|
||||
return [{
|
||||
id: "frame_0",
|
||||
enabled: true,
|
||||
index: 0,
|
||||
rows: normalizeTimelineRows(record?.rows),
|
||||
}];
|
||||
}
|
||||
|
||||
function sortWorkingFrames(frames) {
|
||||
return frames
|
||||
.map((frame, sourceIndex) => ({
|
||||
frame,
|
||||
sourceIndex,
|
||||
sortIndex: Number.isFinite(Number(frame?.index)) ? Number(frame.index) : sourceIndex,
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.sortIndex !== right.sortIndex
|
||||
? left.sortIndex - right.sortIndex
|
||||
: left.sourceIndex - right.sourceIndex
|
||||
))
|
||||
.map((entry) => entry.frame);
|
||||
}
|
||||
|
||||
function normalizeWorkingGraphicRecord(recordType, record) {
|
||||
const source = cloneValue(record) || {};
|
||||
const roles = normalizeRoleList(source.roles);
|
||||
const nextRoles = recordType === "tile"
|
||||
? Array.from(new Set([...roles, "tile"]))
|
||||
: (
|
||||
recordType === "sprite"
|
||||
? Array.from(new Set([...roles, "sprite"]))
|
||||
: roles.filter((entry) => entry !== "sprite")
|
||||
);
|
||||
const frames = normalizeWorkingFrames(source).map((frame, index) => ({
|
||||
...frame,
|
||||
index,
|
||||
}));
|
||||
const requestedDefaultFrameId = String(source.defaultFrame || "").trim();
|
||||
const defaultFrameId = String(
|
||||
frames.find((frame) => String(frame.id || "").trim() === requestedDefaultFrameId)?.id
|
||||
|| frames[0]?.id
|
||||
|| "frame_0",
|
||||
).trim() || "frame_0";
|
||||
const workingRows = normalizeTimelineRows(
|
||||
Array.isArray(source.rows) && source.rows.length > 0
|
||||
? source.rows
|
||||
: (frames.find((frame) => String(frame.id || "").trim() === defaultFrameId)?.rows || frames[0]?.rows || [])
|
||||
);
|
||||
return {
|
||||
...source,
|
||||
id: String(source.id || `${recordType === "tile" ? "tile" : "sprite"}_${Date.now()}`).trim(),
|
||||
name: typeof source.name === "string" ? source.name : "",
|
||||
description: typeof source.description === "string" ? source.description : "",
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
pixelScale: Math.max(1, Number(source.pixelScale) || 2),
|
||||
opacity: Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1,
|
||||
tags: normalizeEditorTags(source.tags),
|
||||
roles: nextRoles,
|
||||
tileSymbol: nextRoles.includes("tile")
|
||||
? (String(source.tileSymbol ?? source.symbol ?? source.id ?? "T").trim().charAt(0) || "T")
|
||||
: "",
|
||||
defaultFrame: defaultFrameId,
|
||||
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
|
||||
playback: normalizeImagePlayback(source.playback),
|
||||
frames,
|
||||
rows: workingRows,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpacityValue(value, fallback = 1) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(0, Math.min(1, parsed));
|
||||
}
|
||||
|
||||
function formatOpacityValue(value) {
|
||||
const normalized = normalizeOpacityValue(value, 1);
|
||||
return normalized.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function buildRowsPreviewRecord(rows) {
|
||||
return {
|
||||
width: TILE_ART_SIZE,
|
||||
height: TILE_ART_SIZE,
|
||||
rows: cloneRows(rows),
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlaybackLabel(value) {
|
||||
const normalized = normalizeImagePlayback(value);
|
||||
if (normalized === "rewind") {
|
||||
return "Rewind";
|
||||
}
|
||||
if (normalized === "stop") {
|
||||
return "Stop";
|
||||
}
|
||||
return "Normal";
|
||||
}
|
||||
|
||||
function cloneRows(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows.map((row) => String(row || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE))
|
||||
: Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE));
|
||||
}
|
||||
|
||||
function getWorkingCellSymbol(record, x, y) {
|
||||
const rows = Array.isArray(record?.rows) ? record.rows : [];
|
||||
const row = String(rows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
return String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
|
||||
function paintWorkingRowsCell(rows, x, y, symbol) {
|
||||
const nextRows = cloneRows(rows);
|
||||
const nextSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const targetRow = String(nextRows[y] || "").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
nextRows[y] = `${targetRow.slice(0, x)}${nextSymbol}${targetRow.slice(x + 1)}`;
|
||||
return nextRows;
|
||||
}
|
||||
|
||||
function getRowsMatrix(rows) {
|
||||
return cloneRows(rows).map((row) => Array.from(row));
|
||||
}
|
||||
|
||||
function buildRowsFromMatrix(matrix) {
|
||||
return Array.from({ length: TILE_ART_SIZE }, (_entry, rowIndex) => {
|
||||
const sourceRow = Array.isArray(matrix?.[rowIndex]) ? matrix[rowIndex] : [];
|
||||
return sourceRow.join("").padEnd(TILE_ART_SIZE, ".").slice(0, TILE_ART_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function getAlternatePaintSymbol(record, preferredSymbol) {
|
||||
const normalizedPreferred = String(preferredSymbol || "").charAt(0) || ".";
|
||||
const palette = getSpritePalette(record || undefined);
|
||||
const nextSymbol = Object.keys(palette)
|
||||
.map((symbol) => String(symbol || "").charAt(0))
|
||||
.find((symbol) => symbol && symbol !== normalizedPreferred && symbol !== ".");
|
||||
return nextSymbol || ".";
|
||||
}
|
||||
|
||||
function shiftRows(rows, offsetX, offsetY) {
|
||||
const nextRows = Array.from({ length: TILE_ART_SIZE }, () => ".".repeat(TILE_ART_SIZE).split(""));
|
||||
const sourceRows = cloneRows(rows);
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
const row = sourceRows[y] || ".".repeat(TILE_ART_SIZE);
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
const nextX = x + offsetX;
|
||||
const nextY = y + offsetY;
|
||||
if (nextX < 0 || nextX >= TILE_ART_SIZE || nextY < 0 || nextY >= TILE_ART_SIZE) {
|
||||
continue;
|
||||
}
|
||||
nextRows[nextY][nextX] = String(row.charAt(x) || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextRows);
|
||||
}
|
||||
|
||||
function flipRowsHorizontally(rows) {
|
||||
return cloneRows(rows).map((row) => row.split("").reverse().join(""));
|
||||
}
|
||||
|
||||
function flipRowsVertically(rows) {
|
||||
return cloneRows(rows).slice().reverse();
|
||||
}
|
||||
|
||||
function rotateRowsClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[x][TILE_ART_SIZE - 1 - y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
function rotateRowsCounterClockwise(rows) {
|
||||
const matrix = getRowsMatrix(rows);
|
||||
const nextMatrix = Array.from({ length: TILE_ART_SIZE }, () => Array.from({ length: TILE_ART_SIZE }, () => "."));
|
||||
for (let y = 0; y < TILE_ART_SIZE; y += 1) {
|
||||
for (let x = 0; x < TILE_ART_SIZE; x += 1) {
|
||||
nextMatrix[TILE_ART_SIZE - 1 - x][y] = String(matrix[y]?.[x] || ".").charAt(0) || ".";
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(nextMatrix);
|
||||
}
|
||||
|
||||
function buildShapeFillMask(shapeKind, startX, startY, endX, endY) {
|
||||
const minX = Math.max(0, Math.min(startX, endX));
|
||||
const maxX = Math.min(TILE_ART_SIZE - 1, Math.max(startX, endX));
|
||||
const minY = Math.max(0, Math.min(startY, endY));
|
||||
const maxY = Math.min(TILE_ART_SIZE - 1, Math.max(startY, endY));
|
||||
const fillMask = new Set();
|
||||
const shape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const width = Math.max(1, (maxX - minX) + 1);
|
||||
const height = Math.max(1, (maxY - minY) + 1);
|
||||
const centerX = minX + (width / 2);
|
||||
const centerY = minY + (height / 2);
|
||||
const denomX = Math.max(0.5, width / 2);
|
||||
const denomY = Math.max(0.5, height / 2);
|
||||
const triangleAx = minX + (width - 1) / 2;
|
||||
const triangleAy = minY;
|
||||
const triangleBx = minX;
|
||||
const triangleBy = maxY;
|
||||
const triangleCx = maxX;
|
||||
const triangleCy = maxY;
|
||||
const triangleDenominator = ((triangleBy - triangleCy) * (triangleAx - triangleCx)) + ((triangleCx - triangleBx) * (triangleAy - triangleCy));
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
let include;
|
||||
const sampleX = x + 0.5;
|
||||
const sampleY = y + 0.5;
|
||||
if (shape === "rectangle") {
|
||||
include = true;
|
||||
} else if (shape === "circle") {
|
||||
const normX = (sampleX - centerX) / denomX;
|
||||
const normY = (sampleY - centerY) / denomY;
|
||||
include = (normX * normX) + (normY * normY) <= 1;
|
||||
} else if (triangleDenominator !== 0) {
|
||||
const a = (((triangleBy - triangleCy) * (sampleX - triangleCx)) + ((triangleCx - triangleBx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const b = (((triangleCy - triangleAy) * (sampleX - triangleCx)) + ((triangleAx - triangleCx) * (sampleY - triangleCy))) / triangleDenominator;
|
||||
const c = 1 - a - b;
|
||||
include = a >= 0 && b >= 0 && c >= 0;
|
||||
} else {
|
||||
include = x === Math.round(triangleAx) && y >= minY && y <= maxY;
|
||||
}
|
||||
if (include === true) {
|
||||
fillMask.add(`${x}:${y}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fillMask;
|
||||
}
|
||||
|
||||
function buildOutlineMask(fillMask) {
|
||||
const outlineMask = new Set();
|
||||
fillMask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
const neighbors = [
|
||||
`${x - 1}:${y}`,
|
||||
`${x + 1}:${y}`,
|
||||
`${x}:${y - 1}`,
|
||||
`${x}:${y + 1}`,
|
||||
];
|
||||
if (neighbors.some((neighbor) => !fillMask.has(neighbor))) {
|
||||
outlineMask.add(key);
|
||||
}
|
||||
});
|
||||
return outlineMask;
|
||||
}
|
||||
|
||||
function applyMaskToRows(baseRows, mask, symbol) {
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
mask.forEach((key) => {
|
||||
const [xText, yText] = String(key || "").split(":");
|
||||
const x = Number(xText);
|
||||
const y = Number(yText);
|
||||
if (x < 0 || x >= TILE_ART_SIZE || y < 0 || y >= TILE_ART_SIZE) {
|
||||
return;
|
||||
}
|
||||
matrix[y][x] = String(symbol || ".").charAt(0) || ".";
|
||||
});
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
function getLineRows(baseRows, startX, startY, endX, endY, symbol) {
|
||||
const normalizedSymbol = String(symbol || ".").charAt(0) || ".";
|
||||
const matrix = getRowsMatrix(baseRows);
|
||||
let x0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startX) || 0));
|
||||
let y0 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(startY) || 0));
|
||||
const x1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endX) || 0));
|
||||
const y1 = Math.max(0, Math.min(TILE_ART_SIZE - 1, Number(endY) || 0));
|
||||
const deltaX = Math.abs(x1 - x0);
|
||||
const deltaY = Math.abs(y1 - y0);
|
||||
const stepX = x0 < x1 ? 1 : -1;
|
||||
const stepY = y0 < y1 ? 1 : -1;
|
||||
let error = deltaX - deltaY;
|
||||
while (true) {
|
||||
matrix[y0][x0] = normalizedSymbol;
|
||||
if (x0 === x1 && y0 === y1) {
|
||||
break;
|
||||
}
|
||||
const nextError = error * 2;
|
||||
if (nextError > -deltaY) {
|
||||
error -= deltaY;
|
||||
x0 += stepX;
|
||||
}
|
||||
if (nextError < deltaX) {
|
||||
error += deltaX;
|
||||
y0 += stepY;
|
||||
}
|
||||
}
|
||||
return buildRowsFromMatrix(matrix);
|
||||
}
|
||||
|
||||
function buildShapeOptionIconMarkup(shapeKind, variant, tone = "draw") {
|
||||
const normalizedShape = shapeKind === "circle" || shapeKind === "triangle" ? shapeKind : "rectangle";
|
||||
const normalizedVariant = variant === "outline" || variant === "two-tone" ? variant : "fill";
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-shape-icon is-${normalizedShape} is-${normalizedVariant} is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-shape-outline\"></span>"
|
||||
+ "<span class=\"tile-art-menu-shape-fill\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
function buildLineOptionIconMarkup(tone = "draw") {
|
||||
const normalizedTone = tone === "erase" ? "erase" : "draw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-line-icon is-${normalizedTone}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-line-stroke\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
function buildCurrentShapeToolIconMarkup(state) {
|
||||
if (state?.activeTool === "line" || String(state?.activeShapeMenuId || "").trim() === "line") {
|
||||
return buildLineOptionIconMarkup("draw");
|
||||
}
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeShapeKind || "rectangle",
|
||||
state?.activeShapeVariant || "outline",
|
||||
"draw",
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentEraseToolIconMarkup(state) {
|
||||
return buildShapeOptionIconMarkup(
|
||||
state?.activeEraseKind || "rectangle",
|
||||
"fill",
|
||||
"erase",
|
||||
);
|
||||
}
|
||||
|
||||
function buildTransformCategoryIconMarkup(kind) {
|
||||
const normalizedKind = kind === "flip" ? "flip" : "rotate";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
function buildTransformOptionIconMarkup(kind) {
|
||||
const normalizedKind = [
|
||||
"rotate-cw",
|
||||
"rotate-ccw",
|
||||
"flip-h",
|
||||
"flip-v",
|
||||
].includes(String(kind || "").trim()) ? String(kind || "").trim() : "rotate-cw";
|
||||
return ""
|
||||
+ `<span class="tile-art-menu-transform-icon is-${normalizedKind}" aria-hidden="true">`
|
||||
+ "<span class=\"tile-art-menu-transform-part part-a\"></span>"
|
||||
+ "<span class=\"tile-art-menu-transform-part part-b\"></span>"
|
||||
+ "</span>";
|
||||
}
|
||||
|
||||
export function createTileArtEditorWindowController(scope) {
|
||||
let initialized = false;
|
||||
const uiScope = scope.uiScope || scope;
|
||||
|
|
@ -460,7 +833,7 @@ export function createTileArtEditorWindowController(scope) {
|
|||
}
|
||||
const currentFrame = playbackFrames.find((frame) => String(frame.id || "").trim() === String(state.animationPreviewFrameId || "").trim()) || playbackFrames[0];
|
||||
state.animationPreviewFrameId = String(currentFrame?.id || "").trim();
|
||||
const previewUrl = buildFramePreviewDataUrl(currentFrame?.rows, 10);
|
||||
const previewUrl = buildSpritePreviewDataUrl(buildRowsPreviewRecord(currentFrame?.rows), 10);
|
||||
if (previewUrl) {
|
||||
state.animationPreviewImageEl.src = previewUrl;
|
||||
state.animationPreviewImageEl.classList.remove("hidden");
|
||||
|
|
|
|||
|
|
@ -1 +1,210 @@
|
|||
export * from "../shared/windowing";
|
||||
export type PopupBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export const WORLDSHAPER_STUDIO_WINDOW_NAME = "worldshaper-studio";
|
||||
export const WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY = "worldshaper:studio-window-bounds";
|
||||
export const WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME = "worldshaper-height-viewer";
|
||||
export const WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY = "worldshaper:height-viewer-window-bounds";
|
||||
|
||||
export function buildWorldshaperStudioUrl(mapId: string, hostWindow: Window = window, options?: { worldId?: string }): string {
|
||||
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-studio.html`, hostWindow.location.origin);
|
||||
const normalizedMapId = String(mapId || "").trim();
|
||||
const normalizedWorldId = String(options?.worldId || "").trim();
|
||||
if (normalizedMapId) {
|
||||
popupUrl.searchParams.set("mapId", normalizedMapId);
|
||||
}
|
||||
if (normalizedWorldId) {
|
||||
popupUrl.searchParams.set("worldId", normalizedWorldId);
|
||||
}
|
||||
return popupUrl.toString();
|
||||
}
|
||||
|
||||
export function buildWorldshaperHeightViewerUrl(mapId: string, token = "", hostWindow: Window = window): string {
|
||||
const popupUrl = new URL(`${import.meta.env.BASE_URL}worldshaper-height-viewer.html`, hostWindow.location.origin);
|
||||
const normalizedMapId = String(mapId || "").trim();
|
||||
const normalizedToken = String(token || "").trim();
|
||||
if (normalizedMapId) {
|
||||
popupUrl.searchParams.set("mapId", normalizedMapId);
|
||||
}
|
||||
if (normalizedToken) {
|
||||
popupUrl.searchParams.set("token", normalizedToken);
|
||||
}
|
||||
return popupUrl.toString();
|
||||
}
|
||||
|
||||
export function getCenteredWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
|
||||
const width = 1360;
|
||||
const height = 900;
|
||||
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
|
||||
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
|
||||
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
|
||||
? hostWindow.outerWidth
|
||||
: hostWindow.innerWidth;
|
||||
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
|
||||
? hostWindow.outerHeight
|
||||
: hostWindow.innerHeight;
|
||||
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
|
||||
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export function getCenteredWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
|
||||
const width = 1280;
|
||||
const height = 820;
|
||||
const hostScreenX = Number.isFinite(hostWindow.screenX) ? hostWindow.screenX : 0;
|
||||
const hostScreenY = Number.isFinite(hostWindow.screenY) ? hostWindow.screenY : 0;
|
||||
const hostOuterWidth = Number.isFinite(hostWindow.outerWidth) && hostWindow.outerWidth > 0
|
||||
? hostWindow.outerWidth
|
||||
: hostWindow.innerWidth;
|
||||
const hostOuterHeight = Number.isFinite(hostWindow.outerHeight) && hostWindow.outerHeight > 0
|
||||
? hostWindow.outerHeight
|
||||
: hostWindow.innerHeight;
|
||||
const left = Math.max(0, Math.round(hostScreenX + (hostOuterWidth - width) / 2));
|
||||
const top = Math.max(0, Math.round(hostScreenY + (hostOuterHeight - height) / 2));
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export function readWorldshaperStudioBounds(hostWindow: Window = window): PopupBounds {
|
||||
try {
|
||||
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
|
||||
const width = Math.max(640, Number(parsed.width) || 0);
|
||||
const height = Math.max(480, Number(parsed.height) || 0);
|
||||
const left = Math.max(0, Number(parsed.left) || 0);
|
||||
const top = Math.max(0, Number(parsed.top) || 0);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
return { left, top, width, height };
|
||||
} catch {
|
||||
return getCenteredWorldshaperStudioBounds(hostWindow);
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorldshaperHeightViewerBounds(hostWindow: Window = window): PopupBounds {
|
||||
try {
|
||||
const raw = hostWindow.localStorage.getItem(WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<PopupBounds>;
|
||||
const width = Math.max(640, Number(parsed.width) || 0);
|
||||
const height = Math.max(480, Number(parsed.height) || 0);
|
||||
const left = Math.max(0, Number(parsed.left) || 0);
|
||||
const top = Math.max(0, Number(parsed.top) || 0);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || !Number.isFinite(left) || !Number.isFinite(top)) {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
return { left, top, width, height };
|
||||
} catch {
|
||||
return getCenteredWorldshaperHeightViewerBounds(hostWindow);
|
||||
}
|
||||
}
|
||||
|
||||
export function persistWorldshaperStudioBounds(sourceWindow: Window = window): void {
|
||||
if (sourceWindow.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
|
||||
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
|
||||
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
|
||||
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
|
||||
sourceWindow.localStorage.setItem(
|
||||
WORLDSHAPER_STUDIO_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify({ left, top, width, height }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage and same-origin failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function persistWorldshaperHeightViewerBounds(sourceWindow: Window = window): void {
|
||||
if (sourceWindow.closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const width = Math.max(640, Math.round(Number(sourceWindow.outerWidth) || 0));
|
||||
const height = Math.max(480, Math.round(Number(sourceWindow.outerHeight) || 0));
|
||||
const left = Math.max(0, Math.round(Number(sourceWindow.screenX) || 0));
|
||||
const top = Math.max(0, Math.round(Number(sourceWindow.screenY) || 0));
|
||||
sourceWindow.localStorage.setItem(
|
||||
WORLDSHAPER_HEIGHT_VIEWER_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify({ left, top, width, height }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage and same-origin failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function openWorldshaperStudioWindow(
|
||||
mapId: string,
|
||||
hostWindow: Window = window,
|
||||
options?: { worldId?: string },
|
||||
): Window | null {
|
||||
const popupUrl = buildWorldshaperStudioUrl(mapId, hostWindow, options);
|
||||
const initialBounds = readWorldshaperStudioBounds(hostWindow);
|
||||
const popupFeatures = [
|
||||
"popup=yes",
|
||||
"resizable=yes",
|
||||
"scrollbars=no",
|
||||
"width=" + initialBounds.width,
|
||||
"height=" + initialBounds.height,
|
||||
"left=" + initialBounds.left,
|
||||
"top=" + initialBounds.top,
|
||||
].join(",");
|
||||
|
||||
const popup = hostWindow.open(popupUrl, WORLDSHAPER_STUDIO_WINDOW_NAME, popupFeatures);
|
||||
if (!popup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
popup.moveTo(initialBounds.left, initialBounds.top);
|
||||
popup.resizeTo(initialBounds.width, initialBounds.height);
|
||||
} catch {
|
||||
// Ignore browser restrictions.
|
||||
}
|
||||
|
||||
popup.location.href = popupUrl;
|
||||
popup.focus();
|
||||
return popup;
|
||||
}
|
||||
|
||||
export function openWorldshaperHeightViewerWindow(mapId: string, token = "", hostWindow: Window = window): Window | null {
|
||||
const popupUrl = buildWorldshaperHeightViewerUrl(mapId, token, hostWindow);
|
||||
const initialBounds = readWorldshaperHeightViewerBounds(hostWindow);
|
||||
const popupFeatures = [
|
||||
"popup=yes",
|
||||
"resizable=yes",
|
||||
"scrollbars=no",
|
||||
"width=" + initialBounds.width,
|
||||
"height=" + initialBounds.height,
|
||||
"left=" + initialBounds.left,
|
||||
"top=" + initialBounds.top,
|
||||
].join(",");
|
||||
|
||||
const popup = hostWindow.open(popupUrl, WORLDSHAPER_HEIGHT_VIEWER_WINDOW_NAME, popupFeatures);
|
||||
if (!popup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
popup.moveTo(initialBounds.left, initialBounds.top);
|
||||
popup.resizeTo(initialBounds.width, initialBounds.height);
|
||||
} catch {
|
||||
// Ignore browser restrictions.
|
||||
}
|
||||
|
||||
popup.location.href = popupUrl;
|
||||
popup.focus();
|
||||
return popup;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,602 +0,0 @@
|
|||
// @ts-nocheck
|
||||
|
||||
export function createFilledRows(width, height, fillChar) {
|
||||
return Array.from({ length: Math.max(1, Number(height) || 1) }, () => String(fillChar || " ").repeat(Math.max(1, Number(width) || 1)));
|
||||
}
|
||||
|
||||
function writeRowSegment(rows, y, x, segment) {
|
||||
if (!Array.isArray(rows) || !segment) {
|
||||
return;
|
||||
}
|
||||
const targetY = Math.floor(Number(y) || 0);
|
||||
if (targetY < 0 || targetY >= rows.length) {
|
||||
return;
|
||||
}
|
||||
const safeX = Math.max(0, Math.floor(Number(x) || 0));
|
||||
const sourceRow = String(rows[targetY] || "");
|
||||
const paddedRow = sourceRow.length >= safeX
|
||||
? sourceRow
|
||||
: (sourceRow + " ".repeat(Math.max(0, safeX - sourceRow.length)));
|
||||
const before = paddedRow.slice(0, safeX);
|
||||
const afterStart = safeX + segment.length;
|
||||
const after = afterStart < paddedRow.length ? paddedRow.slice(afterStart) : "";
|
||||
rows[targetY] = before + segment + after;
|
||||
}
|
||||
|
||||
export function composeWorldRoomLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY, worldWidth, worldHeight) {
|
||||
const layerMap = new Map();
|
||||
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||||
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||||
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||||
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||||
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||||
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
|
||||
rawLayers.forEach((rawLayer) => {
|
||||
const layerNumber = Number(rawLayer?.layer) || 0;
|
||||
const fillChar = layerNumber === 0 ? "." : " ";
|
||||
if (!layerMap.has(layerNumber)) {
|
||||
layerMap.set(layerNumber, {
|
||||
layer: layerNumber,
|
||||
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
|
||||
rows: createFilledRows(worldWidth, worldHeight, fillChar),
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
const targetLayer = layerMap.get(layerNumber);
|
||||
const sourceRows = Array.isArray(rawLayer?.rows) ? rawLayer.rows.map((row) => String(row || "")) : [];
|
||||
sourceRows.forEach((row, localY) => {
|
||||
const targetY = offsetY + localY;
|
||||
if (targetY < 0 || targetY >= targetLayer.rows.length) {
|
||||
return;
|
||||
}
|
||||
const maxWidth = Math.max(0, worldWidth - offsetX);
|
||||
writeRowSegment(targetLayer.rows, targetY, offsetX, row.slice(0, maxWidth));
|
||||
});
|
||||
const sourceInstanceIds = Array.isArray(rawLayer?.instanceIds)
|
||||
? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
targetLayer.instanceIds = Array.from(new Set([...(targetLayer.instanceIds || []), ...sourceInstanceIds]));
|
||||
});
|
||||
});
|
||||
if (!layerMap.has(0)) {
|
||||
layerMap.set(0, {
|
||||
layer: 0,
|
||||
rows: createFilledRows(worldWidth, worldHeight, "."),
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
|
||||
}
|
||||
|
||||
export function composeWorldHeightLayers(chunks, chunkWidth, chunkHeight, originChunkX, originChunkY) {
|
||||
const patches = [];
|
||||
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||||
const baseChunkX = Math.floor(Number(chunk?.chunkX) || 0);
|
||||
const baseChunkY = Math.floor(Number(chunk?.chunkY) || 0);
|
||||
const offsetX = (baseChunkX - originChunkX) * chunkWidth;
|
||||
const offsetY = (baseChunkY - originChunkY) * chunkHeight;
|
||||
const rawHeightLayers = Array.isArray(chunk?.heightLayers) ? chunk.heightLayers : [];
|
||||
rawHeightLayers.forEach((entry, index) => {
|
||||
const fallbackId = `height_${baseChunkX}_${baseChunkY}_${index + 1}`;
|
||||
patches.push({
|
||||
id: String(entry?.id || fallbackId).trim() || fallbackId,
|
||||
name: typeof entry?.name === "string" && String(entry.name).trim() ? String(entry.name).trim() : undefined,
|
||||
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
|
||||
x: offsetX + Math.max(0, Number(entry?.x) || 0),
|
||||
y: offsetY + Math.max(0, Number(entry?.y) || 0),
|
||||
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
return patches.sort((a, b) => {
|
||||
if (a.z !== b.z) {
|
||||
return a.z - b.z;
|
||||
}
|
||||
return String(a.name || a.id).localeCompare(String(b.name || b.id));
|
||||
});
|
||||
}
|
||||
|
||||
export function buildWorldLayerMetadata(chunks) {
|
||||
const layerMap = new Map();
|
||||
(Array.isArray(chunks) ? chunks : []).forEach((chunk) => {
|
||||
const rawLayers = Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [];
|
||||
rawLayers.forEach((rawLayer) => {
|
||||
const layerNumber = Number(rawLayer?.layer) || 0;
|
||||
if (layerMap.has(layerNumber)) {
|
||||
return;
|
||||
}
|
||||
layerMap.set(layerNumber, {
|
||||
layer: layerNumber,
|
||||
name: typeof rawLayer?.name === "string" && String(rawLayer.name).trim() ? String(rawLayer.name).trim() : undefined,
|
||||
rows: [],
|
||||
instanceIds: Array.isArray(rawLayer?.instanceIds) ? rawLayer.instanceIds.map((entry) => String(entry || "").trim()).filter(Boolean) : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
if (!layerMap.has(0)) {
|
||||
layerMap.set(0, {
|
||||
layer: 0,
|
||||
rows: [],
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
if (!Array.from(layerMap.keys()).some((layerNumber) => layerNumber > 0)) {
|
||||
layerMap.set(1, {
|
||||
layer: 1,
|
||||
rows: [],
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
return Array.from(layerMap.values()).sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
|
||||
}
|
||||
|
||||
export function sliceNormalizedRows(rows, startX, startY, width, height, fillChar) {
|
||||
return Array.from({ length: Math.max(1, Number(height) || 1) }, (_, rowOffset) => {
|
||||
const sourceRow = String((Array.isArray(rows) ? rows[startY + rowOffset] : "") || "");
|
||||
const paddedRow = sourceRow.length >= startX + width
|
||||
? sourceRow
|
||||
: sourceRow + String(fillChar || " ").repeat(Math.max(0, (startX + width) - sourceRow.length));
|
||||
return paddedRow.slice(startX, startX + width);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildChunkHeightLayersFromDocument({ mapDocument, cloneHeightLayers, baseTileX, baseTileY, chunkWidth, chunkHeight }) {
|
||||
return (Array.isArray(mapDocument.heightLayers) ? cloneHeightLayers(mapDocument.heightLayers) : [])
|
||||
.map((entry) => {
|
||||
const patchX = Math.max(0, Number(entry?.x) || 0);
|
||||
const patchY = Math.max(0, Number(entry?.y) || 0);
|
||||
const rows = Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [];
|
||||
const patchWidth = rows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
const patchHeight = rows.length;
|
||||
const patchRight = patchX + patchWidth;
|
||||
const patchBottom = patchY + patchHeight;
|
||||
const chunkRight = baseTileX + chunkWidth;
|
||||
const chunkBottom = baseTileY + chunkHeight;
|
||||
const overlapLeft = Math.max(baseTileX, patchX);
|
||||
const overlapTop = Math.max(baseTileY, patchY);
|
||||
const overlapRight = Math.min(chunkRight, patchRight);
|
||||
const overlapBottom = Math.min(chunkBottom, patchBottom);
|
||||
if (overlapRight <= overlapLeft || overlapBottom <= overlapTop) {
|
||||
return null;
|
||||
}
|
||||
const localRows = [];
|
||||
for (let y = overlapTop; y < overlapBottom; y += 1) {
|
||||
const sourceRow = String(rows[y - patchY] || "");
|
||||
localRows.push(sourceRow.slice(overlapLeft - patchX, overlapRight - patchX).replace(/\s+$/g, ""));
|
||||
}
|
||||
return {
|
||||
id: String(entry?.id || "").trim(),
|
||||
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||||
z: Math.max(1, Number(entry?.z) || 1),
|
||||
x: overlapLeft - baseTileX,
|
||||
y: overlapTop - baseTileY,
|
||||
rows: localRows,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry && entry.id);
|
||||
}
|
||||
|
||||
export function buildChunkInstancesFromDocument({ mapDocument, cloneValue, baseTileX, baseTileY, chunkWidth, chunkHeight, tileOffsetX, tileOffsetY }) {
|
||||
const chunkInstances = cloneValue(mapDocument.npcOverlays)
|
||||
.filter((npc) => {
|
||||
const localX = Math.floor(Number(npc?.x));
|
||||
const localY = Math.floor(Number(npc?.y));
|
||||
return Number.isFinite(localX)
|
||||
&& Number.isFinite(localY)
|
||||
&& localX >= baseTileX
|
||||
&& localX < baseTileX + chunkWidth
|
||||
&& localY >= baseTileY
|
||||
&& localY < baseTileY + chunkHeight;
|
||||
})
|
||||
.map((npc) => ({
|
||||
id: String(npc.id || "").trim(),
|
||||
templateId: String(npc?.record?.templateId || "").trim(),
|
||||
layer: Number(npc.layer) || 0,
|
||||
x: Math.floor(Number(npc.x) || 0) - baseTileX,
|
||||
y: Math.floor(Number(npc.y) || 0) - baseTileY,
|
||||
record: {
|
||||
...cloneValue(npc.record || {}),
|
||||
id: String(npc.id || "").trim(),
|
||||
layer: Number(npc.layer) || 0,
|
||||
templateId: String(npc?.record?.templateId || "").trim(),
|
||||
name: String(npc.name || npc?.record?.name || ""),
|
||||
entityType: String(npc?.record?.entityType || npc?.entityType || "friendly"),
|
||||
faction: String(npc.faction || npc?.record?.faction || ""),
|
||||
spriteId: String(npc.spriteId || npc?.record?.spriteId || ""),
|
||||
dialogueId: String(npc.dialogueId || npc?.record?.dialogueId || ""),
|
||||
description: String(npc.description || npc?.record?.description || ""),
|
||||
tags: cloneValue(npc?.record?.tags) || [],
|
||||
enabled: typeof npc?.record?.enabled === "boolean" ? npc.record.enabled : true,
|
||||
position: {
|
||||
x: Math.floor(Number(npc.x) || 0) + tileOffsetX,
|
||||
y: Math.floor(Number(npc.y) || 0) + tileOffsetY,
|
||||
},
|
||||
},
|
||||
}))
|
||||
.filter((entry) => entry.id);
|
||||
const npcIdsByLayer = new Map();
|
||||
chunkInstances.forEach((entry) => {
|
||||
const layerNumber = Number(entry.layer) || 0;
|
||||
if (!npcIdsByLayer.has(layerNumber)) {
|
||||
npcIdsByLayer.set(layerNumber, []);
|
||||
}
|
||||
npcIdsByLayer.get(layerNumber).push(entry.id);
|
||||
});
|
||||
return {
|
||||
chunkInstances,
|
||||
npcIdsByLayer,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorldChunkRows(rows, width, height, fillChar) {
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
return Array.from({ length: safeHeight }, (_entry, rowIndex) => {
|
||||
const sourceRow = String((Array.isArray(rows) ? rows[rowIndex] : "") || "");
|
||||
return sourceRow.length >= safeWidth
|
||||
? sourceRow.slice(0, safeWidth)
|
||||
: (sourceRow + String(fillChar || " ").repeat(Math.max(0, safeWidth - sourceRow.length)));
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneWorldChunkHeightLayers(source) {
|
||||
return (Array.isArray(source) ? source : [])
|
||||
.map((entry, index) => ({
|
||||
id: String(entry?.id || `height_patch_${index + 1}`).trim() || `height_patch_${index + 1}`,
|
||||
name: typeof entry?.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||||
z: Math.max(1, Math.floor(Number(entry?.z) || 1)),
|
||||
x: Math.max(0, Math.floor(Number(entry?.x) || 0)),
|
||||
y: Math.max(0, Math.floor(Number(entry?.y) || 0)),
|
||||
rows: Array.isArray(entry?.rows) ? entry.rows.map((row) => String(row || "")) : [],
|
||||
}))
|
||||
.filter((entry) => entry.id);
|
||||
}
|
||||
|
||||
export function buildWorldChunkLayerInstanceIds(roomLayers, instances, width, height) {
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
const nextLayers = new Map();
|
||||
(Array.isArray(roomLayers) ? roomLayers : []).forEach((layer) => {
|
||||
const layerNumber = Math.max(0, Math.floor(Number(layer?.layer) || 0));
|
||||
nextLayers.set(layerNumber, {
|
||||
layer: layerNumber,
|
||||
name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
|
||||
rows: normalizeWorldChunkRows(layer?.rows, safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
|
||||
instanceIds: [],
|
||||
});
|
||||
});
|
||||
if (!nextLayers.has(0)) {
|
||||
nextLayers.set(0, {
|
||||
layer: 0,
|
||||
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, "."),
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
if (!Array.from(nextLayers.keys()).some((layerNumber) => layerNumber > 0)) {
|
||||
nextLayers.set(1, {
|
||||
layer: 1,
|
||||
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, " "),
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
(Array.isArray(instances) ? instances : []).forEach((entry) => {
|
||||
const layerNumber = Math.max(0, Math.floor(Number(entry?.layer) || 0));
|
||||
const instanceId = String(entry?.id || "").trim();
|
||||
if (!instanceId) {
|
||||
return;
|
||||
}
|
||||
if (!nextLayers.has(layerNumber)) {
|
||||
nextLayers.set(layerNumber, {
|
||||
layer: layerNumber,
|
||||
rows: normalizeWorldChunkRows([], safeWidth, safeHeight, layerNumber === 0 ? "." : " "),
|
||||
instanceIds: [],
|
||||
});
|
||||
}
|
||||
nextLayers.get(layerNumber).instanceIds.push(instanceId);
|
||||
});
|
||||
return Array.from(nextLayers.values())
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
instanceIds: Array.from(new Set((Array.isArray(entry.instanceIds) ? entry.instanceIds : []).map((id) => String(id || "").trim()).filter(Boolean))),
|
||||
}))
|
||||
.sort((left, right) => (Number(left.layer) || 0) - (Number(right.layer) || 0));
|
||||
}
|
||||
|
||||
export function normalizeWorldChunkInstances({ sourceInstances, chunkX, chunkY, width, height, options, cloneValue, runtimeUniqueId }) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const duplicateIds = config.duplicateIds === true;
|
||||
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||||
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
return (Array.isArray(sourceInstances) ? sourceInstances : [])
|
||||
.map((entry) => {
|
||||
const record = entry?.record && typeof entry.record === "object" && !Array.isArray(entry.record)
|
||||
? cloneValue(entry.record)
|
||||
: {};
|
||||
const nextId = duplicateIds
|
||||
? runtimeUniqueId()
|
||||
: (String(entry?.id || record?.id || runtimeUniqueId()).trim() || runtimeUniqueId());
|
||||
const nextLayer = Math.max(0, Math.floor(Number(entry?.layer ?? record?.layer) || 0));
|
||||
const nextX = Math.max(0, Math.min(safeWidth - 1, Math.floor(Number(entry?.x) || 0)));
|
||||
const nextY = Math.max(0, Math.min(safeHeight - 1, Math.floor(Number(entry?.y) || 0)));
|
||||
const nextTemplateId = String(entry?.templateId || record?.templateId || "").trim();
|
||||
record.id = nextId;
|
||||
record.layer = nextLayer;
|
||||
record.templateId = nextTemplateId;
|
||||
record.position = {
|
||||
x: (safeChunkX * safeWidth) + nextX,
|
||||
y: (safeChunkY * safeHeight) + nextY,
|
||||
};
|
||||
return {
|
||||
id: nextId,
|
||||
templateId: nextTemplateId,
|
||||
layer: nextLayer,
|
||||
x: nextX,
|
||||
y: nextY,
|
||||
record,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.id);
|
||||
}
|
||||
|
||||
export function createEmptyWorldChunkPayload({ chunkX, chunkY, chunkWidth, chunkHeight, worldId }) {
|
||||
const safeChunkX = Math.floor(Number(chunkX) || 0);
|
||||
const safeChunkY = Math.floor(Number(chunkY) || 0);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
worldId: String(worldId || "").trim(),
|
||||
chunkX: safeChunkX,
|
||||
chunkY: safeChunkY,
|
||||
width: chunkWidth,
|
||||
height: chunkHeight,
|
||||
backgroundTileId: "",
|
||||
roomLayers: [
|
||||
{
|
||||
layer: 0,
|
||||
rows: Array.from({ length: chunkHeight }, () => ".".repeat(chunkWidth)),
|
||||
instanceIds: [],
|
||||
},
|
||||
{
|
||||
layer: 1,
|
||||
rows: Array.from({ length: chunkHeight }, () => " ".repeat(chunkWidth)),
|
||||
instanceIds: [],
|
||||
},
|
||||
],
|
||||
heightLayers: [],
|
||||
instances: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCachedWorldChunkPayload({ chunkPayload, chunkX, chunkY, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
|
||||
const safeChunkX = Math.floor(Number(chunkX ?? chunkPayload?.chunkX) || 0);
|
||||
const safeChunkY = Math.floor(Number(chunkY ?? chunkPayload?.chunkY) || 0);
|
||||
const safeWidth = Math.max(1, Math.floor(Number(chunkPayload?.width) || Number(chunkWidth) || 32));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(chunkPayload?.height) || Number(chunkHeight) || 32));
|
||||
const instances = normalizeWorldChunkInstances({
|
||||
sourceInstances: chunkPayload?.instances,
|
||||
chunkX: safeChunkX,
|
||||
chunkY: safeChunkY,
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
options,
|
||||
cloneValue,
|
||||
runtimeUniqueId,
|
||||
});
|
||||
const roomLayers = buildWorldChunkLayerInstanceIds(chunkPayload?.roomLayers, instances, safeWidth, safeHeight);
|
||||
return {
|
||||
schemaVersion: Math.max(1, Math.floor(Number(chunkPayload?.schemaVersion) || 1)),
|
||||
worldId: String(chunkPayload?.worldId || worldId || "").trim(),
|
||||
chunkX: safeChunkX,
|
||||
chunkY: safeChunkY,
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
backgroundTileId: String(chunkPayload?.backgroundTileId || "").trim(),
|
||||
roomLayers,
|
||||
heightLayers: cloneWorldChunkHeightLayers(chunkPayload?.heightLayers),
|
||||
instances,
|
||||
};
|
||||
}
|
||||
|
||||
export function isChunkFillSymbol(ch, fillChar) {
|
||||
const symbol = String(ch || "").charAt(0);
|
||||
return !symbol || symbol === fillChar || symbol === "." || symbol === " ";
|
||||
}
|
||||
|
||||
export function isWorldChunkPayloadEmpty({ chunkPayload, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId }) {
|
||||
const normalized = normalizeCachedWorldChunkPayload({
|
||||
chunkPayload,
|
||||
chunkX: chunkPayload?.chunkX,
|
||||
chunkY: chunkPayload?.chunkY,
|
||||
chunkWidth,
|
||||
chunkHeight,
|
||||
worldId,
|
||||
cloneValue,
|
||||
runtimeUniqueId,
|
||||
});
|
||||
if (String(normalized?.backgroundTileId || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(normalized?.instances) && normalized.instances.length > 0) {
|
||||
return false;
|
||||
}
|
||||
if ((Array.isArray(normalized?.heightLayers) ? normalized.heightLayers : []).some((entry) => (
|
||||
Array.isArray(entry?.rows) && entry.rows.some((row) => /[^ .]/.test(String(row || "")))
|
||||
))) {
|
||||
return false;
|
||||
}
|
||||
return !(Array.isArray(normalized?.roomLayers) ? normalized.roomLayers : []).some((layer) => {
|
||||
const fillChar = (Number(layer?.layer) || 0) === 0 ? "." : " ";
|
||||
return (Array.isArray(layer?.rows) ? layer.rows : []).some((row) => {
|
||||
const sourceRow = String(row || "");
|
||||
for (let index = 0; index < sourceRow.length; index += 1) {
|
||||
if (!isChunkFillSymbol(sourceRow.charAt(index), fillChar)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function transformChunkLocalCoord(localX, localY, width, height, operation) {
|
||||
const safeX = Math.floor(Number(localX) || 0);
|
||||
const safeY = Math.floor(Number(localY) || 0);
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
switch (String(operation || "").trim()) {
|
||||
case "flipHorizontal":
|
||||
return { x: (safeWidth - 1) - safeX, y: safeY };
|
||||
case "flipVertical":
|
||||
return { x: safeX, y: (safeHeight - 1) - safeY };
|
||||
case "rotate180":
|
||||
return { x: (safeWidth - 1) - safeX, y: (safeHeight - 1) - safeY };
|
||||
case "rotate90cw":
|
||||
if (safeWidth !== safeHeight) {
|
||||
return null;
|
||||
}
|
||||
return { x: (safeWidth - 1) - safeY, y: safeX };
|
||||
case "rotate90ccw":
|
||||
if (safeWidth !== safeHeight) {
|
||||
return null;
|
||||
}
|
||||
return { x: safeY, y: (safeHeight - 1) - safeX };
|
||||
default:
|
||||
return { x: safeX, y: safeY };
|
||||
}
|
||||
}
|
||||
|
||||
export function transformChunkRows(rows, width, height, fillChar, operation) {
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
const sourceRows = normalizeWorldChunkRows(rows, safeWidth, safeHeight, fillChar);
|
||||
const nextRows = Array.from({ length: safeHeight }, () => Array.from({ length: safeWidth }, () => String(fillChar || " ").charAt(0) || " "));
|
||||
for (let rowIndex = 0; rowIndex < safeHeight; rowIndex += 1) {
|
||||
const sourceRow = sourceRows[rowIndex];
|
||||
for (let columnIndex = 0; columnIndex < safeWidth; columnIndex += 1) {
|
||||
const char = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || String(fillChar || " ").charAt(0) || " ";
|
||||
if (isChunkFillSymbol(char, fillChar)) {
|
||||
continue;
|
||||
}
|
||||
const nextCoord = transformChunkLocalCoord(columnIndex, rowIndex, safeWidth, safeHeight, operation);
|
||||
if (!nextCoord) {
|
||||
continue;
|
||||
}
|
||||
nextRows[nextCoord.y][nextCoord.x] = char;
|
||||
}
|
||||
}
|
||||
return nextRows.map((row) => row.join(""));
|
||||
}
|
||||
|
||||
export function transformChunkHeightPatch(patch, width, height, operation) {
|
||||
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
|
||||
const sourceRows = Array.isArray(patch?.rows) ? patch.rows.map((row) => String(row || "")) : [];
|
||||
const patchWidth = sourceRows.reduce((max, row) => Math.max(max, row.length), 0);
|
||||
const patchHeight = sourceRows.length;
|
||||
const transformedCells = [];
|
||||
for (let localY = 0; localY < patchHeight; localY += 1) {
|
||||
const row = sourceRows[localY] || "";
|
||||
for (let localX = 0; localX < patchWidth; localX += 1) {
|
||||
const char = String(row.charAt(localX) || " ").charAt(0) || " ";
|
||||
if (char === " " || char === ".") {
|
||||
continue;
|
||||
}
|
||||
const worldX = Math.max(0, Math.floor(Number(patch?.x) || 0)) + localX;
|
||||
const worldY = Math.max(0, Math.floor(Number(patch?.y) || 0)) + localY;
|
||||
if (worldX < 0 || worldY < 0 || worldX >= safeWidth || worldY >= safeHeight) {
|
||||
continue;
|
||||
}
|
||||
const nextCoord = transformChunkLocalCoord(worldX, worldY, safeWidth, safeHeight, operation);
|
||||
if (!nextCoord) {
|
||||
continue;
|
||||
}
|
||||
transformedCells.push({
|
||||
x: nextCoord.x,
|
||||
y: nextCoord.y,
|
||||
char,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transformedCells.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
const minX = transformedCells.reduce((min, entry) => Math.min(min, entry.x), transformedCells[0].x);
|
||||
const maxX = transformedCells.reduce((max, entry) => Math.max(max, entry.x), transformedCells[0].x);
|
||||
const minY = transformedCells.reduce((min, entry) => Math.min(min, entry.y), transformedCells[0].y);
|
||||
const maxY = transformedCells.reduce((max, entry) => Math.max(max, entry.y), transformedCells[0].y);
|
||||
const nextRows = Array.from({ length: (maxY - minY) + 1 }, () => Array.from({ length: (maxX - minX) + 1 }, () => " "));
|
||||
transformedCells.forEach((entry) => {
|
||||
nextRows[entry.y - minY][entry.x - minX] = entry.char;
|
||||
});
|
||||
return {
|
||||
id: String(patch?.id || "").trim(),
|
||||
name: typeof patch?.name === "string" && patch.name.trim() ? patch.name.trim() : undefined,
|
||||
z: Math.max(1, Math.floor(Number(patch?.z) || 1)),
|
||||
x: minX,
|
||||
y: minY,
|
||||
rows: nextRows.map((row) => row.join("").replace(/\s+$/g, "")),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformWorldChunkPayload({ chunkPayload, operation, chunkWidth, chunkHeight, worldId, cloneValue, runtimeUniqueId, options }) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const normalized = normalizeCachedWorldChunkPayload({
|
||||
chunkPayload,
|
||||
chunkX: chunkPayload?.chunkX,
|
||||
chunkY: chunkPayload?.chunkY,
|
||||
chunkWidth,
|
||||
chunkHeight,
|
||||
worldId,
|
||||
cloneValue,
|
||||
runtimeUniqueId,
|
||||
options: config,
|
||||
});
|
||||
const safeWidth = Math.max(1, Math.floor(Number(normalized?.width) || 1));
|
||||
const safeHeight = Math.max(1, Math.floor(Number(normalized?.height) || 1));
|
||||
const normalizedOperation = String(operation || "").trim();
|
||||
if ((normalizedOperation === "rotate90cw" || normalizedOperation === "rotate90ccw") && safeWidth !== safeHeight) {
|
||||
throw new Error("Chunk rotation requires square chunks.");
|
||||
}
|
||||
const instances = normalizeWorldChunkInstances({
|
||||
sourceInstances: (Array.isArray(normalized.instances) ? normalized.instances : []).map((entry) => {
|
||||
const nextCoord = transformChunkLocalCoord(entry.x, entry.y, safeWidth, safeHeight, normalizedOperation);
|
||||
return {
|
||||
...cloneValue(entry),
|
||||
x: nextCoord?.x ?? entry.x,
|
||||
y: nextCoord?.y ?? entry.y,
|
||||
};
|
||||
}),
|
||||
chunkX: normalized.chunkX,
|
||||
chunkY: normalized.chunkY,
|
||||
width: safeWidth,
|
||||
height: safeHeight,
|
||||
options: config,
|
||||
cloneValue,
|
||||
runtimeUniqueId,
|
||||
});
|
||||
const roomLayers = buildWorldChunkLayerInstanceIds(
|
||||
(Array.isArray(normalized.roomLayers) ? normalized.roomLayers : []).map((layer) => ({
|
||||
...cloneValue(layer),
|
||||
rows: transformChunkRows(layer?.rows, safeWidth, safeHeight, (Number(layer?.layer) || 0) === 0 ? "." : " ", normalizedOperation),
|
||||
})),
|
||||
instances,
|
||||
safeWidth,
|
||||
safeHeight,
|
||||
);
|
||||
const heightLayers = cloneWorldChunkHeightLayers(normalized.heightLayers)
|
||||
.map((entry) => transformChunkHeightPatch(entry, safeWidth, safeHeight, normalizedOperation))
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
if ((Number(left?.z) || 0) !== (Number(right?.z) || 0)) {
|
||||
return (Number(left?.z) || 0) - (Number(right?.z) || 0);
|
||||
}
|
||||
return String(left?.name || left?.id || "").localeCompare(String(right?.name || right?.id || ""));
|
||||
});
|
||||
return {
|
||||
...normalized,
|
||||
roomLayers,
|
||||
heightLayers,
|
||||
instances,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue