@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,12 +1,17 @@
|
|
|
1
|
-
// The Shelf screen in /admin: the game's looks with what each has sold, and a draft you can change in this page. It runs only in
|
|
1
|
+
// The Shelf screen in /admin (the Cosmetics tab): the game's looks with what each has sold, and a draft you can change in this page. It runs only in
|
|
2
2
|
// the creator's own browser, on the creator's own game. It changes NOTHING on the game: the new looks.json is text to copy and put
|
|
3
3
|
// in the repository (there is no server call that writes looks, on purpose). Its rules are in the package's looks path (../../looks).
|
|
4
|
+
//
|
|
5
|
+
// Two modes. To see: the looks the game sells NOW (its looks.json), in a grid of cards with a big picture, the price and its step on the ladder, what each
|
|
6
|
+
// has sold and its share of the sales, its slots; filters (All, For sale, Free, Not sold yet) and a sort. To edit: the draft, one card open at a time with its
|
|
7
|
+
// fields in groups (name, price, look, slots), a "New" or "Changed" mark on each card, and a change bar at the bottom of the screen with the copy keys.
|
|
4
8
|
import { diffLooks, removalProblems } from "../../looks/change.js";
|
|
5
9
|
import { SLOTS_MAX } from "../../looks/look.js";
|
|
6
10
|
import { looksJson, slugId } from "../../looks/json.js";
|
|
7
11
|
import { PRICE_LADDER, priceText, rungOf, rungUsd } from "../../looks/ladder.js";
|
|
8
|
-
import { HATS, lookProblems, usdText } from "../../looks/look.js";
|
|
9
|
-
import { copy, h } from "./dom.js";
|
|
12
|
+
import { COLOR_PATTERN, HATS, isOutfit, lookProblems, OUTFIT, SLOT_NAME_PATTERN, usdText, wearOf } from "../../looks/look.js";
|
|
13
|
+
import { copy, dollars, h, plural } from "./dom.js";
|
|
14
|
+
import { icon } from "./icons.js";
|
|
10
15
|
import { aiPrompt, changeText, ownedIds, shelfStats, soldText } from "./shelfData.js";
|
|
11
16
|
const NS = "http://www.w3.org/2000/svg";
|
|
12
17
|
const INK = "#1d1530";
|
|
@@ -18,17 +23,35 @@ const svg = (tag, attrs = {}, ...kids) => {
|
|
|
18
23
|
return el;
|
|
19
24
|
};
|
|
20
25
|
const shape = (tag, attrs) => svg(tag, { stroke: INK, "stroke-width": 1.4, "stroke-linejoin": "round", ...attrs });
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
const hat = l.change.hat;
|
|
24
|
-
const hc = l.change.hatColor ?? "#444444";
|
|
26
|
+
/** The shapes of a hat in the flat picture's 64 x 72 box, in the colour `fill`. */
|
|
27
|
+
function hatShapes(hat, fill) {
|
|
25
28
|
const hats = {
|
|
26
|
-
cap: [shape("path", { d: "M19 24 A13 13 0 0 1 45 24 Z", fill
|
|
27
|
-
beanie: [shape("path", { d: "M18 23 A14 14 0 0 1 46 23 Z", fill
|
|
28
|
-
crown: [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill
|
|
29
|
-
tophat: [shape("rect", { x: 23, y: 3, width: 18, height: 17, rx: 2, fill
|
|
29
|
+
cap: () => [shape("path", { d: "M19 24 A13 13 0 0 1 45 24 Z", fill }), shape("rect", { x: 30, y: 22, width: 22, height: 4, rx: 2, fill })],
|
|
30
|
+
beanie: () => [shape("path", { d: "M18 23 A14 14 0 0 1 46 23 Z", fill }), shape("rect", { x: 18, y: 21, width: 28, height: 5, rx: 2, fill }), shape("circle", { cx: 32, cy: 9, r: 3.5, fill })],
|
|
31
|
+
crown: () => [shape("polygon", { points: "20,22 20,11 26,17 32,9 38,17 44,11 44,22", fill })],
|
|
32
|
+
tophat: () => [shape("rect", { x: 23, y: 3, width: 18, height: 17, rx: 2, fill }), shape("rect", { x: 17, y: 19, width: 30, height: 4, rx: 2, fill })],
|
|
30
33
|
};
|
|
31
|
-
return
|
|
34
|
+
return hats[hat]();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 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
|
|
38
|
+
* knows what the item is). (The game draws the real thing; a game can give its own picture.)
|
|
39
|
+
*/
|
|
40
|
+
export function lookPicture(l) {
|
|
41
|
+
if (!isOutfit(l)) {
|
|
42
|
+
const wear = wearOf(l);
|
|
43
|
+
const label = svg("text", { x: 33, y: 40, "font-size": 9, "font-family": "system-ui, sans-serif", "text-anchor": "middle", fill: INK });
|
|
44
|
+
label.textContent = wear.slice(0, 8);
|
|
45
|
+
return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: worn in the ${wear} slot` }, shape("path", { d: "M14 20 L36 20 L52 36 L36 52 L14 52 Z", fill: "#f6e7c1" }), shape("circle", { cx: 22, cy: 36, r: 3.5, fill: "#ffffff" }), label);
|
|
46
|
+
}
|
|
47
|
+
const hat = l.change.hat;
|
|
48
|
+
return svg("svg", { viewBox: "0 0 64 72", width: 56, height: 63, role: "img", "aria-label": `${l.name}: ${l.change.hat ?? "no hat"}` }, shape("rect", { x: 23, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 34, y: 61, width: 7, height: 9, rx: 2, fill: "#3a3f5c" }), shape("rect", { x: 16, y: 37, width: 32, height: 28, rx: 12, fill: l.change.body }), shape("circle", { cx: 32, cy: 26, r: 13, fill: "#f2c9a0" }), svg("circle", { cx: 27, cy: 27, r: 1.7, fill: INK }), svg("circle", { cx: 37, cy: 27, r: 1.7, fill: INK }), ...(hat ? hatShapes(hat, l.change.hatColor ?? "#444444") : []));
|
|
49
|
+
}
|
|
50
|
+
/** The drawing on a hat choice in the editor: the hat alone (or a dashed head for "No hat"), filled with the text colour, so the choice shows the look's hat colour. */
|
|
51
|
+
function hatChoicePicture(hat) {
|
|
52
|
+
const head = shape("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0" });
|
|
53
|
+
const kids = hat ? [head, ...hatShapes(hat, "currentColor")] : [svg("circle", { cx: 32, cy: 30, r: 12, fill: "#f2c9a0", stroke: INK, "stroke-width": 1.4, "stroke-dasharray": "3 3" })];
|
|
54
|
+
return svg("svg", { viewBox: "0 0 64 46", width: 46, height: 33, "aria-hidden": "true", focusable: "false" }, ...kids);
|
|
32
55
|
}
|
|
33
56
|
/**
|
|
34
57
|
* 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
|
|
@@ -41,7 +64,7 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
|
|
|
41
64
|
return (l) => {
|
|
42
65
|
const box = h("span", { class: "shelf-preview" }, fallback(l));
|
|
43
66
|
ready ??= load().catch(() => null);
|
|
44
|
-
const look =
|
|
67
|
+
const look = copyLook(l);
|
|
45
68
|
void ready
|
|
46
69
|
.then((render) => (render ? render(look) : null))
|
|
47
70
|
.then((src) => {
|
|
@@ -54,9 +77,9 @@ export function previewPicture(load, fallback = lookPicture, size = 64) {
|
|
|
54
77
|
return box;
|
|
55
78
|
};
|
|
56
79
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
80
|
+
/** A copy of a look that shares nothing with it: its wear slot, an outfit's change, its slots. */
|
|
81
|
+
const copyLook = (l) => ({ id: l.id, name: l.name, priceUsd: l.priceUsd, ...(l.wear !== undefined ? { wear: l.wear } : {}), ...(l.change ? { change: { ...l.change } } : {}), ...(l.slots ? { slots: { ...l.slots } } : {}) });
|
|
82
|
+
const clone = (ls) => ls.map(copyLook);
|
|
60
83
|
/** The names of the slots that differ between two looks (added, removed or another value). */
|
|
61
84
|
function slotNamesChanged(before, after) {
|
|
62
85
|
const names = new Set([...Object.keys(before.slots ?? {}), ...Object.keys(after.slots ?? {})]);
|
|
@@ -71,27 +94,117 @@ function fieldWords(d) {
|
|
|
71
94
|
if (f === "body")
|
|
72
95
|
return "body colour";
|
|
73
96
|
if (f === "hat")
|
|
74
|
-
return `hat ${d.before.change
|
|
97
|
+
return `hat ${d.before.change?.hat ?? "none"} to ${d.after.change?.hat ?? "none"}`;
|
|
98
|
+
if (f === "wear")
|
|
99
|
+
return `wear slot ${wearOf(d.before)} to ${wearOf(d.after)}`;
|
|
75
100
|
if (f === "slots")
|
|
76
101
|
return `slots changed (${slotNamesChanged(d.before, d.after)})`;
|
|
77
102
|
return "hat colour";
|
|
78
103
|
});
|
|
79
104
|
return `${d.after.name}: ${parts.join(", ")}`;
|
|
80
105
|
}
|
|
106
|
+
/** The price as the cards write it: "Free", "25¢ · statement", "$1.10" (a price that is not a step of the ladder has no step name), "No price". */
|
|
107
|
+
function priceWords(usd) {
|
|
108
|
+
if (!Number.isFinite(usd))
|
|
109
|
+
return "No price";
|
|
110
|
+
if (usd === 0)
|
|
111
|
+
return "Free";
|
|
112
|
+
const r = rungOf(usd);
|
|
113
|
+
return `${priceText(usd)}${r ? ` · ${r.name}` : ""}`;
|
|
114
|
+
}
|
|
115
|
+
/** The colours the body colour offers in one press (any colour is good: the colour field is next to them). */
|
|
116
|
+
export const BODY_COLOURS = ["#4aa8ff", "#3ddc97", "#ffcf5a", "#ff7a3d", "#ff5c8a", "#9b6bff", "#2b2d42", "#f4f1ea"];
|
|
117
|
+
/** A hat's name as the editor writes it. */
|
|
118
|
+
const HAT_WORDS = { cap: "Cap", beanie: "Beanie", crown: "Crown", tophat: "Top hat" };
|
|
119
|
+
const FILTERS = [
|
|
120
|
+
{ id: "all", label: "All" },
|
|
121
|
+
{ id: "sale", label: "For sale" },
|
|
122
|
+
{ id: "free", label: "Free" },
|
|
123
|
+
{ id: "unsold", label: "Not sold yet" },
|
|
124
|
+
];
|
|
125
|
+
const SORTS = [
|
|
126
|
+
{ id: "best", label: "Best selling" },
|
|
127
|
+
{ id: "price-desc", label: "Price: high to low" },
|
|
128
|
+
{ id: "price-asc", label: "Price: low to high" },
|
|
129
|
+
{ id: "name", label: "Name" },
|
|
130
|
+
{ id: "file", label: "File order" },
|
|
131
|
+
];
|
|
132
|
+
/** 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. */
|
|
133
|
+
export function shelfFilter(l, f, s) {
|
|
134
|
+
if (f === "sale")
|
|
135
|
+
return l.priceUsd > 0;
|
|
136
|
+
if (f === "free")
|
|
137
|
+
return l.priceUsd === 0;
|
|
138
|
+
if (f === "unsold")
|
|
139
|
+
return l.priceUsd > 0 && !s?.paid && !s?.owners;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** The looks in a sort's order (a new list; equal looks keep the file's order). Pure, for the screen and its tests. */
|
|
143
|
+
export function shelfSort(ls, by, stats) {
|
|
144
|
+
const place = new Map(ls.map((l, i) => [l, i]));
|
|
145
|
+
const cents = (l) => stats.get(l.id)?.paidCents ?? 0;
|
|
146
|
+
const owners = (l) => stats.get(l.id)?.owners ?? 0;
|
|
147
|
+
const cmp = {
|
|
148
|
+
best: (a, b) => cents(b) - cents(a) || owners(b) - owners(a),
|
|
149
|
+
"price-desc": (a, b) => b.priceUsd - a.priceUsd,
|
|
150
|
+
"price-asc": (a, b) => a.priceUsd - b.priceUsd,
|
|
151
|
+
name: (a, b) => a.name.localeCompare(b.name, "en", { sensitivity: "base" }),
|
|
152
|
+
file: () => 0,
|
|
153
|
+
};
|
|
154
|
+
return [...ls].sort((a, b) => cmp[by](a, b) || place.get(a) - place.get(b));
|
|
155
|
+
}
|
|
156
|
+
/** A slot's value as a chip: a colour gets a swatch. */
|
|
157
|
+
function slotChip(name, value) {
|
|
158
|
+
const sw = COLOR_PATTERN.test(value) ? h("span", { class: "swatch", "aria-hidden": "true" }) : null;
|
|
159
|
+
sw?.style.setProperty("--swatch", value);
|
|
160
|
+
return h("li", { class: "slot-chip" }, sw, h("span", { class: "slot-name" }, name), " ", h("span", { class: "slot-value" }, value));
|
|
161
|
+
}
|
|
81
162
|
/**
|
|
82
163
|
* 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
|
|
83
|
-
* the summary) and keeps the draft. `picture` draws a look (default: the flat picture above). `repoHelp` is
|
|
164
|
+
* 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
|
|
165
|
+
* what to tell a game that has no repository set.
|
|
84
166
|
*/
|
|
85
167
|
export function shelfView(first, looks, picture = lookPicture, repoHelp = "To get a link to your looks.json on GitHub, give the admin your repository (for example your-name/your-game).") {
|
|
86
168
|
const LOOKS = looks;
|
|
87
169
|
let data = first;
|
|
88
170
|
let draft = clone(LOOKS);
|
|
89
171
|
let editing = false;
|
|
172
|
+
/** The one card that is open in the editor (a look of the draft), or none. */
|
|
173
|
+
let open = null;
|
|
174
|
+
/** The open card's marks and its list of problems: the change bar brings them up to date at every key press (the rest of the screen waits for a redraw). */
|
|
175
|
+
let openParts = null;
|
|
176
|
+
let filter = "all";
|
|
177
|
+
let sort = "best";
|
|
90
178
|
const el = h("section", { class: "admin-card shelf", "aria-labelledby": "shelf-title" });
|
|
91
179
|
const stats = () => shelfStats(data.orders, data.players);
|
|
92
180
|
const owned = () => ownedIds(data.players);
|
|
93
181
|
const isOriginal = (id) => LOOKS.some((l) => l.id === id);
|
|
94
|
-
|
|
182
|
+
/** Free outfits in the draft: one must stay (every player starts with it). */
|
|
183
|
+
const freeCount = () => draft.filter((l) => l.priceUsd === 0 && wearOf(l) === OUTFIT).length;
|
|
184
|
+
/** The wear slots that the draft uses besides the outfit, for the wear slot field's suggestions. */
|
|
185
|
+
const wearSlots = () => [...new Set(draft.map(wearOf).filter((w) => w !== OUTFIT))].sort();
|
|
186
|
+
/** What a card says under the price: who has it (a free outfit), what it sold, or that it is new. */
|
|
187
|
+
const fineWords = (l, s) => (l.priceUsd === 0 && wearOf(l) === OUTFIT ? "Every player starts with it" : soldText(s));
|
|
188
|
+
/** Whether the draft differs from the game's looks.json in any way, the order of the looks too (the file's own text is compared). */
|
|
189
|
+
const changedFromGame = () => looksJson(draft) !== looksJson(LOOKS);
|
|
190
|
+
/**
|
|
191
|
+
* The pictures, kept by the look's text: a redraw puts the same picture back instead of drawing it again (a picture on the real character is drawn once,
|
|
192
|
+
* and the card does not flash its flat picture first). Pictures that the last redraw did not use are let go.
|
|
193
|
+
*/
|
|
194
|
+
const pictures = new Map();
|
|
195
|
+
let used = new Set();
|
|
196
|
+
function pictureOf(l) {
|
|
197
|
+
const key = JSON.stringify([l.id, l.name, l.priceUsd, l.wear ?? null, l.change ?? null, l.slots ?? null]);
|
|
198
|
+
const kept = pictures.get(key);
|
|
199
|
+
if (kept && !used.has(key)) {
|
|
200
|
+
used.add(key);
|
|
201
|
+
return kept;
|
|
202
|
+
}
|
|
203
|
+
const made = picture(l);
|
|
204
|
+
pictures.set(key, made);
|
|
205
|
+
used.add(key);
|
|
206
|
+
return made;
|
|
207
|
+
}
|
|
95
208
|
/**
|
|
96
209
|
* The rows of a look's slot editor, as typed: a name can be typed twice (an object cannot hold it), so the rows are the draft's own truth while it is edited, and the
|
|
97
210
|
* look's `slots` is made from them. A row with no name and no value is a row not filled in yet: it is not a slot.
|
|
@@ -120,9 +233,13 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
|
|
|
120
233
|
return own.length ? own : removalProblems(LOOKS, draft, owned());
|
|
121
234
|
}
|
|
122
235
|
function redraw() {
|
|
236
|
+
used = new Set();
|
|
237
|
+
openParts = null;
|
|
123
238
|
const st = stats();
|
|
124
239
|
const own = owned();
|
|
125
|
-
const
|
|
240
|
+
const forSale = LOOKS.filter((l) => l.priceUsd > 0).length;
|
|
241
|
+
const earned = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
|
|
242
|
+
const head = h("div", { class: "card-head" }, h("div", { class: "card-title" }, h("h2", { id: "shelf-title" }, "Shelf"), h("p", { class: "fine card-sub" }, editing ? "Draft: nothing here changes your game until you push the new looks.json." : "The looks your game sells now. Only you see these numbers.")), h("button", {
|
|
126
243
|
class: `btn ${editing ? "" : "btn-primary"}`,
|
|
127
244
|
type: "button",
|
|
128
245
|
"aria-pressed": editing ? "true" : "false",
|
|
@@ -130,81 +247,244 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
|
|
|
130
247
|
editing = !editing;
|
|
131
248
|
redraw();
|
|
132
249
|
},
|
|
133
|
-
}, editing ? "Close the editor" : "Edit the shelf"));
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
: "The looks
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
parts.push(
|
|
250
|
+
}, editing ? icon("close", 18) : icon("pencil", 18), editing ? "Close the editor" : "Edit the shelf"));
|
|
251
|
+
const parts = [head];
|
|
252
|
+
if (!editing) {
|
|
253
|
+
parts.push(h("ul", { class: "shelf-stats", "aria-label": "The shelf in numbers" }, h("li", {}, h("strong", {}, String(LOOKS.length)), ` ${LOOKS.length === 1 ? "look" : "looks"}`), h("li", {}, h("strong", {}, String(forSale)), " for sale"), h("li", {}, h("strong", {}, String(LOOKS.length - forSale)), " free"), h("li", {}, h("strong", {}, dollars(earned)), " earned")));
|
|
254
|
+
if (changedFromGame()) {
|
|
255
|
+
const diff = diffLooks(LOOKS, draft);
|
|
256
|
+
parts.push(h("div", { class: "draft-note", role: "status" }, icon("pencil", 18), h("span", {}, `You have a draft that is not in your game yet: ${changeText(diff)}.`), h("button", { class: "btn btn-small", type: "button", onclick: () => ((editing = true), redraw()) }, "Open the draft")));
|
|
257
|
+
}
|
|
258
|
+
parts.push(toolbar(st), viewGrid(st));
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
parts.push(h("p", { class: "fine" }, "Change a look here to see what it would be. This page changes nothing in your game: when you are happy, copy the new looks.json into your repository and push it. Your game builds again and the new looks go live."), editGrid(st, own), ...foot());
|
|
142
262
|
}
|
|
143
263
|
el.replaceChildren(...parts.filter((p) => p !== null));
|
|
264
|
+
for (const k of [...pictures.keys()])
|
|
265
|
+
if (!used.has(k))
|
|
266
|
+
pictures.delete(k);
|
|
267
|
+
}
|
|
268
|
+
// ---- To see ----
|
|
269
|
+
function toolbar(st) {
|
|
270
|
+
const chips = h("div", { class: "seg", role: "group", "aria-label": "Show looks" });
|
|
271
|
+
for (const f of FILTERS) {
|
|
272
|
+
const n = LOOKS.filter((l) => shelfFilter(l, f.id, st.get(l.id))).length;
|
|
273
|
+
chips.append(h("button", { class: `seg-btn${filter === f.id ? " is-on" : ""}`, type: "button", "aria-pressed": filter === f.id ? "true" : "false", onclick: () => ((filter = f.id), redraw()) }, f.label, h("span", { class: "seg-count" }, String(n))));
|
|
274
|
+
}
|
|
275
|
+
const select = h("select", { class: "input select", "aria-label": "Sort the looks", onchange: (e) => ((sort = e.target.value), redraw()) }, ...SORTS.map((s) => h("option", { value: s.id }, s.label)));
|
|
276
|
+
select.value = sort;
|
|
277
|
+
return h("div", { class: "toolbar" }, chips, h("label", { class: "sort" }, h("span", {}, "Sort"), select));
|
|
278
|
+
}
|
|
279
|
+
function viewGrid(st) {
|
|
280
|
+
const total = [...st.values()].reduce((n, s) => n + s.paidCents, 0);
|
|
281
|
+
const shown = shelfSort(LOOKS.filter((l) => shelfFilter(l, filter, st.get(l.id))), sort, st);
|
|
282
|
+
const grid = h("div", { class: "shelf-list" });
|
|
283
|
+
if (!shown.length)
|
|
284
|
+
grid.append(h("p", { class: "empty" }, "No look matches this filter."));
|
|
285
|
+
for (const l of shown)
|
|
286
|
+
grid.append(viewCard(l, st.get(l.id), total));
|
|
287
|
+
return grid;
|
|
288
|
+
}
|
|
289
|
+
function stage(l, ...extra) {
|
|
290
|
+
const box = h("div", { class: "shelf-stage" }, h("div", { class: "shelf-pic" }, pictureOf(l)), ...extra);
|
|
291
|
+
const body = l.change?.body;
|
|
292
|
+
if (body && COLOR_PATTERN.test(body))
|
|
293
|
+
box.style.setProperty("--tint", body);
|
|
294
|
+
return box;
|
|
295
|
+
}
|
|
296
|
+
/** An item's wear slot, under its name ("Worn in the shovel slot"); nothing for an outfit. */
|
|
297
|
+
function wearLine(l) {
|
|
298
|
+
return wearOf(l) === OUTFIT ? null : h("span", { class: "fine shelf-wear" }, `Worn in the ${wearOf(l)} slot`);
|
|
299
|
+
}
|
|
300
|
+
function slotList(l) {
|
|
301
|
+
const entries = Object.entries(l.slots ?? {});
|
|
302
|
+
if (!entries.length)
|
|
303
|
+
return null;
|
|
304
|
+
return h("ul", { class: "slot-chips shelf-slots", "aria-label": `Slots of ${l.name}` }, h("li", { class: "sr-only" }, "Slots:"), ...entries.map(([k, v]) => slotChip(k, v)));
|
|
305
|
+
}
|
|
306
|
+
function viewCard(l, s, total) {
|
|
307
|
+
const share = s && total > 0 && s.paidCents > 0 ? Math.round((s.paidCents / total) * 100) : 0;
|
|
308
|
+
const bar = h("div", { class: "share-bar" });
|
|
309
|
+
bar.style.setProperty("--share", `${share}%`);
|
|
310
|
+
return h("article", { class: `shelf-card${l.priceUsd === 0 ? " is-free" : ""}` }, stage(l, h("span", { class: `price-pill${l.priceUsd === 0 ? " is-free" : ""}` }, priceWords(l.priceUsd))), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), wearLine(l), h("span", { class: "fine" }, fineWords(l, s)), share ? h("div", { class: "share", title: `${share}% of the money from sales` }, bar, h("span", { class: "fine" }, `${share}% of sales`)) : null, slotList(l)));
|
|
311
|
+
}
|
|
312
|
+
// ---- To edit ----
|
|
313
|
+
function editGrid(st, own) {
|
|
314
|
+
const bad = problems();
|
|
315
|
+
const diff = diffLooks(LOOKS, draft);
|
|
316
|
+
const added = new Set(diff.added.map((l) => l.id));
|
|
317
|
+
const changed = new Set(diff.changed.map((c) => c.id));
|
|
318
|
+
const grid = h("div", { class: "shelf-list is-editing" });
|
|
319
|
+
draft.forEach((l, i) => {
|
|
320
|
+
const mine = bad.filter((p) => p.look === i + 1);
|
|
321
|
+
const mark = added.has(l.id) ? "new" : changed.has(l.id) ? "changed" : null;
|
|
322
|
+
grid.append(l === open ? editCard(l, i, st, own, mine, mark) : compactCard(l, st, own, mine, mark));
|
|
323
|
+
});
|
|
324
|
+
for (const gone of diff.removed)
|
|
325
|
+
grid.append(ghostCard(gone));
|
|
326
|
+
grid.append(h("button", { class: "shelf-add-tile", type: "button", onclick: () => addLook() }, icon("plus", 22), h("span", {}, "Add a look")));
|
|
327
|
+
return grid;
|
|
328
|
+
}
|
|
329
|
+
function marks(l, own, mine, mark) {
|
|
330
|
+
return h("span", { class: "marks" }, ...markList(l, own, mine, mark));
|
|
144
331
|
}
|
|
145
|
-
function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
332
|
+
function markList(l, own, mine, mark) {
|
|
333
|
+
return [
|
|
334
|
+
mark ? h("span", { class: `mark mark-${mark}` }, mark === "new" ? "New" : "Changed") : null,
|
|
335
|
+
isOriginal(l.id) && own.has(l.id) ? h("span", { class: "mark mark-owned", title: "A player owns this look" }, icon("lock", 14), "Owned") : null,
|
|
336
|
+
mine.length ? h("span", { class: "mark mark-bad" }, plural(mine.length, "problem")) : null,
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
function compactCard(l, st, own, mine, mark) {
|
|
340
|
+
return h("article", { class: `shelf-card is-compact${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}` }, stage(l), h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name || "(no name)"), h("span", { class: "shelf-price" }, priceWords(l.priceUsd)), wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), marks(l, own, mine, mark)), h("button", { class: "btn btn-small card-open", type: "button", "aria-expanded": "false", "aria-label": `Edit ${l.name || "this look"}`, onclick: () => openCard(l) }, icon("pencil", 16), "Edit"));
|
|
341
|
+
}
|
|
342
|
+
function ghostCard(l) {
|
|
343
|
+
return h("article", { class: "shelf-ghost" }, h("div", { class: "shelf-body" }, h("strong", { class: "shelf-name" }, l.name), h("span", { class: "fine" }, "Removed from the draft")), h("button", {
|
|
344
|
+
class: "btn btn-small",
|
|
345
|
+
type: "button",
|
|
346
|
+
onclick: () => {
|
|
347
|
+
const at = Math.min(LOOKS.findIndex((x) => x.id === l.id), draft.length);
|
|
348
|
+
draft = [...draft.slice(0, at), ...clone([l]), ...draft.slice(at)];
|
|
349
|
+
redraw();
|
|
350
|
+
},
|
|
351
|
+
}, "Put back"));
|
|
352
|
+
}
|
|
353
|
+
function openCard(l) {
|
|
354
|
+
open = l;
|
|
355
|
+
redraw();
|
|
356
|
+
const card = el.querySelector(".shelf-editing");
|
|
357
|
+
if (card) {
|
|
358
|
+
card.focus({ preventScroll: true });
|
|
359
|
+
card.scrollIntoView?.({ block: "nearest" });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function editCard(l, i, st, own, mine, mark) {
|
|
363
|
+
const pic = h("div", { class: "shelf-pic" }, pictureOf(l));
|
|
364
|
+
const stageBox = h("div", { class: "shelf-stage is-big" }, pic);
|
|
365
|
+
const nameLine = h("strong", { class: "shelf-name" }, l.name || "(no name)");
|
|
366
|
+
const priceLine = h("span", { class: "shelf-price" }, priceWords(l.priceUsd));
|
|
367
|
+
const tint = () => (l.change && COLOR_PATTERN.test(l.change.body) ? stageBox.style.setProperty("--tint", l.change.body) : stageBox.style.removeProperty("--tint"));
|
|
368
|
+
tint();
|
|
369
|
+
// The real picture is drawn a moment after the last change: a colour dragged across the picker draws once, not at every step.
|
|
370
|
+
let later = null;
|
|
153
371
|
const sync = () => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
372
|
+
nameLine.textContent = l.name || "(no name)";
|
|
373
|
+
priceLine.textContent = priceWords(l.priceUsd);
|
|
374
|
+
tint();
|
|
375
|
+
if (later)
|
|
376
|
+
clearTimeout(later);
|
|
377
|
+
later = setTimeout(() => pic.replaceChildren(pictureOf(l)), 150);
|
|
378
|
+
ladder.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", Number(c.dataset.usd) === l.priceUsd ? "true" : "false"));
|
|
379
|
+
if (outfit) {
|
|
380
|
+
lookParts?.swatches.querySelectorAll("button").forEach((c) => c.setAttribute("aria-pressed", c.dataset.colour === outfit.change.body.toLowerCase() ? "true" : "false"));
|
|
381
|
+
lookParts?.hatChoices.style.setProperty("color", outfit.change.hatColor ?? "#9aa4b2");
|
|
382
|
+
}
|
|
158
383
|
refreshFoot();
|
|
159
384
|
};
|
|
160
|
-
const name = h("input", { class: "
|
|
385
|
+
const name = h("input", { class: "input", type: "text", maxlength: 40, value: l.name, "aria-label": `Name of look ${i + 1}`, oninput: (e) => {
|
|
161
386
|
l.name = e.target.value;
|
|
162
387
|
if (!isOriginal(l.id))
|
|
163
388
|
l.id = slugId(l.name, [...draft.filter((x) => x !== l).map((x) => x.id), ...owned()]);
|
|
164
389
|
sync();
|
|
165
390
|
} });
|
|
166
|
-
const priceInput = h("input", { class: "
|
|
391
|
+
const priceInput = h("input", { class: "input input-num", type: "number", step: 0.01, min: 0, max: 1000, inputmode: "decimal", value: Number.isFinite(l.priceUsd) ? l.priceUsd : "", "aria-label": `Price in US dollars of ${l.name}`, oninput: (e) => {
|
|
167
392
|
const v = e.target.value;
|
|
168
393
|
l.priceUsd = v === "" ? Number.NaN : Number(v);
|
|
169
394
|
sync();
|
|
170
395
|
} });
|
|
171
|
-
const ladder = h("div", { class: "
|
|
396
|
+
const ladder = h("div", { class: "ladder", role: "group", "aria-label": "Price ladder" });
|
|
172
397
|
const setPrice = (usd) => {
|
|
173
398
|
l.priceUsd = usd;
|
|
174
399
|
priceInput.value = String(usd);
|
|
175
400
|
sync();
|
|
176
401
|
};
|
|
177
|
-
|
|
402
|
+
const step = (usd, price, word, title) => h("button", { class: "ladder-step", type: "button", title, "data-usd": usd, "aria-pressed": l.priceUsd === usd ? "true" : "false", onclick: () => setPrice(usd) }, h("strong", {}, price), h("span", {}, word));
|
|
403
|
+
ladder.append(step(0, "Free", "everyone", "Every player has it"));
|
|
178
404
|
for (const r of PRICE_LADDER)
|
|
179
|
-
ladder.append(
|
|
180
|
-
|
|
405
|
+
ladder.append(step(rungUsd(r), priceText(rungUsd(r)), r.name, `${r.name}: ${r.for}`));
|
|
406
|
+
// An outfit's body colour and hat (an item of another wear slot has neither).
|
|
407
|
+
const outfit = isOutfit(l) ? l : null;
|
|
408
|
+
const lookParts = outfit ? outfitFields(outfit, i, sync) : null;
|
|
409
|
+
const lastFree = l.priceUsd === 0 && wearOf(l) === OUTFIT && freeCount() === 1;
|
|
410
|
+
const keep = isOriginal(l.id) && own.has(l.id);
|
|
411
|
+
const remove = h("button", { class: "btn btn-danger", type: "button", disabled: keep || lastFree, title: keep ? "A player owns this look. Keep it in the file." : lastFree ? "Keep one free outfit: every player starts with it." : "", onclick: () => {
|
|
412
|
+
draft = draft.filter((x) => x !== l);
|
|
413
|
+
open = null;
|
|
414
|
+
redraw();
|
|
415
|
+
} }, icon("trash", 18), "Remove");
|
|
416
|
+
const dup = h("button", { class: "btn", type: "button", onclick: () => addLook(l) }, icon("dup", 18), "Duplicate");
|
|
417
|
+
const openMarks = marks(l, own, mine, mark);
|
|
418
|
+
const openProblems = h("ul", { class: "card-problems" }, ...mine.map((p) => h("li", {}, p.message)));
|
|
419
|
+
openProblems.hidden = !mine.length;
|
|
420
|
+
openParts = { marks: openMarks, problems: openProblems };
|
|
421
|
+
const group = (title, ...kids) => h("div", { class: "edit-group" }, h("h3", {}, title), ...kids);
|
|
422
|
+
return h("article", { class: `shelf-card shelf-editing${mark ? ` is-${mark}` : ""}${mine.length ? " has-problem" : ""}`, tabindex: -1, "aria-label": `Editing ${l.name || "a look"}` }, h("div", { class: "edit-top" }, stageBox, h("div", { class: "shelf-body" }, nameLine, priceLine, wearLine(l), h("span", { class: "fine" }, isOriginal(l.id) ? fineWords(l, st.get(l.id)) : "New: not in your game yet"), openMarks), h("button", { class: "btn btn-small card-done", type: "button", "aria-expanded": "true", onclick: () => openCard(null) }, icon("check", 16), "Done")), openProblems, h("div", { class: "edit-groups" }, group("Name", h("label", { class: "field" }, h("span", {}, "What players read"), name)), group("Price", h("label", { class: "field" }, h("span", {}, "Price in US dollars"), priceInput), ladder), wearEditor(l, keep || lastFree, i), lookParts?.group ?? null, slotsEditor(l, isOriginal(l.id) && own.has(l.id))), keep ? h("p", { class: "fine owned-note" }, icon("lock", 14), "A player owns this look: its id stays, and it cannot be removed.") : null, h("div", { class: "shelf-actions" }, dup, remove));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The wear slot of a look: "outfit" (the character's own look) or one of the game's own slots ("shovel"). Typed, with the slots the draft already uses as
|
|
426
|
+
* suggestions. Locked for a look that a player owns (it stays in its slot) and for the last free outfit. An outfit that becomes an item loses its colours; an
|
|
427
|
+
* item that becomes an outfit gets the free look's blue body.
|
|
428
|
+
*/
|
|
429
|
+
function wearEditor(l, locked, i) {
|
|
430
|
+
const list = `n133-wears-${i}`;
|
|
431
|
+
const input = h("input", { class: "input", type: "text", maxlength: 24, value: wearOf(l), list, autocapitalize: "none", spellcheck: false, disabled: locked, "aria-label": `Wear slot of ${l.name}`, onchange: (e) => {
|
|
432
|
+
const v = e.target.value.trim();
|
|
433
|
+
if (v === "" || v === OUTFIT) {
|
|
434
|
+
delete l.wear;
|
|
435
|
+
l.change ??= { body: "#4aa8ff" };
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
l.wear = v;
|
|
439
|
+
if (SLOT_NAME_PATTERN.test(v))
|
|
440
|
+
delete l.change;
|
|
441
|
+
}
|
|
442
|
+
redraw();
|
|
443
|
+
} });
|
|
444
|
+
return h("div", { class: "edit-group" }, h("h3", {}, "Wear slot"), h("label", { class: "field" }, h("span", {}, "outfit (the character's colour and hat), or a slot of your game for an item (shovel, charm)"), input), h("datalist", { id: list }, ...[OUTFIT, ...wearSlots()].map((w) => h("option", { value: w }))));
|
|
445
|
+
}
|
|
446
|
+
/** An outfit's fields: the body colour (swatches and a picker), the hat (pictures) and the hat colour, as the editor's "Look" group. */
|
|
447
|
+
function outfitFields(l, i, sync) {
|
|
448
|
+
const body = h("input", { class: "colour", type: "color", value: l.change.body, "aria-label": `Body colour of ${l.name}`, oninput: (e) => {
|
|
181
449
|
l.change.body = e.target.value;
|
|
182
450
|
sync();
|
|
183
451
|
} });
|
|
184
|
-
const
|
|
452
|
+
const swatches = h("div", { class: "swatches", role: "group", "aria-label": "Body colours" });
|
|
453
|
+
for (const c of BODY_COLOURS) {
|
|
454
|
+
const b = h("button", { class: "swatch-btn", type: "button", "data-colour": c, title: c, "aria-label": `Body colour ${c}`, "aria-pressed": c === l.change.body.toLowerCase() ? "true" : "false", onclick: () => {
|
|
455
|
+
l.change.body = c;
|
|
456
|
+
body.value = c;
|
|
457
|
+
sync();
|
|
458
|
+
} }, h("span", { class: "swatch", "aria-hidden": "true" }));
|
|
459
|
+
b.firstChild.style.setProperty("--swatch", c);
|
|
460
|
+
swatches.append(b);
|
|
461
|
+
}
|
|
462
|
+
const hatColor = h("input", { class: "colour", type: "color", value: l.change.hatColor ?? "#1c6e4f", "aria-label": `Hat colour of ${l.name}`, disabled: !l.change.hat, oninput: (e) => {
|
|
185
463
|
l.change.hatColor = e.target.value;
|
|
186
464
|
sync();
|
|
187
465
|
} });
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
466
|
+
const hatName = `n133-hat-${i}`;
|
|
467
|
+
const hatChoices = h("div", { class: "hat-choices", role: "radiogroup", "aria-label": `Hat of ${l.name}` });
|
|
468
|
+
for (const x of [null, ...HATS]) {
|
|
469
|
+
const input = h("input", { type: "radio", name: hatName, value: x ?? "", checked: (l.change.hat ?? null) === x, onchange: () => {
|
|
470
|
+
if (x === null) {
|
|
471
|
+
delete l.change.hat;
|
|
472
|
+
delete l.change.hatColor;
|
|
473
|
+
hatColor.disabled = true;
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
l.change.hat = x;
|
|
477
|
+
l.change.hatColor ??= hatColor.value;
|
|
478
|
+
hatColor.disabled = false;
|
|
479
|
+
}
|
|
480
|
+
hatChoices.querySelectorAll(".hat-choice").forEach((c) => c.classList.toggle("is-on", c.querySelector("input").checked));
|
|
481
|
+
sync();
|
|
482
|
+
} });
|
|
483
|
+
hatChoices.append(h("label", { class: `hat-choice${(l.change.hat ?? null) === x ? " is-on" : ""}` }, input, hatChoicePicture(x), h("span", {}, x ? HAT_WORDS[x] : "No hat")));
|
|
484
|
+
}
|
|
485
|
+
hatChoices.style.setProperty("color", l.change.hatColor ?? "#9aa4b2");
|
|
486
|
+
const group = h("div", { class: "edit-group group-look" }, h("h3", {}, "Look"), h("div", { class: "look-fields" }, h("div", { class: "field" }, h("span", {}, "Body colour"), h("div", { class: "colour-row" }, swatches, body)), h("div", { class: "field" }, h("span", {}, "Hat"), hatChoices), h("label", { class: "field" }, h("span", {}, "Hat colour"), hatColor)));
|
|
487
|
+
return { group, swatches, hatChoices };
|
|
208
488
|
}
|
|
209
489
|
/** The slots of a look: names and values (words or #rrggbb colours) that the game reads. Rows to type in, at most SLOTS_MAX. */
|
|
210
490
|
function slotsEditor(l, owned) {
|
|
@@ -217,7 +497,7 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
|
|
|
217
497
|
setRows(l, cur);
|
|
218
498
|
refreshFoot();
|
|
219
499
|
};
|
|
220
|
-
list.append(h("div", { class: "shelf-slot" }, h("input", { class: "
|
|
500
|
+
list.append(h("div", { class: "shelf-slot" }, h("input", { class: "input", type: "text", maxlength: 24, value: name, placeholder: "name", autocapitalize: "none", spellcheck: false, "aria-label": `Name of slot ${r + 1} of ${l.name}`, oninput: edit(0) }), h("input", { class: "input", type: "text", maxlength: 40, value: value, placeholder: "word or #rrggbb", autocapitalize: "none", spellcheck: false, "aria-label": `Value of slot ${r + 1} of ${l.name}`, oninput: edit(1) }), h("button", { class: "btn btn-small btn-quiet", type: "button", "aria-label": `Remove slot ${name || r + 1} of ${l.name}`, onclick: () => (setRows(l, rowsOf(l).filter((_, i) => i !== r)), redraw()) }, "Remove")));
|
|
221
501
|
});
|
|
222
502
|
const add = h("button", { class: "btn btn-small", type: "button", disabled: rows.length >= SLOTS_MAX, onclick: () => {
|
|
223
503
|
const cur = rowsOf(l);
|
|
@@ -226,39 +506,60 @@ export function shelfView(first, looks, picture = lookPicture, repoHelp = "To ge
|
|
|
226
506
|
n++;
|
|
227
507
|
setRows(l, [...cur, [`slot-${n}`, ""]]);
|
|
228
508
|
redraw();
|
|
229
|
-
} }, "Add a slot");
|
|
230
|
-
return h("div", { class: "
|
|
509
|
+
} }, icon("plus", 16), "Add a slot");
|
|
510
|
+
return h("div", { class: "edit-group shelf-slots-edit" }, h("h3", {}, "Slots"), h("span", { class: "fine" }, `Slots (${rows.length} of ${SLOTS_MAX}): words and colours that your game reads`), rows.length ? list : null, add, owned ? h("p", { class: "fine" }, "A player owns this look: its slots cannot change. Add a new look with the new slots instead.") : null);
|
|
231
511
|
}
|
|
232
512
|
function addLook(from) {
|
|
233
513
|
const name = from ? `${from.name} copy`.slice(0, 40) : "New look";
|
|
234
|
-
const base = from ? { id: "", name
|
|
514
|
+
const base = from ? { ...copyLook(from), id: "", name } : { id: "", name, priceUsd: 0.05, change: { body: "#4aa8ff" } };
|
|
235
515
|
// An id that a player owns is never given to a new look (even when that look is not in looks.json now): the new look would be what the owners own.
|
|
236
516
|
base.id = slugId(name, [...draft.map((x) => x.id), ...owned()]);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
517
|
+
const at = from ? draft.indexOf(from) + 1 : draft.length;
|
|
518
|
+
draft = [...draft.slice(0, at), base, ...draft.slice(at)];
|
|
519
|
+
openCard(base);
|
|
520
|
+
el.querySelector(".shelf-editing input[type=text]")?.focus();
|
|
240
521
|
}
|
|
241
|
-
|
|
522
|
+
// ---- The change bar ----
|
|
523
|
+
//
|
|
524
|
+
// Two parts. The bar that stays at the bottom of the screen while the Shelf is edited holds only what is needed at every moment: the state of the draft
|
|
525
|
+
// and the two copy keys (on a phone it is about 110 px high). The rest comes after it in the page: the problems in full, GitHub, Start over, what changed,
|
|
526
|
+
// the new file.
|
|
527
|
+
const footBar = h("div", { class: "shelf-foot", role: "region", "aria-label": "Your draft" });
|
|
528
|
+
const footMore = h("div", { class: "shelf-foot-more" });
|
|
242
529
|
const foot = () => {
|
|
243
530
|
refreshFoot();
|
|
244
|
-
return
|
|
531
|
+
return [footBar, footMore];
|
|
245
532
|
};
|
|
533
|
+
/** "1 changed", "2 added, 1 removed": only the parts that are not zero. */
|
|
534
|
+
const shortChange = (d) => [d.added.length && `${d.added.length} added`, d.changed.length && `${d.changed.length} changed`, d.removed.length && `${d.removed.length} removed`].filter(Boolean).join(", ");
|
|
246
535
|
function refreshFoot() {
|
|
247
536
|
const bad = problems();
|
|
248
537
|
const diff = diffLooks(LOOKS, draft);
|
|
249
538
|
const changed = diff.added.length + diff.removed.length + diff.changed.length > 0;
|
|
250
539
|
const text = bad.length ? "" : looksJson(draft);
|
|
540
|
+
const ready = !bad.length && changed;
|
|
541
|
+
footBar.replaceChildren(h("div", { class: "foot-status" }, h("strong", {}, changed ? shortChange(diff) : "No change yet"), bad.length ? h("span", { class: "mark mark-bad" }, `${plural(bad.length, "problem")} to fix`) : changed ? h("span", { class: "mark mark-ok" }, icon("check", 14), "Ready to copy") : null), h("div", { class: "foot-keys" }, h("button", { class: "btn btn-primary", type: "button", disabled: !ready, onclick: (e) => void copy(text, e.currentTarget) }, "Copy looks.json"), h("button", { class: "btn", type: "button", title: "Copy a message for your AI assistant: the new looks.json, what changed and the rules", disabled: !ready, onclick: (e) => void copy(aiPrompt(text, diff, [...owned()].sort(), LOOKS), e.currentTarget) }, "Copy for my AI")));
|
|
251
542
|
const out = [];
|
|
252
543
|
if (bad.length)
|
|
253
|
-
out.push(h("div", { class: "late-note", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
|
|
254
|
-
out.push(h("div", { class: "
|
|
544
|
+
out.push(h("div", { class: "late-note foot-problems", role: "alert" }, h("strong", {}, "Fix these first"), h("ul", {}, ...bad.map((p) => h("li", {}, p.message)))));
|
|
545
|
+
out.push(h("div", { class: "foot-links" }, data.repo ? h("a", { class: "btn btn-small", href: `https://github.com/${data.repo}/edit/main/looks.json`, target: "_blank", rel: "noopener noreferrer" }, "Open looks.json on GitHub", icon("external", 16)) : null, h("button", { class: "btn btn-small btn-quiet", type: "button", disabled: !changedFromGame(), onclick: () => ((draft = clone(LOOKS)), (open = null), redraw()) }, "Start over")));
|
|
546
|
+
if (!data.repo)
|
|
547
|
+
out.push(h("p", { class: "fine" }, repoHelp));
|
|
548
|
+
out.push(h("details", { class: "foot-more shelf-changes" }, h("summary", {}, `What changed: ${changed ? changeText(diff) : "No change yet"}`), changed
|
|
255
549
|
? h("ul", {}, ...diff.added.map((l) => h("li", {}, `Added: ${l.name} (${l.priceUsd === 0 ? "free" : usdText(l.priceUsd)})`)), ...diff.changed.map((c) => h("li", {}, fieldWords(c))), ...diff.removed.map((l) => h("li", {}, `Removed: ${l.name}`)))
|
|
256
550
|
: null));
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
551
|
+
if (ready)
|
|
552
|
+
out.push(h("details", { class: "foot-more shelf-file" }, h("summary", {}, "Show the new looks.json"), h("pre", {}, text)));
|
|
553
|
+
footMore.replaceChildren(...out.filter((n) => n !== null));
|
|
554
|
+
// The open card's own marks and problems, from the same check.
|
|
555
|
+
if (open && openParts) {
|
|
556
|
+
const at = draft.indexOf(open) + 1;
|
|
557
|
+
const mine = bad.filter((p) => p.look === at);
|
|
558
|
+
const mark = diff.added.some((l) => l === open) ? "new" : diff.changed.some((c) => c.after === open) ? "changed" : null;
|
|
559
|
+
openParts.marks.replaceChildren(...markList(open, owned(), mine, mark).filter((n) => n !== null));
|
|
560
|
+
openParts.problems.replaceChildren(...mine.map((p) => h("li", {}, p.message)));
|
|
561
|
+
openParts.problems.hidden = !mine.length;
|
|
562
|
+
}
|
|
262
563
|
}
|
|
263
564
|
redraw();
|
|
264
565
|
return {
|