@nano133/nanoterra 0.3.2 → 0.5.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 +29 -0
- package/README.md +51 -7
- package/ai/SPEC.md +2 -1
- 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 +8 -4
- 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 +15 -3
- package/dist/admin/panel/shelf.js +385 -84
- package/dist/admin/panel/shelfData.js +1 -1
- package/dist/admin/panel/tabs.d.ts +23 -0
- package/dist/admin/panel/tabs.js +68 -0
- package/dist/looks/change.d.ts +5 -5
- package/dist/looks/change.js +12 -7
- package/dist/looks/index.d.ts +2 -2
- package/dist/looks/index.js +1 -1
- package/dist/looks/json.d.ts +3 -2
- package/dist/looks/json.js +16 -9
- package/dist/looks/look.d.ts +26 -6
- package/dist/looks/look.js +37 -12
- package/package.json +1 -1
- package/src/admin/panel/dom.ts +34 -0
- package/src/admin/panel/gameCard.ts +8 -4
- 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 +549 -121
- package/src/admin/panel/shelfData.ts +1 -1
- package/src/admin/panel/style.css +1775 -270
- package/src/admin/panel/tabs.ts +94 -0
- package/src/looks/change.ts +12 -9
- package/src/looks/index.ts +2 -2
- package/src/looks/json.ts +12 -7
- package/src/looks/look.ts +47 -9
|
@@ -1,37 +1,58 @@
|
|
|
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
|
import { ApiError, createApi } from "./api.js";
|
|
4
|
-
import { h, shortAddress } from "./dom.js";
|
|
8
|
+
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
5
9
|
import { gameCard } from "./gameCard.js";
|
|
10
|
+
import { icon } from "./icons.js";
|
|
11
|
+
import { ordersView } from "./orders.js";
|
|
12
|
+
import { overviewView } from "./overview.js";
|
|
13
|
+
import { playersView } from "./players.js";
|
|
6
14
|
import { shelfView } from "./shelf.js";
|
|
7
15
|
import { signinsCard } from "./signins.js";
|
|
8
|
-
|
|
9
|
-
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
16
|
+
import { tabBar } from "./tabs.js";
|
|
10
17
|
const when = (ms) => (ms ? new Date(ms).toLocaleString() : "");
|
|
11
18
|
export function mountAdmin(root, options) {
|
|
12
19
|
const api = createApi(options.api ?? "/api/admin", options.fetch);
|
|
13
|
-
const gate = h("section", { id: "n133-gate", class: "admin-card", "aria-live": "polite" });
|
|
20
|
+
const gate = h("section", { id: "n133-gate", class: "admin-card gate", "aria-live": "polite" });
|
|
14
21
|
const data = h("section", { id: "n133-data" });
|
|
15
22
|
data.hidden = true;
|
|
16
23
|
root.classList.add("n133-admin");
|
|
17
24
|
root.replaceChildren(gate, data);
|
|
18
|
-
let filter = "all";
|
|
19
25
|
/** The Shelf keeps its draft while the page redraws (a tab, a payment check): one view for the life of the page. */
|
|
20
26
|
let shelf = null;
|
|
21
27
|
/** The "Game" card keeps its own state for the life of the page (a payment check redraws the rest, not this). */
|
|
22
28
|
let game = null;
|
|
29
|
+
let gameState = null;
|
|
30
|
+
let dash = null;
|
|
23
31
|
/** 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. */
|
|
24
32
|
async function gameSection() {
|
|
25
33
|
if (game)
|
|
26
34
|
return game;
|
|
27
35
|
try {
|
|
28
|
-
|
|
36
|
+
gameState = await api.get("/game");
|
|
37
|
+
game = gameCard(api, gameState, options.defaultsFile, (state) => {
|
|
38
|
+
gameState = state;
|
|
39
|
+
dash?.setGame(state);
|
|
40
|
+
});
|
|
29
41
|
}
|
|
30
42
|
catch {
|
|
31
43
|
game = null;
|
|
44
|
+
gameState = null;
|
|
32
45
|
}
|
|
33
46
|
return game;
|
|
34
47
|
}
|
|
48
|
+
/** 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. */
|
|
49
|
+
function showGate(kind, title, ...body) {
|
|
50
|
+
data.hidden = true;
|
|
51
|
+
data.replaceChildren();
|
|
52
|
+
dash = null;
|
|
53
|
+
gate.hidden = false;
|
|
54
|
+
gate.replaceChildren(h("div", { class: "gate-icon", "aria-hidden": "true" }, icon(kind, 28)), h("h2", {}, title), ...body.filter((b) => b !== null));
|
|
55
|
+
}
|
|
35
56
|
async function load() {
|
|
36
57
|
try {
|
|
37
58
|
const s = await api.get("/summary");
|
|
@@ -41,35 +62,34 @@ export function mountAdmin(root, options) {
|
|
|
41
62
|
}
|
|
42
63
|
catch (e) {
|
|
43
64
|
if (!(e instanceof ApiError)) {
|
|
65
|
+
// The data already drawn (if any) stays: a failed new read does not take it away.
|
|
44
66
|
gate.hidden = false;
|
|
45
|
-
gate.replaceChildren(h("p", {}, "The game's server did not answer. Try again."));
|
|
67
|
+
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"));
|
|
46
68
|
return;
|
|
47
69
|
}
|
|
48
|
-
data.hidden = true;
|
|
49
|
-
gate.hidden = false;
|
|
50
70
|
if (e.status === 403 && e.body.signedInAs) {
|
|
51
|
-
|
|
71
|
+
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());
|
|
52
72
|
return;
|
|
53
73
|
}
|
|
54
74
|
if (e.status === 403 && e.body.reason === "nanoterra") {
|
|
55
|
-
|
|
75
|
+
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());
|
|
56
76
|
return;
|
|
57
77
|
}
|
|
58
78
|
if (e.status === 403 && e.body.reason === "no_address") {
|
|
59
|
-
|
|
79
|
+
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."));
|
|
60
80
|
return;
|
|
61
81
|
}
|
|
62
82
|
if (e.status === 403) {
|
|
63
|
-
|
|
83
|
+
showGate("lock", "This page is closed to you", h("p", {}, e.message), signOutButton());
|
|
64
84
|
return;
|
|
65
85
|
}
|
|
66
86
|
if (e.status === 401) {
|
|
67
|
-
const into = h("div", {});
|
|
68
|
-
|
|
87
|
+
const into = h("div", { class: "gate-signin" });
|
|
88
|
+
showGate("lock", "Sign in with your game's address", h("p", {}, e.message), into);
|
|
69
89
|
options.signIn(into, () => void load());
|
|
70
90
|
return;
|
|
71
91
|
}
|
|
72
|
-
|
|
92
|
+
showGate("alert", "Something went wrong", h("p", {}, e.message));
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
95
|
function signOutButton() {
|
|
@@ -80,7 +100,7 @@ export function mountAdmin(root, options) {
|
|
|
80
100
|
await options.signOut().catch(() => null);
|
|
81
101
|
void load();
|
|
82
102
|
},
|
|
83
|
-
}, "Sign out");
|
|
103
|
+
}, icon("out", 18), "Sign out");
|
|
84
104
|
}
|
|
85
105
|
function shelfCard(s) {
|
|
86
106
|
const d = { orders: s.orders, players: s.players, repo: s.repo ?? null };
|
|
@@ -90,58 +110,72 @@ export function mountAdmin(root, options) {
|
|
|
90
110
|
shelf = shelfView(d, options.looks, options.lookPicture, options.repoHelp);
|
|
91
111
|
return shelf.el;
|
|
92
112
|
}
|
|
113
|
+
/** An outfit's body colour, while the game still has the look (for the dots in the lists); none for an item of another wear slot. */
|
|
114
|
+
const colourOf = (id) => options.looks.find((l) => l.id === id)?.change?.body ?? null;
|
|
115
|
+
function build() {
|
|
116
|
+
const tabs = tabBar("overview");
|
|
117
|
+
const go = (tab, filter) => {
|
|
118
|
+
if (filter)
|
|
119
|
+
orders.setFilter(filter);
|
|
120
|
+
tabs.select(tab);
|
|
121
|
+
};
|
|
122
|
+
const overview = overviewView(options.looks, colourOf, go);
|
|
123
|
+
const orders = ordersView(api, colourOf, (s) => render(s));
|
|
124
|
+
const players = playersView(colourOf);
|
|
125
|
+
tabs.panels.overview.append(overview.el);
|
|
126
|
+
tabs.panels.orders.append(orders.el);
|
|
127
|
+
tabs.panels.players.append(players.el);
|
|
128
|
+
const who = h("div", { class: "who" });
|
|
129
|
+
const chips = h("div", { class: "chips" });
|
|
130
|
+
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()));
|
|
131
|
+
const banner = h("div", { class: "banner warnings", role: "alert" });
|
|
132
|
+
const kpis = h("div", { class: "kpis", role: "group", "aria-label": "Your game in numbers" });
|
|
133
|
+
const settings = h("div", { class: "settings-grid" });
|
|
134
|
+
tabs.panels.settings.append(settings);
|
|
135
|
+
data.replaceChildren(top, banner, kpis, tabs.bar, ...Object.values(tabs.panels));
|
|
136
|
+
const kpi = (cls, mark, label, value, sub, open, alert = false) => 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));
|
|
137
|
+
function about(s) {
|
|
138
|
+
const facts = [];
|
|
139
|
+
if (s.address)
|
|
140
|
+
facts.push(["Signed in as", h("span", { class: "fact-address" }, h("code", {}, s.address), copyKey(s.address, "Copy your address", icon("copy", 14)))]);
|
|
141
|
+
facts.push(["Mode", s.testMode ? "Test mode: payments are not real" : "Live: payments are real"]);
|
|
142
|
+
if (s.turnstile !== undefined)
|
|
143
|
+
facts.push(["Bot check", s.turnstile ? "On" : "Off"]);
|
|
144
|
+
if (s.nodes?.length)
|
|
145
|
+
facts.push(["Payments are checked on", s.nodes.join(", ")]);
|
|
146
|
+
if (s.rate)
|
|
147
|
+
facts.push(["Last price", `$${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}`]);
|
|
148
|
+
return h("section", { class: "admin-card about-card", "aria-labelledby": "n133-about-title" }, h("h2", { id: "n133-about-title" }, "About this admin"), h("dl", { class: "facts" }, ...facts.map(([k, v]) => h("div", {}, h("dt", {}, k), h("dd", {}, v)))), signOutButton());
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
setGame: (state) => overview.setGame(state),
|
|
152
|
+
update(s) {
|
|
153
|
+
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"));
|
|
154
|
+
chips.replaceChildren(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"), 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"));
|
|
155
|
+
banner.hidden = !s.warnings.length;
|
|
156
|
+
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)))));
|
|
157
|
+
const ownedLooks = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
158
|
+
const late = s.totals.paidLate.count;
|
|
159
|
+
kpis.replaceChildren(kpi("earned", "coin", "Earned", `${s.totals.paid.xno} XNO`, `$${s.totals.paid.usd} · ${plural(s.totals.paid.count, "order")}`, () => go("orders", "paid")), kpi("open", "clock", "Open now", String(s.counts.open), `${s.counts.expired} expired`, () => go("orders", "open")), 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), kpi("players", "players", "Players", String(s.players.length), `${plural(ownedLooks, "look")} owned`, () => go("players")));
|
|
160
|
+
tabs.setCount("cosmetics", options.looks.length);
|
|
161
|
+
tabs.setCount("orders", s.orders.length);
|
|
162
|
+
tabs.setCount("players", s.players.length);
|
|
163
|
+
overview.update(s);
|
|
164
|
+
overview.setGame(gameState);
|
|
165
|
+
const shelfEl = shelfCard(s);
|
|
166
|
+
if (shelfEl.parentNode !== tabs.panels.cosmetics)
|
|
167
|
+
tabs.panels.cosmetics.replaceChildren(shelfEl);
|
|
168
|
+
orders.update(s);
|
|
169
|
+
players.update(s);
|
|
170
|
+
// The Game card is the same element at every redraw: moving it keeps its state.
|
|
171
|
+
settings.replaceChildren(...[game, signinsCard(s, api, () => void load(), options.loginsNote), about(s)].filter((p) => p !== null));
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
93
175
|
function render(s) {
|
|
94
176
|
data.hidden = false;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
check.addEventListener("click", async () => {
|
|
98
|
-
check.disabled = true;
|
|
99
|
-
checkNote.textContent = "Checking…";
|
|
100
|
-
try {
|
|
101
|
-
const r = await api.post("/check");
|
|
102
|
-
render(r.summary);
|
|
103
|
-
}
|
|
104
|
-
catch (e) {
|
|
105
|
-
checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
|
|
106
|
-
check.disabled = false;
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
const tabs = h("div", { class: "tabs", role: "group", "aria-label": "Show orders" });
|
|
110
|
-
for (const key of ["all", "open", "paid", "expired", "paid_late"]) {
|
|
111
|
-
const n = key === "all" ? s.orders.length : s.counts[key];
|
|
112
|
-
tabs.append(h("button", {
|
|
113
|
-
class: `tab${filter === key ? " tab-on" : ""}`,
|
|
114
|
-
type: "button",
|
|
115
|
-
"aria-pressed": filter === key ? "true" : "false",
|
|
116
|
-
onclick: () => {
|
|
117
|
-
filter = key;
|
|
118
|
-
render(s);
|
|
119
|
-
},
|
|
120
|
-
}, `${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`));
|
|
121
|
-
}
|
|
122
|
-
const rows = s.orders.filter((o) => filter === "all" || o.status === filter);
|
|
123
|
-
const orders = h("div", { class: "orders" });
|
|
124
|
-
if (!rows.length)
|
|
125
|
-
orders.append(h("p", { class: "fine" }, "No orders here yet."));
|
|
126
|
-
for (const o of rows) {
|
|
127
|
-
orders.append(h("article", { class: `order order-${o.status}` }, h("div", { class: "order-top" }, h("strong", {}, o.look), h("span", { class: `badge badge-${o.status}` }, STATUS_WORDS[o.status] ?? o.status)), h("div", { class: "order-amount" }, `${o.xno} XNO · $${o.usd}`), h("div", { class: "fine" }, `Player ${shortAddress(o.player)} · made ${when(o.createdAt)}`), o.paidAt ? h("div", { class: "fine" }, `Payment seen ${when(o.paidAt)}${o.payer ? ` from ${shortAddress(o.payer)}` : ""}`) : null, o.status === "paid_late" && o.payer ? h("div", { class: "late-note" }, "Send this amount back by hand to ", h("code", {}, o.payer), ". The game holds no key, so it cannot send it.") : null, o.hash ? h("div", { class: "fine hash" }, `Block ${o.hash}`) : null));
|
|
128
|
-
}
|
|
129
|
-
const players = h("div", { class: "players" });
|
|
130
|
-
if (!s.players.length)
|
|
131
|
-
players.append(h("p", { class: "fine" }, "Nobody owns a paid look yet."));
|
|
132
|
-
for (const p of s.players)
|
|
133
|
-
players.append(h("div", { class: "player" }, h("code", {}, p.player), h("span", {}, p.looks.map((l) => l.name).join(", "))));
|
|
134
|
-
const parts = [
|
|
135
|
-
s.warnings.length ? h("section", { class: "admin-card warnings", role: "alert" }, h("h2", {}, "Check these"), h("ul", {}, ...s.warnings.map((w) => h("li", {}, w)))) : null,
|
|
136
|
-
game,
|
|
137
|
-
h("section", { class: "totals" }, 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")}`)), 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")}`)), h("div", { class: "total" }, h("span", {}, "Open now"), h("strong", {}, String(s.counts.open)), h("small", {}, `${s.counts.expired} expired`))),
|
|
138
|
-
shelfCard(s),
|
|
139
|
-
h("section", { class: "admin-card" }, h("div", { class: "card-head" }, h("h2", {}, "Orders"), h("div", {}, ...(s.canCheck === false ? [] : [check, " ", checkNote]))), tabs, orders),
|
|
140
|
-
h("section", { class: "admin-card" }, h("h2", {}, "Owned looks"), players),
|
|
141
|
-
signinsCard(s, api, () => void load(), options.loginsNote),
|
|
142
|
-
h("section", { class: "admin-card fine" }, s.address ? h("p", {}, "Signed in as ", h("code", {}, s.address), ".") : null, s.nodes?.length ? h("p", {}, `Payments are checked on: ${s.nodes.join(", ")}.`) : null, s.rate ? h("p", {}, `Last price: $${s.rate.usd} per XNO (${s.rate.sources.join(", ")}), ${when(s.rate.at)}.`) : null, signOutButton()),
|
|
143
|
-
];
|
|
144
|
-
data.replaceChildren(...parts.filter((p) => p !== null));
|
|
177
|
+
dash ??= build();
|
|
178
|
+
dash.update(s);
|
|
145
179
|
}
|
|
146
180
|
return { reload: () => load(), ready: load() };
|
|
147
181
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AdminOrder, AdminSummary } from "../store.js";
|
|
2
|
+
import type { Api } from "./api.js";
|
|
3
|
+
export type OrderFilter = "all" | "open" | "paid" | "expired" | "paid_late";
|
|
4
|
+
/** How many orders the list draws at first, and how many more each "Show more" adds. */
|
|
5
|
+
export declare const ORDERS_PAGE = 20;
|
|
6
|
+
export declare const STATUS_WORDS: Record<string, string>;
|
|
7
|
+
/** Whether an order matches a search: its look's name or id, the player's or the payer's address, or its block. Upper or lower case does not matter. Pure. */
|
|
8
|
+
export declare function orderMatches(o: AdminOrder, query: string): boolean;
|
|
9
|
+
/** A status as a coloured badge. */
|
|
10
|
+
export declare const badge: (status: string) => HTMLSpanElement;
|
|
11
|
+
/** One order: a card on a phone, a line of the table on a wide screen. `colourOf` gives the look's body colour for its dot, when the game still has the look. */
|
|
12
|
+
export declare function orderRow(o: AdminOrder, colourOf: (lookId: string) => string | null, compact?: boolean): HTMLElement;
|
|
13
|
+
export type OrdersView = {
|
|
14
|
+
el: HTMLElement;
|
|
15
|
+
update(s: AdminSummary): void;
|
|
16
|
+
/** Shows only one status (a press on a number of the Overview does it). */
|
|
17
|
+
setFilter(f: OrderFilter): void;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The tab's content, made once: its filter, its search and its check keep their state when the summary is read again. `colourOf` gives a look's body colour;
|
|
21
|
+
* `onSummary` hears the summary that "Check payments now" answers with (the panel draws it everywhere).
|
|
22
|
+
*/
|
|
23
|
+
export declare function ordersView(api: Api, colourOf: (lookId: string) => string | null, onSummary: (s: AdminSummary) => void): OrdersView;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The Orders tab of /admin: every order of the game (the summary gives the newest 500), a filter by status with its count, a search (a look, a player's or a
|
|
2
|
+
// payer's address, a block), and "Check payments now" when the game gave a check. A row is a card on a phone and a line of a table on a wide screen. A
|
|
3
|
+
// payment that came after its order ran out says what to do: the game holds no key, so the owner sends the amount back by hand.
|
|
4
|
+
import { ago, copyKey, h, plural, shortAddress } from "./dom.js";
|
|
5
|
+
import { icon } from "./icons.js";
|
|
6
|
+
/** How many orders the list draws at first, and how many more each "Show more" adds. */
|
|
7
|
+
export const ORDERS_PAGE = 20;
|
|
8
|
+
export const STATUS_WORDS = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
|
|
9
|
+
const FILTERS = ["all", "paid", "open", "paid_late", "expired"];
|
|
10
|
+
const when = (ms) => (ms ? new Date(ms).toLocaleString() : "");
|
|
11
|
+
/** "A1B2C3D4…E5F6A7" */
|
|
12
|
+
const shortHash = (hash) => (hash.length > 16 ? `${hash.slice(0, 8)}…${hash.slice(-6)}` : hash);
|
|
13
|
+
/** Whether an order matches a search: its look's name or id, the player's or the payer's address, or its block. Upper or lower case does not matter. Pure. */
|
|
14
|
+
export function orderMatches(o, query) {
|
|
15
|
+
const q = query.trim().toLowerCase();
|
|
16
|
+
if (!q)
|
|
17
|
+
return true;
|
|
18
|
+
return [o.look, o.lookId, o.player, o.payer ?? "", o.hash ?? ""].some((v) => v.toLowerCase().includes(q));
|
|
19
|
+
}
|
|
20
|
+
/** A status as a coloured badge. */
|
|
21
|
+
export const badge = (status) => h("span", { class: `badge badge-${status}` }, STATUS_WORDS[status] ?? status);
|
|
22
|
+
/** One order: a card on a phone, a line of the table on a wide screen. `colourOf` gives the look's body colour for its dot, when the game still has the look. */
|
|
23
|
+
export function orderRow(o, colourOf, compact = false) {
|
|
24
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
25
|
+
const c = colourOf(o.lookId);
|
|
26
|
+
if (c)
|
|
27
|
+
dot.style.setProperty("--swatch", c);
|
|
28
|
+
const row = h("article", { class: `order order-${o.status}${compact ? " is-compact" : ""}` }, h("div", { class: "order-look" }, dot, h("strong", {}, o.look)), h("div", { class: "order-status" }, badge(o.status)), h("div", { class: "order-amount" }, h("strong", {}, `${o.xno} XNO`), h("span", { class: "fine" }, `$${o.usd}`)), h("div", { class: "order-player" }, h("span", { class: "fine cell-label" }, "Player "), h("code", { title: o.player }, shortAddress(o.player)), compact ? null : copyKey(o.player, "Copy the player's address", icon("copy", 14))), compact
|
|
29
|
+
? h("div", { class: "order-when" }, o.paidAt ? h("span", { title: when(o.paidAt) }, `Paid ${ago(o.paidAt)}`) : h("span", { title: when(o.createdAt) }, `Made ${ago(o.createdAt)}`))
|
|
30
|
+
: h("div", { class: "order-when" }, h("span", { title: when(o.createdAt) }, `Made ${ago(o.createdAt)}`), o.paidAt ? h("span", { class: "fine", title: when(o.paidAt) }, `Paid ${ago(o.paidAt)}${o.payer && o.payer !== o.player ? ` from ${shortAddress(o.payer)}` : ""}`) : null));
|
|
31
|
+
if (compact)
|
|
32
|
+
return row;
|
|
33
|
+
if (o.status === "paid_late" && o.payer)
|
|
34
|
+
row.append(h("div", { class: "late-note order-extra" }, icon("back", 16), h("span", {}, "Send this amount back by hand to ", h("code", {}, o.payer), ". The game holds no key, so it cannot send it."), copyKey(o.payer, "Copy the address to send it back to", icon("copy", 14))));
|
|
35
|
+
if (o.hash)
|
|
36
|
+
row.append(h("div", { class: "fine order-extra order-hash" }, h("span", {}, "Block "), h("code", { title: o.hash }, shortHash(o.hash)), copyKey(o.hash, "Copy the block hash", icon("copy", 14))));
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The tab's content, made once: its filter, its search and its check keep their state when the summary is read again. `colourOf` gives a look's body colour;
|
|
41
|
+
* `onSummary` hears the summary that "Check payments now" answers with (the panel draws it everywhere).
|
|
42
|
+
*/
|
|
43
|
+
export function ordersView(api, colourOf, onSummary) {
|
|
44
|
+
let s = null;
|
|
45
|
+
let filter = "all";
|
|
46
|
+
let query = "";
|
|
47
|
+
let shown = ORDERS_PAGE;
|
|
48
|
+
const check = h("button", { class: "btn btn-primary", type: "button" }, icon("refresh", 18), "Check payments now");
|
|
49
|
+
const checkNote = h("span", { class: "fine", role: "status" });
|
|
50
|
+
check.addEventListener("click", async () => {
|
|
51
|
+
check.disabled = true;
|
|
52
|
+
checkNote.textContent = "Checking…";
|
|
53
|
+
try {
|
|
54
|
+
const r = await api.post("/check");
|
|
55
|
+
checkNote.textContent = !r.scan?.scanned ? "The check did not finish. Try again in a moment." : r.scan.settled.length ? `Checked: ${plural(r.scan.settled.length, "payment")} found.` : "Checked: no new payment.";
|
|
56
|
+
onSummary(r.summary);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
check.disabled = false;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const seg = h("div", { class: "seg tabs", role: "group", "aria-label": "Show orders" });
|
|
66
|
+
const search = h("input", { class: "input search-input", type: "search", placeholder: "Look, address or block", "aria-label": "Search the orders", autocomplete: "off", spellcheck: false });
|
|
67
|
+
search.addEventListener("input", () => {
|
|
68
|
+
query = search.value;
|
|
69
|
+
shown = ORDERS_PAGE;
|
|
70
|
+
drawList();
|
|
71
|
+
});
|
|
72
|
+
const count = h("p", { class: "fine list-count", role: "status", "aria-live": "polite" });
|
|
73
|
+
const list = h("div", { class: "orders" });
|
|
74
|
+
// The check key is there only when the game gave a check (the summary's canCheck).
|
|
75
|
+
const keys = h("div", { class: "card-keys" });
|
|
76
|
+
const head = h("div", { class: "card-head" }, h("div", { class: "card-title" }, h("h2", {}, "Orders"), h("p", { class: "fine card-sub" }, "Every order your game made, newest first.")), keys);
|
|
77
|
+
const el = h("section", { class: "admin-card orders-card" }, head, h("div", { class: "toolbar" }, seg, h("label", { class: "search" }, icon("search", 18), search)), count, h("div", { class: "order-table-head", "aria-hidden": "true" }, h("span", {}, "Look"), h("span", {}, "Status"), h("span", {}, "Amount"), h("span", {}, "Player"), h("span", {}, "When")), list);
|
|
78
|
+
function drawFilter() {
|
|
79
|
+
if (!s)
|
|
80
|
+
return;
|
|
81
|
+
const sum = s;
|
|
82
|
+
seg.replaceChildren(...FILTERS.map((key) => {
|
|
83
|
+
const n = key === "all" ? sum.orders.length : sum.counts[key];
|
|
84
|
+
return h("button", {
|
|
85
|
+
class: `seg-btn tab${filter === key ? " is-on tab-on" : ""}${key === "paid_late" && n > 0 ? " is-alert" : ""}`,
|
|
86
|
+
type: "button",
|
|
87
|
+
"aria-pressed": filter === key ? "true" : "false",
|
|
88
|
+
onclick: () => {
|
|
89
|
+
filter = key;
|
|
90
|
+
shown = ORDERS_PAGE;
|
|
91
|
+
drawFilter();
|
|
92
|
+
drawList();
|
|
93
|
+
},
|
|
94
|
+
}, `${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`);
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
function drawList() {
|
|
98
|
+
if (!s)
|
|
99
|
+
return;
|
|
100
|
+
const rows = s.orders.filter((o) => (filter === "all" || o.status === filter) && orderMatches(o, query));
|
|
101
|
+
const total = s.orders.filter((o) => filter === "all" || o.status === filter).length;
|
|
102
|
+
count.textContent = query.trim() ? `${plural(rows.length, "order")} of ${total} match “${query.trim()}”.` : "";
|
|
103
|
+
count.hidden = !query.trim();
|
|
104
|
+
list.replaceChildren(...rows.slice(0, shown).map((o) => orderRow(o, colourOf)));
|
|
105
|
+
if (!rows.length)
|
|
106
|
+
list.append(h("p", { class: "empty" }, query.trim() ? "No order matches your search." : "No orders here yet."));
|
|
107
|
+
if (rows.length > shown) {
|
|
108
|
+
const more = Math.min(ORDERS_PAGE, rows.length - shown);
|
|
109
|
+
list.append(h("button", { class: "btn more-key", type: "button", onclick: () => ((shown += ORDERS_PAGE), drawList()) }, `Show ${more} more`, rows.length - shown > more ? h("span", { class: "fine" }, `of ${rows.length - shown}`) : null));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
el,
|
|
114
|
+
update(next) {
|
|
115
|
+
s = next;
|
|
116
|
+
if (next.canCheck === false)
|
|
117
|
+
keys.replaceChildren();
|
|
118
|
+
else if (check.parentNode !== keys)
|
|
119
|
+
keys.replaceChildren(check, checkNote);
|
|
120
|
+
drawFilter();
|
|
121
|
+
drawList();
|
|
122
|
+
},
|
|
123
|
+
setFilter(f) {
|
|
124
|
+
filter = f;
|
|
125
|
+
shown = ORDERS_PAGE;
|
|
126
|
+
drawFilter();
|
|
127
|
+
drawList();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Look } from "../../looks/look.js";
|
|
2
|
+
import type { AdminSummary } from "../store.js";
|
|
3
|
+
import { type GameState } from "../switches.js";
|
|
4
|
+
import { type OrderFilter } from "./orders.js";
|
|
5
|
+
import type { TabId } from "./tabs.js";
|
|
6
|
+
/** A best seller: a look id, its name, its paid orders and their cents, its owners. */
|
|
7
|
+
export type BestSeller = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
paid: number;
|
|
11
|
+
cents: number;
|
|
12
|
+
owners: number;
|
|
13
|
+
};
|
|
14
|
+
/** The looks that sold, best first (by money, then by owners), at most `n`. A look that is no longer in looks.json keeps the name its orders give. Pure. */
|
|
15
|
+
export declare function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks: readonly Look[], n?: number): BestSeller[];
|
|
16
|
+
export type OverviewView = {
|
|
17
|
+
el: HTMLElement;
|
|
18
|
+
update(s: AdminSummary): void;
|
|
19
|
+
/** The two switches as they are now (null: they could not be read). */
|
|
20
|
+
setGame(state: GameState | null): void;
|
|
21
|
+
};
|
|
22
|
+
export declare function overviewView(looks: readonly Look[], colourOf: (lookId: string) => string | null, go: (tab: TabId, filter?: OrderFilter) => void): OverviewView;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The Overview tab of /admin: the game at a glance. The best-selling looks with their share of the money, the last five orders, the two game switches as On or
|
|
2
|
+
// Off, and the shelf in numbers; each part has a key to its own tab. A game with no sale yet gets three first steps instead of empty lists.
|
|
3
|
+
import { priceText } from "../../looks/ladder.js";
|
|
4
|
+
import { SWITCHES, stateOf } from "../switches.js";
|
|
5
|
+
import { dollars, h, plural } from "./dom.js";
|
|
6
|
+
import { icon } from "./icons.js";
|
|
7
|
+
import { orderRow } from "./orders.js";
|
|
8
|
+
import { shelfStats } from "./shelfData.js";
|
|
9
|
+
/** The looks that sold, best first (by money, then by owners), at most `n`. A look that is no longer in looks.json keeps the name its orders give. Pure. */
|
|
10
|
+
export function bestSellers(s, looks, n = 5) {
|
|
11
|
+
const names = new Map();
|
|
12
|
+
for (const o of s.orders)
|
|
13
|
+
names.set(o.lookId, o.look);
|
|
14
|
+
for (const l of looks)
|
|
15
|
+
names.set(l.id, l.name);
|
|
16
|
+
return [...shelfStats(s.orders, s.players)]
|
|
17
|
+
.filter(([, st]) => st.paid > 0)
|
|
18
|
+
.map(([id, st]) => ({ id, name: names.get(id) ?? id, paid: st.paid, cents: st.paidCents, owners: st.owners }))
|
|
19
|
+
.sort((a, b) => b.cents - a.cents || b.owners - a.owners || a.name.localeCompare(b.name))
|
|
20
|
+
.slice(0, n);
|
|
21
|
+
}
|
|
22
|
+
export function overviewView(looks, colourOf, go) {
|
|
23
|
+
let game = null;
|
|
24
|
+
const sellers = h("section", { class: "admin-card best-card", "aria-labelledby": "n133-best-title" });
|
|
25
|
+
const recent = h("section", { class: "admin-card recent-card", "aria-labelledby": "n133-recent-title" });
|
|
26
|
+
const gameBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-game" });
|
|
27
|
+
const shelfBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-shelf" });
|
|
28
|
+
const start = h("section", { class: "admin-card start-card", "aria-labelledby": "n133-start-title" });
|
|
29
|
+
const main = h("div", { class: "overview-main" }, start, sellers, recent);
|
|
30
|
+
const side = h("div", { class: "overview-side" }, gameBox, shelfBox);
|
|
31
|
+
const el = h("div", { class: "overview" }, main, side);
|
|
32
|
+
const linkKey = (words, to) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
|
|
33
|
+
function drawGame() {
|
|
34
|
+
const rows = ["character", "signin"].map((id) => {
|
|
35
|
+
const on = game ? stateOf(id, game) : null;
|
|
36
|
+
return h("li", {}, h("span", {}, SWITCHES[id].title), on === null ? h("span", { class: "pill" }, "Unknown") : h("span", { class: `pill ${on ? "pill-on" : "pill-off"}` }, on ? "On" : "Off"));
|
|
37
|
+
});
|
|
38
|
+
gameBox.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))), game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The two switches could not be read. Reload the page to try again."));
|
|
39
|
+
}
|
|
40
|
+
function drawShelf() {
|
|
41
|
+
const sale = looks.filter((l) => l.priceUsd > 0);
|
|
42
|
+
const prices = sale.map((l) => l.priceUsd);
|
|
43
|
+
const range = prices.length ? (Math.min(...prices) === Math.max(...prices) ? priceText(prices[0]) : `${priceText(Math.min(...prices))} to ${priceText(Math.max(...prices))}`) : "none";
|
|
44
|
+
shelfBox.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-glance-shelf" }, "Shelf"), linkKey("Open", () => go("cosmetics"))), h("ul", { class: "glance-list" }, h("li", {}, h("span", {}, "Looks"), h("strong", {}, String(looks.length))), h("li", {}, h("span", {}, "For sale"), h("strong", {}, String(sale.length))), h("li", {}, h("span", {}, "Prices"), h("strong", {}, range))));
|
|
45
|
+
}
|
|
46
|
+
drawGame();
|
|
47
|
+
drawShelf();
|
|
48
|
+
return {
|
|
49
|
+
el,
|
|
50
|
+
setGame(state) {
|
|
51
|
+
game = state;
|
|
52
|
+
drawGame();
|
|
53
|
+
},
|
|
54
|
+
update(s) {
|
|
55
|
+
const best = bestSellers(s, looks);
|
|
56
|
+
const total = best.length ? [...shelfStats(s.orders, s.players).values()].reduce((n, x) => n + x.paidCents, 0) : 0;
|
|
57
|
+
start.hidden = s.orders.length > 0;
|
|
58
|
+
start.replaceChildren(h("div", { class: "start-icon", "aria-hidden": "true" }, icon("sparkle", 28)), h("h2", { id: "n133-start-title" }, "No sales yet"), h("p", { class: "fine" }, "When a player buys a look, the money and the order show here. Three things to do first:"), h("ol", { class: "start-steps" }, h("li", {}, h("strong", {}, "Check your looks and prices."), " ", linkKey("Open the shelf", () => go("cosmetics"))), h("li", {}, h("strong", {}, "Choose the two game switches."), " ", linkKey("Open the settings", () => go("settings"))), h("li", {}, h("strong", {}, "Share your game's address"), " with your players.")));
|
|
59
|
+
sellers.hidden = !best.length;
|
|
60
|
+
sellers.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-best-title" }, "Best sellers"), linkKey("All looks", () => go("cosmetics"))), h("ol", { class: "best-list" }, ...best.map((b, i) => {
|
|
61
|
+
const share = total ? Math.round((b.cents / total) * 100) : 0;
|
|
62
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
63
|
+
const c = colourOf(b.id);
|
|
64
|
+
if (c)
|
|
65
|
+
dot.style.setProperty("--swatch", c);
|
|
66
|
+
const bar = h("div", { class: "share-bar" });
|
|
67
|
+
bar.style.setProperty("--share", `${share}%`);
|
|
68
|
+
return h("li", {}, h("span", { class: "best-rank" }, String(i + 1)), dot, h("div", { class: "best-main" }, h("div", { class: "best-top" }, h("strong", {}, b.name), h("span", { class: "best-money" }, dollars(b.cents))), h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${plural(b.paid, "sale")} · ${share}%`))));
|
|
69
|
+
})));
|
|
70
|
+
const last = s.orders.slice(0, 5);
|
|
71
|
+
recent.hidden = !last.length;
|
|
72
|
+
recent.replaceChildren(h("div", { class: "card-head" }, h("h2", { id: "n133-recent-title" }, "Recent orders"), linkKey("See all orders", () => go("orders", "all"))), h("div", { class: "orders is-compact" }, ...last.map((o) => orderRow(o, colourOf, true))));
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AdminPlayer, AdminSummary } from "../store.js";
|
|
2
|
+
/** Whether a player matches a search: their address or the name or id of a look they own. Pure. */
|
|
3
|
+
export declare function playerMatches(p: AdminPlayer, query: string): boolean;
|
|
4
|
+
export declare function playersView(colourOf: (lookId: string) => string | null): {
|
|
5
|
+
el: HTMLElement;
|
|
6
|
+
update(s: AdminSummary): void;
|
|
7
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The Players tab of /admin: each player who owns a look that a paid order gave them, with the looks as chips, and a search (an address or a look). It shows
|
|
2
|
+
// only what the summary gives: the player's address and their looks.
|
|
3
|
+
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
4
|
+
import { icon } from "./icons.js";
|
|
5
|
+
/** Whether a player matches a search: their address or the name or id of a look they own. Pure. */
|
|
6
|
+
export function playerMatches(p, query) {
|
|
7
|
+
const q = query.trim().toLowerCase();
|
|
8
|
+
if (!q)
|
|
9
|
+
return true;
|
|
10
|
+
return p.player.toLowerCase().includes(q) || p.looks.some((l) => l.name.toLowerCase().includes(q) || l.id.toLowerCase().includes(q));
|
|
11
|
+
}
|
|
12
|
+
/** A colour for a player's round mark, from their address: the same address always has the same colour. */
|
|
13
|
+
function hueOf(address) {
|
|
14
|
+
let n = 0;
|
|
15
|
+
for (const ch of address.slice(5))
|
|
16
|
+
n = (n * 31 + ch.charCodeAt(0)) % 360;
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
export function playersView(colourOf) {
|
|
20
|
+
let s = null;
|
|
21
|
+
let query = "";
|
|
22
|
+
const search = h("input", { class: "input search-input", type: "search", placeholder: "Address or look", "aria-label": "Search the players", autocomplete: "off", spellcheck: false });
|
|
23
|
+
search.addEventListener("input", () => {
|
|
24
|
+
query = search.value;
|
|
25
|
+
draw();
|
|
26
|
+
});
|
|
27
|
+
const sub = h("p", { class: "fine card-sub" });
|
|
28
|
+
const list = h("div", { class: "players" });
|
|
29
|
+
const tools = h("div", { class: "toolbar" }, h("label", { class: "search" }, icon("search", 18), search));
|
|
30
|
+
const el = h("section", { class: "admin-card players-card" }, h("div", { class: "card-head" }, h("div", { class: "card-title" }, h("h2", {}, "Players who own a look"), sub)), tools, list);
|
|
31
|
+
function draw() {
|
|
32
|
+
if (!s)
|
|
33
|
+
return;
|
|
34
|
+
const owned = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
35
|
+
sub.textContent = s.players.length ? `${plural(s.players.length, "player")} own ${plural(owned, "look")} that a paid order gave them.` : "A player shows here when a paid order gives them a look.";
|
|
36
|
+
tools.hidden = s.players.length < 2;
|
|
37
|
+
const rows = s.players.filter((p) => playerMatches(p, query));
|
|
38
|
+
list.replaceChildren(...rows.map((p) => {
|
|
39
|
+
const mark = h("span", { class: "player-mark", "aria-hidden": "true" });
|
|
40
|
+
mark.style.setProperty("--hue", String(hueOf(p.player)));
|
|
41
|
+
return h("article", { class: "player" }, mark, h("div", { class: "player-main" }, h("div", { class: "player-id" }, h("code", { title: p.player }, shortAddress(p.player)), copyKey(p.player, "Copy the player's address", icon("copy", 14))), h("ul", { class: "look-chips", "aria-label": "Looks they own" }, ...p.looks.map((l) => {
|
|
42
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
43
|
+
const c = colourOf(l.id);
|
|
44
|
+
if (c)
|
|
45
|
+
dot.style.setProperty("--swatch", c);
|
|
46
|
+
return h("li", { class: "look-chip" }, dot, l.name);
|
|
47
|
+
}))), h("span", { class: "player-count" }, plural(p.looks.length, "look")));
|
|
48
|
+
}));
|
|
49
|
+
if (!s.players.length)
|
|
50
|
+
list.append(h("p", { class: "empty" }, "Nobody owns a paid look yet."));
|
|
51
|
+
else if (!rows.length)
|
|
52
|
+
list.append(h("p", { class: "empty" }, "No player matches your search."));
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
el,
|
|
56
|
+
update(next) {
|
|
57
|
+
s = next;
|
|
58
|
+
draw();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type Look } from "../../looks/look.js";
|
|
2
|
-
import { type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
|
|
2
|
+
import { type LookStats, type ShelfOrder, type ShelfPlayer } from "./shelfData.js";
|
|
3
3
|
export type ShelfData = {
|
|
4
4
|
orders: ShelfOrder[];
|
|
5
5
|
players: ShelfPlayer[];
|
|
6
6
|
repo: string | null;
|
|
7
7
|
};
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* A small flat picture of a look: an outfit's body in its colour and its hat in its colour; an item of another wear slot as a tag with the slot's name (the game
|
|
10
|
+
* knows what the item is). (The game draws the real thing; a game can give its own picture.)
|
|
11
|
+
*/
|
|
9
12
|
export declare function lookPicture(l: Look): Element;
|
|
10
13
|
/**
|
|
11
14
|
* A Shelf picture that shows the look on the real character. `load` gives the drawing function (a look to a PNG address, or null), and is
|
|
@@ -14,9 +17,18 @@ export declare function lookPicture(l: Look): Element;
|
|
|
14
17
|
* `@nano133/nanoterra/character` has `createLookPreview` to make the drawing function.
|
|
15
18
|
*/
|
|
16
19
|
export declare function previewPicture(load: () => Promise<(l: Look) => Promise<string | null>>, fallback?: (l: Look) => Element, size?: number): (l: Look) => Element;
|
|
20
|
+
/** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
|
|
21
|
+
export declare const BODY_COLOURS: readonly ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"];
|
|
22
|
+
export type ShelfFilter = "all" | "sale" | "free" | "unsold";
|
|
23
|
+
export type ShelfSort = "best" | "price-desc" | "price-asc" | "name" | "file";
|
|
24
|
+
/** Whether a look passes a filter. "Not sold yet": a look for sale that no paid order and no owner has. Pure, for the screen and its tests. */
|
|
25
|
+
export declare function shelfFilter(l: Look, f: ShelfFilter, s: LookStats | undefined): boolean;
|
|
26
|
+
/** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
|
|
27
|
+
export declare function shelfSort(ls: readonly Look[], by: ShelfSort, stats: Map<string, LookStats>): Look[];
|
|
17
28
|
/**
|
|
18
29
|
* The screen. `looks` is the game's looks now (its looks.json): the draft starts from them. `update` hands the screen fresh numbers (after the page reloads
|
|
19
|
-
* the summary) and keeps the draft. `picture` draws a look (default: the flat picture above). `repoHelp` is
|
|
30
|
+
* the summary) and keeps the draft, the mode, the open card, the filter and the sort. `picture` draws a look (default: the flat picture above). `repoHelp` is
|
|
31
|
+
* what to tell a game that has no repository set.
|
|
20
32
|
*/
|
|
21
33
|
export declare function shelfView(first: ShelfData, looks: readonly Look[], picture?: (l: Look) => Element, repoHelp?: string): {
|
|
22
34
|
el: HTMLElement;
|