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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
local DataStoreService = game:GetService("DataStoreService")
|
|
2
|
+
local HttpService = game:GetService("HttpService")
|
|
3
|
+
local MessagingService = game:GetService("MessagingService")
|
|
4
|
+
local Players = game:GetService("Players")
|
|
5
|
+
|
|
6
|
+
local STORE_NAME = "UpdateDayGifts"
|
|
7
|
+
local TOPIC = "UpdateDayGifts"
|
|
8
|
+
local RETRY_DELAY = 5
|
|
9
|
+
|
|
10
|
+
local UpdateDayGifts = {}
|
|
11
|
+
|
|
12
|
+
local store = DataStoreService:GetDataStore(STORE_NAME)
|
|
13
|
+
local handlers = {}
|
|
14
|
+
local busy = {}
|
|
15
|
+
|
|
16
|
+
local function giftsOf(value)
|
|
17
|
+
if typeof(value) == "table" and typeof(value.gifts) == "table" then
|
|
18
|
+
return value.gifts
|
|
19
|
+
end
|
|
20
|
+
return {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
local function claimAll(userId)
|
|
24
|
+
local claimed = {}
|
|
25
|
+
local ok, err = pcall(store.UpdateAsync, store, tostring(userId), function(current)
|
|
26
|
+
claimed = giftsOf(current)
|
|
27
|
+
if #claimed == 0 then
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
return { gifts = {} }
|
|
31
|
+
end)
|
|
32
|
+
if not ok then
|
|
33
|
+
warn("UpdateDayGifts could not read gifts:", err)
|
|
34
|
+
return {}
|
|
35
|
+
end
|
|
36
|
+
return claimed
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
local function putBack(userId, gifts)
|
|
40
|
+
if #gifts == 0 then
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
local ok, err = pcall(store.UpdateAsync, store, tostring(userId), function(current)
|
|
44
|
+
local merged = giftsOf(current)
|
|
45
|
+
for _, gift in gifts do
|
|
46
|
+
table.insert(merged, gift)
|
|
47
|
+
end
|
|
48
|
+
return { gifts = merged }
|
|
49
|
+
end)
|
|
50
|
+
if not ok then
|
|
51
|
+
warn("UpdateDayGifts could not put gifts back:", err, HttpService:JSONEncode(gifts))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
local function give(player, gift)
|
|
56
|
+
local handler = handlers[gift.category]
|
|
57
|
+
if not handler then
|
|
58
|
+
return false
|
|
59
|
+
end
|
|
60
|
+
local ok, result = pcall(handler, player, gift)
|
|
61
|
+
if not ok then
|
|
62
|
+
warn("UpdateDayGifts handler failed for", gift.category, result)
|
|
63
|
+
return false
|
|
64
|
+
end
|
|
65
|
+
return result ~= false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
local function deliver(player)
|
|
69
|
+
if busy[player] then
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
busy[player] = true
|
|
73
|
+
local failed = {}
|
|
74
|
+
for _, gift in claimAll(player.UserId) do
|
|
75
|
+
if not give(player, gift) then
|
|
76
|
+
table.insert(failed, gift)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
putBack(player.UserId, failed)
|
|
80
|
+
busy[player] = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
local function onMessage(message)
|
|
84
|
+
local ok, payload = pcall(HttpService.JSONDecode, HttpService, message.Data)
|
|
85
|
+
if not ok or typeof(payload) ~= "table" then
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
local player = Players:GetPlayerByUserId(tonumber(payload.userId) or 0)
|
|
89
|
+
if player then
|
|
90
|
+
deliver(player)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
local function subscribe()
|
|
95
|
+
local ok, err = pcall(MessagingService.SubscribeAsync, MessagingService, TOPIC, onMessage)
|
|
96
|
+
if not ok then
|
|
97
|
+
warn("UpdateDayGifts could not subscribe, retrying:", err)
|
|
98
|
+
task.delay(RETRY_DELAY, subscribe)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
function UpdateDayGifts.OnGift(category, handler)
|
|
103
|
+
handlers[category] = handler
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
function UpdateDayGifts.Start()
|
|
107
|
+
Players.PlayerAdded:Connect(function(player)
|
|
108
|
+
task.spawn(deliver, player)
|
|
109
|
+
end)
|
|
110
|
+
Players.PlayerRemoving:Connect(function(player)
|
|
111
|
+
busy[player] = nil
|
|
112
|
+
end)
|
|
113
|
+
for _, player in Players:GetPlayers() do
|
|
114
|
+
task.spawn(deliver, player)
|
|
115
|
+
end
|
|
116
|
+
task.spawn(subscribe)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return UpdateDayGifts
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
local MessagingService = game:GetService("MessagingService")
|
|
2
|
+
local HttpService = game:GetService("HttpService")
|
|
3
|
+
|
|
4
|
+
local DEFAULT_TOPIC = "UpdateDay"
|
|
5
|
+
|
|
6
|
+
local UpdateDayListener = {}
|
|
7
|
+
|
|
8
|
+
local listeners = {}
|
|
9
|
+
|
|
10
|
+
local function decode(message)
|
|
11
|
+
local ok, payload = pcall(HttpService.JSONDecode, HttpService, message.Data)
|
|
12
|
+
if ok and typeof(payload) == "table" then
|
|
13
|
+
return payload
|
|
14
|
+
end
|
|
15
|
+
return nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
local function onMessage(message)
|
|
19
|
+
local payload = decode(message)
|
|
20
|
+
if not payload or typeof(payload.updateAt) ~= "string" then
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
local updateAt = DateTime.fromIsoDate(payload.updateAt)
|
|
24
|
+
if not updateAt then
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
for _, callback in listeners do
|
|
28
|
+
task.spawn(callback, updateAt.UnixTimestamp, payload.mark)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
function UpdateDayListener.OnWarning(callback)
|
|
33
|
+
table.insert(listeners, callback)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
function UpdateDayListener.Start(topic)
|
|
37
|
+
local ok, err = pcall(MessagingService.SubscribeAsync, MessagingService, topic or DEFAULT_TOPIC, onMessage)
|
|
38
|
+
if not ok then
|
|
39
|
+
warn("UpdateDayListener failed to subscribe:", err)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return UpdateDayListener
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: UpdateDay
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "*/5 * * * *"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: updateday
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
tick:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
- name: Fonts for translated countdown labels
|
|
21
|
+
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fonts-noto-core fonts-noto-cjk
|
|
22
|
+
- uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: 24
|
|
25
|
+
- run: npx --yes updateday@latest tick updateday.json
|
|
26
|
+
env:
|
|
27
|
+
UPDATEDAY_API_KEY: ${{ secrets.UPDATEDAY_API_KEY }}
|
|
28
|
+
UPDATEDAY_DISCORD_WEBHOOK: ${{ secrets.UPDATEDAY_DISCORD_WEBHOOK }}
|
|
29
|
+
- name: Save progress
|
|
30
|
+
run: |
|
|
31
|
+
git config user.name "updateday"
|
|
32
|
+
git config user.email "updateday@users.noreply.github.com"
|
|
33
|
+
git add -f updateday*.state.json updateday*.original-icon.png updateday-history.jsonl updateday-icons 2>/dev/null || true
|
|
34
|
+
git diff --cached --quiet || git commit -m "UpdateDay progress"
|
|
35
|
+
git push
|
package/ui/core.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
const UD = {
|
|
2
|
+
draft: null,
|
|
3
|
+
schedule: null,
|
|
4
|
+
revision: 0,
|
|
5
|
+
selected: 0,
|
|
6
|
+
view: "countdown",
|
|
7
|
+
dirty: false,
|
|
8
|
+
configPath: "",
|
|
9
|
+
views: {},
|
|
10
|
+
listeners: new Set(),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ALL_MARKS = ["24h", "12h", "6h", "5h", "4h", "3h", "2h", "1h", "45m", "30m", "15m", "10m", "5m"];
|
|
14
|
+
const UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
15
|
+
const PUSH_DEBOUNCE_MS = 350;
|
|
16
|
+
const TOAST_MS = 4200;
|
|
17
|
+
const THEME_KEY = "updateday-theme";
|
|
18
|
+
const DESKTOP_ALERTS_KEY = "updateday-desktop-alerts";
|
|
19
|
+
const LOCALE = "en-US";
|
|
20
|
+
|
|
21
|
+
let pushTimer = null;
|
|
22
|
+
|
|
23
|
+
const $ = (id) => document.getElementById(id);
|
|
24
|
+
|
|
25
|
+
function h(text) {
|
|
26
|
+
return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function api(method, path, body, isBinary) {
|
|
30
|
+
const options = { method, headers: {} };
|
|
31
|
+
if (body !== undefined) {
|
|
32
|
+
options.body = isBinary ? body : JSON.stringify(body);
|
|
33
|
+
options.headers["content-type"] = isBinary ? "application/octet-stream" : "application/json";
|
|
34
|
+
}
|
|
35
|
+
const response = await fetch(path, options);
|
|
36
|
+
const data = await response.json().catch(() => ({}));
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(data.error || `Request failed (${response.status})`);
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function offsetMs(mark) {
|
|
44
|
+
const match = /^(\d+(?:\.\d+)?)([smhd])$/.exec(mark);
|
|
45
|
+
return match ? Number(match[1]) * UNIT_MS[match[2]] : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toLocalInput(iso) {
|
|
49
|
+
const date = new Date(iso);
|
|
50
|
+
if (Number.isNaN(date.getTime())) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
return new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fromLocalInput(value) {
|
|
57
|
+
const date = new Date(value);
|
|
58
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatWhen(date) {
|
|
62
|
+
return new Date(date).toLocaleString(LOCALE, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatTime(date) {
|
|
66
|
+
return new Date(date).toLocaleTimeString(LOCALE, { hour: "numeric", minute: "2-digit", second: "2-digit" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatSpan(ms) {
|
|
70
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
71
|
+
const days = Math.floor(total / 86400);
|
|
72
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
73
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
74
|
+
if (days > 0) {
|
|
75
|
+
return `${days}d ${hours}h`;
|
|
76
|
+
}
|
|
77
|
+
if (hours > 0) {
|
|
78
|
+
return `${hours}h ${minutes}m`;
|
|
79
|
+
}
|
|
80
|
+
if (minutes > 0) {
|
|
81
|
+
return `${minutes}m`;
|
|
82
|
+
}
|
|
83
|
+
return `${total}s`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clock(ms) {
|
|
87
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
88
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
89
|
+
return `${pad(Math.floor(total / 3600))}:${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stageName(mark) {
|
|
93
|
+
if (mark === "now") {
|
|
94
|
+
return "NOW!";
|
|
95
|
+
}
|
|
96
|
+
const match = /^(\d+)([mh])$/.exec(mark);
|
|
97
|
+
if (!match) {
|
|
98
|
+
return mark.toUpperCase();
|
|
99
|
+
}
|
|
100
|
+
const n = Number(match[1]);
|
|
101
|
+
return `${n} ${match[2] === "h" ? "hour" : "min"}${n === 1 ? "" : "s"}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function currentGame() {
|
|
105
|
+
return UD.draft?.games?.[UD.selected] ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scheduledGame(index = UD.selected) {
|
|
109
|
+
return UD.schedule?.games?.[index] ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function gameUpdateAt(game) {
|
|
113
|
+
return new Date(game?.updateAt ?? UD.draft.updateAt);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function countdown() {
|
|
117
|
+
if (UD.draft.countdown === false) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
UD.draft.countdown = UD.draft.countdown || {};
|
|
121
|
+
return UD.draft.countdown;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function gameQuery() {
|
|
125
|
+
return `game=${UD.selected}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function iconUrl(mark, index = UD.selected) {
|
|
129
|
+
return `/api/icon?game=${index}&mark=${encodeURIComponent(mark)}&r=${UD.revision}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function onChange(listener) {
|
|
133
|
+
UD.listeners.add(listener);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function notify() {
|
|
137
|
+
for (const listener of UD.listeners) {
|
|
138
|
+
listener();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function markDirty() {
|
|
143
|
+
UD.dirty = true;
|
|
144
|
+
renderSaveState();
|
|
145
|
+
clearTimeout(pushTimer);
|
|
146
|
+
pushTimer = setTimeout(pushDraft, PUSH_DEBOUNCE_MS);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function pushDraft() {
|
|
150
|
+
try {
|
|
151
|
+
const result = await api("PUT", "/api/draft", UD.draft);
|
|
152
|
+
UD.revision = result.revision;
|
|
153
|
+
await refreshSchedule();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
toast(error.message, "danger");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function refreshSchedule() {
|
|
160
|
+
UD.schedule = await api("GET", "/api/schedule");
|
|
161
|
+
renderSaveState();
|
|
162
|
+
notify();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderSaveState() {
|
|
166
|
+
const status = $("saveStatus");
|
|
167
|
+
const button = $("saveBtn");
|
|
168
|
+
if (!status || !button) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const error = UD.schedule?.error;
|
|
172
|
+
status.dataset.state = error ? "error" : UD.dirty ? "dirty" : "saved";
|
|
173
|
+
status.textContent = error ? "Fix the highlighted problem to save" : UD.dirty ? "Unsaved changes" : "All changes saved";
|
|
174
|
+
button.disabled = !UD.dirty || Boolean(error);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function save() {
|
|
178
|
+
try {
|
|
179
|
+
if (pushTimer) {
|
|
180
|
+
clearTimeout(pushTimer);
|
|
181
|
+
pushTimer = null;
|
|
182
|
+
await pushDraft();
|
|
183
|
+
}
|
|
184
|
+
const result = await api("POST", "/api/save");
|
|
185
|
+
UD.dirty = false;
|
|
186
|
+
renderSaveState();
|
|
187
|
+
toast(result.running ? "Saved. Stop and start the run to use these changes." : "Saved", "success");
|
|
188
|
+
} catch (error) {
|
|
189
|
+
toast(error.message, "danger");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toast(message, kind = "info") {
|
|
194
|
+
const region = $("toasts");
|
|
195
|
+
const item = document.createElement("div");
|
|
196
|
+
item.className = `toast toast-${kind}`;
|
|
197
|
+
item.setAttribute("role", kind === "danger" ? "alert" : "status");
|
|
198
|
+
item.innerHTML = `<span>${h(message)}</span><button class="icon-btn" aria-label="Dismiss">✕</button>`;
|
|
199
|
+
item.querySelector("button").addEventListener("click", () => item.remove());
|
|
200
|
+
region.appendChild(item);
|
|
201
|
+
setTimeout(() => item.remove(), kind === "danger" ? TOAST_MS * 2 : TOAST_MS);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function dialog({ title, body, confirmText = "Confirm", cancelText = "Cancel", danger = false, html = false }) {
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const element = $("dialog");
|
|
207
|
+
element.innerHTML = `
|
|
208
|
+
<form method="dialog" class="dialog-body">
|
|
209
|
+
<h2>${h(title)}</h2>
|
|
210
|
+
<div class="dialog-text">${html ? body : `<p>${h(body)}</p>`}</div>
|
|
211
|
+
<div class="dialog-actions">
|
|
212
|
+
${cancelText === null ? "" : `<button value="cancel" class="btn btn-secondary">${h(cancelText)}</button>`}
|
|
213
|
+
<button value="confirm" class="btn ${danger ? "btn-danger" : "btn-primary"}">${h(confirmText)}</button>
|
|
214
|
+
</div>
|
|
215
|
+
</form>`;
|
|
216
|
+
element.returnValue = "";
|
|
217
|
+
element.addEventListener("close", () => resolve(element.returnValue === "confirm"), { once: true });
|
|
218
|
+
element.querySelector("form").addEventListener("keydown", (event) => {
|
|
219
|
+
if (event.key === "Enter" && event.target.matches("input")) {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
element.close("confirm");
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
element.showModal();
|
|
225
|
+
const firstInput = element.querySelector(".dialog-text input, .dialog-text select");
|
|
226
|
+
const preferred = element.querySelector(`button[value="${danger && cancelText !== null ? "cancel" : "confirm"}"]`);
|
|
227
|
+
(firstInput ?? preferred).focus();
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function switchHtml(id, checked, label, hint) {
|
|
232
|
+
return `
|
|
233
|
+
<label class="switch-row" for="${id}">
|
|
234
|
+
<span class="switch-text"><span class="switch-label">${label}</span>${hint ? `<span class="hint">${hint}</span>` : ""}</span>
|
|
235
|
+
<input type="checkbox" role="switch" class="switch" id="${id}" ${checked ? "checked" : ""}>
|
|
236
|
+
</label>`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function segmentedHtml(name, options, value) {
|
|
240
|
+
const buttons = options.map(([key, label]) => `<button type="button" role="radio" aria-checked="${key === value}" data-value="${h(key)}" class="${key === value ? "active" : ""}">${label}</button>`);
|
|
241
|
+
return `<div class="segmented" role="radiogroup" data-name="${name}">${buttons.join("")}</div>`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function bindSegmented(root, name, onPick) {
|
|
245
|
+
const group = root.querySelector(`.segmented[data-name="${name}"]`);
|
|
246
|
+
if (!group) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
for (const button of group.querySelectorAll("button")) {
|
|
250
|
+
button.addEventListener("click", () => {
|
|
251
|
+
for (const other of group.querySelectorAll("button")) {
|
|
252
|
+
other.classList.toggle("active", other === button);
|
|
253
|
+
other.setAttribute("aria-checked", String(other === button));
|
|
254
|
+
}
|
|
255
|
+
onPick(button.dataset.value);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function emptyState(title, text, action) {
|
|
261
|
+
return `
|
|
262
|
+
<div class="empty">
|
|
263
|
+
<div class="empty-title">${h(title)}</div>
|
|
264
|
+
${text ? `<div class="empty-text">${text}</div>` : ""}
|
|
265
|
+
${action ?? ""}
|
|
266
|
+
</div>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function errorBox(error) {
|
|
270
|
+
const text = String(error?.message ?? error);
|
|
271
|
+
const action = /API key/.test(text) ? `<button class="btn btn-sm js-add-key" style="margin-top:10px">Add API key</button>` : "";
|
|
272
|
+
return `<div class="callout callout-danger" role="alert">${h(text)}${action ? `<div>${action}</div>` : ""}</div>`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function readSetting(key) {
|
|
276
|
+
try {
|
|
277
|
+
return localStorage.getItem(key);
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function writeSetting(key, value) {
|
|
284
|
+
try {
|
|
285
|
+
if (value === null) {
|
|
286
|
+
localStorage.removeItem(key);
|
|
287
|
+
} else {
|
|
288
|
+
localStorage.setItem(key, value);
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function downloadUrl(url) {
|
|
295
|
+
const link = document.createElement("a");
|
|
296
|
+
link.href = url;
|
|
297
|
+
link.download = "";
|
|
298
|
+
document.body.appendChild(link);
|
|
299
|
+
link.click();
|
|
300
|
+
link.remove();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function loadTheme() {
|
|
304
|
+
try {
|
|
305
|
+
const saved = localStorage.getItem(THEME_KEY);
|
|
306
|
+
if (saved) {
|
|
307
|
+
document.documentElement.dataset.theme = saved;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function toggleTheme() {
|
|
314
|
+
const dark = document.documentElement.dataset.theme
|
|
315
|
+
? document.documentElement.dataset.theme === "dark"
|
|
316
|
+
: matchMedia("(prefers-color-scheme: dark)").matches;
|
|
317
|
+
const next = dark ? "light" : "dark";
|
|
318
|
+
document.documentElement.dataset.theme = next;
|
|
319
|
+
try {
|
|
320
|
+
localStorage.setItem(THEME_KEY, next);
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
loadTheme();
|