@nano133/nanoterra 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +23 -6
- package/dist/admin/panel/dom.d.ts +11 -0
- package/dist/admin/panel/dom.js +33 -0
- package/dist/admin/panel/gameCard.d.ts +5 -2
- package/dist/admin/panel/gameCard.js +9 -5
- package/dist/admin/panel/icons.d.ts +121 -0
- package/dist/admin/panel/icons.js +41 -0
- package/dist/admin/panel/index.d.ts +11 -2
- package/dist/admin/panel/index.js +6 -1
- package/dist/admin/panel/mount.js +101 -67
- package/dist/admin/panel/orders.d.ts +23 -0
- package/dist/admin/panel/orders.js +130 -0
- package/dist/admin/panel/overview.d.ts +22 -0
- package/dist/admin/panel/overview.js +75 -0
- package/dist/admin/panel/players.d.ts +7 -0
- package/dist/admin/panel/players.js +61 -0
- package/dist/admin/panel/shelf.d.ts +11 -2
- package/dist/admin/panel/shelf.js +322 -76
- package/dist/admin/panel/tabs.d.ts +23 -0
- package/dist/admin/panel/tabs.js +68 -0
- package/dist/admin/switches.d.ts +1 -1
- package/dist/admin/switches.js +4 -4
- package/package.json +1 -1
- package/src/admin/panel/dom.ts +34 -0
- package/src/admin/panel/gameCard.ts +9 -5
- package/src/admin/panel/icons.ts +46 -0
- package/src/admin/panel/index.ts +11 -2
- package/src/admin/panel/mount.ts +120 -101
- package/src/admin/panel/orders.ts +172 -0
- package/src/admin/panel/overview.ts +134 -0
- package/src/admin/panel/players.ts +79 -0
- package/src/admin/panel/shelf.ts +467 -109
- package/src/admin/panel/style.css +1775 -270
- package/src/admin/panel/tabs.ts +94 -0
- package/src/admin/switches.ts +4 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The panel's tab bar: Overview, Cosmetics, Orders, Players and Settings. Real tabs (role tablist, tab and tabpanel): a press or the arrow keys, Home and End
|
|
2
|
+
// choose one, and only the chosen tab is in the Tab order. Every panel is made once and only hidden, so a draft, a search or a switch keeps its state when
|
|
3
|
+
// the creator goes to another tab and back. The choice lives in this page's memory only: the panel never writes the page's address (the host page owns it).
|
|
4
|
+
import { h } from "./dom.js";
|
|
5
|
+
import { icon } from "./icons.js";
|
|
6
|
+
export const TABS = [
|
|
7
|
+
{ id: "overview", label: "Overview", icon: "overview" },
|
|
8
|
+
{ id: "cosmetics", label: "Cosmetics", icon: "shirt" },
|
|
9
|
+
{ id: "orders", label: "Orders", icon: "receipt" },
|
|
10
|
+
{ id: "players", label: "Players", icon: "players" },
|
|
11
|
+
{ id: "settings", label: "Settings", icon: "settings" },
|
|
12
|
+
];
|
|
13
|
+
export function tabBar(first = "overview") {
|
|
14
|
+
let current = first;
|
|
15
|
+
const bar = h("div", { class: "tabbar", role: "tablist", "aria-label": "Admin sections" });
|
|
16
|
+
const buttons = {};
|
|
17
|
+
const counts = {};
|
|
18
|
+
const panels = {};
|
|
19
|
+
for (const t of TABS) {
|
|
20
|
+
counts[t.id] = h("span", { class: "tab-count" });
|
|
21
|
+
counts[t.id].hidden = true;
|
|
22
|
+
const b = h("button", { class: "tabbar-tab", type: "button", role: "tab", id: `n133-tab-${t.id}`, "aria-controls": `n133-panel-${t.id}` }, icon(t.icon, 18), h("span", { class: "tab-label" }, t.label), counts[t.id]);
|
|
23
|
+
b.addEventListener("click", () => select(t.id));
|
|
24
|
+
b.addEventListener("keydown", (e) => {
|
|
25
|
+
const i = TABS.findIndex((x) => x.id === t.id);
|
|
26
|
+
const next = e.key === "ArrowRight" ? i + 1 : e.key === "ArrowLeft" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? TABS.length - 1 : null;
|
|
27
|
+
if (next === null)
|
|
28
|
+
return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
select(TABS[(next + TABS.length) % TABS.length].id, true);
|
|
31
|
+
});
|
|
32
|
+
buttons[t.id] = b;
|
|
33
|
+
bar.append(b);
|
|
34
|
+
panels[t.id] = h("section", { class: `tabpanel tabpanel-${t.id}`, role: "tabpanel", id: `n133-panel-${t.id}`, "aria-labelledby": `n133-tab-${t.id}`, tabindex: 0 });
|
|
35
|
+
}
|
|
36
|
+
function select(id, focus = false) {
|
|
37
|
+
current = id;
|
|
38
|
+
for (const t of TABS) {
|
|
39
|
+
const on = t.id === id;
|
|
40
|
+
buttons[t.id].setAttribute("aria-selected", on ? "true" : "false");
|
|
41
|
+
buttons[t.id].tabIndex = on ? 0 : -1;
|
|
42
|
+
buttons[t.id].classList.toggle("is-on", on);
|
|
43
|
+
panels[t.id].hidden = !on;
|
|
44
|
+
}
|
|
45
|
+
if (focus)
|
|
46
|
+
buttons[id].focus({ preventScroll: true });
|
|
47
|
+
// Keep the chosen tab in view in the bar, when the bar scrolls sideways (only the bar moves).
|
|
48
|
+
const b = buttons[id];
|
|
49
|
+
if (bar.scrollWidth > bar.clientWidth)
|
|
50
|
+
bar.scrollLeft = Math.max(0, b.offsetLeft - (bar.clientWidth - b.offsetWidth) / 2);
|
|
51
|
+
const panel = panels[id];
|
|
52
|
+
if (panel.isConnected && panel.getBoundingClientRect().top < bar.getBoundingClientRect().bottom)
|
|
53
|
+
panel.scrollIntoView?.({ block: "start" });
|
|
54
|
+
}
|
|
55
|
+
select(first);
|
|
56
|
+
return {
|
|
57
|
+
bar,
|
|
58
|
+
panels,
|
|
59
|
+
select,
|
|
60
|
+
get current() {
|
|
61
|
+
return current;
|
|
62
|
+
},
|
|
63
|
+
setCount(id, n) {
|
|
64
|
+
counts[id].hidden = n === null;
|
|
65
|
+
counts[id].textContent = n === null ? "" : n.toLocaleString("en-US");
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/admin/switches.d.ts
CHANGED
|
@@ -49,7 +49,7 @@ export declare const SWITCHES: Record<SwitchId, {
|
|
|
49
49
|
* to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
|
|
50
50
|
*/
|
|
51
51
|
export declare const CHARACTER_NEEDS_SIGNIN = "This needs \u201CPlayers can sign in with Nanoterra\u201D to be On: only a player who signs in from Nanoterra brings a character.";
|
|
52
|
-
export declare const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player
|
|
52
|
+
export declare const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player starts as the island's first character, a player who signed in from Nanoterra before included. The game may also let a player switch to its own character.";
|
|
53
53
|
/** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
|
|
54
54
|
export declare const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
|
|
55
55
|
export declare const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
|
package/dist/admin/switches.js
CHANGED
|
@@ -36,9 +36,9 @@ export function mergeSettings(stored, defaults) {
|
|
|
36
36
|
}
|
|
37
37
|
export const SWITCHES = {
|
|
38
38
|
character: {
|
|
39
|
-
title: "Players
|
|
40
|
-
on: "On: a player who signs in from Nanoterra plays as the character they picked there. Anyone else plays as the island's first character.",
|
|
41
|
-
off: "Off: every player plays as this game's own character.",
|
|
39
|
+
title: "Players can play as their Nanoterra character",
|
|
40
|
+
on: "On: a player who signs in from Nanoterra plays as the character they picked there. Anyone else plays as the island's first character. The game may also let a player switch to its own character.",
|
|
41
|
+
off: "Off: every player plays as this game's own character, with no choice.",
|
|
42
42
|
},
|
|
43
43
|
signin: {
|
|
44
44
|
title: "Players can sign in with Nanoterra",
|
|
@@ -52,7 +52,7 @@ export const SWITCHES = {
|
|
|
52
52
|
* to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
|
|
53
53
|
*/
|
|
54
54
|
export const CHARACTER_NEEDS_SIGNIN = "This needs “Players can sign in with Nanoterra” to be On: only a player who signs in from Nanoterra brings a character.";
|
|
55
|
-
export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player
|
|
55
|
+
export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player starts as the island's first character, a player who signed in from Nanoterra before included. The game may also let a player switch to its own character.";
|
|
56
56
|
/** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
|
|
57
57
|
export const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
|
|
58
58
|
export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano133/nanoterra",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton) and the looks ladder.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/admin/panel/dom.ts
CHANGED
|
@@ -29,3 +29,37 @@ export async function copy(text: string, button: HTMLButtonElement): Promise<voi
|
|
|
29
29
|
}
|
|
30
30
|
setTimeout(() => (button.textContent = was), 1500);
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A small copy key: an icon and the word "Copy". A press puts `value` on the clipboard and says "Copied" for a moment; only the word changes, so the icon
|
|
35
|
+
* stays. `label` names what is copied, for a screen reader and the tooltip ("Copy the player's address").
|
|
36
|
+
*/
|
|
37
|
+
export function copyKey(value: string, label: string, icon?: Element): HTMLButtonElement {
|
|
38
|
+
const word = h("span", { class: "copy-word" }, "Copy");
|
|
39
|
+
const button = h("button", { class: "btn btn-small btn-quiet copy-key", type: "button", "aria-label": label, title: label }, icon ?? null, word);
|
|
40
|
+
button.addEventListener("click", async () => {
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(value);
|
|
43
|
+
word.textContent = "Copied";
|
|
44
|
+
} catch {
|
|
45
|
+
word.textContent = "Copy failed";
|
|
46
|
+
}
|
|
47
|
+
setTimeout(() => (word.textContent = "Copy"), 1500);
|
|
48
|
+
});
|
|
49
|
+
return button;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** "just now", "5 min ago", "3 h ago", "2 days ago". A time in the future reads "just now". */
|
|
53
|
+
export function ago(ms: number, now = Date.now()): string {
|
|
54
|
+
const m = Math.max(0, Math.round((now - ms) / 60_000));
|
|
55
|
+
if (m < 1) return "just now";
|
|
56
|
+
if (m < 60) return `${m} min ago`;
|
|
57
|
+
const hours = Math.round(m / 60);
|
|
58
|
+
return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "3 orders", "1 order" */
|
|
62
|
+
export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
63
|
+
|
|
64
|
+
/** "$1.25" from cents. */
|
|
65
|
+
export const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The "Game" card of /admin: two switches, "Players
|
|
1
|
+
// The "Game" card of /admin: two switches, "Players can play as their Nanoterra character" and "Players can sign in with Nanoterra". A press saves at once
|
|
2
2
|
// (POST {api}/game, behind the same admin guard as every admin call) and the game reads the setting at run time, so the creator pushes and deploys
|
|
3
3
|
// nothing. The words are in ../switches.ts. The card changes ONLY these two settings: never a look, a price or an order.
|
|
4
4
|
|
|
@@ -6,14 +6,17 @@ import { CHARACTER_NEEDS_SIGNIN, CHARACTER_SIGNIN_OFF, REVIEW_LINK, REVIEW_NOTE,
|
|
|
6
6
|
import { ApiError, type Api } from "./api.js";
|
|
7
7
|
import { h } from "./dom.js";
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The card for the settings `initial`. It keeps its own state: a press shows "Saving…", then "Saved." or the server's words. `defaultsFile` is where the game's defaults are
|
|
11
|
+
* written ("game.config.json"). `onChange` hears each saved state (the panel's Overview shows the two switches too).
|
|
12
|
+
*/
|
|
13
|
+
export function gameCard(api: Api, initial: GameState, defaultsFile = "the game's own settings", onChange?: (state: GameState) => void): HTMLElement {
|
|
11
14
|
let state = initial;
|
|
12
15
|
const rows = new Map<SwitchId, { button: HTMLButtonElement; sentence: HTMLElement; status: HTMLElement }>();
|
|
13
16
|
/** Under the character switch: it works only through sign-in, so it says so, and says what a player gets while sign-in is Off. */
|
|
14
17
|
const needs = h("p", { class: "fine switch-needs" });
|
|
15
|
-
const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("h2", { id: "game-title" }, "Game"));
|
|
16
|
-
card.append(h("p", { class: "fine" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
|
|
18
|
+
const card = h("section", { class: "admin-card game-card", "aria-labelledby": "game-title" }, h("div", { class: "card-head" }, h("h2", { id: "game-title" }, "Game")));
|
|
19
|
+
card.append(h("p", { class: "fine card-intro" }, `Two choices about your game. A press saves at once: you push nothing and deploy nothing. They start from ${defaultsFile}, and a press here wins.`));
|
|
17
20
|
|
|
18
21
|
const draw = () => {
|
|
19
22
|
for (const [id, row] of rows) {
|
|
@@ -35,6 +38,7 @@ export function gameCard(api: Api, initial: GameState, defaultsFile = "the game'
|
|
|
35
38
|
state = await api.post<GameState>("/game", pressOf(id, state));
|
|
36
39
|
row.status.textContent = "Saved.";
|
|
37
40
|
draw();
|
|
41
|
+
onChange?.(state);
|
|
38
42
|
} catch (e) {
|
|
39
43
|
row.status.textContent = e instanceof ApiError ? e.message : "The game's server did not answer. Nothing was changed.";
|
|
40
44
|
} finally {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The panel's small line icons, drawn as inline SVG with createElementNS (no icon font, no innerHTML: fine under a strict CSP). Each is 24 units square, drawn
|
|
2
|
+
// with the text colour (currentColor), and hidden from screen readers: the word next to it says what it is.
|
|
3
|
+
|
|
4
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
5
|
+
|
|
6
|
+
type Shape = [tag: string, attrs: Record<string, string | number>];
|
|
7
|
+
|
|
8
|
+
const SHAPES = {
|
|
9
|
+
overview: [["rect", { x: 3, y: 3, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 13.5, y: 3, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 3, y: 13.5, width: 7.5, height: 7.5, rx: 2 }], ["rect", { x: 13.5, y: 13.5, width: 7.5, height: 7.5, rx: 2 }]],
|
|
10
|
+
shirt: [["path", { d: "M8.5 3.5 4 6 2.5 10.5l3.5 1.5V21h12v-9l3.5-1.5L20 6l-4.5-2.5a3.5 3.5 0 0 1-7 0Z" }]],
|
|
11
|
+
receipt: [["path", { d: "M5 3h14v18l-2.5-1.5L14 21l-2-1.5L10 21l-2.5-1.5L5 21Z" }], ["path", { d: "M9 8h6M9 12h6M9 16h3" }]],
|
|
12
|
+
players: [["circle", { cx: 9, cy: 8, r: 3.5 }], ["path", { d: "M2.5 20a6.5 6.5 0 0 1 13 0" }], ["path", { d: "M16 4.8a3.5 3.5 0 0 1 0 6.4M18 14.2a6.5 6.5 0 0 1 3.5 5.8" }]],
|
|
13
|
+
settings: [["circle", { cx: 12, cy: 12, r: 3 }], ["path", { d: "M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1" }]],
|
|
14
|
+
coin: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M12 6.5v11M14.8 9.2a2.6 2.6 0 0 0-2.6-1.7h-.6a2.2 2.2 0 0 0 0 4.4h.8a2.2 2.2 0 0 1 0 4.4h-.7a2.6 2.6 0 0 1-2.6-1.7" }]],
|
|
15
|
+
clock: [["circle", { cx: 12, cy: 12, r: 9 }], ["path", { d: "M12 7v5l3.5 2" }]],
|
|
16
|
+
back: [["path", { d: "M9 6 4 11l5 5" }], ["path", { d: "M4 11h10.5a5.5 5.5 0 0 1 0 11H11" }]],
|
|
17
|
+
tag: [["path", { d: "M3 12.2V4a1 1 0 0 1 1-1h8.2l8.6 8.6a1.5 1.5 0 0 1 0 2.1l-6.9 6.9a1.5 1.5 0 0 1-2.1 0Z" }], ["circle", { cx: 7.5, cy: 7.5, r: 1.5 }]],
|
|
18
|
+
copy: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
|
|
19
|
+
lock: [["rect", { x: 4.5, y: 10.5, width: 15, height: 10.5, rx: 2.5 }], ["path", { d: "M8 10.5V7.5a4 4 0 0 1 8 0v3" }]],
|
|
20
|
+
plus: [["path", { d: "M12 5v14M5 12h14" }]],
|
|
21
|
+
search: [["circle", { cx: 10.5, cy: 10.5, r: 6.5 }], ["path", { d: "m15.5 15.5 5 5" }]],
|
|
22
|
+
alert: [["path", { d: "M10.3 3.9 2.4 17.6A2 2 0 0 0 4.1 20.6h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" }], ["path", { d: "M12 9v4.5M12 17h.01" }]],
|
|
23
|
+
check: [["path", { d: "m4.5 12.5 5 5 10-11" }]],
|
|
24
|
+
external: [["path", { d: "M14 4h6v6M20 4l-9 9" }], ["path", { d: "M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10" }]],
|
|
25
|
+
refresh: [["path", { d: "M20 11a8 8 0 0 0-14.3-4.9L4 8M4 4v4h4" }], ["path", { d: "M4 13a8 8 0 0 0 14.3 4.9L20 16M20 20v-4h-4" }]],
|
|
26
|
+
out: [["path", { d: "M15 4h3.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H15" }], ["path", { d: "M10 8l-4 4 4 4M6 12h10" }]],
|
|
27
|
+
pencil: [["path", { d: "M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16Z" }], ["path", { d: "m13.5 6.5 4 4" }]],
|
|
28
|
+
close: [["path", { d: "M6 6l12 12M18 6 6 18" }]],
|
|
29
|
+
dup: [["rect", { x: 8, y: 8, width: 13, height: 13, rx: 2.5 }], ["path", { d: "M14.5 11.5v6M11.5 14.5h6M16 8V5.5A2.5 2.5 0 0 0 13.5 3h-8A2.5 2.5 0 0 0 3 5.5v8A2.5 2.5 0 0 0 5.5 16H8" }]],
|
|
30
|
+
trash: [["path", { d: "M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13" }]],
|
|
31
|
+
sparkle: [["path", { d: "M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6" }]],
|
|
32
|
+
} satisfies Record<string, Shape[]>;
|
|
33
|
+
|
|
34
|
+
export type IconName = keyof typeof SHAPES;
|
|
35
|
+
|
|
36
|
+
/** An icon of `size` CSS pixels (default 20). */
|
|
37
|
+
export function icon(name: IconName, size = 20): SVGSVGElement {
|
|
38
|
+
const svg = document.createElementNS(NS, "svg");
|
|
39
|
+
for (const [k, v] of Object.entries({ viewBox: "0 0 24 24", width: size, height: size, fill: "none", stroke: "currentColor", "stroke-width": 1.8, "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false", class: `icon icon-${name}` })) svg.setAttribute(k, String(v));
|
|
40
|
+
for (const [tag, attrs] of SHAPES[name] as Shape[]) {
|
|
41
|
+
const el = document.createElementNS(NS, tag);
|
|
42
|
+
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
|
43
|
+
svg.append(el);
|
|
44
|
+
}
|
|
45
|
+
return svg;
|
|
46
|
+
}
|
package/src/admin/panel/index.ts
CHANGED
|
@@ -11,8 +11,17 @@ export type { MountedAdmin, MountOptions } from "./mount.js";
|
|
|
11
11
|
export { ApiError, createApi } from "./api.js";
|
|
12
12
|
export type { Api } from "./api.js";
|
|
13
13
|
export { gameCard } from "./gameCard.js";
|
|
14
|
-
export { lookPicture, previewPicture, shelfView } from "./shelf.js";
|
|
14
|
+
export { BODY_COLOURS, lookPicture, previewPicture, shelfFilter, shelfSort, shelfView } from "./shelf.js";
|
|
15
15
|
export { DEFAULT_LOGINS_NOTE, signinsCard } from "./signins.js";
|
|
16
|
-
export type { ShelfData } from "./shelf.js";
|
|
16
|
+
export type { ShelfData, ShelfFilter, ShelfSort } from "./shelf.js";
|
|
17
|
+
export { orderMatches, ordersView } from "./orders.js";
|
|
18
|
+
export type { OrderFilter, OrdersView } from "./orders.js";
|
|
19
|
+
export { bestSellers, overviewView } from "./overview.js";
|
|
20
|
+
export type { BestSeller, OverviewView } from "./overview.js";
|
|
21
|
+
export { playerMatches, playersView } from "./players.js";
|
|
22
|
+
export { TABS, tabBar } from "./tabs.js";
|
|
23
|
+
export type { TabBar, TabId } from "./tabs.js";
|
|
24
|
+
export { icon } from "./icons.js";
|
|
25
|
+
export type { IconName } from "./icons.js";
|
|
17
26
|
export { aiPrompt, centsOf, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
|
|
18
27
|
export type { LookStats, ShelfOrder, ShelfPlayer } from "./shelfData.js";
|
package/src/admin/panel/mount.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
// The admin panel: orders, owned looks, totals and the two game switches, from the game's own database. Only the game's owner can read them (the
|
|
2
2
|
// handler checks it on every call); this page only shows them. `mountAdmin(element, options)` draws the panel into `element`.
|
|
3
|
+
//
|
|
4
|
+
// The page: a top bar (who is signed in, Live or Test mode, Sign out), what is still off (one banner), four numbers (earned, open now, to send back, players;
|
|
5
|
+
// each opens its list), then five tabs: Overview, Cosmetics (the Shelf), Orders, Players and Settings (the Game card, the sign-ins, the facts). Every tab is
|
|
6
|
+
// made once; a new read of the summary fills them again and keeps the chosen tab, the Shelf's draft, a search and the Game card.
|
|
3
7
|
|
|
4
8
|
import type { Look } from "../../looks/look.js";
|
|
5
9
|
import type { AdminSummary } from "../store.js";
|
|
6
10
|
import type { GameState } from "../switches.js";
|
|
7
11
|
import { ApiError, createApi, type Api } from "./api.js";
|
|
8
|
-
import { h, shortAddress } from "./dom.js";
|
|
12
|
+
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
9
13
|
import { gameCard } from "./gameCard.js";
|
|
14
|
+
import { icon, type IconName } from "./icons.js";
|
|
15
|
+
import { ordersView, type OrderFilter } from "./orders.js";
|
|
16
|
+
import { overviewView } from "./overview.js";
|
|
17
|
+
import { playersView } from "./players.js";
|
|
10
18
|
import { shelfView } from "./shelf.js";
|
|
11
19
|
import { signinsCard } from "./signins.js";
|
|
20
|
+
import { tabBar, type TabId } from "./tabs.js";
|
|
12
21
|
|
|
13
22
|
export type MountOptions = {
|
|
14
23
|
/** The game's looks now (its looks.json). The Shelf starts from them. */
|
|
@@ -40,35 +49,51 @@ export type MountedAdmin = {
|
|
|
40
49
|
ready: Promise<void>;
|
|
41
50
|
};
|
|
42
51
|
|
|
43
|
-
const STATUS_WORDS: Record<string, string> = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
|
|
44
|
-
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
45
52
|
const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
|
|
46
53
|
|
|
54
|
+
/** The drawn page, made at the first read that the owner may see. */
|
|
55
|
+
type Dashboard = { update(s: AdminSummary): void; setGame(state: GameState | null): void };
|
|
56
|
+
|
|
47
57
|
export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdmin {
|
|
48
58
|
const api: Api = createApi(options.api ?? "/api/admin", options.fetch);
|
|
49
|
-
const gate = h("section", { id: "n133-gate", class: "admin-card", "aria-live": "polite" });
|
|
59
|
+
const gate = h("section", { id: "n133-gate", class: "admin-card gate", "aria-live": "polite" });
|
|
50
60
|
const data = h("section", { id: "n133-data" });
|
|
51
61
|
data.hidden = true;
|
|
52
62
|
root.classList.add("n133-admin");
|
|
53
63
|
root.replaceChildren(gate, data);
|
|
54
64
|
|
|
55
|
-
let filter = "all";
|
|
56
65
|
/** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
|
|
57
66
|
let shelf: ReturnType<typeof shelfView> | null = null;
|
|
58
67
|
/** The "Game" card keeps its own state for the life of the page (a payment check redraws the rest, not this). */
|
|
59
68
|
let game: HTMLElement | null = null;
|
|
69
|
+
let gameState: GameState | null = null;
|
|
70
|
+
let dash: Dashboard | null = null;
|
|
60
71
|
|
|
61
72
|
/** The card, made once. A fault in reading the two switches must not hide the orders: the card is left out and the rest still shows. */
|
|
62
73
|
async function gameSection(): Promise<HTMLElement | null> {
|
|
63
74
|
if (game) return game;
|
|
64
75
|
try {
|
|
65
|
-
|
|
76
|
+
gameState = await api.get<GameState>("/game");
|
|
77
|
+
game = gameCard(api, gameState, options.defaultsFile, (state) => {
|
|
78
|
+
gameState = state;
|
|
79
|
+
dash?.setGame(state);
|
|
80
|
+
});
|
|
66
81
|
} catch {
|
|
67
82
|
game = null;
|
|
83
|
+
gameState = null;
|
|
68
84
|
}
|
|
69
85
|
return game;
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/** The gate's screen: an icon, a title and what to do. The data drawn before goes (a signed-out page holds none); the Shelf's draft and the Game card stay in memory. */
|
|
89
|
+
function showGate(kind: IconName, title: string, ...body: (Node | null)[]) {
|
|
90
|
+
data.hidden = true;
|
|
91
|
+
data.replaceChildren();
|
|
92
|
+
dash = null;
|
|
93
|
+
gate.hidden = false;
|
|
94
|
+
gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b): b is Node => b !== null));
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
async function load(): Promise<void> {
|
|
73
98
|
try {
|
|
74
99
|
const s = await api.get<AdminSummary>("/summary");
|
|
@@ -77,43 +102,34 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
77
102
|
render(s);
|
|
78
103
|
} catch (e) {
|
|
79
104
|
if (!(e instanceof ApiError)) {
|
|
105
|
+
// The data already drawn (if any) stays: a failed new read does not take it away.
|
|
80
106
|
gate.hidden = false;
|
|
81
|
-
gate.replaceChildren(h("p", {}, "The game's server did not answer. Try again."));
|
|
107
|
+
gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon("alert", 28)), h("p", {}, "The game's server did not answer. Try again."), h("button", { class: "btn", type: "button", onclick: () => void load() }, "Try again"));
|
|
82
108
|
return;
|
|
83
109
|
}
|
|
84
|
-
data.hidden = true;
|
|
85
|
-
gate.hidden = false;
|
|
86
110
|
if (e.status === 403 && e.body.signedInAs) {
|
|
87
|
-
|
|
88
|
-
h("h2", {}, "Not the game's address"),
|
|
89
|
-
h("p", {}, `You are signed in as ${shortAddress(String(e.body.signedInAs))}. Only the game's owner address can open this page.`),
|
|
90
|
-
signOutButton(),
|
|
91
|
-
);
|
|
111
|
+
showGate("lock", "Not the game's address", h("p", {}, `You are signed in as ${shortAddress(String(e.body.signedInAs))}. Only the game's owner address can open this page.`), signOutButton());
|
|
92
112
|
return;
|
|
93
113
|
}
|
|
94
114
|
if (e.status === 403 && e.body.reason === "nanoterra") {
|
|
95
|
-
|
|
96
|
-
h("h2", {}, "You signed in from Nanoterra"),
|
|
97
|
-
h("p", {}, "A Nanoterra sign-in names your address but proves no key, so it cannot open this page. Sign out, then sign in with the wallet that holds your game's address."),
|
|
98
|
-
signOutButton(),
|
|
99
|
-
);
|
|
115
|
+
showGate("lock", "You signed in from Nanoterra", h("p", {}, "A Nanoterra sign-in names your address but proves no key, so it cannot open this page. Sign out, then sign in with the wallet that holds your game's address."), signOutButton());
|
|
100
116
|
return;
|
|
101
117
|
}
|
|
102
118
|
if (e.status === 403 && e.body.reason === "no_address") {
|
|
103
|
-
|
|
119
|
+
showGate("alert", "No owner address yet", h("p", {}, e.message), h("p", { class: "fine" }, options.noAddressHelp ?? "Set the game's owner address, then deploy again."));
|
|
104
120
|
return;
|
|
105
121
|
}
|
|
106
122
|
if (e.status === 403) {
|
|
107
|
-
|
|
123
|
+
showGate("lock", "This page is closed to you", h("p", {}, e.message), signOutButton());
|
|
108
124
|
return;
|
|
109
125
|
}
|
|
110
126
|
if (e.status === 401) {
|
|
111
|
-
const into = h("div", {});
|
|
112
|
-
|
|
127
|
+
const into = h("div", { class: "gate-signin" });
|
|
128
|
+
showGate("lock", "Sign in with your game's address", h("p", {}, e.message), into);
|
|
113
129
|
options.signIn(into, () => void load());
|
|
114
130
|
return;
|
|
115
131
|
}
|
|
116
|
-
|
|
132
|
+
showGate("alert", "Something went wrong", h("p", {}, e.message));
|
|
117
133
|
}
|
|
118
134
|
}
|
|
119
135
|
|
|
@@ -128,6 +144,7 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
128
144
|
void load();
|
|
129
145
|
},
|
|
130
146
|
},
|
|
147
|
+
icon("out", 18),
|
|
131
148
|
"Sign out",
|
|
132
149
|
);
|
|
133
150
|
}
|
|
@@ -139,89 +156,91 @@ export function mountAdmin(root: HTMLElement, options: MountOptions): MountedAdm
|
|
|
139
156
|
return shelf.el;
|
|
140
157
|
}
|
|
141
158
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
159
|
+
/** A look's body colour, while the game still has the look (for the dots in the lists). */
|
|
160
|
+
const colourOf = (id: string): string | null => options.looks.find((l) => l.id === id)?.change.body ?? null;
|
|
161
|
+
|
|
162
|
+
function build(): Dashboard {
|
|
163
|
+
const tabs = tabBar("overview");
|
|
164
|
+
const go = (tab: TabId, filter?: OrderFilter) => {
|
|
165
|
+
if (filter) orders.setFilter(filter);
|
|
166
|
+
tabs.select(tab);
|
|
167
|
+
};
|
|
168
|
+
const overview = overviewView(options.looks, colourOf, go);
|
|
169
|
+
const orders = ordersView(api, colourOf, (s) => render(s));
|
|
170
|
+
const players = playersView(colourOf);
|
|
171
|
+
tabs.panels.overview.append(overview.el);
|
|
172
|
+
tabs.panels.orders.append(orders.el);
|
|
173
|
+
tabs.panels.players.append(players.el);
|
|
174
|
+
|
|
175
|
+
const who = h("div", { class: "who" });
|
|
176
|
+
const chips = h("div", { class: "chips" });
|
|
177
|
+
const top = h("header", { class: "topbar" }, h("div", { class: "who-mark", "aria-hidden": "true" }, icon("lock", 18)), who, chips, h("div", { class: "topbar-keys" }, signOutButton()));
|
|
178
|
+
const banner = h("div", { class: "banner warnings", role: "alert" });
|
|
179
|
+
const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
|
|
180
|
+
const settings = h("div", { class: "settings-grid" });
|
|
181
|
+
tabs.panels.settings.append(settings);
|
|
182
|
+
data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
|
|
183
|
+
|
|
184
|
+
const kpi = (cls: string, mark: IconName, label: string, value: string, sub: string, open: () => void, alert = false) =>
|
|
185
|
+
h("button", { class: `kpi kpi-${cls}${alert ? " is-alert" : ""}`, type: "button", onclick: open }, h("span", { class: "kpi-top" }, h("span", { class: "kpi-icon", "aria-hidden": "true" }, icon(mark, 18)), h("span", { class: "kpi-label" }, label)), h("strong", { class: "kpi-value" }, value), h("span", { class: "kpi-sub" }, sub));
|
|
186
|
+
|
|
187
|
+
function about(s: AdminSummary): HTMLElement {
|
|
188
|
+
const facts: [string, Node | string][] = [];
|
|
189
|
+
if (s.address) facts.push(["Signed in as", h("span", { class: "fact-address" }, h("code", {}, s.address), copyKey(s.address, "Copy your address", icon("copy", 14)))]);
|
|
190
|
+
facts.push(["Mode", s.testMode ? "Test mode: payments are not real" : "Live: payments are real"]);
|
|
191
|
+
if (s.turnstile !== undefined) facts.push(["Bot check", s.turnstile ? "On" : "Off"]);
|
|
192
|
+
if (s.nodes?.length) facts.push(["Payments are checked on", s.nodes.join(", ")]);
|
|
193
|
+
if (s.rate) facts.push(["Last price", `$${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}`]);
|
|
194
|
+
return h(
|
|
195
|
+
"section",
|
|
196
|
+
{ class: "admin-card about-card", "aria-labelledby": "n133-about-title" },
|
|
197
|
+
h("h2", { id: "n133-about-title" }, "About this admin"),
|
|
198
|
+
h("dl", { class: "facts" }, ...facts.map(([k, v]) => h("div", {}, h("dt", {}, k), h("dd", {}, v)))),
|
|
199
|
+
signOutButton(),
|
|
175
200
|
);
|
|
176
201
|
}
|
|
177
202
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
203
|
+
return {
|
|
204
|
+
setGame: (state) => overview.setGame(state),
|
|
205
|
+
update(s) {
|
|
206
|
+
who.replaceChildren(h("span", { class: "who-label" }, "Signed in as"), s.address ? h("span", { class: "who-address" }, h("code", { title: s.address }, shortAddress(s.address)), copyKey(s.address, "Copy your address", icon("copy", 14))) : h("span", {}, "the game's owner"));
|
|
207
|
+
chips.replaceChildren(
|
|
208
|
+
h("span", { class: `chip ${s.testMode ? "chip-warn" : "chip-ok"}`, title: s.testMode ? "Payments are not real" : "Payments are real" }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.testMode ? "Test mode" : "Live"),
|
|
209
|
+
s.turnstile === undefined ? "" : h("span", { class: `chip ${s.turnstile ? "chip-ok" : "chip-warn"}` }, h("span", { class: "chip-dot", "aria-hidden": "true" }), s.turnstile ? "Bot check on" : "Bot check off"),
|
|
210
|
+
);
|
|
211
|
+
banner.hidden = !s.warnings.length;
|
|
212
|
+
banner.replaceChildren(h("div", { class: "banner-icon", "aria-hidden": "true" }, icon("alert", 20)), h("div", {}, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))));
|
|
213
|
+
|
|
214
|
+
const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
215
|
+
const late = s.totals.paidLate.count;
|
|
216
|
+
kpis.replaceChildren(
|
|
217
|
+
kpi("earned", "coin", "Earned", `${s.totals.paid.xno} XNO`, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`, () => go("orders", "paid")),
|
|
218
|
+
kpi("open", "clock", "Open now", String(s.counts.open), `${s.counts.expired} expired`, () => go("orders", "open")),
|
|
219
|
+
kpi("late", "back", "To send back", `${s.totals.paidLate.xno} XNO`, late ? `$${s.totals.paidLate.usd} · ${plural(late, "order")}` : "Nothing to send back", () => go("orders", "paid_late"), late > 0),
|
|
220
|
+
kpi("players", "players", "Players", String(s.players.length), `${plural(ownedLooks, "look")} owned`, () => go("players")),
|
|
221
|
+
);
|
|
195
222
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
tabs.setCount("cosmetics", options.looks.length);
|
|
224
|
+
tabs.setCount("orders", s.orders.length);
|
|
225
|
+
tabs.setCount("players", s.players.length);
|
|
226
|
+
overview.update(s);
|
|
227
|
+
overview.setGame(gameState);
|
|
228
|
+
const shelfEl = shelfCard(s);
|
|
229
|
+
if (shelfEl.parentNode !== tabs.panels.cosmetics) tabs.panels.cosmetics.replaceChildren(shelfEl);
|
|
230
|
+
orders.update(s);
|
|
231
|
+
players.update(s);
|
|
232
|
+
// The Game card is the same element at every redraw: moving it keeps its state.
|
|
233
|
+
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p): p is HTMLElement => p !== null));
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
199
237
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"section",
|
|
205
|
-
{ class: "totals" },
|
|
206
|
-
h("div", { class: "total" }, h("span", {}, "Paid"), h("strong", {}, `${s.totals.paid.xno} XNO`), h("small", {}, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`)),
|
|
207
|
-
h("div", { class: "total" }, h("span", {}, "Paid late (send back)"), h("strong", {}, `${s.totals.paidLate.xno} XNO`), h("small", {}, `$${s.totals.paidLate.usd} · ${plural(s.totals.paidLate.count, "order")}`)),
|
|
208
|
-
h("div", { class: "total" }, h("span", {}, "Open now"), h("strong", {}, String(s.counts.open)), h("small", {}, `${s.counts.expired} expired`)),
|
|
209
|
-
),
|
|
210
|
-
shelfCard(s),
|
|
211
|
-
h("section", { class: "admin-card" }, h("div", { class: "card-head" }, h("h2", {}, "Orders"), h("div", {}, ...(s.canCheck === false ? [] : [check, " ", checkNote]))), tabs, orders),
|
|
212
|
-
h("section", { class: "admin-card" }, h("h2", {}, "Owned looks"), players),
|
|
213
|
-
signinsCard(s, api, () => void load(), options.loginsNote),
|
|
214
|
-
h(
|
|
215
|
-
"section",
|
|
216
|
-
{ class: "admin-card fine" },
|
|
217
|
-
s.address ? h("p", {}, "Signed in as ", h("code", {}, s.address), ".") : null,
|
|
218
|
-
s.nodes?.length ? h("p", {}, `Payments are checked on: ${s.nodes.join(", ")}.`) : null,
|
|
219
|
-
s.rate ? h("p", {}, `Last price: $${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}.`) : null,
|
|
220
|
-
signOutButton(),
|
|
221
|
-
),
|
|
222
|
-
];
|
|
223
|
-
data.replaceChildren(...parts.filter((p): p is Node => p !== null));
|
|
238
|
+
function render(s: AdminSummary): void {
|
|
239
|
+
data.hidden = false;
|
|
240
|
+
dash ??= build();
|
|
241
|
+
dash.update(s);
|
|
224
242
|
}
|
|
225
243
|
|
|
226
244
|
return { reload: () => load(), ready: load() };
|
|
227
245
|
}
|
|
246
|
+
|