Tighten request board layout
This commit is contained in:
parent
db3e080640
commit
496df474b8
3 changed files with 622 additions and 672 deletions
|
|
@ -454,7 +454,11 @@ function createLauncherRequestId() {
|
|||
}
|
||||
|
||||
function normalizeLauncherRequestStatus(value) {
|
||||
return String(value || "").trim().toLowerCase() === "active" ? "active" : "pending";
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "active" || normalized === "implemented") {
|
||||
return normalized;
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function normalizeLauncherRequestTags(value) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type WorldDefaultPayload = {
|
|||
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
||||
type BoardTab = "news" | "requests";
|
||||
type LauncherWindowMode = "public" | "admin";
|
||||
type LauncherRequestStatus = "pending" | "active" | "implemented";
|
||||
|
||||
type LauncherRequestAnalysisRouting = {
|
||||
summary?: string;
|
||||
|
|
@ -38,7 +39,7 @@ type LauncherRequest = {
|
|||
id: string;
|
||||
sourceSubmissionId?: string;
|
||||
title: string;
|
||||
status: "pending" | "active";
|
||||
status: LauncherRequestStatus;
|
||||
category: string;
|
||||
tags: string[];
|
||||
sourceText: string;
|
||||
|
|
@ -145,6 +146,38 @@ function readLauncherWindowMode(): LauncherWindowMode {
|
|||
return searchParams.get("admin") === "requests" ? "admin" : "public";
|
||||
}
|
||||
|
||||
function normalizeCommaSeparatedList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveDefaultWorldId(): Promise<string> {
|
||||
const response = await fetch("/api/world-default");
|
||||
if (!response.ok) {
|
||||
|
|
@ -193,137 +226,6 @@ function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
|||
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
||||
}
|
||||
|
||||
function getPrimaryAnalysisItem(requestEntry: LauncherRequest): LauncherRequestAnalysisItem | null {
|
||||
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis?.items : [];
|
||||
return items.length > 0 ? items[0] : null;
|
||||
}
|
||||
|
||||
function formatConfidence(value: number | null | undefined): string {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
return "Unscored";
|
||||
}
|
||||
return `${Math.round(Number(value) * 100)}%`;
|
||||
}
|
||||
|
||||
function escapePopupHtml(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function openReviewDetailsPopup(requestEntry: LauncherRequest): void {
|
||||
const popup = window.open("", `worldshaper-review-${requestEntry.id}`, "popup=yes,width=840,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
const routing = requestEntry.analysis?.routing;
|
||||
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : [];
|
||||
const renderedItems = items.map((item, index) => {
|
||||
const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : [];
|
||||
return `
|
||||
<section class="review-card">
|
||||
<div class="review-kicker">Review Item ${index + 1}</div>
|
||||
<h2>${escapePopupHtml(String(item?.title || `Request ${index + 1}`))}</h2>
|
||||
<div class="review-meta">
|
||||
<span>${escapePopupHtml(String(item?.primaryCategory || "Unsorted"))}</span>
|
||||
<span>${escapePopupHtml(String(item?.statusRecommendation || "needs_review"))}</span>
|
||||
<span>${escapePopupHtml(formatConfidence(item?.confidence))}</span>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Review Rationale</h3>
|
||||
<p>${escapePopupHtml(String(item?.reviewRationale || "No structured review rationale was returned."))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Parsed Interpretation</h3>
|
||||
<p>${escapePopupHtml(String(item?.parsedInterpretation || ""))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Implementation Approach</h3>
|
||||
<p>${escapePopupHtml(String(item?.implementationApproach || ""))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Possible Options</h3>
|
||||
${reviewOptions.length > 0
|
||||
? `<ul>${reviewOptions.map((option) => `<li>${escapePopupHtml(String(option || ""))}</li>`).join("")}</ul>`
|
||||
: "<p>No structured options were returned.</p>"}
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Notes</h3>
|
||||
<p>${escapePopupHtml(String(item?.notes || "No extra notes."))}</p>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
popup.document.open();
|
||||
popup.document.write(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Worldshaper Review Details</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: Georgia, "Segoe UI", sans-serif; background: #08111f; color: #e8f2ff; }
|
||||
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
||||
.hero { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: linear-gradient(180deg, rgba(17,32,63,.96), rgba(10,19,38,.98)); }
|
||||
.hero h1 { margin: 0 0 8px; font-size: 28px; }
|
||||
.hero p { margin: 0; color: #b8cfee; line-height: 1.6; white-space: pre-wrap; }
|
||||
.review-card { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: rgba(17,32,63,.88); display: grid; gap: 12px; }
|
||||
.review-kicker { color: #ffd166; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.review-card h2, .review-block h3 { margin: 0; }
|
||||
.review-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.review-meta span { padding: 4px 8px; border: 1px solid #365782; border-radius: 999px; background: rgba(8,16,31,.75); font-size: 12px; }
|
||||
.review-block { display: grid; gap: 6px; }
|
||||
.review-block p, .review-block ul { margin: 0; color: #d7e7ff; line-height: 1.6; white-space: pre-wrap; }
|
||||
.review-block ul { padding-left: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>${escapePopupHtml(requestEntry.title)}</h1>
|
||||
<p>${escapePopupHtml(requestEntry.sourceText)}</p>
|
||||
</section>
|
||||
${routing ? `
|
||||
<section class="review-card">
|
||||
<div class="review-kicker">Routing Summary</div>
|
||||
<h2>${escapePopupHtml(String(routing.summary || "KB routing context"))}</h2>
|
||||
<div class="review-meta">
|
||||
<span>Ambiguity: ${escapePopupHtml(String(routing.ambiguity || "medium"))}</span>
|
||||
${(Array.isArray(routing.suggestedTags) ? routing.suggestedTags : []).map((tag) => `<span>${escapePopupHtml(tag)}</span>`).join("")}
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Rationale</h3>
|
||||
<p>${escapePopupHtml(String(routing.rationale || "No routing rationale was stored."))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Matched Terms</h3>
|
||||
${(Array.isArray(routing.matchedTerms) && routing.matchedTerms.length > 0)
|
||||
? `<ul>${routing.matchedTerms.map((term) => `<li>${escapePopupHtml(String(term || ""))}</li>`).join("")}</ul>`
|
||||
: "<p>No explicit terminology matches were stored.</p>"}
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Likely Systems</h3>
|
||||
${(Array.isArray(routing.suggestedSystems) && routing.suggestedSystems.length > 0)
|
||||
? `<ul>${routing.suggestedSystems.map((systemId) => `<li>${escapePopupHtml(String(systemId || ""))}</li>`).join("")}</ul>`
|
||||
: "<p>No likely systems were stored.</p>"}
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Possible Directions</h3>
|
||||
${(Array.isArray(routing.possibleDirections) && routing.possibleDirections.length > 0)
|
||||
? `<ul>${routing.possibleDirections.map((direction) => `<li>${escapePopupHtml(String(direction || ""))}</li>`).join("")}</ul>`
|
||||
: "<p>No alternate directions were stored.</p>"}
|
||||
</div>
|
||||
</section>
|
||||
` : ""}
|
||||
${renderedItems || '<section class="review-card"><h2>No Review Items</h2><p>This request does not have structured review details yet.</p></section>'}
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
popup.document.close();
|
||||
}
|
||||
|
||||
function formatRequestTimestamp(value: string): string {
|
||||
const parsed = Date.parse(String(value || ""));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
|
@ -337,10 +239,6 @@ function formatRequestTimestamp(value: string): string {
|
|||
}).format(parsed);
|
||||
}
|
||||
|
||||
function formatRequestStatusLabel(status: "pending" | "active"): string {
|
||||
return status === "active" ? "Active" : "Pending";
|
||||
}
|
||||
|
||||
function normalizeAnalysisState(value: string | undefined): string {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -363,6 +261,9 @@ function formatAnalysisStateLabel(value: string | undefined): string {
|
|||
}
|
||||
|
||||
function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
|
||||
if (requestEntry.status === "implemented") {
|
||||
return "Implemented";
|
||||
}
|
||||
if (requestEntry.status === "active") {
|
||||
return "Active";
|
||||
}
|
||||
|
|
@ -383,6 +284,9 @@ function getRequestDisplayStateLabel(requestEntry: LauncherRequest): string {
|
|||
}
|
||||
|
||||
function getRequestDisplayStateClassName(requestEntry: LauncherRequest): string {
|
||||
if (requestEntry.status === "implemented") {
|
||||
return "implemented";
|
||||
}
|
||||
if (requestEntry.status === "active") {
|
||||
return "active";
|
||||
}
|
||||
|
|
@ -504,6 +408,7 @@ function WorldshaperLauncher() {
|
|||
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
||||
const [adminPasswordError, setAdminPasswordError] = useState("");
|
||||
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
|
||||
const [selectedAdminAnalysisIndex, setSelectedAdminAnalysisIndex] = useState(0);
|
||||
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
|
||||
const [adminSaving, setAdminSaving] = useState(false);
|
||||
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
|
||||
|
|
@ -671,6 +576,10 @@ function WorldshaperLauncher() {
|
|||
setAdminEditorDraft(cloneLauncherRequest(requests[0]));
|
||||
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAdminAnalysisIndex(0);
|
||||
}, [selectedAdminRequestId]);
|
||||
|
||||
async function handleLaunch(): Promise<void> {
|
||||
setError("");
|
||||
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||
|
|
@ -766,6 +675,7 @@ function WorldshaperLauncher() {
|
|||
function handleSelectAdminRequest(requestId: string): void {
|
||||
const nextRequest = requests.find((entry) => entry.id === requestId);
|
||||
setSelectedAdminRequestId(requestId);
|
||||
setSelectedAdminAnalysisIndex(0);
|
||||
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(nextRequest) : null);
|
||||
setAdminNotice("");
|
||||
setAdminPasswordError("");
|
||||
|
|
@ -1053,6 +963,7 @@ function WorldshaperLauncher() {
|
|||
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
|
||||
|
|
@ -1063,6 +974,12 @@ function WorldshaperLauncher() {
|
|||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean),
|
||||
))).sort((a, b) => a.localeCompare(b));
|
||||
const requestTagFilterOptions = requestTags
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
count: requests.filter((entry) => entry.status !== "pending" && entry.tags.includes(tag)).length,
|
||||
}))
|
||||
.filter((entry) => entry.count > 0);
|
||||
const filteredRequests = requests.filter((entry) => {
|
||||
if (requestFilter === "status:pending") {
|
||||
return entry.status === "pending";
|
||||
|
|
@ -1076,18 +993,22 @@ function WorldshaperLauncher() {
|
|||
if (requestFilter === "status:active") {
|
||||
return entry.status === "active";
|
||||
}
|
||||
if (requestFilter === "status:implemented") {
|
||||
return entry.status === "implemented";
|
||||
}
|
||||
if (requestFilter.startsWith("tag:")) {
|
||||
const tag = requestFilter.slice(4);
|
||||
return entry.status === "active" && entry.tags.includes(tag);
|
||||
return entry.status !== "pending" && entry.tags.includes(tag);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const boardTitle = adminWindowMode ? "Worldshaper Admin" : "Worldshaper Board";
|
||||
const boardHint = adminWindowMode
|
||||
? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`
|
||||
? `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`
|
||||
: (activeBoardTab === "news"
|
||||
? "Latest announcements"
|
||||
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active`);
|
||||
: `${queuedPendingRequestCount} queued, ${needsReviewRequestCount} review, ${activeRequestCount} active, ${implementedRequestCount} implemented`);
|
||||
const selectedAnalysisItem = adminEditorDraft?.analysis?.items?.[selectedAdminAnalysisIndex] || null;
|
||||
|
||||
return (
|
||||
<main
|
||||
|
|
@ -1197,7 +1118,7 @@ function WorldshaperLauncher() {
|
|||
<div className="changelog-splash-kicker">{adminWindowMode ? "Protected Review Workspace" : "Shared Request Board"}</div>
|
||||
<div className="changelog-splash-title" id="launcher-requests-title">{adminWindowMode ? "Admin Review Console" : "Requests"}</div>
|
||||
<div className="changelog-splash-meta">
|
||||
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
|
||||
{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">
|
||||
|
|
@ -1233,8 +1154,9 @@ function WorldshaperLauncher() {
|
|||
<option value="status:queued">Queued For Analysis</option>
|
||||
<option value="status:review">Needs Review</option>
|
||||
<option value="status:active">Active Requests</option>
|
||||
{requestTags.map((tag) => (
|
||||
<option key={tag} value={`tag:${tag}`}>{tag}</option>
|
||||
<option value="status:implemented">Implemented Requests</option>
|
||||
{requestTagFilterOptions.map(({ tag, count }) => (
|
||||
<option key={tag} value={`tag:${tag}`}>({count}) {tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -1292,6 +1214,7 @@ function WorldshaperLauncher() {
|
|||
<span>{needsReviewRequestCount} review</span>
|
||||
<span>{pendingRequestCount} pending</span>
|
||||
<span>{activeRequestCount} active</span>
|
||||
<span>{implementedRequestCount} implemented</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-actions">
|
||||
|
|
@ -1316,16 +1239,15 @@ function WorldshaperLauncher() {
|
|||
{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">
|
||||
<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 review or edit it.</div>
|
||||
<div className="launcher-request-admin-card-hint">Select a request to load it on the right.</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-request-list">
|
||||
{requests.map((requestEntry) => {
|
||||
const isMutating = requestMutatingId === requestEntry.id;
|
||||
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
|
||||
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||
const isSelected = requestEntry.id === selectedAdminRequestId;
|
||||
return (
|
||||
<article
|
||||
|
|
@ -1336,17 +1258,15 @@ function WorldshaperLauncher() {
|
|||
<div className="launcher-request-admin-request-copy">
|
||||
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
|
||||
<div className="launcher-request-admin-request-meta">
|
||||
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
|
||||
<span>{requestEntry.category}</span>
|
||||
<span>{analysisState}</span>
|
||||
<span>{formatConfidence(reviewItem?.confidence ?? requestEntry.analysis?.confidence)}</span>
|
||||
<span>{getRequestDisplayStateLabel(requestEntry)}</span>
|
||||
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
|
||||
</div>
|
||||
{reviewItem?.reviewRationale ? (
|
||||
<div className="launcher-request-admin-request-rationale">{reviewItem.reviewRationale}</div>
|
||||
) : null}
|
||||
{!reviewItem?.reviewRationale && requestEntry.analysis?.routing?.summary ? (
|
||||
<div className="launcher-request-admin-request-rationale">{requestEntry.analysis.routing.summary}</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
|
||||
|
|
@ -1358,6 +1278,7 @@ function WorldshaperLauncher() {
|
|||
}}
|
||||
disabled={isMutating}
|
||||
aria-label={`Delete ${requestEntry.title}`}
|
||||
title="Delete request"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
|
|
@ -1366,82 +1287,104 @@ function WorldshaperLauncher() {
|
|||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="launcher-request-admin-card launcher-request-admin-editor-card">
|
||||
<section className="launcher-request-admin-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Review Editor</h4>
|
||||
<div className="launcher-request-admin-card-hint">Edit request fields, review details, and approval state.</div>
|
||||
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
|
||||
<div className="launcher-request-admin-card-hint">Newest events first.</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-log-list">
|
||||
{logsLoading && recentSaveEvents.length === 0 ? (
|
||||
<div className="launcher-request-empty">Loading admin logs...</div>
|
||||
) : null}
|
||||
{!logsLoading && recentSaveEvents.length === 0 ? (
|
||||
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
|
||||
) : null}
|
||||
{recentSaveEvents.map((eventEntry, index) => (
|
||||
<article key={`log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-request-admin-log-row">
|
||||
<div className="launcher-request-admin-log-head">
|
||||
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
|
||||
<div className="launcher-request-admin-log-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section 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-editor">
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">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">Category</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={adminEditorDraft.category}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Original Submission</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
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">Request Summary</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={adminEditorDraft.summary}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, summary: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Request Implementation Notes</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={adminEditorDraft.implementationNotes}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, implementationNotes: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Request Tags</span>
|
||||
<div className="launcher-request-admin-tag-grid">
|
||||
{requestTags.map((tag) => {
|
||||
const isActive = adminEditorDraft.tags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={`draft-tag-${tag}`}
|
||||
type="button"
|
||||
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||
onClick={() => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
tags: isActive
|
||||
? current.tags.filter((entry) => entry !== tag)
|
||||
: [...current.tags, tag].sort((left, right) => left.localeCompare(right)),
|
||||
}))}
|
||||
<div className="launcher-request-admin-detail-controls">
|
||||
<label className="launcher-request-admin-field launcher-request-admin-field-inline">
|
||||
<span className="launcher-request-filter-label">Request State</span>
|
||||
<select
|
||||
className="launcher-request-filter-select"
|
||||
value={adminEditorDraft.status}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, status: event.target.value as LauncherRequestStatus }))}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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>
|
||||
<div className="launcher-request-admin-query-grid">
|
||||
<section className="launcher-request-admin-analysis-item">
|
||||
<div className="launcher-request-admin-analysis-head">
|
||||
<div>
|
||||
|
|
@ -1483,7 +1426,7 @@ function WorldshaperLauncher() {
|
|||
...(current.analysis || {}),
|
||||
routing: {
|
||||
...(current.analysis?.routing || {}),
|
||||
suggestedTags: event.target.value.split(",").map((entry) => entry.trim()).filter(Boolean),
|
||||
suggestedTags: normalizeCommaSeparatedList(event.target.value),
|
||||
},
|
||||
},
|
||||
}))}
|
||||
|
|
@ -1493,7 +1436,7 @@ function WorldshaperLauncher() {
|
|||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Routing Summary</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={String(adminEditorDraft.analysis?.routing?.summary || "")}
|
||||
onChange={(event) => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
|
|
@ -1510,7 +1453,7 @@ function WorldshaperLauncher() {
|
|||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Routing Rationale</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={String(adminEditorDraft.analysis?.routing?.rationale || "")}
|
||||
onChange={(event) => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
|
|
@ -1524,10 +1467,11 @@ function WorldshaperLauncher() {
|
|||
}))}
|
||||
/>
|
||||
</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 launcher-request-admin-textarea-sm"
|
||||
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,
|
||||
|
|
@ -1544,7 +1488,7 @@ function WorldshaperLauncher() {
|
|||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Likely Systems</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
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,
|
||||
|
|
@ -1558,10 +1502,11 @@ function WorldshaperLauncher() {
|
|||
}))}
|
||||
/>
|
||||
</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 launcher-request-admin-textarea-sm"
|
||||
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,
|
||||
|
|
@ -1576,29 +1521,51 @@ function WorldshaperLauncher() {
|
|||
/>
|
||||
</label>
|
||||
</section>
|
||||
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
|
||||
<section key={`analysis-item-${itemIndex}`} className="launcher-request-admin-analysis-item">
|
||||
<section className="launcher-request-admin-analysis-item">
|
||||
<div className="launcher-request-admin-analysis-head">
|
||||
<div>
|
||||
<div className="launcher-request-admin-kicker">Review Item {itemIndex + 1}</div>
|
||||
<div className="launcher-request-admin-request-title">{item.title || `Request ${itemIndex + 1}`}</div>
|
||||
<div className="launcher-request-admin-kicker">Analysis</div>
|
||||
<div className="launcher-request-admin-request-title">{selectedAnalysisItem?.title || "No structured analysis item yet"}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => openReviewDetailsPopup(adminEditorDraft)}
|
||||
>
|
||||
Open Review Popup
|
||||
</button>
|
||||
</div>
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">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">Request Tags</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={adminEditorDraft.tags.join(", ")}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, tags: normalizeCommaSeparatedList(event.target.value) }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
{selectedAnalysisItem ? (
|
||||
<>
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<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(item.title || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, title: event.target.value }))}
|
||||
value={String(selectedAnalysisItem.title || "")}
|
||||
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
|
|
@ -1606,16 +1573,16 @@ function WorldshaperLauncher() {
|
|||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.primaryCategory || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
|
||||
value={String(selectedAnalysisItem.primaryCategory || "")}
|
||||
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Recommendation</span>
|
||||
<select
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.statusRecommendation || "needs_review")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
|
||||
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>
|
||||
|
|
@ -1623,12 +1590,29 @@ function WorldshaperLauncher() {
|
|||
<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>
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Problem Type</span>
|
||||
<select
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.problemType || "unknown")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, problemType: event.target.value }))}
|
||||
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>
|
||||
|
|
@ -1639,153 +1623,73 @@ function WorldshaperLauncher() {
|
|||
<option value="unknown">Unknown</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(item.confidence)) ? String(item.confidence) : ""}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||
...current,
|
||||
confidence: event.target.value === "" ? null : Number(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Standardized Tags</span>
|
||||
<div className="launcher-request-admin-tag-grid">
|
||||
{requestTags.map((tag) => {
|
||||
const isActive = Array.isArray(item.tags) && item.tags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={`item-${itemIndex}-tag-${tag}`}
|
||||
type="button"
|
||||
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||
onClick={() => updateAdminDraftItem(itemIndex, (current) => {
|
||||
const currentTags = Array.isArray(current.tags) ? current.tags : [];
|
||||
return {
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={Array.isArray(selectedAnalysisItem.tags) ? selectedAnalysisItem.tags.join(", ") : ""}
|
||||
onChange={(event) => updateAdminDraftItem(selectedAdminAnalysisIndex, (current) => ({
|
||||
...current,
|
||||
tags: isActive
|
||||
? currentTags.filter((entry) => entry !== tag)
|
||||
: [...currentTags, tag].sort((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Review Rationale</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={String(item.reviewRationale || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (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 launcher-request-admin-textarea-sm"
|
||||
value={Array.isArray(item.reviewOptions) ? item.reviewOptions.join("\n") : ""}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||
...current,
|
||||
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
||||
tags: normalizeCommaSeparatedList(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Parsed Interpretation</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={String(item.parsedInterpretation || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
|
||||
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"
|
||||
value={String(item.implementationApproach || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
|
||||
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">Notes</span>
|
||||
<span className="launcher-request-filter-label">Possible Options</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
value={String(item.notes || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, notes: event.target.value }))}
|
||||
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 className="launcher-request-admin-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-primary-btn"
|
||||
onClick={() => void handleSaveAdminRequest()}
|
||||
disabled={adminSaving || requeueingMode !== ""}
|
||||
>
|
||||
{adminSaving ? "Saving..." : "Save Review Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => void handleRequeueAnalysis("saved")}
|
||||
disabled={adminSaving || requeueingMode !== ""}
|
||||
>
|
||||
{requeueingMode === "saved" ? "Re-running Saved Review..." : "Review Saved Request"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => void handleRequeueAnalysis("draft")}
|
||||
disabled={adminSaving || requeueingMode !== ""}
|
||||
>
|
||||
{requeueingMode === "draft" ? "Submitting Draft Review..." : "Review Edited Draft"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => void handleApproveAdminRequest()}
|
||||
disabled={adminSaving || requeueingMode !== ""}
|
||||
>
|
||||
Approve Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="launcher-request-admin-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
|
||||
<div className="launcher-request-admin-card-hint">Newest events first.</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-log-list">
|
||||
{logsLoading && recentSaveEvents.length === 0 ? (
|
||||
<div className="launcher-request-empty">Loading admin logs...</div>
|
||||
) : null}
|
||||
{!logsLoading && recentSaveEvents.length === 0 ? (
|
||||
<div className="launcher-request-empty">No admin logs have been recorded yet.</div>
|
||||
) : null}
|
||||
{recentSaveEvents.map((eventEntry, index) => (
|
||||
<article key={`log-${eventEntry.at || index}-${eventEntry.type || "event"}`} className="launcher-request-admin-log-row">
|
||||
<div className="launcher-request-admin-log-head">
|
||||
<div className="launcher-request-admin-log-title">{formatEventLabel(eventEntry)}</div>
|
||||
<div className="launcher-request-admin-log-time">{formatRequestTimestamp(String(eventEntry.at || ""))}</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-log-detail">{formatEventDetail(eventEntry) || "No extra details recorded."}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -1845,13 +1749,9 @@ function WorldshaperLauncher() {
|
|||
) : null}
|
||||
{!requestsLoading ? filteredRequests.map((requestEntry) => {
|
||||
const isExpanded = expandedRequestIds.includes(requestEntry.id);
|
||||
const isActiveRequest = requestEntry.status === "active";
|
||||
const isActiveRequest = requestEntry.status === "active" || requestEntry.status === "implemented";
|
||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||
const analysisStateLabel = requestEntry.status === "pending"
|
||||
? formatAnalysisStateLabel(requestEntry.analysis?.state)
|
||||
: "";
|
||||
return (
|
||||
<section
|
||||
key={requestEntry.id}
|
||||
|
|
@ -1865,7 +1765,6 @@ function WorldshaperLauncher() {
|
|||
</div>
|
||||
<div className="launcher-request-entry-title-block">
|
||||
<h3 className="launcher-request-entry-title">{requestEntry.title}</h3>
|
||||
<div className="launcher-request-entry-category">{requestEntry.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1876,18 +1775,6 @@ function WorldshaperLauncher() {
|
|||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="launcher-request-entry-text">
|
||||
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
||||
</div>
|
||||
{requestEntry.status === "pending" && reviewItem?.reviewRationale ? (
|
||||
<div className="launcher-request-entry-review-note">
|
||||
Review reason: {reviewItem.reviewRationale}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="launcher-request-entry-meta">
|
||||
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
|
||||
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}
|
||||
</div>
|
||||
{isActiveRequest && isExpanded ? (
|
||||
<div className="launcher-request-expanded">
|
||||
<div className="launcher-request-expanded-block">
|
||||
|
|
|
|||
211
src/index.css
211
src/index.css
|
|
@ -234,11 +234,11 @@ body {
|
|||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.changelog-splash-hero {
|
||||
padding: 14px 16px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #4f79af;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
|
|
@ -249,25 +249,25 @@ body {
|
|||
|
||||
.changelog-splash-kicker {
|
||||
color: #ffd166;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.changelog-splash-title {
|
||||
color: #eef6ff;
|
||||
font-size: 22px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.changelog-splash-meta {
|
||||
color: #a9c2ec;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.changelog-splash-list {
|
||||
|
|
@ -281,9 +281,9 @@ body {
|
|||
.launcher-request-hero-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
margin-top: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.launcher-request-toolbar-buttons {
|
||||
|
|
@ -325,8 +325,8 @@ body {
|
|||
|
||||
.launcher-request-admin-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid #476d9d;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
|
|
@ -402,6 +402,12 @@ body {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.launcher-request-admin-notice {
|
||||
margin: 0;
|
||||
color: #a8e6c2;
|
||||
|
|
@ -411,21 +417,21 @@ body {
|
|||
|
||||
.launcher-request-admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.launcher-shell-admin .launcher-request-admin-grid {
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.launcher-request-admin-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 16, 31, 0.74);
|
||||
|
|
@ -447,7 +453,7 @@ body {
|
|||
|
||||
.launcher-request-admin-card-hint {
|
||||
color: #9fb8e5;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
|
@ -463,14 +469,14 @@ body {
|
|||
|
||||
.launcher-shell-admin .launcher-request-admin-request-list,
|
||||
.launcher-shell-admin .launcher-request-admin-log-list {
|
||||
max-height: 42dvh;
|
||||
max-height: 31dvh;
|
||||
}
|
||||
|
||||
.launcher-request-admin-request-row,
|
||||
.launcher-request-admin-log-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 10px;
|
||||
background: rgba(17, 32, 63, 0.82);
|
||||
|
|
@ -491,19 +497,13 @@ body {
|
|||
.launcher-request-admin-request-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-request-rationale {
|
||||
color: #d7e7ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-request-title,
|
||||
.launcher-request-admin-log-title {
|
||||
color: #eef6ff;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
|
@ -539,26 +539,84 @@ body {
|
|||
|
||||
.launcher-request-admin-log-detail {
|
||||
color: #d7e7ff;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.launcher-request-admin-editor-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.launcher-request-admin-editor {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
.launcher-request-admin-detail-card {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-height: 58dvh;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.launcher-shell-admin .launcher-request-admin-editor {
|
||||
max-height: 68dvh;
|
||||
.launcher-request-admin-detail {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.launcher-request-admin-detail-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-detail-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.launcher-request-admin-detail-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-field-inline {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-icon-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-icon-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background: rgba(17, 32, 63, 0.9);
|
||||
color: #e7ecf3;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-request-admin-icon-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-icon-btn.is-success {
|
||||
border-color: rgba(110, 255, 173, 0.52);
|
||||
background: linear-gradient(135deg, rgba(33, 146, 86, 0.88) 0%, rgba(24, 108, 123, 0.88) 100%);
|
||||
color: #f3fff8;
|
||||
}
|
||||
|
||||
.launcher-request-admin-icon-btn:disabled {
|
||||
opacity: 0.56;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.launcher-request-admin-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.launcher-request-admin-editor-grid {
|
||||
|
|
@ -585,11 +643,12 @@ body {
|
|||
|
||||
.launcher-request-admin-analysis-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 16, 31, 0.72);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.launcher-request-admin-analysis-head {
|
||||
|
|
@ -604,7 +663,11 @@ body {
|
|||
}
|
||||
|
||||
.launcher-request-admin-textarea-sm {
|
||||
min-height: 74px;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.launcher-request-admin-textarea-xs {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.launcher-request-composer-label {
|
||||
|
|
@ -635,15 +698,15 @@ body {
|
|||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.launcher-request-entry {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
gap: 7px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background: rgba(17, 32, 63, 0.84);
|
||||
|
|
@ -700,6 +763,12 @@ body {
|
|||
color: #b7f0d5;
|
||||
}
|
||||
|
||||
.launcher-request-status-pill.is-implemented {
|
||||
border-color: #4a8f60;
|
||||
background: rgba(23, 88, 53, 0.86);
|
||||
color: #d6ffe3;
|
||||
}
|
||||
|
||||
.launcher-request-status-pill.is-pending {
|
||||
border-color: #9c8140;
|
||||
background: rgba(93, 70, 19, 0.78);
|
||||
|
|
@ -733,21 +802,14 @@ body {
|
|||
|
||||
.launcher-request-entry-title-block {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.launcher-request-entry-title {
|
||||
margin: 0;
|
||||
color: #eef6ff;
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.launcher-request-entry-category {
|
||||
color: #9fb8e5;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.launcher-request-delete-btn {
|
||||
|
|
@ -771,28 +833,15 @@ body {
|
|||
}
|
||||
|
||||
.launcher-request-tag {
|
||||
padding: 4px 8px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 999px;
|
||||
background: rgba(25, 48, 87, 0.72);
|
||||
color: #d7e7ff;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.launcher-request-entry-text {
|
||||
color: #d7e7ff;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.launcher-request-entry-review-note {
|
||||
color: #ffd5b0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.launcher-request-entry-meta,
|
||||
.launcher-request-empty {
|
||||
color: #9fb8e5;
|
||||
|
|
@ -839,7 +888,7 @@ body {
|
|||
}
|
||||
|
||||
.changelog-splash-section {
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #365782;
|
||||
border-radius: 12px;
|
||||
background: rgba(17, 32, 63, 0.84);
|
||||
|
|
@ -887,10 +936,10 @@ body {
|
|||
|
||||
.launcher-primary-btn,
|
||||
.launcher-secondary-btn {
|
||||
min-height: 46px;
|
||||
padding: 12px 16px;
|
||||
min-height: 40px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.96rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
|
@ -2034,6 +2083,10 @@ button.danger:not(:disabled):hover {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.launcher-request-admin-query-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.launcher-request-admin-request-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -2042,6 +2095,12 @@ button.danger:not(:disabled):hover {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.launcher-request-admin-detail-top,
|
||||
.launcher-request-admin-detail-controls {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.launcher-request-admin-analysis-head {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue