@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,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,5 +1,5 @@
|
|
|
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[];
|
|
@@ -14,9 +14,18 @@ export declare function lookPicture(l: Look): Element;
|
|
|
14
14
|
* `@nano133/nanoterra/character` has `createLookPreview` to make the drawing function.
|
|
15
15
|
*/
|
|
16
16
|
export declare function previewPicture(load: () => Promise<(l: Look) => Promise<string | null>>, fallback?: (l: Look) => Element, size?: number): (l: Look) => Element;
|
|
17
|
+
/** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
|
|
18
|
+
export declare const BODY_COLOURS: readonly ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"];
|
|
19
|
+
export type ShelfFilter = "all" | "sale" | "free" | "unsold";
|
|
20
|
+
export type ShelfSort = "best" | "price-desc" | "price-asc" | "name" | "file";
|
|
21
|
+
/** 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. */
|
|
22
|
+
export declare function shelfFilter(l: Look, f: ShelfFilter, s: LookStats | undefined): boolean;
|
|
23
|
+
/** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
|
|
24
|
+
export declare function shelfSort(ls: readonly Look[], by: ShelfSort, stats: Map<string, LookStats>): Look[];
|
|
17
25
|
/**
|
|
18
26
|
* 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
|
|
27
|
+
* 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
|
|
28
|
+
* what to tell a game that has no repository set.
|
|
20
29
|
*/
|
|
21
30
|
export declare function shelfView(first: ShelfData, looks: readonly Look[], picture?: (l: Look) => Element, repoHelp?: string): {
|
|
22
31
|
el: HTMLElement;
|