updateday 0.0.0-stage → 0.2.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -2
  3. package/assets/hourglass/10m.png +0 -0
  4. package/assets/hourglass/12h.png +0 -0
  5. package/assets/hourglass/15m.png +0 -0
  6. package/assets/hourglass/1h.png +0 -0
  7. package/assets/hourglass/24h.png +0 -0
  8. package/assets/hourglass/2h.png +0 -0
  9. package/assets/hourglass/30m.png +0 -0
  10. package/assets/hourglass/3h.png +0 -0
  11. package/assets/hourglass/45m.png +0 -0
  12. package/assets/hourglass/4h.png +0 -0
  13. package/assets/hourglass/5h.png +0 -0
  14. package/assets/hourglass/5m.png +0 -0
  15. package/assets/hourglass/6h.png +0 -0
  16. package/assets/hourglass/default.png +0 -0
  17. package/assets/hourglass/now.png +0 -0
  18. package/assets/hourglass-styles/blocky.png +0 -0
  19. package/assets/hourglass-styles/classic.png +0 -0
  20. package/assets/hourglass-styles/glow.png +0 -0
  21. package/assets/hourglass-styles/outline.png +0 -0
  22. package/assets/hourglass-styles/pixel.png +0 -0
  23. package/assets/hourglass-styles/rounded.png +0 -0
  24. package/bin/updateday.js +5 -0
  25. package/dist/bundle.js +54 -0
  26. package/dist/cli.js +254 -0
  27. package/dist/config.js +261 -0
  28. package/dist/doctor.js +119 -0
  29. package/dist/experiments.js +349 -0
  30. package/dist/gamePage.js +143 -0
  31. package/dist/gifts.js +55 -0
  32. package/dist/history.js +68 -0
  33. package/dist/icons.js +216 -0
  34. package/dist/labels.js +64 -0
  35. package/dist/mcp.js +302 -0
  36. package/dist/moderation.js +86 -0
  37. package/dist/notify.js +72 -0
  38. package/dist/openCloud.js +211 -0
  39. package/dist/playerData.js +109 -0
  40. package/dist/preflight.js +202 -0
  41. package/dist/runner.js +360 -0
  42. package/dist/schedule.js +162 -0
  43. package/dist/server.js +929 -0
  44. package/dist/state.js +29 -0
  45. package/dist/swap.js +196 -0
  46. package/dist/zip.js +82 -0
  47. package/docs/AGENTS.md +123 -0
  48. package/docs/COVERAGE.md +50 -0
  49. package/docs/GUIDE.md +288 -0
  50. package/docs/hourglass-styles.png +0 -0
  51. package/docs/screenshot.png +0 -0
  52. package/example/multi-game.json +16 -0
  53. package/example/updateday.json +37 -0
  54. package/package.json +63 -5
  55. package/roblox/UpdateDayBanner.client.luau +159 -0
  56. package/roblox/UpdateDayCountdown.server.luau +48 -0
  57. package/roblox/UpdateDayGifts.luau +119 -0
  58. package/roblox/UpdateDayListener.luau +43 -0
  59. package/templates/github-actions/updateday.yml +35 -0
  60. package/ui/core.js +325 -0
  61. package/ui/countdown.js +775 -0
  62. package/ui/events.js +249 -0
  63. package/ui/gifts.js +215 -0
  64. package/ui/history.js +156 -0
  65. package/ui/index.html +530 -0
  66. package/ui/jsonEditor.js +417 -0
  67. package/ui/page.js +94 -0
  68. package/ui/players.js +256 -0
  69. package/ui/run.js +391 -0
  70. package/ui/sale.js +366 -0
  71. package/ui/settings.js +414 -0
  72. package/ui/shell.js +193 -0
