Add launcher request board
This commit is contained in:
parent
6c6b1295a3
commit
4eef0d4850
3 changed files with 628 additions and 33 deletions
166
server.js
166
server.js
|
|
@ -38,6 +38,7 @@ const dataRoot = path.resolve(__dirname, "data");
|
||||||
const catalogMetaPath = path.join(dataRoot, "catalog_meta.json");
|
const catalogMetaPath = path.join(dataRoot, "catalog_meta.json");
|
||||||
const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
|
const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
|
||||||
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
|
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
|
||||||
|
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
|
||||||
const imagesCatalogPath = path.join(contentRoot, "images.json");
|
const imagesCatalogPath = path.join(contentRoot, "images.json");
|
||||||
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
|
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
|
||||||
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
|
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
|
||||||
|
|
@ -218,6 +219,57 @@ function readEditorSettings() {
|
||||||
return normalizeEditorSettings(readJsonSafe(editorSettingsPath, createDefaultEditorSettings()));
|
return normalizeEditorSettings(readJsonSafe(editorSettingsPath, createDefaultEditorSettings()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLauncherRequestId() {
|
||||||
|
return `request_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLauncherRequestEntry(entry, index = 0) {
|
||||||
|
const source = entry && typeof entry === "object" && !Array.isArray(entry)
|
||||||
|
? entry
|
||||||
|
: null;
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const text = String(source.text || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const createdAt = String(source.createdAt || "").trim() || new Date().toISOString();
|
||||||
|
const updatedAt = String(source.updatedAt || "").trim() || createdAt;
|
||||||
|
const fallbackId = `request_${index + 1}`;
|
||||||
|
return {
|
||||||
|
id: String(source.id || fallbackId).trim() || fallbackId,
|
||||||
|
text,
|
||||||
|
done: source.done === true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLauncherRequestsPayload() {
|
||||||
|
const fallback = { schemaVersion: 1, requests: [] };
|
||||||
|
const payload = readJsonSafe(launcherRequestsPath, fallback);
|
||||||
|
const requests = Array.isArray(payload?.requests)
|
||||||
|
? payload.requests
|
||||||
|
.map((entry, index) => normalizeLauncherRequestEntry(entry, index))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
||||||
|
requests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLauncherRequestsPayload(payload) {
|
||||||
|
const requests = Array.isArray(payload?.requests) ? payload.requests : [];
|
||||||
|
writeJsonAtomic(launcherRequestsPath, {
|
||||||
|
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
||||||
|
requests: requests
|
||||||
|
.map((entry, index) => normalizeLauncherRequestEntry(entry, index))
|
||||||
|
.filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBackgroundTileId(value, idToSymbol = null) {
|
function normalizeBackgroundTileId(value, idToSymbol = null) {
|
||||||
const normalizedId = String(value || "").trim();
|
const normalizedId = String(value || "").trim();
|
||||||
if (!normalizedId) {
|
if (!normalizedId) {
|
||||||
|
|
@ -2043,6 +2095,120 @@ app.get("/api/debug/recent-saves", (_req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/launcher-requests", (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(readLauncherRequestsPayload());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: `Failed to read launcher requests: ${String(err)}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/launcher-requests", (req, res) => {
|
||||||
|
try {
|
||||||
|
const text = String(req.body?.text || "").trim();
|
||||||
|
if (!text) {
|
||||||
|
res.status(400).json({ error: "Request text is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.length > 1000) {
|
||||||
|
res.status(400).json({ error: "Request text must be 1000 characters or fewer." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = readLauncherRequestsPayload();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const requestEntry = normalizeLauncherRequestEntry({
|
||||||
|
id: createLauncherRequestId(),
|
||||||
|
text,
|
||||||
|
done: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}, payload.requests.length);
|
||||||
|
const nextPayload = {
|
||||||
|
schemaVersion: payload.schemaVersion,
|
||||||
|
requests: [...payload.requests, requestEntry],
|
||||||
|
};
|
||||||
|
writeLauncherRequestsPayload(nextPayload);
|
||||||
|
recordSaveEvent({
|
||||||
|
type: "launcher-request-add",
|
||||||
|
requestId: requestEntry.id,
|
||||||
|
textPreview: requestEntry.text.slice(0, 80),
|
||||||
|
});
|
||||||
|
res.status(201).json({
|
||||||
|
ok: true,
|
||||||
|
request: requestEntry,
|
||||||
|
requests: nextPayload.requests,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: `Failed to save launcher request: ${String(err)}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/launcher-requests/:requestId", (req, res) => {
|
||||||
|
const requestId = String(req.params.requestId || "").trim();
|
||||||
|
try {
|
||||||
|
const payload = readLauncherRequestsPayload();
|
||||||
|
const index = payload.requests.findIndex((entry) => entry.id === requestId);
|
||||||
|
if (index < 0) {
|
||||||
|
res.status(404).json({ error: "Request not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = payload.requests[index];
|
||||||
|
const nextDone = req.body?.done === true;
|
||||||
|
const updated = normalizeLauncherRequestEntry({
|
||||||
|
...existing,
|
||||||
|
done: nextDone,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}, index);
|
||||||
|
const nextRequests = [...payload.requests];
|
||||||
|
nextRequests[index] = updated;
|
||||||
|
writeLauncherRequestsPayload({
|
||||||
|
schemaVersion: payload.schemaVersion,
|
||||||
|
requests: nextRequests,
|
||||||
|
});
|
||||||
|
recordSaveEvent({
|
||||||
|
type: "launcher-request-update",
|
||||||
|
requestId,
|
||||||
|
done: updated.done,
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
request: updated,
|
||||||
|
requests: nextRequests,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: `Failed to update launcher request: ${String(err)}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/launcher-requests/:requestId", (req, res) => {
|
||||||
|
const requestId = String(req.params.requestId || "").trim();
|
||||||
|
try {
|
||||||
|
const payload = readLauncherRequestsPayload();
|
||||||
|
const existing = payload.requests.find((entry) => entry.id === requestId);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ error: "Request not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextRequests = payload.requests.filter((entry) => entry.id !== requestId);
|
||||||
|
writeLauncherRequestsPayload({
|
||||||
|
schemaVersion: payload.schemaVersion,
|
||||||
|
requests: nextRequests,
|
||||||
|
});
|
||||||
|
recordSaveEvent({
|
||||||
|
type: "launcher-request-delete",
|
||||||
|
requestId,
|
||||||
|
textPreview: existing.text.slice(0, 80),
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
deletedRequestId: requestId,
|
||||||
|
requests: nextRequests,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: `Failed to delete launcher request: ${String(err)}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/world-default", (_req, res) => {
|
app.get("/api/world-default", (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const indexPayload = readWorldIndexPayload();
|
const indexPayload = readWorldIndexPayload();
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@ type WorldDefaultPayload = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
type LaunchState = "ready" | "opening" | "opened" | "blocked" | "error";
|
||||||
|
type BoardTab = "news" | "requests";
|
||||||
|
|
||||||
|
type LauncherRequest = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LauncherRequestsPayload = {
|
||||||
|
requests?: LauncherRequest[];
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld";
|
||||||
|
|
||||||
|
|
@ -32,6 +45,34 @@ async function resolveDefaultWorldId(): Promise<string> {
|
||||||
return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
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 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 openStudioPopup(worldId: string): boolean {
|
function openStudioPopup(worldId: string): boolean {
|
||||||
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
const popup = openWorldshaperStudioWindow(worldId, window, { worldId });
|
||||||
return Boolean(popup);
|
return Boolean(popup);
|
||||||
|
|
@ -46,6 +87,14 @@ function WorldshaperLauncher() {
|
||||||
const [status, setStatus] = useState("Launch Worldshaper Studio in its floating window.");
|
const [status, setStatus] = useState("Launch Worldshaper Studio in its floating window.");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
const [worldId, setWorldId] = useState(DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
||||||
|
const [activeBoardTab, setActiveBoardTab] = useState<BoardTab>("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("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -68,6 +117,37 @@ function WorldshaperLauncher() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadRequests() {
|
||||||
|
setRequestsLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
|
setRequestsError("");
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRequestsError(String(nextError || "Failed to load requests."));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRequestsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadRequests();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleLaunch(): Promise<void> {
|
async function handleLaunch(): Promise<void> {
|
||||||
setError("");
|
setError("");
|
||||||
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||||
|
|
@ -92,7 +172,76 @@ function WorldshaperLauncher() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddRequest(): Promise<void> {
|
||||||
|
const text = requestDraft.trim();
|
||||||
|
if (!text) {
|
||||||
|
setRequestsError("Write a request before saving it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRequestSubmitting(true);
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
|
setRequestDraft("");
|
||||||
|
setRequestDraftOpen(false);
|
||||||
|
setRequestsError("");
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
setRequestsError(String(nextError || "Failed to save request."));
|
||||||
|
} finally {
|
||||||
|
setRequestSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleRequest(requestEntry: LauncherRequest): Promise<void> {
|
||||||
|
setRequestMutatingId(requestEntry.id);
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ done: !requestEntry.done }),
|
||||||
|
});
|
||||||
|
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
|
setRequestsError("");
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
setRequestsError(String(nextError || "Failed to update request."));
|
||||||
|
} finally {
|
||||||
|
setRequestMutatingId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRequest(requestEntry: LauncherRequest): Promise<void> {
|
||||||
|
const confirmed = window.confirm(`Delete this request?\n\n${requestEntry.text}`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRequestMutatingId(requestEntry.id);
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
|
setRequestsError("");
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
setRequestsError(String(nextError || "Failed to delete request."));
|
||||||
|
} finally {
|
||||||
|
setRequestMutatingId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isBusy = launchState === "opening";
|
const isBusy = launchState === "opening";
|
||||||
|
const requestCount = requests.length;
|
||||||
|
const pendingRequestCount = requests.filter((entry) => entry.done !== true).length;
|
||||||
|
const boardHint = activeBoardTab === "news"
|
||||||
|
? "Latest announcements"
|
||||||
|
: `${pendingRequestCount} open request${pendingRequestCount === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
|
|
@ -141,12 +290,30 @@ function WorldshaperLauncher() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="launcher-changelog-window" aria-labelledby="launcher-whats-new-title">
|
<section className="launcher-changelog-window" aria-labelledby="launcher-board-title">
|
||||||
<div className="launcher-changelog-titlebar">
|
<div className="launcher-changelog-titlebar">
|
||||||
<div className="launcher-changelog-title">What's New</div>
|
<div className="launcher-changelog-title" id="launcher-board-title">Worldshaper Board</div>
|
||||||
<div className="launcher-changelog-hint">Current release highlights</div>
|
<div className="launcher-changelog-hint">{boardHint}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="launcher-changelog-body">
|
<div className="launcher-changelog-body">
|
||||||
|
<div className="launcher-board-content">
|
||||||
|
<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>
|
||||||
|
{activeBoardTab === "news" ? (
|
||||||
<div className="changelog-splash-card">
|
<div className="changelog-splash-card">
|
||||||
<div className="changelog-splash-hero">
|
<div className="changelog-splash-hero">
|
||||||
<div className="changelog-splash-kicker">{CHANGELOG_SPLASH_KICKER}</div>
|
<div className="changelog-splash-kicker">{CHANGELOG_SPLASH_KICKER}</div>
|
||||||
|
|
@ -179,6 +346,116 @@ function WorldshaperLauncher() {
|
||||||
<div className="changelog-splash-footnote">{CHANGELOG_SPLASH_FOOTNOTE}</div>
|
<div className="changelog-splash-footnote">{CHANGELOG_SPLASH_FOOTNOTE}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<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"} with {pendingRequestCount} still open.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="launcher-request-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-primary-btn"
|
||||||
|
onClick={() => setRequestDraftOpen((value) => !value)}
|
||||||
|
disabled={requestSubmitting}
|
||||||
|
>
|
||||||
|
{requestDraftOpen ? "Hide Request Form" : "Add New Request"}
|
||||||
|
</button>
|
||||||
|
</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 && requests.length === 0 ? (
|
||||||
|
<section className="launcher-request-entry">
|
||||||
|
<div className="launcher-request-empty">No requests yet. Add the first one to start the board.</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{!requestsLoading ? requests.map((requestEntry) => {
|
||||||
|
const isMutating = requestMutatingId === requestEntry.id;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={requestEntry.id}
|
||||||
|
className={`launcher-request-entry ${requestEntry.done ? "is-done" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="launcher-request-entry-head">
|
||||||
|
<label className="launcher-request-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={requestEntry.done}
|
||||||
|
onChange={() => void handleToggleRequest(requestEntry)}
|
||||||
|
disabled={isMutating}
|
||||||
|
/>
|
||||||
|
<span>{requestEntry.done ? "Complete" : "Open"}</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-request-delete-btn"
|
||||||
|
onClick={() => void handleDeleteRequest(requestEntry)}
|
||||||
|
disabled={isMutating}
|
||||||
|
aria-label="Delete request"
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="launcher-request-entry-text">{requestEntry.text}</div>
|
||||||
|
<div className="launcher-request-entry-meta">{formatRequestTimestamp(requestEntry.createdAt)}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}) : null}
|
||||||
|
</div>
|
||||||
|
<div className="changelog-splash-footer">
|
||||||
|
<div className="changelog-splash-footnote">
|
||||||
|
Requests are saved and shared from this launcher. Use the checkbox to mark progress and the X to remove an entry.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestsError ? (
|
||||||
|
<p className="launcher-request-error">{requestsError}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
152
src/index.css
152
src/index.css
|
|
@ -185,6 +185,28 @@ body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-board-content {
|
||||||
|
min-height: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-board-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-board-tab {
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-board-tab.is-active {
|
||||||
|
border-color: #6eaef5;
|
||||||
|
background: linear-gradient(180deg, #2c4e80 0%, #18355e 100%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(168, 208, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.changelog-splash-card {
|
.changelog-splash-card {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -233,6 +255,131 @@ body {
|
||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-request-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #365782;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(17, 32, 63, 0.84);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-composer-label {
|
||||||
|
color: #d7e7ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 110px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid #365782;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(8, 16, 31, 0.88);
|
||||||
|
color: #eef6ff;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-composer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #365782;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(17, 32, 63, 0.84);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(10, 16, 32, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry.is-done {
|
||||||
|
border-color: #2f7e60;
|
||||||
|
background: rgba(15, 40, 33, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #eef6ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-check input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-delete-btn {
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-color: #7b3141;
|
||||||
|
background: #4f1d2a;
|
||||||
|
color: #ffe4ea;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-delete-btn:not(:disabled):hover {
|
||||||
|
background: #672536;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry-text {
|
||||||
|
color: #d7e7ff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry.is-done .launcher-request-entry-text {
|
||||||
|
color: #9ec7b4;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry-meta,
|
||||||
|
.launcher-request-empty {
|
||||||
|
color: #9fb8e5;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #ff9aa4;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.changelog-splash-section {
|
.changelog-splash-section {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid #365782;
|
border: 1px solid #365782;
|
||||||
|
|
@ -1415,6 +1562,11 @@ button.danger:not(:disabled):hover {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-board-tabs,
|
||||||
|
.launcher-request-composer-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-primary-btn,
|
.launcher-primary-btn,
|
||||||
.launcher-secondary-btn {
|
.launcher-secondary-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue