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,775 @@
1
+ const LIVE_TICK_MS = 1000;
2
+ const COUNTDOWN_SPAN_MS = 24 * 36e5;
3
+ const POSITIONS = [["center", "Centre"], ["full", "Full icon"], ["top-right", "Top right"], ["top-left", "Top left"], ["bottom-right", "Bottom right"], ["bottom-left", "Bottom left"]];
4
+
5
+ const ART_DIR = "updateday-art";
6
+ const STYLE_CHOICES = [["original", "Hand-drawn"], ["classic", "Classic"], ["pixel", "Pixel"], ["rounded", "Rounded"], ["glow", "Glow"], ["blocky", "Blocky"], ["outline", "Outline"], ["own", "My own"]];
7
+ const STYLE_SLIDERS = [
8
+ ["dim", "Dark layer", 0, 0.9, 0.05, 0.45],
9
+ ["hourglassSize", "Hourglass size", 0.2, 1, 0.02, 0.66],
10
+ ["hourglassY", "Hourglass height", 0.1, 0.9, 0.01, 0.4],
11
+ ["textSize", "Text size", 0.05, 0.3, 0.01, 0.15],
12
+ ["textY", "Text height", 0.1, 0.95, 0.01, 0.85],
13
+ ];
14
+ const CLASSIC_SLIDERS = [
15
+ ["dim", "Dark layer", 0, 0.9, 0.05, 0.45],
16
+ ["scale", "Hourglass size", 0.3, 1, 0.02, 0.88],
17
+ ];
18
+
19
+ let previewMark = null;
20
+ let moderation = {};
21
+ let moderationTimer = null;
22
+
23
+ const DEFAULT_TITLE = { format: "[{time}] {name}", nowFormat: "[LIVE] {name}", afterFormat: "{name}" };
24
+ const MAX_TITLE_LENGTH = 50;
25
+ const MODERATION_POLL_MS = 15000;
26
+ const MODERATION_LABELS = { Approved: ["Approved", "badge-success"], Reviewing: ["In review", "badge-warning"], Rejected: ["Rejected", "badge-danger"], unknown: ["Checking", ""] };
27
+
28
+ let liveTimer = null;
29
+ let artMarks = null;
30
+ let artRevision = 0;
31
+
32
+ function stageMarks() {
33
+ return [...(countdown()?.marks ?? ALL_MARKS), "now"];
34
+ }
35
+
36
+ function skipList() {
37
+ return countdown()?.skip ?? [];
38
+ }
39
+
40
+ function activeStages() {
41
+ const skip = skipList();
42
+ return stageMarks().filter((mark) => !skip.includes(mark));
43
+ }
44
+
45
+ function stepDone(game, id) {
46
+ return game?.steps?.find((step) => step.id === id)?.done ?? null;
47
+ }
48
+
49
+ function currentMark(game) {
50
+ const updateAt = new Date(game.updateAt).getTime();
51
+ const started = activeStages().filter((mark) => updateAt - offsetMs(mark) <= Date.now());
52
+ return started.length ? started[started.length - 1] : null;
53
+ }
54
+
55
+ function liveHtml() {
56
+ const game = scheduledGame();
57
+ if (!game) {
58
+ return emptyState("Waiting for the game link", "Add the game's Roblox link to preview its countdown.", `<button class="btn btn-primary btn-sm js-open-settings">Add game link</button>`);
59
+ }
60
+ const updateAt = new Date(game.updateAt).getTime();
61
+ const left = updateAt - Date.now();
62
+ const mark = currentMark(game);
63
+ const finalStep = game.steps.find((step) => step.id === "icon:final");
64
+ const finished = finalStep?.at && Date.now() >= Date.parse(finalStep.at);
65
+ const shown = finished || !mark ? "final" : mark;
66
+ const first = activeStages()[0];
67
+ const startsIn = updateAt - offsetMs(first ?? "24h") - Date.now();
68
+ const label = finished ? "Back to normal" : mark ? (left > 0 ? "Update in" : "Update is live") : "Countdown starts in";
69
+ const timer = finished || (mark && left <= 0) ? "00:00:00" : clock(mark ? left : startsIn);
70
+ const progress = Math.min(100, Math.max(0, 100 - (Math.max(0, left) / COUNTDOWN_SPAN_MS) * 100));
71
+ return `
72
+ <div class="live">
73
+ <img src="${iconUrl(shown)}" alt="The game icon players see right now">
74
+ <div>
75
+ <div class="eyebrow">How players see it now</div>
76
+ <div class="hint" id="liveLabel">${label}</div>
77
+ <div class="timer" id="liveTimer" aria-live="off">${timer}</div>
78
+ <div class="hint">${h(formatWhen(updateAt))}</div>
79
+ <div class="progress" aria-hidden="true"><div id="liveBar" style="width:${progress}%"></div></div>
80
+ </div>
81
+ </div>`;
82
+ }
83
+
84
+ function reminderHtml() {
85
+ const game = scheduledGame();
86
+ const latest = (game?.reminders ?? []).slice(-1)[0];
87
+ if (!latest) {
88
+ return "";
89
+ }
90
+ const file = `/api/file?path=${encodeURIComponent(latest.file)}`;
91
+ return `
92
+ <div class="reminder" role="status">
93
+ <img src="${file}&r=${UD.revision}" alt="">
94
+ <div><b>Upload this icon in Creator Hub now</b><div class="hint">${h(latest.text.replace(/^REMINDER [^:]+: /, ""))}</div></div>
95
+ <a class="btn btn-sm" href="${file}" download>Download</a>
96
+ </div>`;
97
+ }
98
+
99
+ function stageState(game, mark) {
100
+ const done = stepDone(game, `icon:${mark}`);
101
+ if (currentMark(game) === mark) {
102
+ return `<span class="badge badge-accent state">Live</span>`;
103
+ }
104
+ if (done && !done.startsWith("skipped")) {
105
+ return `<span class="badge badge-success state">Done</span>`;
106
+ }
107
+ if (done) {
108
+ return `<span class="badge state">Skipped</span>`;
109
+ }
110
+ return "";
111
+ }
112
+
113
+ function stagesHtml() {
114
+ const game = scheduledGame();
115
+ if (!game) {
116
+ return emptyState("No stages yet", "Stages appear once the game has a link.");
117
+ }
118
+ const updateAt = new Date(game.updateAt).getTime();
119
+ const skip = skipList();
120
+ const tiles = stageMarks().map((mark) => {
121
+ const on = !skip.includes(mark);
122
+ return `
123
+ <button class="stage" data-mark="${mark}" aria-pressed="${on}" aria-label="${stageName(mark)} stage, ${on ? "on" : "off"}">
124
+ <img loading="lazy" src="${iconUrl(mark)}" alt="">
125
+ <span class="tick" aria-hidden="true">✓</span>
126
+ ${on ? stageState(game, mark) : ""}
127
+ ${on ? moderationBadge(mark) : ""}
128
+ <span class="stage-name">${stageName(mark)}</span>
129
+ <span class="stage-when">${h(formatWhen(updateAt - offsetMs(mark)))}</span>
130
+ </button>`;
131
+ });
132
+ const finalStep = game.steps.find((step) => step.id === "icon:final");
133
+ const hasNew = Boolean(currentGame()?.countdown?.newIcon);
134
+ tiles.push(`
135
+ <div class="stage fixed">
136
+ <img loading="lazy" src="${iconUrl("final")}" alt="">
137
+ ${finalStep?.done ? `<span class="badge badge-success state">Done</span>` : ""}
138
+ <span class="stage-name">${hasNew ? "New icon" : "Back to normal"}</span>
139
+ <span class="stage-when">${finalStep?.at ? h(formatWhen(finalStep.at)) : ""}</span>
140
+ </div>`);
141
+ return `<div class="stages">${tiles.join("")}</div>`;
142
+ }
143
+
144
+ function timelineHtml() {
145
+ const game = scheduledGame();
146
+ if (!game) {
147
+ return emptyState("Nothing scheduled yet", "");
148
+ }
149
+ const rows = game.steps.map((step) => {
150
+ const at = step.at ? new Date(step.at) : null;
151
+ let status = "";
152
+ if (step.done) {
153
+ status = step.done.startsWith("skipped") ? `<span class="badge">Skipped</span>` : `<span class="badge badge-success">Done</span>`;
154
+ } else if (!at || at.getTime() <= Date.now()) {
155
+ status = `<span class="badge badge-warning">Due</span>`;
156
+ }
157
+ return `<div class="list-row"><span class="when">${at ? h(formatWhen(at)) : "Right away"}</span><span>${h(step.label)}</span>${status}</div>`;
158
+ });
159
+ return `<div class="list timeline">${rows.join("")}</div>`;
160
+ }
161
+
162
+ function toggleStage(mark) {
163
+ const cd = countdown();
164
+ const skip = new Set(cd.skip ?? []);
165
+ if (skip.has(mark)) {
166
+ skip.delete(mark);
167
+ } else {
168
+ skip.add(mark);
169
+ }
170
+ cd.skip = stageMarks().filter((item) => skip.has(item));
171
+ if (cd.skip.length === 0) {
172
+ delete cd.skip;
173
+ }
174
+ markDirty();
175
+ const tile = document.querySelector(`.stage[data-mark="${mark}"]`);
176
+ tile.setAttribute("aria-pressed", String(!skip.has(mark)));
177
+ }
178
+
179
+ function iconSettingsHtml() {
180
+ const cd = countdown();
181
+ const game = currentGame();
182
+ const newIcon = game?.countdown?.newIcon;
183
+ const method = cd?.iconMethod ?? "reminder";
184
+ return `
185
+ <div class="card">
186
+ ${switchHtml("cdOn", Boolean(cd), "Countdown icons", "Swap the game icon through the countdown.")}
187
+ <div id="cdFields" ${cd ? "" : "hidden"}>
188
+ ${cd ? lookHtml(cd) : ""}
189
+ <div class="card-section" id="cdArtSection" ${cd?.overlayDir ? "" : "hidden"}>
190
+ <div class="section-title">Your hourglass art</div>
191
+ <div id="cdArt">
192
+ <div class="hint" style="margin-bottom:10px">Upload art for any stage. Stages without their own art use your default art with the time drawn on it, or the built-in hourglass.</div>
193
+ <div class="art-grid" id="cdArtGrid">${artGridHtml()}</div>
194
+ <input type="file" id="cdArtFile" accept="image/png,image/webp,image/svg+xml,image/*" hidden>
195
+ ${switchHtml("cdLabel", cd?.label !== false, "Draw the time on art that has none", "Used for your default art and the built-in hourglass.")}
196
+ </div>
197
+ </div>
198
+ <div class="card-section">
199
+ <div class="section-title">New icon for this update</div>
200
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:10px">
201
+ ${newIcon ? `<img src="/api/file?path=${encodeURIComponent(newIcon)}&r=${UD.revision}" alt="New icon" style="width:56px;height:56px;border-radius:12px">` : ""}
202
+ <input type="file" id="cdNewFile" accept="image/*" hidden>
203
+ <button class="btn btn-sm" id="cdNewPick">${newIcon ? "Replace" : "Upload image"}</button>
204
+ ${newIcon ? `<button class="btn btn-sm btn-danger-ghost" id="cdNewClear">Remove</button>` : ""}
205
+ </div>
206
+ <div class="hint" style="margin-bottom:8px">Optional. Shown at the end, or under the NOW hourglass:</div>
207
+ ${segmentedHtml("newIconAt", [["end", "After the admin hour"], ["now", "With NOW"]], cd?.newIconAt ?? "end")}
208
+ </div>
209
+ <div class="card-section">
210
+ <div class="section-title">How the icon changes on Roblox</div>
211
+ <div class="choice-list">
212
+ <label class="choice"><input type="radio" name="cdMethod" value="reminder" ${method === "reminder" ? "checked" : ""}><span><b>Remind me</b><span class="hint">UpdateDay makes each icon; you upload it in Creator Hub when asked.</span></span></label>
213
+ <label class="choice"><input type="radio" name="cdMethod" value="languageSwap" ${method === "languageSwap" ? "checked" : ""}><span><b>Automatic</b><span class="hint">Uses a language swap, because Roblox doesn't let API keys change the main icon.</span></span></label>
214
+ </div>
215
+ <div class="revealed" id="cdSwap" ${method === "languageSwap" ? "" : "hidden"}>
216
+ <details class="hint" style="margin-bottom:10px"><summary>How the language swap works</summary>
217
+ <p style="margin-top:8px">UpdateDay briefly makes another language the game's source language, sets the countdown as every other language's icon, then switches back. Players whose Roblox language is the temporary one see your normal icon. A new icon still has to be uploaded in Creator Hub at the end.</p>
218
+ </details>
219
+ <div class="row">
220
+ <label class="field"><span>Temporary language</span><input type="text" id="cdSwapLang" placeholder="Auto (least used)" value="${h(cd?.swapLanguage ?? "")}"></label>
221
+ <label class="field"><span>Upload early (seconds)</span><input type="number" id="cdLead" min="0" max="900" value="${cd?.uploadLeadSeconds ?? 90}"></label>
222
+ </div>
223
+ ${switchHtml("cdLocalize", cd?.localizeLabels !== false, "Translate the labels", "\"24 STUNDEN\", \"24時間\" and so on for each language.")}
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>`;
228
+ }
229
+
230
+ function artSlots() {
231
+ return ["default", ...stageMarks()];
232
+ }
233
+
234
+ function artGridHtml() {
235
+ if (artMarks === null) {
236
+ return `<div class="skeleton" style="height:84px;grid-column:1 / -1"></div>`;
237
+ }
238
+ return artSlots().map((mark) => {
239
+ const has = artMarks.includes(mark);
240
+ const name = mark === "default" ? "Default" : stageName(mark);
241
+ const preview = has
242
+ ? `<img src="/api/file?path=${encodeURIComponent(`${ART_DIR}/${mark}.png`)}&r=${artRevision}" alt="">`
243
+ : `<span class="art-empty" aria-hidden="true">+</span>`;
244
+ return `
245
+ <div class="art-slot ${has ? "has-art" : ""}">
246
+ <button type="button" class="art-pick" data-art="${mark}" aria-label="${has ? "Replace" : "Upload"} art for ${h(name)}">${preview}</button>
247
+ <span class="art-name">${h(name)}</span>
248
+ ${has ? `<button type="button" class="link-btn art-remove" data-art-remove="${mark}">Remove</button>` : `<span class="art-note">${mark === "default" ? "Optional" : "Built-in"}</span>`}
249
+ </div>`;
250
+ }).join("");
251
+ }
252
+
253
+ async function loadArt() {
254
+ try {
255
+ artMarks = (await api("GET", "/api/art")).marks;
256
+ } catch {
257
+ artMarks = [];
258
+ }
259
+ const grid = $("cdArtGrid");
260
+ if (grid) {
261
+ grid.innerHTML = artGridHtml();
262
+ }
263
+ }
264
+
265
+ function artChanged(root) {
266
+ artRevision++;
267
+ UD.revision++;
268
+ $("cdArtGrid").innerHTML = artGridHtml();
269
+ const stages = $("cdStages");
270
+ if (stages) {
271
+ stages.innerHTML = stagesHtml();
272
+ bindStages(root);
273
+ }
274
+ const live = $("cdLive");
275
+ if (live) {
276
+ live.innerHTML = liveHtml();
277
+ }
278
+ }
279
+
280
+ function styleChoice(cd) {
281
+ if (cd.overlayDir) {
282
+ return "own";
283
+ }
284
+ return cd.style ?? "original";
285
+ }
286
+
287
+ function sliderHtml([key, label, min, max, step, fallback], cd) {
288
+ const value = cd[key] ?? fallback;
289
+ return `<div class="range-row"><label for="look-${key}">${label}</label><input type="range" id="look-${key}" data-look="${key}" min="${min}" max="${max}" step="${step}" value="${value}"><output for="look-${key}" id="look-${key}-out">${Math.round(value * 100)}%</output></div>`;
290
+ }
291
+
292
+ function stageTextHtml(cd) {
293
+ const own = cd.labels ?? {};
294
+ const count = Object.keys(own).length;
295
+ return `
296
+ <details class="stage-text" ${count ? "open" : ""}>
297
+ <summary>Text on each stage${count ? ` <span class="badge badge-accent">${count} changed</span>` : ""}</summary>
298
+ <div class="hint" style="margin:8px 0 10px">Leave a box empty to use the standard text. Translated labels still win in other languages.</div>
299
+ <div class="stage-text-grid">${stageMarks().map((mark) => `
300
+ <label class="stage-text-row"><span>${stageName(mark)}</span><input type="text" maxlength="16" data-stage-text="${mark}" placeholder="${h(standardText(mark))}" value="${h(own[mark] ?? "")}"></label>`).join("")}
301
+ </div>
302
+ </details>`;
303
+ }
304
+
305
+ function standardText(mark) {
306
+ if (mark === "now") {
307
+ return "NOW!";
308
+ }
309
+ const match = /^(\d+)([mh])$/.exec(mark);
310
+ const count = match ? Number(match[1]) : 0;
311
+ return match ? `${count} ${match[2] === "h" ? "HOUR" : "MIN"}${count === 1 ? "" : "S"}` : mark.toUpperCase();
312
+ }
313
+
314
+ function lookHtml(cd) {
315
+ const choice = styleChoice(cd);
316
+ const styled = !["original", "own"].includes(choice);
317
+ const marks = stageMarks();
318
+ previewMark = marks.includes(previewMark) ? previewMark : marks[Math.min(2, marks.length - 1)];
319
+ return `
320
+ <div class="card-section">
321
+ <div class="section-title">Look</div>
322
+ <div class="look-preview">
323
+ <img id="cdPreview" src="${iconUrl(previewMark)}" alt="Preview of the ${h(stageName(previewMark))} icon">
324
+ <div class="chips" id="cdPreviewChips">${marks.map((mark) => `<button type="button" class="chip ${mark === previewMark ? "active" : ""}" data-preview="${mark}" aria-pressed="${mark === previewMark}">${mark === "now" ? "NOW" : mark.toUpperCase()}</button>`).join("")}</div>
325
+ </div>
326
+ <div class="field"><span>Hourglass style</span>
327
+ <div class="style-grid" role="radiogroup" aria-label="Hourglass style">${STYLE_CHOICES.map(([value, label]) => `
328
+ <button type="button" class="style-tile ${value === choice ? "active" : ""}" role="radio" aria-checked="${value === choice}" data-style="${value}">
329
+ ${value === "own" ? `<span class="style-own" aria-hidden="true">+</span>` : `<img src="/api/style-art?name=${value}" alt="">`}
330
+ <span>${label}</span>
331
+ </button>`).join("")}
332
+ </div>
333
+ </div>
334
+ ${(styled ? STYLE_SLIDERS : CLASSIC_SLIDERS).map((slider) => sliderHtml(slider, cd)).join("")}
335
+ ${styled ? "" : `<label class="field"><span>Position</span>
336
+ <select id="cdAnchor">${POSITIONS.map(([value, label]) => `<option value="${value}" ${value === (cd.anchor ?? "center") ? "selected" : ""}>${label}</option>`).join("")}</select>
337
+ </label>`}
338
+ ${styled ? switchHtml("cdStyleLabel", cd.label !== false, "Show the time text", "Turn off for an hourglass on its own.") : ""}
339
+ ${styled ? `<button type="button" class="link-btn" id="cdLookReset">Reset size and position</button>` : ""}
340
+ ${stageTextHtml(cd)}
341
+ </div>`;
342
+ }
343
+
344
+ function refreshPreview() {
345
+ const preview = $("cdPreview");
346
+ if (preview && previewMark) {
347
+ preview.src = iconUrl(previewMark);
348
+ }
349
+ }
350
+
351
+ function bindLook(root, cd) {
352
+ for (const tile of root.querySelectorAll("[data-style]")) {
353
+ tile.addEventListener("click", () => {
354
+ const value = tile.dataset.style;
355
+ if (value === "own") {
356
+ cd.overlayDir = ART_DIR;
357
+ delete cd.style;
358
+ } else {
359
+ delete cd.overlayDir;
360
+ if (value === "original") {
361
+ delete cd.style;
362
+ } else {
363
+ cd.style = value;
364
+ }
365
+ }
366
+ markDirty();
367
+ renderCountdown(root);
368
+ });
369
+ }
370
+ for (const chip of root.querySelectorAll("[data-preview]")) {
371
+ chip.addEventListener("click", () => {
372
+ previewMark = chip.dataset.preview;
373
+ for (const other of root.querySelectorAll("[data-preview]")) {
374
+ other.classList.toggle("active", other === chip);
375
+ other.setAttribute("aria-pressed", String(other === chip));
376
+ }
377
+ refreshPreview();
378
+ });
379
+ }
380
+ for (const input of root.querySelectorAll("[data-look]")) {
381
+ input.addEventListener("input", () => {
382
+ cd[input.dataset.look] = Number(input.value);
383
+ $(`look-${input.dataset.look}-out`).textContent = `${Math.round(cd[input.dataset.look] * 100)}%`;
384
+ markDirty();
385
+ });
386
+ }
387
+ root.querySelector("#cdAnchor")?.addEventListener("change", (event) => {
388
+ cd.anchor = event.target.value;
389
+ markDirty();
390
+ });
391
+ root.querySelector("#cdStyleLabel")?.addEventListener("change", (event) => {
392
+ cd.label = event.target.checked;
393
+ markDirty();
394
+ });
395
+ root.querySelector("#cdLookReset")?.addEventListener("click", () => {
396
+ for (const [key] of STYLE_SLIDERS) {
397
+ if (key !== "dim") {
398
+ delete cd[key];
399
+ }
400
+ }
401
+ markDirty();
402
+ renderCountdown(root);
403
+ });
404
+ for (const input of root.querySelectorAll("[data-stage-text]")) {
405
+ input.addEventListener("change", () => {
406
+ const text = input.value.trim();
407
+ cd.labels = { ...(cd.labels ?? {}) };
408
+ if (text) {
409
+ cd.labels[input.dataset.stageText] = text;
410
+ } else {
411
+ delete cd.labels[input.dataset.stageText];
412
+ }
413
+ if (Object.keys(cd.labels).length === 0) {
414
+ delete cd.labels;
415
+ }
416
+ markDirty();
417
+ });
418
+ }
419
+ }
420
+
421
+ function bindArt(root, cd) {
422
+ root.querySelector("#cdLabel").addEventListener("change", (event) => {
423
+ cd.label = event.target.checked;
424
+ markDirty();
425
+ });
426
+ const file = root.querySelector("#cdArtFile");
427
+ let pendingMark = null;
428
+ root.querySelector("#cdArtGrid").addEventListener("click", async (event) => {
429
+ const pick = event.target.closest("[data-art]");
430
+ const remove = event.target.closest("[data-art-remove]");
431
+ if (pick) {
432
+ pendingMark = pick.dataset.art;
433
+ file.value = "";
434
+ file.click();
435
+ }
436
+ if (remove) {
437
+ try {
438
+ await api("DELETE", `/api/art?mark=${encodeURIComponent(remove.dataset.artRemove)}`);
439
+ artMarks = artMarks.filter((mark) => mark !== remove.dataset.artRemove);
440
+ artChanged(root);
441
+ } catch (error) {
442
+ toast(error.message, "danger");
443
+ }
444
+ }
445
+ });
446
+ file.addEventListener("change", async () => {
447
+ if (!file.files[0] || !pendingMark) {
448
+ return;
449
+ }
450
+ try {
451
+ await api("POST", `/api/art?mark=${encodeURIComponent(pendingMark)}`, await file.files[0].arrayBuffer(), true);
452
+ artMarks = [...new Set([...(artMarks ?? []), pendingMark])];
453
+ artChanged(root);
454
+ toast(`Art added for ${pendingMark === "default" ? "the default" : stageName(pendingMark)}`, "success");
455
+ } catch (error) {
456
+ toast(error.message, "danger");
457
+ }
458
+ });
459
+ if (cd.overlayDir && artMarks === null) {
460
+ loadArt();
461
+ }
462
+ }
463
+
464
+ async function downloadIcons() {
465
+ const sure = await dialog({
466
+ title: "Download the countdown icons",
467
+ html: true,
468
+ confirmText: "Download .zip",
469
+ body: `<p>Every stage as a 512×512 PNG, numbered in order, ready to upload.</p>
470
+ <label class="switch-row" for="zipLanguages" style="margin-top:12px">
471
+ <span class="switch-text"><span class="switch-label">Include translated versions</span><span class="hint">A folder per language with "24 STUNDEN", "24時間" and so on.</span></span>
472
+ <input type="checkbox" role="switch" class="switch" id="zipLanguages">
473
+ </label>`,
474
+ });
475
+ if (sure) {
476
+ downloadUrl(`/api/icons.zip?${gameQuery()}&languages=${$("zipLanguages").checked ? 1 : 0}`);
477
+ toast("Building the icons. The download starts in a moment.", "success");
478
+ }
479
+ }
480
+
481
+ function bindIconSettings(root) {
482
+ const cdOn = root.querySelector("#cdOn");
483
+ cdOn.addEventListener("change", () => {
484
+ UD.draft.countdown = cdOn.checked ? {} : false;
485
+ markDirty();
486
+ renderCountdown(root);
487
+ });
488
+ if (!countdown()) {
489
+ return;
490
+ }
491
+ const cd = countdown();
492
+ bindLook(root, cd);
493
+ bindSegmented(root, "newIconAt", (value) => {
494
+ cd.newIconAt = value;
495
+ markDirty();
496
+ });
497
+ for (const radio of root.querySelectorAll("input[name=cdMethod]")) {
498
+ radio.addEventListener("change", () => {
499
+ cd.iconMethod = radio.value;
500
+ root.querySelector("#cdSwap").hidden = radio.value !== "languageSwap";
501
+ markDirty();
502
+ });
503
+ }
504
+ root.querySelector("#cdSwapLang").addEventListener("change", (event) => {
505
+ const value = event.target.value.trim();
506
+ if (value) {
507
+ cd.swapLanguage = value;
508
+ } else {
509
+ delete cd.swapLanguage;
510
+ }
511
+ markDirty();
512
+ });
513
+ root.querySelector("#cdLead").addEventListener("change", (event) => {
514
+ cd.uploadLeadSeconds = Number(event.target.value);
515
+ markDirty();
516
+ });
517
+ root.querySelector("#cdLocalize").addEventListener("change", (event) => {
518
+ cd.localizeLabels = event.target.checked;
519
+ markDirty();
520
+ });
521
+ bindArt(root, cd);
522
+ const game = currentGame();
523
+ const file = root.querySelector("#cdNewFile");
524
+ root.querySelector("#cdNewPick").addEventListener("click", () => file.click());
525
+ file.addEventListener("change", async () => {
526
+ if (!file.files[0]) {
527
+ return;
528
+ }
529
+ try {
530
+ const result = await api("POST", `/api/upload?universeId=${game.universeId || ""}`, await file.files[0].arrayBuffer(), true);
531
+ game.countdown = { ...(game.countdown ?? {}), newIcon: result.path };
532
+ markDirty();
533
+ toast("New icon added", "success");
534
+ renderCountdown(root);
535
+ } catch (error) {
536
+ toast(error.message, "danger");
537
+ }
538
+ });
539
+ root.querySelector("#cdNewClear")?.addEventListener("click", () => {
540
+ delete game.countdown.newIcon;
541
+ if (Object.keys(game.countdown).length === 0) {
542
+ delete game.countdown;
543
+ }
544
+ markDirty();
545
+ renderCountdown(root);
546
+ });
547
+ }
548
+
549
+ function moderationBadge(mark) {
550
+ const label = MODERATION_LABELS[moderation[mark]];
551
+ return label ? `<span class="badge ${label[1]} stage-mod">${label[0]}</span>` : "";
552
+ }
553
+
554
+ function moderationSummary() {
555
+ const states = Object.values(moderation);
556
+ if (!states.length || states.every((state) => state === "unchecked")) {
557
+ return "";
558
+ }
559
+ const count = (state) => states.filter((item) => item === state).length;
560
+ const parts = [[count("Approved"), "approved"], [count("Reviewing"), "in review"], [count("Rejected"), "rejected"], [count("unchecked"), "changed since the check"]].filter(([n]) => n > 0);
561
+ return `Moderation: ${parts.map(([n, word]) => `${n} ${word}`).join(", ")}.`;
562
+ }
563
+
564
+ function renderModeration() {
565
+ const stages = $("cdStages");
566
+ if (stages) {
567
+ stages.innerHTML = stagesHtml();
568
+ bindStages($("view"));
569
+ }
570
+ const summary = $("cdModSummary");
571
+ if (summary) {
572
+ summary.textContent = moderationSummary();
573
+ }
574
+ }
575
+
576
+ async function loadModeration() {
577
+ clearTimeout(moderationTimer);
578
+ if (!currentGame()?.universeId || !countdown()) {
579
+ return;
580
+ }
581
+ try {
582
+ const { stages } = await api("GET", `/api/moderation?${gameQuery()}`);
583
+ moderation = Object.fromEntries(stages.map((stage) => [stage.mark, stage.state]));
584
+ } catch {
585
+ moderation = {};
586
+ }
587
+ renderModeration();
588
+ if (Object.values(moderation).some((state) => state === "Reviewing" || state === "unknown") && UD.view === "countdown") {
589
+ moderationTimer = setTimeout(loadModeration, MODERATION_POLL_MS);
590
+ }
591
+ }
592
+
593
+ async function checkModeration() {
594
+ const sure = await dialog({
595
+ title: "Check the icons with Roblox moderation?",
596
+ body: "UpdateDay uploads each countdown icon to your Roblox inventory as a private image so Roblox reviews it now, days before the update, instead of at the moment it goes up. Only icons that changed since the last check are sent again.",
597
+ confirmText: "Check now",
598
+ });
599
+ if (!sure) {
600
+ return;
601
+ }
602
+ const button = $("cdModCheck");
603
+ button.disabled = true;
604
+ button.textContent = "Uploading…";
605
+ try {
606
+ const { sent, stages } = await api("POST", `/api/moderation?${gameQuery()}`);
607
+ moderation = Object.fromEntries(stages.map((stage) => [stage.mark, stage.state]));
608
+ renderModeration();
609
+ toast(sent ? `Sent ${sent} icon${sent === 1 ? "" : "s"} for review. Results usually take a minute.` : "Nothing changed since the last check", "success");
610
+ moderationTimer = setTimeout(loadModeration, MODERATION_POLL_MS);
611
+ } catch (error) {
612
+ toast(error.message, "danger");
613
+ } finally {
614
+ button.disabled = false;
615
+ button.textContent = "Check with moderation";
616
+ }
617
+ }
618
+
619
+ function titlePreview(format, name, time) {
620
+ const shell = format.replace(/\{time\}/g, time);
621
+ if (!shell.includes("{name}")) {
622
+ return shell.trim().slice(0, MAX_TITLE_LENGTH);
623
+ }
624
+ const room = Math.max(0, MAX_TITLE_LENGTH - (shell.length - "{name}".length));
625
+ return shell.replace("{name}", name.length > room ? name.slice(0, room).trimEnd() : name).trim();
626
+ }
627
+
628
+ function titleRowsHtml(title) {
629
+ const name = currentGame()?.name || "Your Game";
630
+ const marks = stageMarks().filter((mark) => mark !== "now" && !skipList().includes(mark));
631
+ const rows = [
632
+ ...marks.map((mark) => [mark.toUpperCase(), titlePreview(title.format, name, mark.toUpperCase())]),
633
+ ...(skipList().includes("now") ? [] : [["NOW", titlePreview(title.nowFormat, name, "LIVE")]]),
634
+ ["After", titlePreview(title.afterFormat, name, "")],
635
+ ];
636
+ return rows.map(([stage, text]) => `<div class="title-row"><span class="badge">${stage}</span><span>${h(text)}</span></div>`).join("");
637
+ }
638
+
639
+ function titleCardHtml() {
640
+ const title = UD.draft.title;
641
+ const values = { ...DEFAULT_TITLE, ...(title || {}) };
642
+ return `
643
+ <div class="card">
644
+ ${switchHtml("tiOn", Boolean(title), "Title countdown", "Puts the time left in the game's title, e.g. [6H] Your Game, then [LIVE], then back.")}
645
+ <div class="revealed" id="tiFields" ${title ? "" : "hidden"}>
646
+ <label class="field"><span>Title during the countdown</span><input type="text" id="tiFormat" maxlength="60" value="${h(values.format)}"><span class="hint">{time} becomes 6H, 45M and so on; {name} is the game's current title.</span></label>
647
+ <label class="field"><span>At update time</span><input type="text" id="tiNow" maxlength="60" value="${h(values.nowFormat)}"></label>
648
+ <label class="field"><span>After the admin-abuse hour</span><input type="text" id="tiAfter" maxlength="60" value="${h(values.afterFormat)}"><span class="hint">{name} puts the original title back. Or name the update, e.g. {name} [UPDATE 5].</span></label>
649
+ <div class="section-title" style="margin-top:6px">Preview</div>
650
+ <div class="title-rows" id="tiPreview">${titleRowsHtml(values)}</div>
651
+ <div class="hint" style="margin-top:10px">The real title is saved first. Undo all puts it back. Titles are capped at 50 characters.</div>
652
+ </div>
653
+ </div>`;
654
+ }
655
+
656
+ function bindTitle(root) {
657
+ root.querySelector("#tiOn").addEventListener("change", (event) => {
658
+ if (event.target.checked) {
659
+ UD.draft.title = {};
660
+ } else {
661
+ delete UD.draft.title;
662
+ }
663
+ root.querySelector("#tiFields").hidden = !event.target.checked;
664
+ markDirty();
665
+ });
666
+ const fields = [["#tiFormat", "format"], ["#tiNow", "nowFormat"], ["#tiAfter", "afterFormat"]];
667
+ for (const [selector, key] of fields) {
668
+ root.querySelector(selector).addEventListener("input", (event) => {
669
+ const title = UD.draft.title || (UD.draft.title = {});
670
+ const value = event.target.value;
671
+ if (value.trim() && value !== DEFAULT_TITLE[key]) {
672
+ title[key] = value;
673
+ } else {
674
+ delete title[key];
675
+ }
676
+ $("tiPreview").innerHTML = titleRowsHtml({ ...DEFAULT_TITLE, ...title });
677
+ markDirty();
678
+ });
679
+ }
680
+ }
681
+
682
+ function bindStages(root) {
683
+ for (const tile of root.querySelectorAll(".stage[data-mark]")) {
684
+ tile.addEventListener("click", () => toggleStage(tile.dataset.mark));
685
+ }
686
+ }
687
+
688
+ function tickLive() {
689
+ const box = $("cdLive");
690
+ if (!box || UD.view !== "countdown") {
691
+ clearInterval(liveTimer);
692
+ liveTimer = null;
693
+ return;
694
+ }
695
+ const game = scheduledGame();
696
+ if (!game) {
697
+ return;
698
+ }
699
+ const fresh = document.createElement("div");
700
+ fresh.innerHTML = liveHtml();
701
+ const nextImg = fresh.querySelector("img");
702
+ const img = box.querySelector("img");
703
+ if (!img || !nextImg || img.getAttribute("src") !== nextImg.getAttribute("src")) {
704
+ box.innerHTML = fresh.innerHTML;
705
+ return;
706
+ }
707
+ $("liveLabel").textContent = fresh.querySelector("#liveLabel").textContent;
708
+ $("liveTimer").textContent = fresh.querySelector("#liveTimer").textContent;
709
+ $("liveBar").style.width = fresh.querySelector("#liveBar").style.width;
710
+ }
711
+
712
+ function renderCountdown(root) {
713
+ const cd = countdown();
714
+ root.innerHTML = `
715
+ <div class="grid-2">
716
+ <div class="stack">
717
+ <div class="card">
718
+ <div id="cdReminder">${reminderHtml()}</div>
719
+ <div id="cdLive">${cd ? liveHtml() : emptyState("Countdown icons are off", "Turn them on to swap the game icon through the countdown.")}</div>
720
+ </div>
721
+ ${cd ? `
722
+ <div class="card">
723
+ <div class="card-head"><div><h2>Stages</h2><div class="hint">Click a stage to switch it off or on. Every stage is on by default.</div></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-sm" id="cdModCheck" ${currentGame()?.universeId ? "" : "disabled"}>Check with moderation</button><button class="btn btn-sm" id="cdZip" ${currentGame()?.universeId ? "" : "disabled"}>Download icons</button></div></div>
724
+ <div class="hint" id="cdModSummary" style="margin:-6px 0 12px">${moderationSummary()}</div>
725
+ <div id="cdStages">${stagesHtml()}</div>
726
+ </div>` : ""}
727
+ <div class="card">
728
+ <div class="card-head"><div><h2>Full schedule</h2><div class="hint">Everything that will happen for this game, in order.</div></div></div>
729
+ <div id="cdTimeline">${timelineHtml()}</div>
730
+ </div>
731
+ </div>
732
+ <div class="stack">${iconSettingsHtml()}${titleCardHtml()}</div>
733
+ </div>`;
734
+ bindIconSettings(root);
735
+ bindStages(root);
736
+ root.querySelector("#cdZip")?.addEventListener("click", downloadIcons);
737
+ root.querySelector("#cdModCheck")?.addEventListener("click", checkModeration);
738
+ bindTitle(root);
739
+ loadModeration();
740
+ clearInterval(liveTimer);
741
+ liveTimer = cd ? setInterval(tickLive, LIVE_TICK_MS) : null;
742
+ }
743
+
744
+ UD.views.countdown = {
745
+ label: "Countdown",
746
+ render: renderCountdown,
747
+ refresh() {
748
+ const root = $("view");
749
+ if (!countdown()) {
750
+ return;
751
+ }
752
+ const stages = $("cdStages");
753
+ if (stages) {
754
+ stages.innerHTML = stagesHtml();
755
+ bindStages(root);
756
+ }
757
+ const timeline = $("cdTimeline");
758
+ if (timeline) {
759
+ timeline.innerHTML = timelineHtml();
760
+ }
761
+ const reminder = $("cdReminder");
762
+ if (reminder) {
763
+ reminder.innerHTML = reminderHtml();
764
+ }
765
+ const live = $("cdLive");
766
+ if (live) {
767
+ live.innerHTML = liveHtml();
768
+ }
769
+ refreshPreview();
770
+ const titles = $("tiPreview");
771
+ if (titles && !document.activeElement?.closest("#tiFields")) {
772
+ titles.innerHTML = titleRowsHtml({ ...DEFAULT_TITLE, ...(UD.draft.title || {}) });
773
+ }
774
+ },
775
+ };