@@ -0,0 +1,417 @@
1
+ const JE_MODE_KEY = "updateday-json-mode";
2
+ const JE_LONG_TEXT = 70;
3
+ const JE_PREVIEW_KEYS = ["name", "displayName", "title", "type", "id"];
4
+ const JE_TOKEN = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
5
+ const JE_NEW_TYPES = [["string", "Text"], ["number", "Number"], ["boolean", "True / false"], ["object", "Group { }"], ["array", "List [ ]"], ["null", "Empty (null)"]];
6
+ const JE_BLANKS = { string: "", number: 0, boolean: false, null: null };
7
+
8
+ function jeClone(value) {
9
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
10
+ }
11
+
12
+ function jeKind(value) {
13
+ if (value === null) {
14
+ return "null";
15
+ }
16
+ return Array.isArray(value) ? "array" : typeof value;
17
+ }
18
+
19
+ function jeBlank(type) {
20
+ if (type === "object") {
21
+ return {};
22
+ }
23
+ if (type === "array") {
24
+ return [];
25
+ }
26
+ return JE_BLANKS[type];
27
+ }
28
+
29
+ function jeGet(root, path) {
30
+ return path.reduce((node, key) => (node == null ? undefined : node[key]), root);
31
+ }
32
+
33
+ function jeSet(root, path, value) {
34
+ jeGet(root, path.slice(0, -1))[path[path.length - 1]] = value;
35
+ }
36
+
37
+ function jeRemove(root, path) {
38
+ const parent = jeGet(root, path.slice(0, -1));
39
+ const key = path[path.length - 1];
40
+ if (Array.isArray(parent)) {
41
+ parent.splice(key, 1);
42
+ } else {
43
+ delete parent[key];
44
+ }
45
+ }
46
+
47
+ function jeSame(a, b) {
48
+ return JSON.stringify(a) === JSON.stringify(b);
49
+ }
50
+
51
+ function jePathText(path) {
52
+ return path.join(".").toLowerCase();
53
+ }
54
+
55
+ function jeLabel(key) {
56
+ return typeof key === "number" ? `[${key}]` : key;
57
+ }
58
+
59
+ function jePreview(value) {
60
+ if (jeKind(value) !== "object") {
61
+ return "";
62
+ }
63
+ const named = JE_PREVIEW_KEYS.find((key) => ["string", "number"].includes(typeof value[key]));
64
+ if (named) {
65
+ return String(value[named]);
66
+ }
67
+ const text = Object.values(value).find((item) => typeof item === "string");
68
+ return text ?? "";
69
+ }
70
+
71
+ function jeCount(kind, size) {
72
+ const noun = kind === "array" ? "item" : "field";
73
+ return `${size} ${noun}${size === 1 ? "" : "s"}`;
74
+ }
75
+
76
+ function jeHighlight(text) {
77
+ let out = "";
78
+ let last = 0;
79
+ text.replace(JE_TOKEN, (match, string, colon, word, offset) => {
80
+ out += h(text.slice(last, offset));
81
+ if (string && colon) {
82
+ out += `<span class="je-k">${h(string)}</span>${colon}`;
83
+ } else if (string) {
84
+ out += `<span class="je-s">${h(string)}</span>`;
85
+ } else if (word) {
86
+ out += `<span class="${word === "null" ? "je-null" : "je-b"}">${word}</span>`;
87
+ } else {
88
+ out += `<span class="je-n">${match}</span>`;
89
+ }
90
+ last = offset + match.length;
91
+ return match;
92
+ });
93
+ return out + h(text.slice(last)) + "\n";
94
+ }
95
+
96
+ function jeControlHtml(value, label) {
97
+ const kind = jeKind(value);
98
+ const aria = `aria-label="${h(label)}"`;
99
+ if (kind === "boolean") {
100
+ return `<input type="checkbox" role="switch" class="switch je-input" data-type="boolean" ${value ? "checked" : ""} ${aria}>`;
101
+ }
102
+ if (kind === "number") {
103
+ return `<input type="number" step="any" class="je-input" data-type="number" value="${value}" ${aria}>`;
104
+ }
105
+ if (kind === "string" && (value.length > JE_LONG_TEXT || value.includes("\n"))) {
106
+ return `<textarea class="je-input" data-type="string" rows="3" ${aria}>${h(value)}</textarea>`;
107
+ }
108
+ if (kind === "string") {
109
+ return `<input type="text" class="je-input" data-type="string" value="${h(value)}" ${aria}>`;
110
+ }
111
+ return `<span class="je-empty">empty (null)</span>`;
112
+ }
113
+
114
+ function jeRemoveHtml(path, label) {
115
+ return `<button type="button" class="icon-btn je-remove" data-path="${h(JSON.stringify(path))}" aria-label="Remove ${h(label)}" title="Remove">✕</button>`;
116
+ }
117
+
118
+ function jeNodeHtml(state, key, value, path, depth) {
119
+ const kind = jeKind(value);
120
+ const label = jeLabel(key);
121
+ const pathAttr = h(JSON.stringify(path));
122
+ const pathText = h(jePathText(path));
123
+ if (kind !== "object" && kind !== "array") {
124
+ const search = h(`${jePathText(path)} ${String(value)}`.toLowerCase());
125
+ return `
126
+ <div class="je-row je-leaf" data-path="${pathAttr}" data-pt="${pathText}" data-search="${search}">
127
+ <span class="je-key" title="${h(path.join("."))}">${h(label)}</span>
128
+ <span class="je-control">${jeControlHtml(value, path.join("."))}</span>
129
+ ${jeRemoveHtml(path, path.join("."))}
130
+ </div>`;
131
+ }
132
+ const entries = kind === "array" ? value.map((item, index) => [index, item]) : Object.entries(value);
133
+ const open = state.open ? state.open.has(JSON.stringify(path)) : depth < 1;
134
+ const preview = jePreview(value);
135
+ return `
136
+ <details class="je-node" data-path="${pathAttr}" data-pt="${pathText}" data-search="${pathText}" ${open ? "open" : ""}>
137
+ <summary class="je-row">
138
+ <span class="je-key" title="${h(path.join("."))}">${h(label)}</span>
139
+ <span class="je-meta"><span class="je-count">${jeCount(kind, entries.length)}</span>${preview ? `<span class="je-preview">${h(preview)}</span>` : ""}</span>
140
+ ${jeRemoveHtml(path, path.join("."))}
141
+ </summary>
142
+ <div class="je-children">
143
+ ${entries.map(([childKey, child]) => jeNodeHtml(state, childKey, child, [...path, childKey], depth + 1)).join("")}
144
+ <button type="button" class="link-btn je-add" data-path="${pathAttr}">+ Add ${kind === "array" ? "item" : "field"}</button>
145
+ </div>
146
+ </details>`;
147
+ }
148
+
149
+ function jeFieldsHtml(state) {
150
+ const kind = jeKind(state.value);
151
+ if (kind === "null") {
152
+ return emptyState("No saved data", "Start a save for this player from scratch.", `<button type="button" class="btn btn-sm je-start">Start with an empty group</button>`);
153
+ }
154
+ if (kind !== "object" && kind !== "array") {
155
+ return `<div class="callout callout-info">This save is a single ${kind}. Edit it in the JSON view.</div>`;
156
+ }
157
+ const entries = kind === "array" ? state.value.map((item, index) => [index, item]) : Object.entries(state.value);
158
+ return `
159
+ <div class="je-tree">
160
+ ${entries.map(([key, child]) => jeNodeHtml(state, key, child, [key], 0)).join("")}
161
+ <button type="button" class="link-btn je-add" data-path="[]">+ Add ${kind === "array" ? "item" : "field"}</button>
162
+ </div>`;
163
+ }
164
+
165
+ function jeBarHtml(state) {
166
+ const fields = state.mode === "fields";
167
+ return `
168
+ <div class="je-bar">
169
+ ${segmentedHtml("jeMode", [["fields", "Fields"], ["json", "JSON"]], state.mode)}
170
+ ${fields ? `<input type="search" class="je-search" placeholder="Find a field or value" aria-label="Find a field or value" value="${h(state.filter)}">` : `<span class="je-status" aria-live="polite"></span>`}
171
+ <span class="je-spacer"></span>
172
+ ${fields ? `<button type="button" class="btn btn-ghost btn-sm" data-je="expand">Expand all</button><button type="button" class="btn btn-ghost btn-sm" data-je="collapse">Collapse all</button>` : `<button type="button" class="btn btn-ghost btn-sm" data-je="format">Format</button>`}
173
+ <button type="button" class="btn btn-ghost btn-sm" data-je="copy">Copy</button>
174
+ </div>`;
175
+ }
176
+
177
+ function jeCodeHtml() {
178
+ return `
179
+ <div class="je-code">
180
+ <pre aria-hidden="true"></pre>
181
+ <textarea class="je-text" spellcheck="false" autocomplete="off" aria-label="Saved data as JSON"></textarea>
182
+ </div>`;
183
+ }
184
+
185
+ function jeMarkChanges(state) {
186
+ for (const element of state.root.querySelectorAll(".je-body [data-path]:not(.je-add):not(.je-remove)")) {
187
+ const path = JSON.parse(element.dataset.path);
188
+ const differs = !jeSame(jeGet(state.original, path), jeGet(state.value, path));
189
+ if (element.matches("details")) {
190
+ element.querySelector(":scope > summary").classList.toggle("has-changes", differs);
191
+ } else {
192
+ element.classList.toggle("changed", differs);
193
+ }
194
+ }
195
+ }
196
+
197
+ function jeApplyFilter(state) {
198
+ const query = state.filter.trim().toLowerCase();
199
+ const items = [...state.root.querySelectorAll(".je-body [data-search]")];
200
+ state.root.querySelector(".je-body").classList.toggle("filtering", Boolean(query));
201
+ if (!query) {
202
+ for (const item of items) {
203
+ item.hidden = false;
204
+ }
205
+ return;
206
+ }
207
+ const matched = items.filter((item) => item.dataset.search.includes(query)).map((item) => item.dataset.pt);
208
+ for (const item of items) {
209
+ const own = item.dataset.pt;
210
+ const visible = matched.some((hit) => hit === own || hit.startsWith(`${own}.`) || own.startsWith(`${hit}.`));
211
+ item.hidden = !visible;
212
+ if (visible && item.matches("details")) {
213
+ item.open = true;
214
+ }
215
+ }
216
+ }
217
+
218
+ function jeRememberOpen(state) {
219
+ state.open = new Set([...state.root.querySelectorAll(".je-body details[open]")].map((item) => item.dataset.path));
220
+ }
221
+
222
+ function jeValidate(state) {
223
+ const status = state.root.querySelector(".je-status");
224
+ try {
225
+ state.value = JSON.parse(state.root.querySelector(".je-text").value);
226
+ status.textContent = "Valid JSON";
227
+ status.dataset.ok = "true";
228
+ return true;
229
+ } catch (error) {
230
+ status.textContent = error.message.replace(/^JSON\.parse: /, "");
231
+ status.dataset.ok = "false";
232
+ return false;
233
+ }
234
+ }
235
+
236
+ function jeRender(state) {
237
+ if (state.mode === "fields" && state.root.querySelector(".je-tree")) {
238
+ jeRememberOpen(state);
239
+ }
240
+ state.root.innerHTML = `<div class="je">${jeBarHtml(state)}<div class="je-body je-${state.mode}">${state.mode === "fields" ? jeFieldsHtml(state) : jeCodeHtml()}</div></div>`;
241
+ bindSegmented(state.root, "jeMode", (mode) => jeSwitchMode(state, mode));
242
+ if (state.mode === "json") {
243
+ const text = state.root.querySelector(".je-text");
244
+ const pre = state.root.querySelector(".je-code pre");
245
+ text.value = JSON.stringify(state.value, null, 2);
246
+ pre.innerHTML = jeHighlight(text.value);
247
+ jeValidate(state);
248
+ text.addEventListener("input", () => {
249
+ pre.innerHTML = jeHighlight(text.value);
250
+ if (jeValidate(state)) {
251
+ state.onChange();
252
+ }
253
+ });
254
+ text.addEventListener("scroll", () => {
255
+ pre.scrollTop = text.scrollTop;
256
+ pre.scrollLeft = text.scrollLeft;
257
+ });
258
+ return;
259
+ }
260
+ jeMarkChanges(state);
261
+ jeApplyFilter(state);
262
+ state.root.querySelector(".je-search")?.addEventListener("input", (event) => {
263
+ state.filter = event.target.value;
264
+ jeApplyFilter(state);
265
+ });
266
+ }
267
+
268
+ function jeSwitchMode(state, mode) {
269
+ if (mode === state.mode) {
270
+ return;
271
+ }
272
+ if (mode === "fields" && !jeValidate(state)) {
273
+ toast("Fix the JSON before switching to Fields", "danger");
274
+ jeRender(state);
275
+ return;
276
+ }
277
+ if (state.mode === "fields") {
278
+ jeRememberOpen(state);
279
+ }
280
+ state.mode = mode;
281
+ try {
282
+ localStorage.setItem(JE_MODE_KEY, mode);
283
+ } catch {
284
+ }
285
+ jeRender(state);
286
+ }
287
+
288
+ async function jeAddTo(state, path) {
289
+ const target = jeGet(state.value, path);
290
+ const isList = Array.isArray(target);
291
+ const types = isList && target.length ? [["copy", "Copy of the last item"], ...JE_NEW_TYPES] : JE_NEW_TYPES;
292
+ const sure = await dialog({
293
+ title: isList ? "Add an item" : "Add a field",
294
+ html: true,
295
+ confirmText: "Add",
296
+ body: `
297
+ ${isList ? "" : `<label class="field"><span>Name</span><input type="text" id="jeNewKey" class="mono" autocomplete="off"></label>`}
298
+ <label class="field"><span>Type</span><select id="jeNewType">${types.map(([value, label]) => `<option value="${value}">${label}</option>`).join("")}</select></label>`,
299
+ });
300
+ if (!sure) {
301
+ return;
302
+ }
303
+ const type = $("jeNewType").value;
304
+ const value = type === "copy" ? jeClone(target[target.length - 1]) : jeBlank(type);
305
+ if (isList) {
306
+ target.push(value);
307
+ jeRememberOpen(state);
308
+ state.open.add(JSON.stringify(path));
309
+ } else {
310
+ const key = $("jeNewKey").value.trim();
311
+ if (!key) {
312
+ toast("Give the field a name", "danger");
313
+ return;
314
+ }
315
+ if (Object.hasOwn(target, key)) {
316
+ toast(`There's already a field called ${key}`, "danger");
317
+ return;
318
+ }
319
+ target[key] = value;
320
+ jeRememberOpen(state);
321
+ state.open.add(JSON.stringify(path));
322
+ }
323
+ jeRender(state);
324
+ state.onChange();
325
+ }
326
+
327
+ function jeOnInput(state, input) {
328
+ const row = input.closest("[data-path]");
329
+ const path = JSON.parse(row.dataset.path);
330
+ if (input.dataset.type === "boolean") {
331
+ jeSet(state.value, path, input.checked);
332
+ } else if (input.dataset.type === "number") {
333
+ const number = Number(input.value);
334
+ if (input.value === "" || Number.isNaN(number)) {
335
+ return;
336
+ }
337
+ jeSet(state.value, path, number);
338
+ } else {
339
+ jeSet(state.value, path, input.value);
340
+ }
341
+ row.dataset.search = `${jePathText(path)} ${String(jeGet(state.value, path))}`.toLowerCase();
342
+ jeMarkChanges(state);
343
+ state.onChange();
344
+ }
345
+
346
+ async function jeOnClick(state, event) {
347
+ const button = event.target.closest("button");
348
+ if (!button || !state.root.contains(button)) {
349
+ return;
350
+ }
351
+ if (button.matches(".je-remove")) {
352
+ event.preventDefault();
353
+ jeRememberOpen(state);
354
+ jeRemove(state.value, JSON.parse(button.dataset.path));
355
+ jeRender(state);
356
+ state.onChange();
357
+ } else if (button.matches(".je-add")) {
358
+ jeAddTo(state, JSON.parse(button.dataset.path));
359
+ } else if (button.matches(".je-start")) {
360
+ state.value = {};
361
+ jeRender(state);
362
+ state.onChange();
363
+ } else if (button.dataset.je === "expand" || button.dataset.je === "collapse") {
364
+ for (const item of state.root.querySelectorAll(".je-body details")) {
365
+ item.open = button.dataset.je === "expand";
366
+ }
367
+ } else if (button.dataset.je === "format") {
368
+ if (jeValidate(state)) {
369
+ jeRender(state);
370
+ } else {
371
+ toast("Fix the JSON first", "danger");
372
+ }
373
+ } else if (button.dataset.je === "copy") {
374
+ try {
375
+ await navigator.clipboard.writeText(JSON.stringify(state.mode === "json" ? JSON.parse(state.root.querySelector(".je-text").value) : state.value, null, 2));
376
+ toast("Copied", "success");
377
+ } catch {
378
+ toast("Couldn't copy", "danger");
379
+ }
380
+ }
381
+ }
382
+
383
+ function jeSavedMode() {
384
+ try {
385
+ return localStorage.getItem(JE_MODE_KEY) === "json" ? "json" : "fields";
386
+ } catch {
387
+ return "fields";
388
+ }
389
+ }
390
+
391
+ function createJsonEditor(root, value, onChange) {
392
+ const state = { root, original: jeClone(value), value: jeClone(value) ?? null, mode: jeSavedMode(), open: null, filter: "", onChange };
393
+ const primitive = !["object", "array", "null"].includes(jeKind(state.value));
394
+ if (primitive) {
395
+ state.mode = "json";
396
+ }
397
+ root.addEventListener("input", (event) => {
398
+ if (event.target.matches(".je-input") && event.target.dataset.type !== "boolean") {
399
+ jeOnInput(state, event.target);
400
+ }
401
+ });
402
+ root.addEventListener("change", (event) => {
403
+ if (event.target.matches(".je-input[data-type=boolean]")) {
404
+ jeOnInput(state, event.target);
405
+ }
406
+ });
407
+ root.addEventListener("click", (event) => jeOnClick(state, event));
408
+ jeRender(state);
409
+ return {
410
+ getValue() {
411
+ if (state.mode === "json" && !jeValidate(state)) {
412
+ throw new Error(`That isn't valid JSON: ${state.root.querySelector(".je-status").textContent}`);
413
+ }
414
+ return jeClone(state.value);
415
+ },
416
+ };
417
+ }
package/ui/page.js ADDED
@@ -0,0 +1,94 @@
1
+ const CHECK_SYMBOLS = { ok: "✓", warn: "!", info: "i" };
2
+
3
+ function statHtml(label, value) {
4
+ return `<div class="stat"><span>${label}</span><b>${h(value ?? "—")}</b></div>`;
5
+ }
6
+
7
+ function pageStatsHtml(stats) {
8
+ if (!stats) {
9
+ return `<div class="callout callout-info">Public stats appear once the game is public.</div>`;
10
+ }
11
+ return `<div class="stats">${[
12
+ statHtml("Playing", stats.playing?.toLocaleString(LOCALE)),
13
+ statHtml("Visits", stats.visits?.toLocaleString(LOCALE)),
14
+ statHtml("Favourites", stats.favorites?.toLocaleString(LOCALE)),
15
+ statHtml("Liked", stats.liked != null ? `${stats.liked}%` : null),
16
+ statHtml("Server size", stats.serverSize),
17
+ statHtml("Genre", stats.genre),
18
+ statHtml("Updated", stats.updated ? new Date(stats.updated).toLocaleDateString(LOCALE) : null),
19
+ ].join("")}</div>`;
20
+ }
21
+
22
+ function pagePreviewHtml(page) {
23
+ const meta = [
24
+ page.creator ? `By ${h(page.creator)}` : "",
25
+ page.visibility ? (page.visibility === "PRIVATE" ? "Private" : "Public") : "",
26
+ page.ageRating ? h(page.ageRating.replace("AGE_RATING_", "").replace("_PLUS", "+")) : "",
27
+ ].filter(Boolean).join(" · ");
28
+ const thumbs = page.thumbnails.length
29
+ ? page.thumbnails.map((url) => `<img src="${h(url)}" alt="" loading="lazy">`).join("")
30
+ : `<div class="thumb-empty">No thumbnails</div>`;
31
+ return `
32
+ <div class="card">
33
+ <div class="page-hero">
34
+ ${page.iconUrl ? `<img src="${h(page.iconUrl)}" alt="">` : `<img alt="">`}
35
+ <div style="min-width:0"><div class="title">${h(page.name)}</div><div class="hint">${meta}</div></div>
36
+ </div>
37
+ <div class="thumbs">${thumbs}</div>
38
+ ${pageStatsHtml(page.stats)}
39
+ <div class="section-title">Description</div>
40
+ <div class="page-desc">${page.description ? h(page.description) : `<span class="hint">No description</span>`}</div>
41
+ ${page.devices.length ? `<div class="hint" style="margin-top:12px">Plays on ${h(page.devices.join(", "))}</div>` : ""}
42
+ </div>`;
43
+ }
44
+
45
+ function pageChecksHtml(page) {
46
+ const warnings = page.checks.filter((check) => check.level === "warn").length;
47
+ const rows = page.checks.map((check) => `
48
+ <li class="check ${check.level}">
49
+ <span class="dot" aria-hidden="true">${CHECK_SYMBOLS[check.level]}</span>
50
+ <div><b>${h(check.title)}</b>${check.detail ? `<div class="hint">${h(check.detail)}</div>` : ""}</div>
51
+ </li>`).join("");
52
+ return `
53
+ <div class="card">
54
+ <div class="card-head">
55
+ <div><h2>${warnings ? `${warnings} thing${warnings === 1 ? "" : "s"} to look at` : "Ready for the update"}</h2><div class="hint">Checked against your plan for this game.</div></div>
56
+ <button class="btn btn-sm" id="pgAgain">Check again</button>
57
+ </div>
58
+ ${page.keyUsed ? "" : `<div class="callout callout-info" style="margin-bottom:10px">Add the API key to check private games and events.</div>`}
59
+ <ul class="checks">${rows}</ul>
60
+ </div>`;
61
+ }
62
+
63
+ function pageSkeletonHtml() {
64
+ return `
65
+ <div class="grid-2">
66
+ <div class="card"><div class="skeleton" style="height:84px;margin-bottom:16px"></div><div class="skeleton" style="height:128px;margin-bottom:16px"></div><div class="skeleton" style="height:80px"></div></div>
67
+ <div class="card"><div class="skeleton" style="height:22px;margin-bottom:16px;width:60%"></div><div class="skeleton" style="height:180px"></div></div>
68
+ </div>`;
69
+ }
70
+
71
+ async function renderPage(root) {
72
+ if (!currentGame()?.universeId) {
73
+ root.innerHTML = `<div class="card">${emptyState("Add the game link first", "The page check reads your game's public page from Roblox.")}</div>`;
74
+ return;
75
+ }
76
+ const selected = UD.selected;
77
+ root.innerHTML = pageSkeletonHtml();
78
+ try {
79
+ const page = await api("GET", `/api/page?${gameQuery()}`);
80
+ if (UD.view !== "page" || UD.selected !== selected) {
81
+ return;
82
+ }
83
+ root.innerHTML = `<div class="grid-2"><div class="stack">${pagePreviewHtml(page)}</div><div class="stack">${pageChecksHtml(page)}</div></div>`;
84
+ root.querySelector("#pgAgain").addEventListener("click", () => renderPage(root));
85
+ } catch (error) {
86
+ root.innerHTML = `<div class="card">${errorBox(error)}<button class="btn btn-sm" id="pgAgain" style="margin-top:12px">Try again</button></div>`;
87
+ root.querySelector("#pgAgain").addEventListener("click", () => renderPage(root));
88
+ }
89
+ }
90
+
91
+ UD.views.page = {
92
+ label: "Game page",
93
+ render: renderPage,
94
+ };