@nano133/nanoterra 0.3.2 → 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 +17 -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 +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 +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/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 +467 -109
- package/src/admin/panel/style.css +1775 -270
- package/src/admin/panel/tabs.ts +94 -0
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
|
|
5
|
+
import type { AdminOrder, AdminSummary } from "../store.js";
|
|
6
|
+
import type { Api } from "./api.js";
|
|
7
|
+
import { ago, copyKey, h, plural, shortAddress } from "./dom.js";
|
|
8
|
+
import { icon } from "./icons.js";
|
|
9
|
+
|
|
10
|
+
export type OrderFilter = "all" | "open" | "paid" | "expired" | "paid_late";
|
|
11
|
+
|
|
12
|
+
/** How many orders the list draws at first, and how many more each "Show more" adds. */
|
|
13
|
+
export const ORDERS_PAGE = 20;
|
|
14
|
+
|
|
15
|
+
export const STATUS_WORDS: Record<string, string> = { open: "Open", paid: "Paid", expired: "Expired", paid_late: "Paid late" };
|
|
16
|
+
const FILTERS: readonly OrderFilter[] = ["all", "paid", "open", "paid_late", "expired"];
|
|
17
|
+
|
|
18
|
+
const when = (ms: number | null) => (ms ? new Date(ms).toLocaleString() : "");
|
|
19
|
+
/** "A1B2C3D4…E5F6A7" */
|
|
20
|
+
const shortHash = (hash: string) => (hash.length > 16 ? `${hash.slice(0, 8)}…${hash.slice(-6)}` : hash);
|
|
21
|
+
|
|
22
|
+
/** 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. */
|
|
23
|
+
export function orderMatches(o: AdminOrder, query: string): boolean {
|
|
24
|
+
const q = query.trim().toLowerCase();
|
|
25
|
+
if (!q) return true;
|
|
26
|
+
return [o.look, o.lookId, o.player, o.payer ?? "", o.hash ?? ""].some((v) => v.toLowerCase().includes(q));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A status as a coloured badge. */
|
|
30
|
+
export const badge = (status: string) => h("span", { class: `badge badge-${status}` }, STATUS_WORDS[status] ?? status);
|
|
31
|
+
|
|
32
|
+
/** 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. */
|
|
33
|
+
export function orderRow(o: AdminOrder, colourOf: (lookId: string) => string | null, compact = false): HTMLElement {
|
|
34
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
35
|
+
const c = colourOf(o.lookId);
|
|
36
|
+
if (c) dot.style.setProperty("--swatch", c);
|
|
37
|
+
const row = h(
|
|
38
|
+
"article",
|
|
39
|
+
{ class: `order order-${o.status}${compact ? " is-compact" : ""}` },
|
|
40
|
+
h("div", { class: "order-look" }, dot, h("strong", {}, o.look)),
|
|
41
|
+
h("div", { class: "order-status" }, badge(o.status)),
|
|
42
|
+
h("div", { class: "order-amount" }, h("strong", {}, `${o.xno} XNO`), h("span", { class: "fine" }, `$${o.usd}`)),
|
|
43
|
+
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))),
|
|
44
|
+
compact
|
|
45
|
+
? 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)}`))
|
|
46
|
+
: h(
|
|
47
|
+
"div",
|
|
48
|
+
{ class: "order-when" },
|
|
49
|
+
h("span", { title: when(o.createdAt) }, `Made ${ago(o.createdAt)}`),
|
|
50
|
+
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,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
if (compact) return row;
|
|
54
|
+
if (o.status === "paid_late" && o.payer)
|
|
55
|
+
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))));
|
|
56
|
+
if (o.hash) 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))));
|
|
57
|
+
return row;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type OrdersView = {
|
|
61
|
+
el: HTMLElement;
|
|
62
|
+
update(s: AdminSummary): void;
|
|
63
|
+
/** Shows only one status (a press on a number of the Overview does it). */
|
|
64
|
+
setFilter(f: OrderFilter): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 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;
|
|
69
|
+
* `onSummary` hears the summary that "Check payments now" answers with (the panel draws it everywhere).
|
|
70
|
+
*/
|
|
71
|
+
export function ordersView(api: Api, colourOf: (lookId: string) => string | null, onSummary: (s: AdminSummary) => void): OrdersView {
|
|
72
|
+
let s: AdminSummary | null = null;
|
|
73
|
+
let filter: OrderFilter = "all";
|
|
74
|
+
let query = "";
|
|
75
|
+
let shown = ORDERS_PAGE;
|
|
76
|
+
|
|
77
|
+
const check = h("button", { class: "btn btn-primary", type: "button" }, icon("refresh", 18), "Check payments now");
|
|
78
|
+
const checkNote = h("span", { class: "fine", role: "status" });
|
|
79
|
+
check.addEventListener("click", async () => {
|
|
80
|
+
check.disabled = true;
|
|
81
|
+
checkNote.textContent = "Checking…";
|
|
82
|
+
try {
|
|
83
|
+
const r = await api.post<{ scan: { scanned: boolean; settled: unknown[]; error?: string }; summary: AdminSummary }>("/check");
|
|
84
|
+
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.";
|
|
85
|
+
onSummary(r.summary);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
checkNote.textContent = e instanceof Error ? e.message : "The check failed.";
|
|
88
|
+
} finally {
|
|
89
|
+
check.disabled = false;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const seg = h("div", { class: "seg tabs", role: "group", "aria-label": "Show orders" });
|
|
94
|
+
const search = h("input", { class: "input search-input", type: "search", placeholder: "Look, address or block", "aria-label": "Search the orders", autocomplete: "off", spellcheck: false });
|
|
95
|
+
search.addEventListener("input", () => {
|
|
96
|
+
query = search.value;
|
|
97
|
+
shown = ORDERS_PAGE;
|
|
98
|
+
drawList();
|
|
99
|
+
});
|
|
100
|
+
const count = h("p", { class: "fine list-count", role: "status", "aria-live": "polite" });
|
|
101
|
+
const list = h("div", { class: "orders" });
|
|
102
|
+
// The check key is there only when the game gave a check (the summary's canCheck).
|
|
103
|
+
const keys = h("div", { class: "card-keys" });
|
|
104
|
+
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);
|
|
105
|
+
const el = h(
|
|
106
|
+
"section",
|
|
107
|
+
{ class: "admin-card orders-card" },
|
|
108
|
+
head,
|
|
109
|
+
h("div", { class: "toolbar" }, seg, h("label", { class: "search" }, icon("search", 18), search)),
|
|
110
|
+
count,
|
|
111
|
+
h("div", { class: "order-table-head", "aria-hidden": "true" }, h("span", {}, "Look"), h("span", {}, "Status"), h("span", {}, "Amount"), h("span", {}, "Player"), h("span", {}, "When")),
|
|
112
|
+
list,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
function drawFilter() {
|
|
116
|
+
if (!s) return;
|
|
117
|
+
const sum = s;
|
|
118
|
+
seg.replaceChildren(
|
|
119
|
+
...FILTERS.map((key) => {
|
|
120
|
+
const n = key === "all" ? sum.orders.length : sum.counts[key];
|
|
121
|
+
return h(
|
|
122
|
+
"button",
|
|
123
|
+
{
|
|
124
|
+
class: `seg-btn tab${filter === key ? " is-on tab-on" : ""}${key === "paid_late" && n > 0 ? " is-alert" : ""}`,
|
|
125
|
+
type: "button",
|
|
126
|
+
"aria-pressed": filter === key ? "true" : "false",
|
|
127
|
+
onclick: () => {
|
|
128
|
+
filter = key;
|
|
129
|
+
shown = ORDERS_PAGE;
|
|
130
|
+
drawFilter();
|
|
131
|
+
drawList();
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
`${key === "all" ? "All" : STATUS_WORDS[key]} (${n})`,
|
|
135
|
+
);
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function drawList() {
|
|
141
|
+
if (!s) return;
|
|
142
|
+
const rows = s.orders.filter((o) => (filter === "all" || o.status === filter) && orderMatches(o, query));
|
|
143
|
+
const total = s.orders.filter((o) => filter === "all" || o.status === filter).length;
|
|
144
|
+
count.textContent = query.trim() ? `${plural(rows.length, "order")} of ${total} match “${query.trim()}”.` : "";
|
|
145
|
+
count.hidden = !query.trim();
|
|
146
|
+
list.replaceChildren(...rows.slice(0, shown).map((o) => orderRow(o, colourOf)));
|
|
147
|
+
if (!rows.length) list.append(h("p", { class: "empty" }, query.trim() ? "No order matches your search." : "No orders here yet."));
|
|
148
|
+
if (rows.length > shown) {
|
|
149
|
+
const more = Math.min(ORDERS_PAGE, rows.length - shown);
|
|
150
|
+
list.append(
|
|
151
|
+
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),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
el,
|
|
158
|
+
update(next) {
|
|
159
|
+
s = next;
|
|
160
|
+
if (next.canCheck === false) keys.replaceChildren();
|
|
161
|
+
else if (check.parentNode !== keys) keys.replaceChildren(check, checkNote);
|
|
162
|
+
drawFilter();
|
|
163
|
+
drawList();
|
|
164
|
+
},
|
|
165
|
+
setFilter(f) {
|
|
166
|
+
filter = f;
|
|
167
|
+
shown = ORDERS_PAGE;
|
|
168
|
+
drawFilter();
|
|
169
|
+
drawList();
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
|
|
4
|
+
import type { Look } from "../../looks/look.js";
|
|
5
|
+
import { priceText } from "../../looks/ladder.js";
|
|
6
|
+
import type { AdminSummary } from "../store.js";
|
|
7
|
+
import { SWITCHES, stateOf, type GameState } from "../switches.js";
|
|
8
|
+
import { dollars, h, plural } from "./dom.js";
|
|
9
|
+
import { icon } from "./icons.js";
|
|
10
|
+
import { orderRow, type OrderFilter } from "./orders.js";
|
|
11
|
+
import { shelfStats } from "./shelfData.js";
|
|
12
|
+
import type { TabId } from "./tabs.js";
|
|
13
|
+
|
|
14
|
+
/** A best seller: a look id, its name, its paid orders and their cents, its owners. */
|
|
15
|
+
export type BestSeller = { id: string; name: string; paid: number; cents: number; owners: number };
|
|
16
|
+
|
|
17
|
+
/** 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. */
|
|
18
|
+
export function bestSellers(s: Pick<AdminSummary, "orders" | "players">, looks: readonly Look[], n = 5): BestSeller[] {
|
|
19
|
+
const names = new Map<string, string>();
|
|
20
|
+
for (const o of s.orders) names.set(o.lookId, o.look);
|
|
21
|
+
for (const l of looks) names.set(l.id, l.name);
|
|
22
|
+
return [...shelfStats(s.orders, s.players)]
|
|
23
|
+
.filter(([, st]) => st.paid > 0)
|
|
24
|
+
.map(([id, st]) => ({ id, name: names.get(id) ?? id, paid: st.paid, cents: st.paidCents, owners: st.owners }))
|
|
25
|
+
.sort((a, b) => b.cents - a.cents || b.owners - a.owners || a.name.localeCompare(b.name))
|
|
26
|
+
.slice(0, n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type OverviewView = {
|
|
30
|
+
el: HTMLElement;
|
|
31
|
+
update(s: AdminSummary): void;
|
|
32
|
+
/** The two switches as they are now (null: they could not be read). */
|
|
33
|
+
setGame(state: GameState | null): void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function overviewView(looks: readonly Look[], colourOf: (lookId: string) => string | null, go: (tab: TabId, filter?: OrderFilter) => void): OverviewView {
|
|
37
|
+
let game: GameState | null = null;
|
|
38
|
+
const sellers = h("section", { class: "admin-card best-card", "aria-labelledby": "n133-best-title" });
|
|
39
|
+
const recent = h("section", { class: "admin-card recent-card", "aria-labelledby": "n133-recent-title" });
|
|
40
|
+
const gameBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-game" });
|
|
41
|
+
const shelfBox = h("section", { class: "admin-card glance-card", "aria-labelledby": "n133-glance-shelf" });
|
|
42
|
+
const start = h("section", { class: "admin-card start-card", "aria-labelledby": "n133-start-title" });
|
|
43
|
+
const main = h("div", { class: "overview-main" }, start, sellers, recent);
|
|
44
|
+
const side = h("div", { class: "overview-side" }, gameBox, shelfBox);
|
|
45
|
+
const el = h("div", { class: "overview" }, main, side);
|
|
46
|
+
|
|
47
|
+
const linkKey = (words: string, to: () => void) => h("button", { class: "btn btn-small btn-quiet link-key", type: "button", onclick: to }, words, h("span", { "aria-hidden": "true" }, "→"));
|
|
48
|
+
|
|
49
|
+
function drawGame() {
|
|
50
|
+
const rows = (["character", "signin"] as const).map((id) => {
|
|
51
|
+
const on = game ? stateOf(id, game) : null;
|
|
52
|
+
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"));
|
|
53
|
+
});
|
|
54
|
+
gameBox.replaceChildren(
|
|
55
|
+
h("div", { class: "card-head" }, h("h2", { id: "n133-glance-game" }, "Game switches"), linkKey("Change", () => go("settings"))),
|
|
56
|
+
game ? h("ul", { class: "glance-list" }, ...rows) : h("p", { class: "fine" }, "The two switches could not be read. Reload the page to try again."),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function drawShelf() {
|
|
61
|
+
const sale = looks.filter((l) => l.priceUsd > 0);
|
|
62
|
+
const prices = sale.map((l) => l.priceUsd);
|
|
63
|
+
const range = prices.length ? (Math.min(...prices) === Math.max(...prices) ? priceText(prices[0]) : `${priceText(Math.min(...prices))} to ${priceText(Math.max(...prices))}`) : "none";
|
|
64
|
+
shelfBox.replaceChildren(
|
|
65
|
+
h("div", { class: "card-head" }, h("h2", { id: "n133-glance-shelf" }, "Shelf"), linkKey("Open", () => go("cosmetics"))),
|
|
66
|
+
h(
|
|
67
|
+
"ul",
|
|
68
|
+
{ class: "glance-list" },
|
|
69
|
+
h("li", {}, h("span", {}, "Looks"), h("strong", {}, String(looks.length))),
|
|
70
|
+
h("li", {}, h("span", {}, "For sale"), h("strong", {}, String(sale.length))),
|
|
71
|
+
h("li", {}, h("span", {}, "Prices"), h("strong", {}, range)),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
drawGame();
|
|
77
|
+
drawShelf();
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
el,
|
|
81
|
+
setGame(state) {
|
|
82
|
+
game = state;
|
|
83
|
+
drawGame();
|
|
84
|
+
},
|
|
85
|
+
update(s) {
|
|
86
|
+
const best = bestSellers(s, looks);
|
|
87
|
+
const total = best.length ? [...shelfStats(s.orders, s.players).values()].reduce((n, x) => n + x.paidCents, 0) : 0;
|
|
88
|
+
start.hidden = s.orders.length > 0;
|
|
89
|
+
start.replaceChildren(
|
|
90
|
+
h("div", { class: "start-icon", "aria-hidden": "true" }, icon("sparkle", 28)),
|
|
91
|
+
h("h2", { id: "n133-start-title" }, "No sales yet"),
|
|
92
|
+
h("p", { class: "fine" }, "When a player buys a look, the money and the order show here. Three things to do first:"),
|
|
93
|
+
h(
|
|
94
|
+
"ol",
|
|
95
|
+
{ class: "start-steps" },
|
|
96
|
+
h("li", {}, h("strong", {}, "Check your looks and prices."), " ", linkKey("Open the shelf", () => go("cosmetics"))),
|
|
97
|
+
h("li", {}, h("strong", {}, "Choose the two game switches."), " ", linkKey("Open the settings", () => go("settings"))),
|
|
98
|
+
h("li", {}, h("strong", {}, "Share your game's address"), " with your players."),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
sellers.hidden = !best.length;
|
|
103
|
+
sellers.replaceChildren(
|
|
104
|
+
h("div", { class: "card-head" }, h("h2", { id: "n133-best-title" }, "Best sellers"), linkKey("All looks", () => go("cosmetics"))),
|
|
105
|
+
h(
|
|
106
|
+
"ol",
|
|
107
|
+
{ class: "best-list" },
|
|
108
|
+
...best.map((b, i) => {
|
|
109
|
+
const share = total ? Math.round((b.cents / total) * 100) : 0;
|
|
110
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
111
|
+
const c = colourOf(b.id);
|
|
112
|
+
if (c) dot.style.setProperty("--swatch", c);
|
|
113
|
+
const bar = h("div", { class: "share-bar" });
|
|
114
|
+
bar.style.setProperty("--share", `${share}%`);
|
|
115
|
+
return h(
|
|
116
|
+
"li",
|
|
117
|
+
{},
|
|
118
|
+
h("span", { class: "best-rank" }, String(i + 1)),
|
|
119
|
+
dot,
|
|
120
|
+
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}%`))),
|
|
121
|
+
);
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const last = s.orders.slice(0, 5);
|
|
127
|
+
recent.hidden = !last.length;
|
|
128
|
+
recent.replaceChildren(
|
|
129
|
+
h("div", { class: "card-head" }, h("h2", { id: "n133-recent-title" }, "Recent orders"), linkKey("See all orders", () => go("orders", "all"))),
|
|
130
|
+
h("div", { class: "orders is-compact" }, ...last.map((o) => orderRow(o, colourOf, true))),
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
|
|
4
|
+
import type { AdminPlayer, AdminSummary } from "../store.js";
|
|
5
|
+
import { copyKey, h, plural, shortAddress } from "./dom.js";
|
|
6
|
+
import { icon } from "./icons.js";
|
|
7
|
+
|
|
8
|
+
/** Whether a player matches a search: their address or the name or id of a look they own. Pure. */
|
|
9
|
+
export function playerMatches(p: AdminPlayer, query: string): boolean {
|
|
10
|
+
const q = query.trim().toLowerCase();
|
|
11
|
+
if (!q) return true;
|
|
12
|
+
return p.player.toLowerCase().includes(q) || p.looks.some((l) => l.name.toLowerCase().includes(q) || l.id.toLowerCase().includes(q));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A colour for a player's round mark, from their address: the same address always has the same colour. */
|
|
16
|
+
function hueOf(address: string): number {
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (const ch of address.slice(5)) n = (n * 31 + ch.charCodeAt(0)) % 360;
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function playersView(colourOf: (lookId: string) => string | null): { el: HTMLElement; update(s: AdminSummary): void } {
|
|
23
|
+
let s: AdminSummary | null = null;
|
|
24
|
+
let query = "";
|
|
25
|
+
const search = h("input", { class: "input search-input", type: "search", placeholder: "Address or look", "aria-label": "Search the players", autocomplete: "off", spellcheck: false });
|
|
26
|
+
search.addEventListener("input", () => {
|
|
27
|
+
query = search.value;
|
|
28
|
+
draw();
|
|
29
|
+
});
|
|
30
|
+
const sub = h("p", { class: "fine card-sub" });
|
|
31
|
+
const list = h("div", { class: "players" });
|
|
32
|
+
const tools = h("div", { class: "toolbar" }, h("label", { class: "search" }, icon("search", 18), search));
|
|
33
|
+
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);
|
|
34
|
+
|
|
35
|
+
function draw() {
|
|
36
|
+
if (!s) return;
|
|
37
|
+
const owned = s.players.reduce((n, p) => n + p.looks.length, 0);
|
|
38
|
+
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.";
|
|
39
|
+
tools.hidden = s.players.length < 2;
|
|
40
|
+
const rows = s.players.filter((p) => playerMatches(p, query));
|
|
41
|
+
list.replaceChildren(
|
|
42
|
+
...rows.map((p) => {
|
|
43
|
+
const mark = h("span", { class: "player-mark", "aria-hidden": "true" });
|
|
44
|
+
mark.style.setProperty("--hue", String(hueOf(p.player)));
|
|
45
|
+
return h(
|
|
46
|
+
"article",
|
|
47
|
+
{ class: "player" },
|
|
48
|
+
mark,
|
|
49
|
+
h(
|
|
50
|
+
"div",
|
|
51
|
+
{ class: "player-main" },
|
|
52
|
+
h("div", { class: "player-id" }, h("code", { title: p.player }, shortAddress(p.player)), copyKey(p.player, "Copy the player's address", icon("copy", 14))),
|
|
53
|
+
h(
|
|
54
|
+
"ul",
|
|
55
|
+
{ class: "look-chips", "aria-label": "Looks they own" },
|
|
56
|
+
...p.looks.map((l) => {
|
|
57
|
+
const dot = h("span", { class: "look-dot", "aria-hidden": "true" });
|
|
58
|
+
const c = colourOf(l.id);
|
|
59
|
+
if (c) dot.style.setProperty("--swatch", c);
|
|
60
|
+
return h("li", { class: "look-chip" }, dot, l.name);
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
h("span", { class: "player-count" }, plural(p.looks.length, "look")),
|
|
65
|
+
);
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
if (!s.players.length) list.append(h("p", { class: "empty" }, "Nobody owns a paid look yet."));
|
|
69
|
+
else if (!rows.length) list.append(h("p", { class: "empty" }, "No player matches your search."));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
el,
|
|
74
|
+
update(next) {
|
|
75
|
+
s = next;
|
|
76
|
+
draw();
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|