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.
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/assets/hourglass/10m.png +0 -0
- package/assets/hourglass/12h.png +0 -0
- package/assets/hourglass/15m.png +0 -0
- package/assets/hourglass/1h.png +0 -0
- package/assets/hourglass/24h.png +0 -0
- package/assets/hourglass/2h.png +0 -0
- package/assets/hourglass/30m.png +0 -0
- package/assets/hourglass/3h.png +0 -0
- package/assets/hourglass/45m.png +0 -0
- package/assets/hourglass/4h.png +0 -0
- package/assets/hourglass/5h.png +0 -0
- package/assets/hourglass/5m.png +0 -0
- package/assets/hourglass/6h.png +0 -0
- package/assets/hourglass/default.png +0 -0
- package/assets/hourglass/now.png +0 -0
- package/assets/hourglass-styles/blocky.png +0 -0
- package/assets/hourglass-styles/classic.png +0 -0
- package/assets/hourglass-styles/glow.png +0 -0
- package/assets/hourglass-styles/outline.png +0 -0
- package/assets/hourglass-styles/pixel.png +0 -0
- package/assets/hourglass-styles/rounded.png +0 -0
- package/bin/updateday.js +5 -0
- package/dist/bundle.js +54 -0
- package/dist/cli.js +254 -0
- package/dist/config.js +261 -0
- package/dist/doctor.js +119 -0
- package/dist/experiments.js +349 -0
- package/dist/gamePage.js +143 -0
- package/dist/gifts.js +55 -0
- package/dist/history.js +68 -0
- package/dist/icons.js +216 -0
- package/dist/labels.js +64 -0
- package/dist/mcp.js +302 -0
- package/dist/moderation.js +86 -0
- package/dist/notify.js +72 -0
- package/dist/openCloud.js +211 -0
- package/dist/playerData.js +109 -0
- package/dist/preflight.js +202 -0
- package/dist/runner.js +360 -0
- package/dist/schedule.js +162 -0
- package/dist/server.js +929 -0
- package/dist/state.js +29 -0
- package/dist/swap.js +196 -0
- package/dist/zip.js +82 -0
- package/docs/AGENTS.md +123 -0
- package/docs/COVERAGE.md +50 -0
- package/docs/GUIDE.md +288 -0
- package/docs/hourglass-styles.png +0 -0
- package/docs/screenshot.png +0 -0
- package/example/multi-game.json +16 -0
- package/example/updateday.json +37 -0
- package/package.json +63 -5
- package/roblox/UpdateDayBanner.client.luau +159 -0
- package/roblox/UpdateDayCountdown.server.luau +48 -0
- package/roblox/UpdateDayGifts.luau +119 -0
- package/roblox/UpdateDayListener.luau +43 -0
- package/templates/github-actions/updateday.yml +35 -0
- package/ui/core.js +325 -0
- package/ui/countdown.js +775 -0
- package/ui/events.js +249 -0
- package/ui/gifts.js +215 -0
- package/ui/history.js +156 -0
- package/ui/index.html +530 -0
- package/ui/jsonEditor.js +417 -0
- package/ui/page.js +94 -0
- package/ui/players.js +256 -0
- package/ui/run.js +391 -0
- package/ui/sale.js +366 -0
- package/ui/settings.js +414 -0
- package/ui/shell.js +193 -0
package/ui/events.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const EVENT_CATEGORIES = [
|
|
2
|
+
["newContent", "New content"], ["newFeature", "New feature"], ["newSeason", "New season"],
|
|
3
|
+
["newMap", "New map"], ["newLocation", "New location"], ["itemDrop", "Item drop"], ["festival", "Festival"],
|
|
4
|
+
["challenge", "Challenge"], ["quest", "Quest"], ["earlyAccess", "Early access"], ["expansion", "Expansion"],
|
|
5
|
+
["moreLevels", "More levels"],
|
|
6
|
+
];
|
|
7
|
+
const DEFAULT_AUTO_EVENT = { title: "New update!", subtitle: "Come play the update", durationHours: 2, visibility: "Public" };
|
|
8
|
+
const EVENT_LEAD_MINUTES = 15;
|
|
9
|
+
|
|
10
|
+
let editingEvent = undefined;
|
|
11
|
+
let eventsCache = null;
|
|
12
|
+
|
|
13
|
+
function categoryLabel(category) {
|
|
14
|
+
return EVENT_CATEGORIES.find(([value]) => value === category)?.[1] ?? category;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function eventTimeRange(event) {
|
|
18
|
+
const time = (date) => new Date(date).toLocaleTimeString(LOCALE, { hour: "numeric", minute: "2-digit" });
|
|
19
|
+
return `${time(event.startTime)} – ${time(event.endTime)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function eventRowHtml(event) {
|
|
23
|
+
const start = new Date(event.startTime);
|
|
24
|
+
const past = Date.parse(event.endTime) < Date.now();
|
|
25
|
+
const live = !past && start.getTime() <= Date.now();
|
|
26
|
+
const categories = (event.categories ?? []).map((item) => `<span class="badge">${h(categoryLabel(item.category))}</span>`).join(" ");
|
|
27
|
+
const state = live ? `<span class="badge badge-success">Live</span>` : past ? `<span class="badge">Ended</span>` : "";
|
|
28
|
+
return `
|
|
29
|
+
<div class="event-row" data-id="${h(event.id)}">
|
|
30
|
+
<div class="date-badge" aria-hidden="true"><span>${start.toLocaleDateString(LOCALE, { month: "short" })}</span><b>${start.getDate()}</b></div>
|
|
31
|
+
<div style="min-width:0">
|
|
32
|
+
<b>${h(event.title)}</b> ${state}
|
|
33
|
+
${event.subtitle ? `<div class="hint">${h(event.subtitle)}</div>` : ""}
|
|
34
|
+
<div class="hint">${h(start.toLocaleDateString(LOCALE, { weekday: "short", month: "short", day: "numeric" }))}, ${eventTimeRange(event)} · ${event.visibility === "private" ? "Private" : "Everyone"} ${categories}</div>
|
|
35
|
+
</div>
|
|
36
|
+
<div style="display:flex;gap:6px">
|
|
37
|
+
<button class="btn btn-sm" data-act="edit" aria-label="Edit ${h(event.title)}">Edit</button>
|
|
38
|
+
<button class="btn btn-sm btn-danger-ghost" data-act="delete" aria-label="Delete ${h(event.title)}">Delete</button>
|
|
39
|
+
</div>
|
|
40
|
+
</div>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function eventFormHtml(event) {
|
|
44
|
+
const auto = UD.draft.event || DEFAULT_AUTO_EVENT;
|
|
45
|
+
const updateAt = gameUpdateAt(currentGame()).toISOString();
|
|
46
|
+
const values = event ?? {
|
|
47
|
+
title: auto.title ?? "",
|
|
48
|
+
subtitle: auto.subtitle ?? "",
|
|
49
|
+
description: "",
|
|
50
|
+
startTime: updateAt,
|
|
51
|
+
endTime: new Date(Date.parse(updateAt) + (auto.durationHours ?? 2) * 36e5).toISOString(),
|
|
52
|
+
visibility: "public",
|
|
53
|
+
categories: [{ category: "newContent", rank: 0 }],
|
|
54
|
+
};
|
|
55
|
+
const category = values.categories?.[0]?.category ?? "newContent";
|
|
56
|
+
return `
|
|
57
|
+
<div class="card" id="evForm">
|
|
58
|
+
<div class="card-head"><div><h2>${event ? "Edit event" : "New event"}</h2><div class="hint">Events have to start at least ${EVENT_LEAD_MINUTES} minutes from now.</div></div></div>
|
|
59
|
+
<label class="field"><span>Title</span><input type="text" id="evTitle" maxlength="50" value="${h(values.title)}" required></label>
|
|
60
|
+
<label class="field"><span>Subtitle</span><input type="text" id="evSubtitle" maxlength="100" value="${h(values.subtitle)}"></label>
|
|
61
|
+
<label class="field"><span>Description</span><textarea id="evDescription" rows="3">${h(values.description ?? "")}</textarea></label>
|
|
62
|
+
<div class="row">
|
|
63
|
+
<label class="field"><span>Starts</span><input type="datetime-local" id="evStart" value="${toLocalInput(values.startTime)}"></label>
|
|
64
|
+
<label class="field"><span>Ends</span><input type="datetime-local" id="evEnd" value="${toLocalInput(values.endTime)}"></label>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="row">
|
|
67
|
+
<label class="field"><span>Category</span><select id="evCategory">${EVENT_CATEGORIES.map(([value, label]) => `<option value="${value}" ${value === category ? "selected" : ""}>${label}</option>`).join("")}</select></label>
|
|
68
|
+
<label class="field"><span>Who can see it</span>
|
|
69
|
+
<select id="evVisibility">
|
|
70
|
+
<option value="public" ${values.visibility !== "private" ? "selected" : ""}>Everyone</option>
|
|
71
|
+
<option value="private" ${values.visibility === "private" ? "selected" : ""}>Only me (private)</option>
|
|
72
|
+
</select>
|
|
73
|
+
</label>
|
|
74
|
+
</div>
|
|
75
|
+
<label class="field"><span>Thumbnail</span><input type="file" id="evImage" accept="image/*"><span class="hint">Optional. 16:9 works best.</span></label>
|
|
76
|
+
<div id="evError"></div>
|
|
77
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
78
|
+
<button class="btn" id="evCancel">Cancel</button>
|
|
79
|
+
<button class="btn btn-primary" id="evSave">${event ? "Save changes" : "Create event"}</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function autoEventHtml() {
|
|
85
|
+
const auto = UD.draft.event;
|
|
86
|
+
return `
|
|
87
|
+
<div class="card">
|
|
88
|
+
${switchHtml("evAuto", Boolean(auto), "Create an event automatically", "Every game gets an event that starts at its update.")}
|
|
89
|
+
<div class="revealed" id="evAutoFields" ${auto ? "" : "hidden"}>
|
|
90
|
+
<label class="field"><span>Title</span><input type="text" id="evAutoTitle" maxlength="50" value="${h(auto?.title ?? "")}"></label>
|
|
91
|
+
<label class="field"><span>Subtitle</span><input type="text" id="evAutoSubtitle" maxlength="100" value="${h(auto?.subtitle ?? "")}"></label>
|
|
92
|
+
<label class="field"><span>Length (hours)</span><input type="number" id="evAutoHours" min="1" max="2160" value="${auto?.durationHours ?? 2}"></label>
|
|
93
|
+
</div>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function bindAutoEvent(root) {
|
|
98
|
+
const fields = root.querySelector("#evAutoFields");
|
|
99
|
+
const auto = () => UD.draft.event || (UD.draft.event = { ...DEFAULT_AUTO_EVENT });
|
|
100
|
+
root.querySelector("#evAuto").addEventListener("change", (event) => {
|
|
101
|
+
if (event.target.checked) {
|
|
102
|
+
const values = auto();
|
|
103
|
+
root.querySelector("#evAutoTitle").value = values.title;
|
|
104
|
+
root.querySelector("#evAutoSubtitle").value = values.subtitle;
|
|
105
|
+
root.querySelector("#evAutoHours").value = values.durationHours;
|
|
106
|
+
} else {
|
|
107
|
+
delete UD.draft.event;
|
|
108
|
+
}
|
|
109
|
+
fields.hidden = !event.target.checked;
|
|
110
|
+
markDirty();
|
|
111
|
+
});
|
|
112
|
+
root.querySelector("#evAutoTitle").addEventListener("input", (event) => {
|
|
113
|
+
auto().title = event.target.value;
|
|
114
|
+
markDirty();
|
|
115
|
+
});
|
|
116
|
+
root.querySelector("#evAutoSubtitle").addEventListener("input", (event) => {
|
|
117
|
+
auto().subtitle = event.target.value;
|
|
118
|
+
markDirty();
|
|
119
|
+
});
|
|
120
|
+
root.querySelector("#evAutoHours").addEventListener("input", (event) => {
|
|
121
|
+
auto().durationHours = Number(event.target.value);
|
|
122
|
+
markDirty();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function eventThumbnail() {
|
|
127
|
+
const file = $("evImage").files[0];
|
|
128
|
+
if (!file) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const result = await api("POST", `/api/event-image?${gameQuery()}`, await file.arrayBuffer(), true);
|
|
132
|
+
return [{ mediaId: result.assetId, rank: 0 }];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function saveEventForm() {
|
|
136
|
+
const button = $("evSave");
|
|
137
|
+
const title = $("evTitle").value.trim();
|
|
138
|
+
if (!title) {
|
|
139
|
+
$("evError").innerHTML = errorBox("Give the event a title.");
|
|
140
|
+
$("evTitle").focus();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
button.disabled = true;
|
|
144
|
+
button.textContent = "Saving…";
|
|
145
|
+
try {
|
|
146
|
+
const body = {
|
|
147
|
+
title,
|
|
148
|
+
subtitle: $("evSubtitle").value.trim(),
|
|
149
|
+
description: $("evDescription").value.trim(),
|
|
150
|
+
startTime: fromLocalInput($("evStart").value),
|
|
151
|
+
endTime: fromLocalInput($("evEnd").value),
|
|
152
|
+
visibility: $("evVisibility").value === "private" ? "Private" : "Public",
|
|
153
|
+
categories: [{ category: $("evCategory").value, rank: 0 }],
|
|
154
|
+
};
|
|
155
|
+
const thumbnails = await eventThumbnail();
|
|
156
|
+
if (thumbnails) {
|
|
157
|
+
body.thumbnails = thumbnails;
|
|
158
|
+
}
|
|
159
|
+
const id = editingEvent?.id;
|
|
160
|
+
await api(id ? "PATCH" : "POST", `/api/events?${gameQuery()}${id ? `&id=${encodeURIComponent(id)}` : ""}`, body);
|
|
161
|
+
toast(id ? "Event updated" : "Event created", "success");
|
|
162
|
+
editingEvent = undefined;
|
|
163
|
+
renderEvents($("view"));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
button.disabled = false;
|
|
166
|
+
button.textContent = "Try again";
|
|
167
|
+
$("evError").innerHTML = errorBox(error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function openEventForm(event) {
|
|
172
|
+
editingEvent = event;
|
|
173
|
+
const slot = $("evFormSlot");
|
|
174
|
+
slot.innerHTML = eventFormHtml(event);
|
|
175
|
+
$("evSave").addEventListener("click", saveEventForm);
|
|
176
|
+
$("evCancel").addEventListener("click", () => {
|
|
177
|
+
editingEvent = undefined;
|
|
178
|
+
slot.innerHTML = "";
|
|
179
|
+
});
|
|
180
|
+
slot.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
181
|
+
$("evTitle").focus();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function eventListHtml(events) {
|
|
185
|
+
if (!events.length) {
|
|
186
|
+
return emptyState("No events yet", "Events show on your game page and in players' calendars. Make one for every update.");
|
|
187
|
+
}
|
|
188
|
+
const upcoming = events.filter((event) => Date.parse(event.endTime) >= Date.now()).sort((a, b) => Date.parse(a.startTime) - Date.parse(b.startTime));
|
|
189
|
+
const past = events.filter((event) => Date.parse(event.endTime) < Date.now()).sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime));
|
|
190
|
+
const group = (title, list) => list.length ? `<div class="section-title" style="margin-top:8px">${title}</div>${list.map(eventRowHtml).join("")}` : "";
|
|
191
|
+
return group("Upcoming", upcoming) + group("Past", past);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function loadEvents(root) {
|
|
195
|
+
const list = root.querySelector("#evList");
|
|
196
|
+
try {
|
|
197
|
+
const { events } = await api("GET", `/api/events?${gameQuery()}`);
|
|
198
|
+
eventsCache = events;
|
|
199
|
+
list.innerHTML = eventListHtml(events);
|
|
200
|
+
for (const row of list.querySelectorAll(".event-row")) {
|
|
201
|
+
const event = events.find((item) => item.id === row.dataset.id);
|
|
202
|
+
row.querySelector("[data-act=edit]").addEventListener("click", () => openEventForm(event));
|
|
203
|
+
row.querySelector("[data-act=delete]").addEventListener("click", async () => {
|
|
204
|
+
const sure = await dialog({ title: "Delete this event?", body: `"${event.title}" will be removed from Roblox. This can't be undone.`, confirmText: "Delete event", danger: true });
|
|
205
|
+
if (!sure) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
await api("DELETE", `/api/events?${gameQuery()}&id=${encodeURIComponent(event.id)}`);
|
|
210
|
+
toast("Event deleted", "success");
|
|
211
|
+
loadEvents(root);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
toast(error.message, "danger");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
list.innerHTML = errorBox(error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderEvents(root) {
|
|
223
|
+
root.innerHTML = `
|
|
224
|
+
<div class="grid-2">
|
|
225
|
+
<div class="stack">
|
|
226
|
+
<div id="evFormSlot"></div>
|
|
227
|
+
<div class="card">
|
|
228
|
+
<div class="card-head">
|
|
229
|
+
<div><h2>Roblox events</h2><div class="hint">Live from Roblox for this game.</div></div>
|
|
230
|
+
<button class="btn btn-primary btn-sm" id="evNew">+ New event</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div id="evList"><div class="skeleton" style="height:64px;margin-bottom:10px"></div><div class="skeleton" style="height:64px"></div></div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="stack">${autoEventHtml()}</div>
|
|
236
|
+
</div>`;
|
|
237
|
+
root.querySelector("#evNew").addEventListener("click", () => openEventForm(null));
|
|
238
|
+
bindAutoEvent(root);
|
|
239
|
+
if (!currentGame()?.universeId) {
|
|
240
|
+
root.querySelector("#evList").innerHTML = emptyState("Add the game link first", "Events load once UpdateDay knows the game.");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
loadEvents(root);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
UD.views.events = {
|
|
247
|
+
label: "Events",
|
|
248
|
+
render: renderEvents,
|
|
249
|
+
};
|
package/ui/gifts.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const MAX_GIFT_AMOUNT = 50;
|
|
2
|
+
|
|
3
|
+
let giftCategory = null;
|
|
4
|
+
let pendingGifts = null;
|
|
5
|
+
|
|
6
|
+
function giftCatalog() {
|
|
7
|
+
return currentGame().gifts?.catalog ?? UD.draft.gifts?.catalog ?? {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function setGiftCatalog(catalog) {
|
|
11
|
+
const game = currentGame();
|
|
12
|
+
game.gifts = { ...(game.gifts ?? {}), catalog };
|
|
13
|
+
markDirty();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function splitList(text) {
|
|
17
|
+
return text.split(",").map((item) => item.trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function catalogHtml() {
|
|
21
|
+
const rows = Object.entries(giftCatalog()).map(([name, category]) => `
|
|
22
|
+
<div class="cat-row" data-name="${h(name)}">
|
|
23
|
+
<b>${h(name)}</b>
|
|
24
|
+
<input type="text" data-k="items" value="${h((category.items ?? []).join(", "))}" placeholder="Items, comma separated" aria-label="${h(name)} items">
|
|
25
|
+
<input type="text" data-k="tiers" value="${h((category.tiers ?? []).join(", "))}" placeholder="Tiers (optional)" aria-label="${h(name)} tiers">
|
|
26
|
+
<button class="icon-btn" data-act="remove" aria-label="Remove ${h(name)}" title="Remove">✕</button>
|
|
27
|
+
</div>`).join("");
|
|
28
|
+
return `
|
|
29
|
+
<div class="card">
|
|
30
|
+
<div class="card-head"><div><h2>What this game can give</h2><div class="hint">Category names must match the handlers in UpdateDayGifts.luau.</div></div></div>
|
|
31
|
+
${rows || `<div class="hint" style="margin-bottom:10px">No categories yet. Add one, e.g. Pet, Currency or Boost.</div>`}
|
|
32
|
+
<form class="row" id="catForm" style="align-items:flex-end;margin-top:6px">
|
|
33
|
+
<label class="field" style="flex:1"><span>New category</span><input type="text" id="catNew" placeholder="e.g. Pet"></label>
|
|
34
|
+
<div class="field" style="flex:none"><button class="btn" type="submit">Add</button></div>
|
|
35
|
+
</form>
|
|
36
|
+
</div>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bindCatalog(root) {
|
|
40
|
+
for (const row of root.querySelectorAll(".cat-row")) {
|
|
41
|
+
const name = row.dataset.name;
|
|
42
|
+
for (const input of row.querySelectorAll("input")) {
|
|
43
|
+
input.addEventListener("change", () => {
|
|
44
|
+
const catalog = giftCatalog();
|
|
45
|
+
catalog[name] = { ...(catalog[name] ?? {}), [input.dataset.k]: splitList(input.value) };
|
|
46
|
+
setGiftCatalog(catalog);
|
|
47
|
+
renderGiftForm();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
row.querySelector("[data-act=remove]").addEventListener("click", async () => {
|
|
51
|
+
const sure = await dialog({ title: `Remove ${name}?`, body: "It comes off this game's gift list. Gifts already sent are not affected.", confirmText: "Remove", danger: true });
|
|
52
|
+
if (!sure) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const catalog = giftCatalog();
|
|
56
|
+
delete catalog[name];
|
|
57
|
+
setGiftCatalog(catalog);
|
|
58
|
+
renderGifts(root);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
root.querySelector("#catForm").addEventListener("submit", (event) => {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
const name = $("catNew").value.trim();
|
|
64
|
+
if (!name) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setGiftCatalog({ ...giftCatalog(), [name]: { items: [], tiers: [] } });
|
|
68
|
+
giftCategory = name;
|
|
69
|
+
renderGifts(root);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderGiftForm() {
|
|
74
|
+
const slot = $("giftForm");
|
|
75
|
+
if (!slot) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const catalog = giftCatalog();
|
|
79
|
+
const names = Object.keys(catalog);
|
|
80
|
+
if (!currentPlayer) {
|
|
81
|
+
slot.innerHTML = "";
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (names.length === 0) {
|
|
85
|
+
slot.innerHTML = `<div class="callout callout-info">Add what this game can give first.</div>`;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
giftCategory = names.includes(giftCategory) ? giftCategory : names[0];
|
|
89
|
+
const category = catalog[giftCategory];
|
|
90
|
+
const tiers = category.tiers ?? [];
|
|
91
|
+
const items = category.items ?? [];
|
|
92
|
+
slot.innerHTML = `
|
|
93
|
+
<div class="section-title">Send a gift</div>
|
|
94
|
+
<div class="chips" style="margin-bottom:12px" role="radiogroup" aria-label="Gift category">${names.map((name) => `<button type="button" class="chip ${name === giftCategory ? "active" : ""}" role="radio" aria-checked="${name === giftCategory}" data-cat="${h(name)}">${h(name)}</button>`).join("")}</div>
|
|
95
|
+
<div class="row">
|
|
96
|
+
<label class="field"><span>What</span>
|
|
97
|
+
${items.length ? `<select id="giftItem">${items.map((item) => `<option>${h(item)}</option>`).join("")}</select>` : `<input type="text" id="giftItem" placeholder="Item name">`}
|
|
98
|
+
</label>
|
|
99
|
+
${tiers.length ? `<label class="field"><span>Tier</span><select id="giftTier">${tiers.map((tier) => `<option>${h(tier)}</option>`).join("")}</select></label>` : ""}
|
|
100
|
+
<label class="field" style="max-width:130px"><span>How many</span><input type="number" id="giftAmount" min="1" max="${MAX_GIFT_AMOUNT}" value="1"></label>
|
|
101
|
+
</div>
|
|
102
|
+
<div style="display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap">
|
|
103
|
+
<span class="hint">Arrives right away if @${h(currentPlayer.name)} is in a server, otherwise next time they join.</span>
|
|
104
|
+
<button class="btn btn-primary" id="giftSend">Send gift</button>
|
|
105
|
+
</div>`;
|
|
106
|
+
for (const chip of slot.querySelectorAll("[data-cat]")) {
|
|
107
|
+
chip.addEventListener("click", () => {
|
|
108
|
+
giftCategory = chip.dataset.cat;
|
|
109
|
+
renderGiftForm();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
$("giftSend").addEventListener("click", sendGift);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function pendingHtml() {
|
|
116
|
+
if (!pendingGifts) {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
const rows = pendingGifts.map((gift) => `
|
|
120
|
+
<div class="list-row">
|
|
121
|
+
<span><b>${gift.amount}×</b></span>
|
|
122
|
+
<span>${h(gift.item)}${gift.tier ? ` <span class="badge">${h(gift.tier)}</span>` : ""}<div class="hint">${h(gift.category)} · sent ${h(formatWhen(gift.sentAt))}</div></span>
|
|
123
|
+
<button class="btn btn-sm btn-danger-ghost" data-gift="${h(gift.id)}">Cancel</button>
|
|
124
|
+
</div>`).join("");
|
|
125
|
+
return `
|
|
126
|
+
<div class="section-title">Waiting in their gift box</div>
|
|
127
|
+
${rows ? `<div class="list" style="margin-bottom:16px">${rows}</div>` : `<div class="hint" style="margin-bottom:16px">Nothing waiting. Delivered gifts leave the box.</div>`}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function showPending(gifts) {
|
|
131
|
+
pendingGifts = gifts ?? [];
|
|
132
|
+
const slot = $("giftPending");
|
|
133
|
+
if (!slot) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
slot.innerHTML = pendingHtml();
|
|
137
|
+
for (const button of slot.querySelectorAll("[data-gift]")) {
|
|
138
|
+
button.addEventListener("click", async () => {
|
|
139
|
+
try {
|
|
140
|
+
const result = await api("DELETE", `/api/gifts?${gameQuery()}&userId=${currentPlayer.id}&id=${encodeURIComponent(button.dataset.gift)}`);
|
|
141
|
+
showPending(result.gifts);
|
|
142
|
+
toast("Gift cancelled", "success");
|
|
143
|
+
} catch (error) {
|
|
144
|
+
toast(error.message, "danger");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function sendGift() {
|
|
151
|
+
const amount = Number($("giftAmount").value);
|
|
152
|
+
if (!(amount >= 1 && amount <= MAX_GIFT_AMOUNT)) {
|
|
153
|
+
toast(`Send between 1 and ${MAX_GIFT_AMOUNT} at a time`, "danger");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const item = $("giftItem").value.trim();
|
|
157
|
+
if (!item) {
|
|
158
|
+
$("giftItem").focus();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const button = $("giftSend");
|
|
162
|
+
button.disabled = true;
|
|
163
|
+
try {
|
|
164
|
+
const result = await api("POST", `/api/gifts?${gameQuery()}&userId=${currentPlayer.id}`, {
|
|
165
|
+
category: giftCategory,
|
|
166
|
+
item,
|
|
167
|
+
tier: $("giftTier")?.value ?? null,
|
|
168
|
+
amount,
|
|
169
|
+
});
|
|
170
|
+
showPending(result.gifts);
|
|
171
|
+
toast(`Sent ${amount}× ${item} to @${currentPlayer.name}`, "success");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
toast(error.message, "danger");
|
|
174
|
+
} finally {
|
|
175
|
+
button.disabled = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderGifts(root) {
|
|
180
|
+
if (!currentGame()?.universeId) {
|
|
181
|
+
root.innerHTML = `<div class="card">${emptyState("Add the game link first", "Gifts go through the game's own gift box.")}</div>`;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
root.innerHTML = `
|
|
185
|
+
<div class="grid-2">
|
|
186
|
+
<div class="stack">
|
|
187
|
+
<div class="card">
|
|
188
|
+
<div class="card-head"><div><h2>Gift a player</h2><div class="hint">Give something through your game's gift box.</div></div></div>
|
|
189
|
+
${lookupHtml("giftLookup")}
|
|
190
|
+
<div id="giftPlayer">${currentPlayer ? playerHtml(currentPlayer) : emptyState("No player picked", "Search by username or user ID.")}</div>
|
|
191
|
+
<div id="giftPending">${pendingHtml()}</div>
|
|
192
|
+
<div id="giftForm"></div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="stack">
|
|
196
|
+
${catalogHtml()}
|
|
197
|
+
<div class="callout callout-info">Needs <code>roblox/UpdateDayGifts.luau</code> in your game. Gifts are claimed safely, so a player never gets one twice.</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>`;
|
|
200
|
+
bindCatalog(root);
|
|
201
|
+
bindLookup("giftLookup", (result) => {
|
|
202
|
+
$("giftPlayer").innerHTML = playerHtml(result.player);
|
|
203
|
+
showPending(result.gifts);
|
|
204
|
+
renderGiftForm();
|
|
205
|
+
});
|
|
206
|
+
if (pendingGifts) {
|
|
207
|
+
showPending(pendingGifts);
|
|
208
|
+
}
|
|
209
|
+
renderGiftForm();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
UD.views.gifts = {
|
|
213
|
+
label: "Gifts",
|
|
214
|
+
render: renderGifts,
|
|
215
|
+
};
|
package/ui/history.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const HISTORY_REFRESH_MS = 10000;
|
|
2
|
+
const HISTORY_KINDS = {
|
|
3
|
+
step: ["Step", "badge-accent"],
|
|
4
|
+
sale: ["Price", "badge-success"],
|
|
5
|
+
reminder: ["Reminder", "badge-warning"],
|
|
6
|
+
error: ["Problem", "badge-danger"],
|
|
7
|
+
skip: ["Skipped", ""],
|
|
8
|
+
info: ["Note", ""],
|
|
9
|
+
};
|
|
10
|
+
const HISTORY_FILTERS = [["all", "All"], ["step", "Steps"], ["sale", "Prices"], ["reminder", "Reminders"], ["error", "Problems"]];
|
|
11
|
+
|
|
12
|
+
let historyEntries = null;
|
|
13
|
+
let historyScope = "game";
|
|
14
|
+
let historyFilter = "all";
|
|
15
|
+
let historyQuery = "";
|
|
16
|
+
let historyTimer = null;
|
|
17
|
+
|
|
18
|
+
function historyText(entry) {
|
|
19
|
+
return entry.text
|
|
20
|
+
.replace(/^RUN\s+/, "")
|
|
21
|
+
.replace(/^REMINDER [^:]+: /, "")
|
|
22
|
+
.replace(/ in Creator Hub now: .*$/, " in Creator Hub")
|
|
23
|
+
.replace(/^FAILED /, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function visibleEntries() {
|
|
27
|
+
const query = historyQuery.trim().toLowerCase();
|
|
28
|
+
return (historyEntries ?? []).filter((entry) => {
|
|
29
|
+
const kindMatch = historyFilter === "all" || entry.kind === historyFilter;
|
|
30
|
+
const textMatch = !query || `${entry.text} ${entry.game}`.toLowerCase().includes(query);
|
|
31
|
+
return kindMatch && textMatch;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dayLabel(date) {
|
|
36
|
+
const day = new Date(date);
|
|
37
|
+
const today = new Date();
|
|
38
|
+
const yesterday = new Date(Date.now() - 864e5);
|
|
39
|
+
if (day.toDateString() === today.toDateString()) {
|
|
40
|
+
return "Today";
|
|
41
|
+
}
|
|
42
|
+
if (day.toDateString() === yesterday.toDateString()) {
|
|
43
|
+
return "Yesterday";
|
|
44
|
+
}
|
|
45
|
+
return day.toLocaleDateString(LOCALE, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function historyListHtml() {
|
|
49
|
+
if (historyEntries === null) {
|
|
50
|
+
return `<div class="skeleton" style="height:48px;margin-bottom:8px"></div>`.repeat(4);
|
|
51
|
+
}
|
|
52
|
+
if (historyEntries.length === 0) {
|
|
53
|
+
return emptyState("Nothing has run yet", "Everything UpdateDay does is recorded here: each step, every price it changes and puts back, reminders and problems.");
|
|
54
|
+
}
|
|
55
|
+
const entries = visibleEntries();
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
return emptyState("No matches", "Try another filter or search.");
|
|
58
|
+
}
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const label = dayLabel(entry.at);
|
|
62
|
+
groups.set(label, [...(groups.get(label) ?? []), entry]);
|
|
63
|
+
}
|
|
64
|
+
const showGame = historyScope === "all";
|
|
65
|
+
return [...groups].map(([label, items]) => `
|
|
66
|
+
<div class="section-title" style="margin-top:14px">${h(label)}</div>
|
|
67
|
+
<div class="list">${items.map((entry) => {
|
|
68
|
+
const [kindLabel, tone] = HISTORY_KINDS[entry.kind] ?? HISTORY_KINDS.info;
|
|
69
|
+
return `
|
|
70
|
+
<div class="list-row history-row">
|
|
71
|
+
<span class="hint mono">${h(formatTime(entry.at))}</span>
|
|
72
|
+
<span style="min-width:0">${h(historyText(entry))}${showGame ? `<div class="hint">${h(entry.game)}</div>` : ""}</span>
|
|
73
|
+
<span class="badge ${tone}">${kindLabel}</span>
|
|
74
|
+
</div>`;
|
|
75
|
+
}).join("")}</div>`).join("");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function csvCell(value) {
|
|
79
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function downloadHistory() {
|
|
83
|
+
const rows = [["Time", "Game", "Type", "What happened"], ...visibleEntries().map((entry) => [entry.at, entry.game, HISTORY_KINDS[entry.kind]?.[0] ?? entry.kind, entry.text])];
|
|
84
|
+
const blob = new Blob([rows.map((row) => row.map(csvCell).join(",")).join("\n")], { type: "text/csv" });
|
|
85
|
+
const url = URL.createObjectURL(blob);
|
|
86
|
+
downloadUrl(url);
|
|
87
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function loadHistory() {
|
|
91
|
+
try {
|
|
92
|
+
const query = historyScope === "game" ? `${gameQuery()}&` : "";
|
|
93
|
+
historyEntries = (await api("GET", `/api/history?${query}limit=1000`)).entries;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
historyEntries = [];
|
|
96
|
+
toast(error.message, "danger");
|
|
97
|
+
}
|
|
98
|
+
const list = $("hiList");
|
|
99
|
+
if (list) {
|
|
100
|
+
list.innerHTML = historyListHtml();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderHistory(root) {
|
|
105
|
+
root.innerHTML = `
|
|
106
|
+
<div class="card">
|
|
107
|
+
<div class="items-head">
|
|
108
|
+
<div><h2>History</h2><div class="hint">Everything UpdateDay did, newest first. Kept in updateday-history.jsonl next to your plan.</div></div>
|
|
109
|
+
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
110
|
+
${UD.draft.games.length > 1 ? segmentedHtml("hiScope", [["game", "This game"], ["all", "All games"]], historyScope) : ""}
|
|
111
|
+
<button class="btn btn-sm" id="hiCsv">Download CSV</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="je-bar" style="margin-bottom:6px">
|
|
115
|
+
<div class="chips">${HISTORY_FILTERS.map(([value, label]) => `<button type="button" class="chip ${value === historyFilter ? "active" : ""}" aria-pressed="${value === historyFilter}" data-filter="${value}">${label}</button>`).join("")}</div>
|
|
116
|
+
<span class="je-spacer"></span>
|
|
117
|
+
<input type="search" id="hiSearch" class="je-search" placeholder="Search history" aria-label="Search history" value="${h(historyQuery)}">
|
|
118
|
+
</div>
|
|
119
|
+
<div id="hiList">${historyListHtml()}</div>
|
|
120
|
+
</div>`;
|
|
121
|
+
bindSegmented(root, "hiScope", (scope) => {
|
|
122
|
+
historyScope = scope;
|
|
123
|
+
historyEntries = null;
|
|
124
|
+
$("hiList").innerHTML = historyListHtml();
|
|
125
|
+
loadHistory();
|
|
126
|
+
});
|
|
127
|
+
for (const chip of root.querySelectorAll("[data-filter]")) {
|
|
128
|
+
chip.addEventListener("click", () => {
|
|
129
|
+
historyFilter = chip.dataset.filter;
|
|
130
|
+
for (const other of root.querySelectorAll("[data-filter]")) {
|
|
131
|
+
other.classList.toggle("active", other === chip);
|
|
132
|
+
other.setAttribute("aria-pressed", String(other === chip));
|
|
133
|
+
}
|
|
134
|
+
$("hiList").innerHTML = historyListHtml();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
root.querySelector("#hiSearch").addEventListener("input", (event) => {
|
|
138
|
+
historyQuery = event.target.value;
|
|
139
|
+
$("hiList").innerHTML = historyListHtml();
|
|
140
|
+
});
|
|
141
|
+
root.querySelector("#hiCsv").addEventListener("click", downloadHistory);
|
|
142
|
+
loadHistory();
|
|
143
|
+
clearInterval(historyTimer);
|
|
144
|
+
historyTimer = setInterval(() => {
|
|
145
|
+
if (UD.view !== "history") {
|
|
146
|
+
clearInterval(historyTimer);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
loadHistory();
|
|
150
|
+
}, HISTORY_REFRESH_MS);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
UD.views.history = {
|
|
154
|
+
label: "History",
|
|
155
|
+
render: renderHistory,
|
|
156
|
+
};
|