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
package/ui/settings.js ADDED
@@ -0,0 +1,414 @@
1
+ const DEFAULT_ADMIN_MINUTES = 60;
2
+ const DEFAULT_BLEED_MINUTES = 5;
3
+ const DEFAULT_BROADCAST = { topic: "UpdateDay", marks: ["10m", "5m", "1m"] };
4
+ const DEFAULT_KEY_ENV = "UPDATEDAY_API_KEY";
5
+ const QUICK_TIMES = [["+1 hour", 36e5], ["+1 day", 864e5], ["+1 week", 6048e5]];
6
+ const REPEAT_OPTIONS = [["", "Doesn't repeat"], ["day", "Every day"], ["week", "Every week"], ["2weeks", "Every 2 weeks"], ["month", "Every month"]];
7
+ const DEFAULT_WEBHOOK_ENV = "UPDATEDAY_DISCORD_WEBHOOK";
8
+ const ALERT_EVENTS = [["reminders", "Upload reminders"], ["failures", "Problems"], ["live", "Update is live"], ["finished", "All done"], ["steps", "Every step"]];
9
+ const DEFAULT_ALERT_EVENTS = ["reminders", "failures", "live", "finished"];
10
+ const MENTIONS = [["", "No ping"], ["@here", "@here"], ["@everyone", "@everyone"], ["custom", "A role or person…"]];
11
+
12
+ function setOrDelete(target, key, value) {
13
+ if (value === undefined || value === null || value === "") {
14
+ delete target[key];
15
+ } else {
16
+ target[key] = value;
17
+ }
18
+ }
19
+
20
+ function gameCardHtml(game) {
21
+ return `
22
+ <div class="card">
23
+ <div class="card-head"><div><h2>This game</h2><div class="hint">Paste the Roblox link and UpdateDay fills in the rest.</div></div></div>
24
+ <form id="setLookupForm" class="row" style="align-items:flex-end">
25
+ <label class="field" style="flex:1"><span>Game link or place ID</span><input type="text" id="setLookup" placeholder="https://www.roblox.com/games/…" autocomplete="off"></label>
26
+ <div class="field" style="flex:none"><button class="btn ${game.universeId ? "" : "btn-primary"}" type="submit">Find game</button></div>
27
+ </form>
28
+ <div id="setLookupError"></div>
29
+ <div class="row">
30
+ <label class="field"><span>Name</span><input type="text" id="setName" placeholder="Shown in UpdateDay only" value="${h(game.name ?? "")}"></label>
31
+ <label class="field"><span>Universe ID</span><input type="number" id="setUniverse" class="mono" value="${h(game.universeId ?? "")}"></label>
32
+ </div>
33
+ <div class="card-section">
34
+ ${switchHtml("setOwnTime", Boolean(game.updateAt), "Own update time", "Use a different time from the other games.")}
35
+ <div class="revealed" id="setOwnFields" ${game.updateAt ? "" : "hidden"}>
36
+ <label class="field"><span>This game updates at</span><input type="datetime-local" id="setOwnAt" value="${game.updateAt ? toLocalInput(game.updateAt) : ""}"></label>
37
+ </div>
38
+ </div>
39
+ <details class="card-section">
40
+ <summary class="section-title" style="cursor:pointer">Advanced</summary>
41
+ <label class="field" style="margin-top:12px"><span>API key name</span><input type="text" id="setKeyEnv" class="mono" placeholder="${DEFAULT_KEY_ENV}" value="${h(game.apiKeyEnv ?? "")}"><span class="hint">Only for a game owned by another account or group that needs its own key.</span></label>
42
+ </details>
43
+ <div class="card-section" style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
44
+ <span class="hint">Removing a game only takes it out of this plan.</span>
45
+ <button class="btn btn-sm btn-danger-ghost" id="setRemove">Remove game</button>
46
+ </div>
47
+ </div>`;
48
+ }
49
+
50
+ function sharedTimeHtml() {
51
+ return `
52
+ <div class="card">
53
+ <div class="card-head"><div><h2>Update time</h2><div class="hint">Shared by every game without its own time.</div></div></div>
54
+ <label class="field"><span>Update goes live at</span><input type="datetime-local" id="setUpdateAt" value="${toLocalInput(UD.draft.updateAt)}"></label>
55
+ <div class="chips" style="margin:-4px 0 14px">
56
+ ${QUICK_TIMES.map(([label, ms]) => `<button type="button" class="chip" data-shift="${ms}">${label}</button>`).join("")}
57
+ </div>
58
+ ${repeatHtml()}
59
+ <label class="field"><span>Admin-abuse length (minutes)</span><input type="number" id="setAdmin" min="5" max="600" value="${UD.draft.adminAbuseMinutes ?? DEFAULT_ADMIN_MINUTES}"><span class="hint">How long the icon stays on NOW, and the length of a quick sale.</span></label>
60
+ </div>`;
61
+ }
62
+
63
+ function extrasHtml() {
64
+ const restart = UD.draft.restartServers;
65
+ const broadcast = UD.draft.broadcast;
66
+ return `
67
+ <div class="card">
68
+ <div class="card-head"><div><h2>At update time</h2><div class="hint">Shared by every game.</div></div></div>
69
+ ${switchHtml("setRestartOn", Boolean(restart), "Restart old servers", "Move players onto the new version.")}
70
+ <div class="revealed" id="setRestartFields" ${restart ? "" : "hidden"}>
71
+ <label class="field"><span>Let players finish for (minutes)</span><input type="number" id="setBleed" min="0" max="60" value="${restart?.bleedOffMinutes ?? DEFAULT_BLEED_MINUTES}"><span class="hint">0 restarts right away.</span></label>
72
+ </div>
73
+ <div class="card-section">
74
+ ${switchHtml("setWarnOn", Boolean(broadcast), "Warn players in live servers", "Sends a message your game shows before the update.")}
75
+ <div class="revealed" id="setWarnFields" ${broadcast ? "" : "hidden"}>
76
+ <div class="row">
77
+ <label class="field"><span>When</span><input type="text" id="setWarnMarks" value="${h((broadcast?.marks ?? DEFAULT_BROADCAST.marks).join(", "))}"><span class="hint">Before the update, e.g. 10m, 5m, 1m.</span></label>
78
+ <label class="field"><span>Topic</span><input type="text" id="setWarnTopic" class="mono" value="${h(broadcast?.topic ?? DEFAULT_BROADCAST.topic)}"></label>
79
+ </div>
80
+ <div class="callout callout-info" style="display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap">
81
+ <span>Show a live "UPDATE IN 04:59" banner and these warnings in your game with the Roblox kit.</span>
82
+ <button class="btn btn-sm" id="setKit">Download kit</button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>`;
87
+ }
88
+
89
+ async function lookupInto(game, root) {
90
+ const input = $("setLookup");
91
+ const text = input.value.trim();
92
+ const errorSlot = $("setLookupError");
93
+ errorSlot.innerHTML = "";
94
+ if (!text) {
95
+ input.focus();
96
+ return;
97
+ }
98
+ try {
99
+ const found = await api("GET", `/api/lookup?q=${encodeURIComponent(text)}`);
100
+ game.universeId = found.universeId;
101
+ if (!game.name && found.name) {
102
+ game.name = found.name;
103
+ }
104
+ markDirty();
105
+ renderSidebar();
106
+ renderHeader();
107
+ renderSettings(root);
108
+ toast(`Found ${found.name || `universe ${found.universeId}`}`, "success");
109
+ } catch (error) {
110
+ errorSlot.innerHTML = errorBox(`Couldn't find that game: ${error.message}`);
111
+ }
112
+ }
113
+
114
+ function bindGameCard(root, game) {
115
+ root.querySelector("#setLookupForm").addEventListener("submit", (event) => {
116
+ event.preventDefault();
117
+ lookupInto(game, root);
118
+ });
119
+ root.querySelector("#setName").addEventListener("input", (event) => {
120
+ game.name = event.target.value;
121
+ markDirty();
122
+ renderSidebar();
123
+ renderHeader();
124
+ });
125
+ root.querySelector("#setUniverse").addEventListener("change", (event) => {
126
+ game.universeId = Number(event.target.value) || undefined;
127
+ markDirty();
128
+ renderSidebar();
129
+ renderHeader();
130
+ });
131
+ root.querySelector("#setOwnTime").addEventListener("change", (event) => {
132
+ if (event.target.checked) {
133
+ game.updateAt = UD.draft.updateAt;
134
+ root.querySelector("#setOwnAt").value = toLocalInput(game.updateAt);
135
+ } else {
136
+ delete game.updateAt;
137
+ }
138
+ root.querySelector("#setOwnFields").hidden = !event.target.checked;
139
+ markDirty();
140
+ renderHeader();
141
+ });
142
+ root.querySelector("#setOwnAt").addEventListener("change", (event) => {
143
+ const iso = fromLocalInput(event.target.value);
144
+ if (iso) {
145
+ game.updateAt = iso;
146
+ markDirty();
147
+ renderHeader();
148
+ }
149
+ });
150
+ root.querySelector("#setKeyEnv").addEventListener("change", (event) => {
151
+ setOrDelete(game, "apiKeyEnv", event.target.value.trim());
152
+ markDirty();
153
+ });
154
+ root.querySelector("#setRemove").addEventListener("click", async () => {
155
+ const sure = await dialog({ title: `Remove ${game.name || "this game"}?`, body: "It comes out of the plan. Nothing changes on Roblox.", confirmText: "Remove game", danger: true });
156
+ if (!sure) {
157
+ return;
158
+ }
159
+ UD.draft.games.splice(UD.selected, 1);
160
+ UD.selected = Math.max(0, Math.min(UD.selected, UD.draft.games.length - 1));
161
+ markDirty();
162
+ renderAll();
163
+ });
164
+ }
165
+
166
+ function bindShared(root) {
167
+ const updateInput = root.querySelector("#setUpdateAt");
168
+ const setUpdate = (iso) => {
169
+ UD.draft.updateAt = iso;
170
+ updateInput.value = toLocalInput(iso);
171
+ markDirty();
172
+ renderSidebar();
173
+ renderHeader();
174
+ };
175
+ updateInput.addEventListener("change", () => {
176
+ const iso = fromLocalInput(updateInput.value);
177
+ if (iso) {
178
+ setUpdate(iso);
179
+ }
180
+ });
181
+ for (const chip of root.querySelectorAll("[data-shift]")) {
182
+ chip.addEventListener("click", () => setUpdate(new Date(Date.parse(UD.draft.updateAt) + Number(chip.dataset.shift)).toISOString()));
183
+ }
184
+ root.querySelector("#setAdmin").addEventListener("change", (event) => {
185
+ UD.draft.adminAbuseMinutes = Number(event.target.value) || DEFAULT_ADMIN_MINUTES;
186
+ markDirty();
187
+ });
188
+ const restart = () => UD.draft.restartServers || (UD.draft.restartServers = { bleedOffMinutes: DEFAULT_BLEED_MINUTES });
189
+ root.querySelector("#setRestartOn").addEventListener("change", (event) => {
190
+ if (event.target.checked) {
191
+ restart();
192
+ } else {
193
+ delete UD.draft.restartServers;
194
+ }
195
+ root.querySelector("#setRestartFields").hidden = !event.target.checked;
196
+ markDirty();
197
+ });
198
+ root.querySelector("#setBleed").addEventListener("input", (event) => {
199
+ restart().bleedOffMinutes = Number(event.target.value);
200
+ markDirty();
201
+ });
202
+ const broadcast = () => UD.draft.broadcast || (UD.draft.broadcast = { ...DEFAULT_BROADCAST, marks: [...DEFAULT_BROADCAST.marks] });
203
+ root.querySelector("#setWarnOn").addEventListener("change", (event) => {
204
+ if (event.target.checked) {
205
+ broadcast();
206
+ } else {
207
+ delete UD.draft.broadcast;
208
+ }
209
+ root.querySelector("#setWarnFields").hidden = !event.target.checked;
210
+ markDirty();
211
+ });
212
+ root.querySelector("#setWarnMarks").addEventListener("change", (event) => {
213
+ broadcast().marks = event.target.value.split(/[\s,]+/).filter(Boolean);
214
+ markDirty();
215
+ });
216
+ root.querySelector("#setWarnTopic").addEventListener("change", (event) => {
217
+ broadcast().topic = event.target.value.trim() || DEFAULT_BROADCAST.topic;
218
+ markDirty();
219
+ });
220
+ }
221
+
222
+ function repeatHtml() {
223
+ const repeat = UD.draft.repeat;
224
+ const next = scheduledGame()?.nextUpdateAt;
225
+ return `
226
+ <div class="row">
227
+ <label class="field"><span>Repeat</span>
228
+ <select id="setRepeat">${REPEAT_OPTIONS.map(([value, label]) => `<option value="${value}" ${value === (repeat?.every ?? "") ? "selected" : ""}>${label}</option>`).join("")}</select>
229
+ </label>
230
+ <label class="field" ${repeat ? "" : "hidden"} id="setUntilField"><span>Until (optional)</span><input type="date" id="setUntil" value="${repeat?.until ? repeat.until.slice(0, 10) : ""}"></label>
231
+ </div>
232
+ ${repeat ? `<div class="hint" id="setRepeatHint" style="margin:-4px 0 12px">${repeatHint(next)}</div>` : ""}`;
233
+ }
234
+
235
+ function repeatHint(next) {
236
+ return next ? `After this one: ${h(formatWhen(next))}. Sales and events repeat too.` : "This is the last one.";
237
+ }
238
+
239
+ function mentionChoice(mention) {
240
+ if (!mention) {
241
+ return "";
242
+ }
243
+ return ["@here", "@everyone"].includes(mention) ? mention : "custom";
244
+ }
245
+
246
+ function alertsHtml() {
247
+ const notify = UD.draft.notify;
248
+ const events = notify?.events ?? DEFAULT_ALERT_EVENTS;
249
+ const game = scheduledGame();
250
+ const choice = mentionChoice(notify?.mention);
251
+ const desktop = readSetting(DESKTOP_ALERTS_KEY) === "on";
252
+ return `
253
+ <div class="card">
254
+ <div class="card-head"><div><h2>Alerts</h2><div class="hint">Know what's happening without watching UpdateDay.</div></div></div>
255
+ ${switchHtml("setDiscordOn", Boolean(notify), "Discord alerts", "Posts to a channel through a webhook, with the icon to upload attached.")}
256
+ <div class="revealed" id="setDiscordFields" ${notify ? "" : "hidden"}>
257
+ <form class="row" id="setHookForm" style="align-items:flex-end">
258
+ <label class="field" style="flex:1"><span>Webhook link ${game?.hasWebhook ? `<span class="badge badge-success">Set</span>` : `<span class="badge badge-warning">Missing</span>`}</span><input type="password" id="setHook" autocomplete="off" placeholder="${game?.hasWebhook ? "Paste to replace" : "https://discord.com/api/webhooks/…"}"></label>
259
+ <div class="field" style="flex:none"><button class="btn btn-sm" type="submit">Use link</button></div>
260
+ </form>
261
+ <div class="hint" style="margin:-4px 0 12px">Channel settings → Integrations → Webhooks → Copy link. Kept in memory only, like the API key.</div>
262
+ <div class="field"><span>Send an alert for</span>
263
+ <div class="chips" id="setAlertEvents">${ALERT_EVENTS.map(([value, label]) => `<button type="button" class="chip ${events.includes(value) ? "active" : ""}" aria-pressed="${events.includes(value)}" data-alert="${value}">${label}</button>`).join("")}</div>
264
+ </div>
265
+ <div class="row">
266
+ <label class="field"><span>Ping</span><select id="setMention">${MENTIONS.map(([value, label]) => `<option value="${value}" ${value === choice ? "selected" : ""}>${label}</option>`).join("")}</select></label>
267
+ <label class="field" id="setMentionField" ${choice === "custom" ? "" : "hidden"}><span>Role or person</span><input type="text" id="setMentionText" class="mono" placeholder="<@&role id> or <@user id>" value="${h(choice === "custom" ? notify.mention : "")}"></label>
268
+ </div>
269
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
270
+ <details class="hint"><summary style="cursor:pointer">Advanced</summary>
271
+ <label class="field" style="margin-top:10px"><span>Webhook variable name</span><input type="text" id="setHookEnv" class="mono" value="${h(notify?.discordWebhookEnv ?? DEFAULT_WEBHOOK_ENV)}"><span class="hint">For running from GitHub Actions or a server.</span></label>
272
+ </details>
273
+ <button class="btn btn-sm" id="setHookTest">Send a test</button>
274
+ </div>
275
+ </div>
276
+ <div class="card-section">
277
+ ${switchHtml("setDesktop", desktop, "Desktop alerts on this computer", "Pops up when an icon needs uploading or something goes wrong, while UpdateDay is open.")}
278
+ </div>
279
+ </div>`;
280
+ }
281
+
282
+ function notifySettings() {
283
+ UD.draft.notify = UD.draft.notify || { events: [...DEFAULT_ALERT_EVENTS] };
284
+ return UD.draft.notify;
285
+ }
286
+
287
+ function bindAlerts(root) {
288
+ root.querySelector("#setDiscordOn").addEventListener("change", (event) => {
289
+ if (event.target.checked) {
290
+ notifySettings();
291
+ } else {
292
+ delete UD.draft.notify;
293
+ }
294
+ root.querySelector("#setDiscordFields").hidden = !event.target.checked;
295
+ markDirty();
296
+ });
297
+ root.querySelector("#setHookForm").addEventListener("submit", async (event) => {
298
+ event.preventDefault();
299
+ const input = root.querySelector("#setHook");
300
+ const value = input.value.trim();
301
+ if (!value) {
302
+ input.focus();
303
+ return;
304
+ }
305
+ try {
306
+ await api("POST", "/api/key", { env: UD.draft.notify?.discordWebhookEnv ?? DEFAULT_WEBHOOK_ENV, value, kind: "webhook" });
307
+ input.value = "";
308
+ await refreshSchedule();
309
+ renderSettings(root);
310
+ toast("Webhook added for this session", "success");
311
+ } catch (error) {
312
+ toast(error.message, "danger");
313
+ }
314
+ });
315
+ for (const chip of root.querySelectorAll("[data-alert]")) {
316
+ chip.addEventListener("click", () => {
317
+ const settings = notifySettings();
318
+ const events = new Set(settings.events ?? DEFAULT_ALERT_EVENTS);
319
+ if (events.has(chip.dataset.alert)) {
320
+ events.delete(chip.dataset.alert);
321
+ } else {
322
+ events.add(chip.dataset.alert);
323
+ }
324
+ settings.events = ALERT_EVENTS.map(([value]) => value).filter((value) => events.has(value));
325
+ chip.classList.toggle("active", events.has(chip.dataset.alert));
326
+ chip.setAttribute("aria-pressed", String(events.has(chip.dataset.alert)));
327
+ markDirty();
328
+ });
329
+ }
330
+ root.querySelector("#setMention").addEventListener("change", (event) => {
331
+ const custom = event.target.value === "custom";
332
+ root.querySelector("#setMentionField").hidden = !custom;
333
+ setOrDelete(notifySettings(), "mention", custom ? root.querySelector("#setMentionText").value.trim() : event.target.value);
334
+ markDirty();
335
+ });
336
+ root.querySelector("#setMentionText").addEventListener("change", (event) => {
337
+ setOrDelete(notifySettings(), "mention", event.target.value.trim());
338
+ markDirty();
339
+ });
340
+ root.querySelector("#setHookEnv").addEventListener("change", (event) => {
341
+ const value = event.target.value.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
342
+ setOrDelete(notifySettings(), "discordWebhookEnv", value === DEFAULT_WEBHOOK_ENV ? "" : value);
343
+ markDirty();
344
+ });
345
+ root.querySelector("#setHookTest").addEventListener("click", async () => {
346
+ try {
347
+ await pushDraft();
348
+ await api("POST", `/api/notify-test?${gameQuery()}`);
349
+ toast("Test sent. Check your Discord channel.", "success");
350
+ } catch (error) {
351
+ toast(error.message, "danger");
352
+ }
353
+ });
354
+ root.querySelector("#setDesktop").addEventListener("change", async (event) => {
355
+ if (!event.target.checked) {
356
+ writeSetting(DESKTOP_ALERTS_KEY, null);
357
+ return;
358
+ }
359
+ const permission = typeof Notification === "undefined" ? "denied" : await Notification.requestPermission();
360
+ if (permission !== "granted") {
361
+ event.target.checked = false;
362
+ toast("Your browser blocked notifications. Allow them for this page and try again.", "danger");
363
+ return;
364
+ }
365
+ writeSetting(DESKTOP_ALERTS_KEY, "on");
366
+ new Notification("UpdateDay", { body: "Desktop alerts are on." });
367
+ });
368
+ }
369
+
370
+ function bindRepeat(root) {
371
+ const select = root.querySelector("#setRepeat");
372
+ const until = root.querySelector("#setUntil");
373
+ const apply = () => {
374
+ if (!select.value) {
375
+ delete UD.draft.repeat;
376
+ } else {
377
+ UD.draft.repeat = { every: select.value };
378
+ if (until.value) {
379
+ UD.draft.repeat.until = new Date(`${until.value}T23:59:59`).toISOString();
380
+ }
381
+ }
382
+ markDirty();
383
+ };
384
+ select.addEventListener("change", () => {
385
+ apply();
386
+ renderSettings(root);
387
+ });
388
+ until.addEventListener("change", apply);
389
+ }
390
+
391
+ function renderSettings(root) {
392
+ const game = currentGame();
393
+ root.innerHTML = `
394
+ <div class="grid-2">
395
+ <div class="stack">${gameCardHtml(game)}${alertsHtml()}</div>
396
+ <div class="stack">${sharedTimeHtml()}${extrasHtml()}</div>
397
+ </div>`;
398
+ bindGameCard(root, game);
399
+ bindShared(root);
400
+ bindRepeat(root);
401
+ bindAlerts(root);
402
+ root.querySelector("#setKit").addEventListener("click", () => downloadUrl("/api/kit.zip"));
403
+ }
404
+
405
+ UD.views.settings = {
406
+ label: "Settings",
407
+ render: renderSettings,
408
+ refresh() {
409
+ const hint = $("setRepeatHint");
410
+ if (hint) {
411
+ hint.innerHTML = repeatHint(scheduledGame()?.nextUpdateAt);
412
+ }
413
+ },
414
+ };
package/ui/shell.js ADDED
@@ -0,0 +1,193 @@
1
+ const TAB_ORDER = ["countdown", "sale", "events", "page", "players", "gifts", "history", "settings"];
2
+ const STATUS_TICK_MS = 15000;
3
+
4
+ function gameName(game, index) {
5
+ return game?.name || (game?.universeId ? `Game ${game.universeId}` : `New game ${index + 1}`);
6
+ }
7
+
8
+ function gameStatus(game, index) {
9
+ if (!game?.universeId) {
10
+ return { text: "Needs a game link", tone: "warning" };
11
+ }
12
+ const scheduled = scheduledGame(index);
13
+ const updateAt = gameUpdateAt(game).getTime();
14
+ const now = Date.now();
15
+ if (updateAt > now) {
16
+ return { text: `Update in ${formatSpan(updateAt - now)}`, tone: "accent" };
17
+ }
18
+ const pending = scheduled?.steps?.some((step) => !step.done);
19
+ return pending ? { text: "Update is live", tone: "success" } : { text: "Update finished", tone: "" };
20
+ }
21
+
22
+ function thumbFor(game, index) {
23
+ return game?.universeId ? `/api/icon?game=${index}&raw=1&r=${game.universeId}` : "";
24
+ }
25
+
26
+ function renderSidebar() {
27
+ const list = $("gameList");
28
+ list.innerHTML = UD.draft.games.map((game, index) => {
29
+ const status = gameStatus(game, index);
30
+ const thumb = thumbFor(game, index);
31
+ return `
32
+ <button class="game-item" data-index="${index}" aria-current="${index === UD.selected}">
33
+ ${thumb ? `<img class="game-thumb" src="${thumb}" alt="">` : `<span class="game-thumb"></span>`}
34
+ <span class="grow" style="min-width:0">
35
+ <span class="name">${h(gameName(game, index))}</span>
36
+ <span class="sub">${h(status.text)}</span>
37
+ </span>
38
+ </button>`;
39
+ }).join("") || `<div class="hint" style="padding:4px 10px 10px">No games yet.</div>`;
40
+ for (const item of list.querySelectorAll(".game-item")) {
41
+ item.addEventListener("click", () => selectGame(Number(item.dataset.index)));
42
+ }
43
+ const select = $("mobileGame");
44
+ select.innerHTML = UD.draft.games.map((game, index) => `<option value="${index}" ${index === UD.selected ? "selected" : ""}>${h(gameName(game, index))}</option>`).join("")
45
+ + `<option value="add">+ Add game</option>`;
46
+ }
47
+
48
+ function renderHeader() {
49
+ const box = $("gameHeader");
50
+ const game = currentGame();
51
+ if (!game) {
52
+ box.innerHTML = "";
53
+ return;
54
+ }
55
+ const status = gameStatus(game, UD.selected);
56
+ const thumb = thumbFor(game, UD.selected);
57
+ const own = game.updateAt ? `<span class="badge">Own update time</span>` : "";
58
+ const error = UD.schedule?.error ? `<div class="callout callout-danger" role="alert" style="margin-top:14px">${h(UD.schedule.error)}</div>` : "";
59
+ box.innerHTML = `
60
+ <div class="game-header">
61
+ ${thumb ? `<img class="game-thumb" src="${thumb}" alt="">` : `<span class="game-thumb"></span>`}
62
+ <div style="min-width:0">
63
+ <h1>${h(gameName(game, UD.selected))}</h1>
64
+ <div class="meta">
65
+ <span class="badge badge-${status.tone || "neutral"}">${h(status.text)}</span>
66
+ ${game.universeId ? `<span class="badge">${h(formatWhen(gameUpdateAt(game)))}</span>` : ""}
67
+ ${own}
68
+ </div>
69
+ </div>
70
+ </div>${error}`;
71
+ }
72
+
73
+ function renderTabs() {
74
+ const nav = $("tabs");
75
+ const game = currentGame();
76
+ if (!game) {
77
+ nav.innerHTML = "";
78
+ return;
79
+ }
80
+ nav.innerHTML = TAB_ORDER.map((key) => `
81
+ <button class="tab" role="tab" id="tab-${key}" aria-controls="view" aria-selected="${key === UD.view}" tabindex="${key === UD.view ? 0 : -1}" data-view="${key}">${UD.views[key].label}</button>`).join("");
82
+ for (const tab of nav.querySelectorAll(".tab")) {
83
+ tab.addEventListener("click", () => showView(tab.dataset.view));
84
+ tab.addEventListener("keydown", (event) => {
85
+ const index = TAB_ORDER.indexOf(tab.dataset.view);
86
+ const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
87
+ if (step) {
88
+ const next = TAB_ORDER[(index + step + TAB_ORDER.length) % TAB_ORDER.length];
89
+ showView(next);
90
+ $(`tab-${next}`).focus();
91
+ }
92
+ });
93
+ }
94
+ }
95
+
96
+ function renderView() {
97
+ const container = $("view");
98
+ const game = currentGame();
99
+ if (!game) {
100
+ container.innerHTML = `
101
+ <div class="card">${emptyState("Plan your first update day", "Add a game by pasting its Roblox link. UpdateDay will build countdown icons from its current icon, plan your sale and events, and run everything on time.", `<button class="btn btn-primary" id="emptyAdd">+ Add a game</button>`)}</div>`;
102
+ $("emptyAdd").addEventListener("click", addGame);
103
+ return;
104
+ }
105
+ container.setAttribute("aria-labelledby", `tab-${UD.view}`);
106
+ UD.views[UD.view].render(container);
107
+ }
108
+
109
+ function showView(key) {
110
+ UD.view = key;
111
+ renderTabs();
112
+ renderView();
113
+ }
114
+
115
+ function selectGame(index) {
116
+ UD.selected = index;
117
+ renderSidebar();
118
+ renderHeader();
119
+ renderView();
120
+ }
121
+
122
+ function addGame() {
123
+ UD.draft.games.push({ name: "" });
124
+ UD.selected = UD.draft.games.length - 1;
125
+ UD.view = "settings";
126
+ markDirty();
127
+ renderAll();
128
+ setTimeout(() => $("setLookup")?.focus(), 50);
129
+ }
130
+
131
+ function renderAll() {
132
+ renderSidebar();
133
+ renderHeader();
134
+ renderTabs();
135
+ renderView();
136
+ }
137
+
138
+ function refreshChrome() {
139
+ renderSidebar();
140
+ renderHeader();
141
+ UD.views[UD.view]?.refresh?.();
142
+ }
143
+
144
+ async function init() {
145
+ const loaded = await api("GET", "/api/draft");
146
+ UD.draft = loaded.draft;
147
+ UD.draft.games = UD.draft.games || [];
148
+ UD.revision = loaded.revision;
149
+ UD.configPath = loaded.configPath;
150
+ $("configPath").textContent = loaded.configPath.split(/[\\/]/).pop();
151
+ $("configPath").title = loaded.configPath;
152
+ $("saveBtn").addEventListener("click", save);
153
+ $("themeBtn").addEventListener("click", toggleTheme);
154
+ $("addGame").addEventListener("click", addGame);
155
+ $("mobileGame").addEventListener("change", (event) => {
156
+ if (event.target.value === "add") {
157
+ addGame();
158
+ } else {
159
+ selectGame(Number(event.target.value));
160
+ }
161
+ });
162
+ window.addEventListener("keydown", (event) => {
163
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
164
+ event.preventDefault();
165
+ if (!$("saveBtn").disabled) {
166
+ save();
167
+ }
168
+ }
169
+ });
170
+ document.addEventListener("click", (event) => {
171
+ if (event.target.closest(".js-open-settings")) {
172
+ showView("settings");
173
+ setTimeout(() => $("setLookup")?.focus(), 50);
174
+ }
175
+ });
176
+ window.addEventListener("beforeunload", (event) => {
177
+ if (UD.dirty) {
178
+ event.preventDefault();
179
+ }
180
+ });
181
+ onChange(refreshChrome);
182
+ await refreshSchedule();
183
+ renderAll();
184
+ initRunbar();
185
+ setInterval(() => {
186
+ renderSidebar();
187
+ renderHeader();
188
+ }, STATUS_TICK_MS);
189
+ }
190
+
191
+ init().catch((error) => {
192
+ $("view").innerHTML = errorBox(`UpdateDay couldn't load: ${error.message}`);
193
+ });