mevebu 1.11.0 → 2.0.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/README.md +16 -20
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +25 -17
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/registry.js +73 -37
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +252 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// CSV to INSERT statements. Quoting follows the dialect, because Postgres and
|
|
2
|
+
// MySQL disagree about backslashes and about boolean literals.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, textInput, checkbox,
|
|
5
|
+
button, actions, toolcard, blank, notice, output,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
8
|
+
import { parseDelimited, detectDelimiter, headerKeys } from "../csv.js";
|
|
9
|
+
|
|
10
|
+
const DIALECTS = {
|
|
11
|
+
postgres: { label: "PostgreSQL", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "TRUE" : "FALSE") },
|
|
12
|
+
mysql: { label: "MySQL", quote: "'", escape: (v) => v.replace(/\\/g, "\\\\").replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
13
|
+
sqlite: { label: "SQLite", quote: "'", escape: (v) => v.replace(/'/g, "''"), bool: (v) => (/^(true|1|yes)$/i.test(v) ? "1" : "0") },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function mount(slot) {
|
|
17
|
+
let file = null;
|
|
18
|
+
let rows = [];
|
|
19
|
+
let header = [];
|
|
20
|
+
let headerTypes = [];
|
|
21
|
+
|
|
22
|
+
const tableName = textInput({ value: "my_table", "aria-label": "Table name" });
|
|
23
|
+
const schema = textInput({ placeholder: "public", "aria-label": "Schema" });
|
|
24
|
+
const dialect = selectInput(
|
|
25
|
+
Object.entries(DIALECTS).map(([value, config]) => ({ value, label: config.label })),
|
|
26
|
+
{ value: "postgres", "aria-label": "Dialect" }
|
|
27
|
+
);
|
|
28
|
+
const perStatement = selectInput(
|
|
29
|
+
[
|
|
30
|
+
{ value: "row", label: "One per row" },
|
|
31
|
+
{ value: "batch", label: "One multi row insert" },
|
|
32
|
+
],
|
|
33
|
+
{ value: "row", "aria-label": "Statement shape" }
|
|
34
|
+
);
|
|
35
|
+
const useNulls = checkbox("Empty cells become NULL", { checked: true });
|
|
36
|
+
const truncate = checkbox("Start with TRUNCATE", { checked: false });
|
|
37
|
+
|
|
38
|
+
const error = notice("warn");
|
|
39
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
40
|
+
const result = output({ rows: 14 });
|
|
41
|
+
const empty = blank("database", "Drop a CSV to build the statements.");
|
|
42
|
+
|
|
43
|
+
const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "database" });
|
|
44
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
45
|
+
const copyButton = button("Copy SQL", {
|
|
46
|
+
icon: "copy",
|
|
47
|
+
onclick: async () => {
|
|
48
|
+
if (!result.textContent) return;
|
|
49
|
+
try {
|
|
50
|
+
await navigator.clipboard.writeText(result.textContent);
|
|
51
|
+
toast("SQL copied");
|
|
52
|
+
} catch {
|
|
53
|
+
error.write("The clipboard is blocked here. Download the .sql file instead.");
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const downloadButton = button("Download .sql", { icon: "download", kind: "fill" });
|
|
58
|
+
|
|
59
|
+
function quote(value, config) {
|
|
60
|
+
if (useNulls.input.checked && value === "") return "NULL";
|
|
61
|
+
return `${config.quote}${config.escape(value)}${config.quote}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function build() {
|
|
65
|
+
if (!rows.length) return "";
|
|
66
|
+
const config = DIALECTS[dialect.value] ?? DIALECTS.postgres;
|
|
67
|
+
const name = tableName.value.trim() || "my_table";
|
|
68
|
+
const prefix = schema.value.trim() ? `${schema.value.trim()}.${name}` : name;
|
|
69
|
+
const columns = header.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(", ");
|
|
70
|
+
const lines = [];
|
|
71
|
+
|
|
72
|
+
if (truncate.input.checked) lines.push(`TRUNCATE TABLE ${prefix};`);
|
|
73
|
+
|
|
74
|
+
if (perStatement.value === "batch") {
|
|
75
|
+
const values = rows
|
|
76
|
+
.map((row) => ` (${row.map((cell, index) => {
|
|
77
|
+
const columnType = headerTypes[index];
|
|
78
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
79
|
+
}).join(", ")})`)
|
|
80
|
+
.join(",\n");
|
|
81
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES\n${values};`);
|
|
82
|
+
} else {
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const values = row.map((cell, index) => {
|
|
85
|
+
const columnType = headerTypes[index];
|
|
86
|
+
return columnType === "boolean" ? config.bool(cell) : quote(cell, config);
|
|
87
|
+
}).join(", ");
|
|
88
|
+
lines.push(`INSERT INTO ${prefix} (${columns}) VALUES (${values});`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectTypes() {
|
|
95
|
+
headerTypes = header.map((_, index) => {
|
|
96
|
+
const sample = rows.slice(0, 20).map((row) => row[index] ?? "").filter((cell) => cell !== "");
|
|
97
|
+
if (sample.length && sample.every((cell) => /^(true|false|1|0|yes|no)$/i.test(cell))) return "boolean";
|
|
98
|
+
return "text";
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function render() {
|
|
103
|
+
empty.hidden = rows.length > 0;
|
|
104
|
+
result.hidden = rows.length === 0;
|
|
105
|
+
copyButton.disabled = rows.length === 0;
|
|
106
|
+
downloadButton.disabled = rows.length === 0;
|
|
107
|
+
if (!rows.length) {
|
|
108
|
+
summary.textContent = "";
|
|
109
|
+
result.write("");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
detectTypes();
|
|
113
|
+
const sql = build();
|
|
114
|
+
result.write(sql);
|
|
115
|
+
const bytes = new TextEncoder().encode(sql).length;
|
|
116
|
+
summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${formatBytes(bytes)} of SQL`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function open(files) {
|
|
120
|
+
const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
|
|
121
|
+
if (!picked) {
|
|
122
|
+
error.write("That file is not a CSV.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const text = await picked.text();
|
|
127
|
+
const delimiter = detectDelimiter(text);
|
|
128
|
+
const parsed = parseDelimited(text, delimiter);
|
|
129
|
+
if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
|
|
130
|
+
file = picked;
|
|
131
|
+
header = headerKeys(parsed[0]);
|
|
132
|
+
rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
|
|
133
|
+
error.write("");
|
|
134
|
+
render();
|
|
135
|
+
} catch (caught) {
|
|
136
|
+
error.write(caught?.message || "This file could not be read.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
drop.onFiles(open);
|
|
141
|
+
tableName.addEventListener("input", render);
|
|
142
|
+
schema.addEventListener("input", render);
|
|
143
|
+
dialect.addEventListener("change", render);
|
|
144
|
+
perStatement.addEventListener("change", render);
|
|
145
|
+
useNulls.input.addEventListener("change", render);
|
|
146
|
+
truncate.input.addEventListener("change", render);
|
|
147
|
+
|
|
148
|
+
downloadButton.addEventListener("click", () => {
|
|
149
|
+
if (!rows.length) return;
|
|
150
|
+
download(`${baseName(file?.name || "data")}.sql`, result.textContent, "application/sql");
|
|
151
|
+
});
|
|
152
|
+
clearButton.addEventListener("click", () => {
|
|
153
|
+
file = null;
|
|
154
|
+
rows = [];
|
|
155
|
+
header = [];
|
|
156
|
+
error.write("");
|
|
157
|
+
render();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
render();
|
|
161
|
+
|
|
162
|
+
const shell = toolcard({
|
|
163
|
+
body: bench(
|
|
164
|
+
stack(
|
|
165
|
+
drop,
|
|
166
|
+
panel("Table", tableName),
|
|
167
|
+
panel("Schema", schema, "Optional. Leave empty for the default."),
|
|
168
|
+
panel("Dialect", dialect),
|
|
169
|
+
panel("Statement shape", perStatement),
|
|
170
|
+
useNulls,
|
|
171
|
+
truncate,
|
|
172
|
+
error
|
|
173
|
+
),
|
|
174
|
+
stack(summary, empty, result)
|
|
175
|
+
),
|
|
176
|
+
foot: actions(clearButton, copyButton, downloadButton),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
slot.append(shell.node);
|
|
180
|
+
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default { mount };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Split a list into balanced groups. Two modes: a fixed number of groups, or a
|
|
2
|
+
// fixed size per group. Exclusion pairs keep two people apart, and leads are
|
|
3
|
+
// spread one per group.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, download, bench, stack, panel, textArea, numberInput, checkbox,
|
|
6
|
+
button, actions, toolcard, blank, notice, chipRow, copyButton,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
function shuffle(list) {
|
|
10
|
+
const out = [...list];
|
|
11
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
12
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
13
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseNames(text) {
|
|
19
|
+
return text
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.map((line) => line.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePairs(text) {
|
|
26
|
+
return text
|
|
27
|
+
.split(/\r?\n/)
|
|
28
|
+
.map((line) => line.split(/\s*(?:\||,|;|\+)\s*/).map((part) => part.trim()).filter(Boolean))
|
|
29
|
+
.filter((pair) => pair.length === 2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function violates(groups, pairs) {
|
|
33
|
+
const where = new Map();
|
|
34
|
+
groups.forEach((group, index) => group.forEach((name) => where.set(name, index)));
|
|
35
|
+
return pairs.some(([a, b]) => where.has(a) && where.has(b) && where.get(a) === where.get(b));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function build(names, mode, value, pairs, leads, attempts = 400) {
|
|
39
|
+
if (!names.length) return null;
|
|
40
|
+
let best = null;
|
|
41
|
+
for (let round = 0; round < attempts; round++) {
|
|
42
|
+
const pool = shuffle(names);
|
|
43
|
+
let groups;
|
|
44
|
+
if (mode === "groups") {
|
|
45
|
+
const count = Math.max(1, Math.min(value, names.length));
|
|
46
|
+
groups = Array.from({ length: count }, () => []);
|
|
47
|
+
pool.forEach((name, index) => groups[index % count].push(name));
|
|
48
|
+
} else {
|
|
49
|
+
const size = Math.max(1, value);
|
|
50
|
+
groups = [];
|
|
51
|
+
for (let i = 0; i < pool.length; i += size) groups.push(pool.slice(i, i + size));
|
|
52
|
+
}
|
|
53
|
+
if (pairs.length && violates(groups, pairs)) {
|
|
54
|
+
best ??= groups;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
return groups;
|
|
58
|
+
}
|
|
59
|
+
return best;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function placeLeads(groups, leads) {
|
|
63
|
+
if (!leads.length) return groups;
|
|
64
|
+
const pool = [...leads];
|
|
65
|
+
// One lead per group first, then any leftover leads join the smallest group.
|
|
66
|
+
for (const group of groups) {
|
|
67
|
+
if (!pool.length) break;
|
|
68
|
+
const lead = pool.shift();
|
|
69
|
+
if (group.includes(lead)) continue;
|
|
70
|
+
const at = group.findIndex((name) => !leads.includes(name));
|
|
71
|
+
if (at >= 0) group[at] = lead;
|
|
72
|
+
else group.push(lead);
|
|
73
|
+
}
|
|
74
|
+
for (const lead of pool) {
|
|
75
|
+
const smallest = groups.reduce((a, b) => (a.length <= b.length ? a : b));
|
|
76
|
+
if (!smallest.includes(lead)) smallest.push(lead);
|
|
77
|
+
}
|
|
78
|
+
return groups;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mount(slot) {
|
|
82
|
+
const names = textArea({ rows: 8, placeholder: "Ani\nBudi\nCitra\nDewi", "aria-label": "Names" });
|
|
83
|
+
const leads = textArea({ rows: 3, placeholder: "Ani\nBudi", "aria-label": "Leads" });
|
|
84
|
+
const exclusions = textArea({ rows: 3, placeholder: "Ani, Budi", "aria-label": "Exclusion pairs" });
|
|
85
|
+
|
|
86
|
+
let mode = "groups";
|
|
87
|
+
const modeRow = chipRow(
|
|
88
|
+
[
|
|
89
|
+
{ value: "groups", label: "Number of groups" },
|
|
90
|
+
{ value: "size", label: "Size per group" },
|
|
91
|
+
],
|
|
92
|
+
{ value: "groups", label: "Split by", onChange: (value) => { mode = value; sync(); } }
|
|
93
|
+
);
|
|
94
|
+
const amount = numberInput({ min: "1", max: "200", step: "1", value: "4", "aria-label": "Group count or size" });
|
|
95
|
+
const amountLabel = el("span", { class: "text-sm font-semibold text-ink-2" }, "Groups");
|
|
96
|
+
const keepLeads = checkbox("One lead per group", { checked: true });
|
|
97
|
+
|
|
98
|
+
const error = notice("warn");
|
|
99
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
100
|
+
const output = el("div", { class: "grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(220px,1fr))]" });
|
|
101
|
+
const empty = blank("users-round", "Paste names to build the groups.");
|
|
102
|
+
|
|
103
|
+
const generate = button("Generate", { icon: "shuffle", kind: "fill" });
|
|
104
|
+
const copyAll = copyButton(() => summary.dataset.plain ?? "", "Copy groups");
|
|
105
|
+
|
|
106
|
+
let groups = [];
|
|
107
|
+
|
|
108
|
+
function sync() {
|
|
109
|
+
amountLabel.textContent = mode === "groups" ? "Groups" : "People per group";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function render() {
|
|
113
|
+
empty.hidden = groups.length > 0;
|
|
114
|
+
output.hidden = groups.length === 0;
|
|
115
|
+
copyAll.disabled = groups.length === 0;
|
|
116
|
+
output.replaceChildren(
|
|
117
|
+
...groups.map((group, index) =>
|
|
118
|
+
el(
|
|
119
|
+
"section",
|
|
120
|
+
{ class: "grid content-start gap-2 rounded-r2 border border-orbit bg-deck-2 p-3.5" },
|
|
121
|
+
el("h3", { class: "m-0 flex items-baseline justify-between gap-2 text-sm font-bold" },
|
|
122
|
+
el("span", {}, `Group ${index + 1}`),
|
|
123
|
+
el("span", { class: "text-2xs font-semibold text-ink-3 tabular-nums" }, `${group.length}`)),
|
|
124
|
+
el("ul", { class: "m-0 grid list-none gap-1 p-0" },
|
|
125
|
+
...group.map((name) =>
|
|
126
|
+
el("li", { class: "flex items-center gap-1.5 text-sm text-ink-2" },
|
|
127
|
+
el("span", { class: "size-1.5 flex-none rounded-full bg-jade" }), name)))
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function run() {
|
|
134
|
+
const list = parseNames(names.value);
|
|
135
|
+
const leadList = keepLeads.input.checked ? parseNames(leads.value).filter((name) => list.includes(name)) : [];
|
|
136
|
+
const pairs = parsePairs(exclusions.value).filter(([a, b]) => list.includes(a) && list.includes(b));
|
|
137
|
+
error.write("");
|
|
138
|
+
|
|
139
|
+
if (!list.length) {
|
|
140
|
+
groups = [];
|
|
141
|
+
summary.textContent = "";
|
|
142
|
+
render();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (list.length !== new Set(list).size) error.write("A name appears more than once, so the count is off.");
|
|
146
|
+
if (leadList.length && mode === "groups" && leadList.length > Number(amount.value)) {
|
|
147
|
+
error.write("There are more leads than groups, so some groups get two.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
groups = build(list, mode, Math.max(1, Number(amount.value) || 1), pairs, leadList) ?? [];
|
|
151
|
+
if (keepLeads.input.checked) groups = placeLeads(groups, leadList);
|
|
152
|
+
|
|
153
|
+
const sizes = groups.map((group) => group.length);
|
|
154
|
+
const spread = sizes.length ? Math.max(...sizes) - Math.min(...sizes) : 0;
|
|
155
|
+
summary.textContent = `${list.length} people · ${groups.length} group${groups.length === 1 ? "" : "s"} · ${Math.min(...sizes)} to ${Math.max(...sizes)} each${spread > 1 ? ", uneven because of the rules" : ""}`;
|
|
156
|
+
summary.dataset.plain = groups.map((group, index) => `Group ${index + 1}: ${group.join(", ")}`).join("\n");
|
|
157
|
+
render();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
generate.addEventListener("click", () => {
|
|
161
|
+
run();
|
|
162
|
+
if (groups.length) toast("Groups ready");
|
|
163
|
+
});
|
|
164
|
+
amount.addEventListener("input", run);
|
|
165
|
+
keepLeads.input.addEventListener("change", run);
|
|
166
|
+
names.addEventListener("input", run);
|
|
167
|
+
|
|
168
|
+
const downloadButton = button("Download CSV", {
|
|
169
|
+
icon: "download",
|
|
170
|
+
onclick: () => {
|
|
171
|
+
if (!groups.length) return;
|
|
172
|
+
const rows = [["group", "name"]];
|
|
173
|
+
groups.forEach((group, index) => group.forEach((name) => rows.push([String(index + 1), name])));
|
|
174
|
+
const csv = rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")).join("\n");
|
|
175
|
+
download("groups.csv", csv, "text/csv");
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
sync();
|
|
180
|
+
run();
|
|
181
|
+
|
|
182
|
+
const shell = toolcard({
|
|
183
|
+
body: bench(
|
|
184
|
+
stack(
|
|
185
|
+
panel("Names", names, "One per line."),
|
|
186
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Split by"), modeRow),
|
|
187
|
+
panel(amountLabel, amount),
|
|
188
|
+
panel("Leads", leads, "Optional. One per line."),
|
|
189
|
+
keepLeads,
|
|
190
|
+
panel("Keep apart", exclusions, "One pair per line, like Ani, Budi."),
|
|
191
|
+
error
|
|
192
|
+
),
|
|
193
|
+
stack(summary, empty, output)
|
|
194
|
+
),
|
|
195
|
+
foot: actions(copyAll, downloadButton, generate),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
slot.append(shell.node);
|
|
199
|
+
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default { mount };
|
package/package.json
CHANGED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Heads or tails with a running count that survives a page change.
|
|
2
|
-
import { el, button, bench, stack, panel, actions, readPin, writePin, randomInt, toolcard } from "../core.js";
|
|
3
|
-
|
|
4
|
-
// A toss is one flip: a click while the coin is still turning replaces the turn
|
|
5
|
-
// in progress instead of queueing another one behind it.
|
|
6
|
-
const TOSS_MS = 400;
|
|
7
|
-
|
|
8
|
-
/** A tally tile. The value is the node the tool writes to. */
|
|
9
|
-
function tile(key) {
|
|
10
|
-
const value = el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
|
|
11
|
-
const row = el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" }, value, el("span", { class: "text-2xs font-semibold text-ink-3" }, key));
|
|
12
|
-
return { row, value };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function mount(slot) {
|
|
16
|
-
const PIN = "coin-flip";
|
|
17
|
-
const saved = readPin(PIN, { heads: 0, tails: 0 });
|
|
18
|
-
let heads = Number(saved.heads) || 0;
|
|
19
|
-
let tails = Number(saved.tails) || 0;
|
|
20
|
-
let timer = 0;
|
|
21
|
-
|
|
22
|
-
const coin = el("div", { class: "grid size-32 select-none place-items-center rounded-full bg-jade text-display font-bold text-on-jade data-[toss]:animate-toss", role: "img", "aria-label": "Coin, not flipped yet" }, "?");
|
|
23
|
-
const verdict = el("p", { class: "text-lg font-bold tracking-[-0.01em] min-h-7", "aria-live": "polite" });
|
|
24
|
-
const headsTile = tile("Heads");
|
|
25
|
-
const tailsTile = tile("Tails");
|
|
26
|
-
const totalTile = tile("Total");
|
|
27
|
-
const stats = el("div", { class: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" }, headsTile.row, tailsTile.row, totalTile.row);
|
|
28
|
-
|
|
29
|
-
function render(decision) {
|
|
30
|
-
verdict.textContent = decision;
|
|
31
|
-
coin.setAttribute("aria-label", decision ? `Coin: ${decision.toLowerCase()}` : "Coin, not flipped yet");
|
|
32
|
-
headsTile.value.textContent = String(heads);
|
|
33
|
-
tailsTile.value.textContent = String(tails);
|
|
34
|
-
totalTile.value.textContent = String(heads + tails);
|
|
35
|
-
writePin(PIN, { heads, tails });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const flipButton = button("Flip", { kind: "fill" });
|
|
39
|
-
const resetButton = button("Reset the count", { kind: "quiet" });
|
|
40
|
-
|
|
41
|
-
flipButton.addEventListener("click", () => {
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
delete coin.dataset.toss;
|
|
44
|
-
void coin.offsetWidth;
|
|
45
|
-
coin.dataset.toss = "";
|
|
46
|
-
timer = setTimeout(() => {
|
|
47
|
-
const isHeads = randomInt(2) === 0;
|
|
48
|
-
if (isHeads) heads++;
|
|
49
|
-
else tails++;
|
|
50
|
-
coin.textContent = isHeads ? "H" : "T";
|
|
51
|
-
render(isHeads ? "Heads" : "Tails");
|
|
52
|
-
}, TOSS_MS);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
resetButton.addEventListener("click", () => {
|
|
56
|
-
clearTimeout(timer);
|
|
57
|
-
heads = 0;
|
|
58
|
-
tails = 0;
|
|
59
|
-
coin.textContent = "?";
|
|
60
|
-
render("");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const card = toolcard({
|
|
64
|
-
body: bench(
|
|
65
|
-
stack(panel("Coin", coin)),
|
|
66
|
-
stack(panel("Result", verdict), stats)
|
|
67
|
-
),
|
|
68
|
-
foot: actions(resetButton, flipButton)});
|
|
69
|
-
slot.append(card.node);
|
|
70
|
-
render("");
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
unmount() {
|
|
74
|
-
clearTimeout(timer);
|
|
75
|
-
}};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export default { mount };
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// Draws one or more names from a list, every entry with equal odds.
|
|
2
|
-
import { el, toast, copyText, shuffle, randomInt, debounce, bench, stack, panel, textArea, numberInput, checkbox, button, output, readPin, writePin, actions, toolcard } from "../core.js";
|
|
3
|
-
|
|
4
|
-
export function mount(slot) {
|
|
5
|
-
const PIN = "name-picker";
|
|
6
|
-
const saved = readPin(PIN, { entries: "Ana\nBudi\nCitra\nDimas\nElena\nFajar\nGita\nHendra", winners: "1", unique: true });
|
|
7
|
-
|
|
8
|
-
const listField = textArea({ value: saved.entries ?? "", rows: 8, "aria-label": "Entries, one per line", placeholder: "One entry per line" });
|
|
9
|
-
const countField = numberInput({ value: saved.winners ?? "1", min: "1", max: "500", "aria-label": "How many to draw" });
|
|
10
|
-
const uniqueBox = checkbox("No repeats", { checked: saved.unique !== false });
|
|
11
|
-
const notify = el("p", { class: "border-s-[3px] border-transparent ps-2 text-sm text-ink-3" });
|
|
12
|
-
|
|
13
|
-
const drawButton = button("Draw", { icon: "play", kind: "fill" });
|
|
14
|
-
const copyButton = button("Copy", { icon: "copy" });
|
|
15
|
-
const result = output();
|
|
16
|
-
let current = [];
|
|
17
|
-
|
|
18
|
-
function entries() {
|
|
19
|
-
const raw = listField.value.includes(",") && !listField.value.includes("\n")
|
|
20
|
-
? listField.value.split(",")
|
|
21
|
-
: listField.value.split(/\n+/);
|
|
22
|
-
return raw.map((line) => line.trim()).filter(Boolean);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function draw(syncInput = false) {
|
|
26
|
-
const list = entries();
|
|
27
|
-
if (!list.length) {
|
|
28
|
-
current = [];
|
|
29
|
-
result.write("");
|
|
30
|
-
notify.textContent = "Add entries to draw.";
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const wants = Math.max(1, parseInt(countField.value || "1", 10) || 1);
|
|
34
|
-
const total = uniqueBox.input.checked ? Math.min(wants, list.length) : Math.min(wants, 500);
|
|
35
|
-
if (syncInput) countField.value = String(total);
|
|
36
|
-
current = uniqueBox.input.checked
|
|
37
|
-
? shuffle(list).slice(0, total)
|
|
38
|
-
: Array.from({ length: total }, () => list[randomInt(list.length)]);
|
|
39
|
-
result.write(current.join("\n"));
|
|
40
|
-
notify.textContent = `${current.length} drawn from ${list.length}.`;
|
|
41
|
-
writePin(PIN, { entries: listField.value, winners: String(current.length), unique: uniqueBox.input.checked });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Editing the list, the count or the repeat rule redraws right away. The
|
|
45
|
-
// button stays for a fresh draw with the same settings.
|
|
46
|
-
drawButton.addEventListener("click", () => draw(true));
|
|
47
|
-
copyButton.addEventListener("click", async () => {
|
|
48
|
-
if (!current.length) return toast("Draw first, then copy");
|
|
49
|
-
toast((await copyText(result.textContent)) ? "Copied" : "Copy failed");
|
|
50
|
-
});
|
|
51
|
-
listField.addEventListener("input", debounce(() => draw(false), 200));
|
|
52
|
-
countField.addEventListener("input", () => draw(false));
|
|
53
|
-
countField.addEventListener("change", () => draw(true));
|
|
54
|
-
uniqueBox.input.addEventListener("change", () => draw(true));
|
|
55
|
-
|
|
56
|
-
const card = toolcard({
|
|
57
|
-
body: bench(
|
|
58
|
-
stack(panel("Entries", listField), panel("Winners", countField), panel(null, uniqueBox)),
|
|
59
|
-
stack(panel("Drawn", result), notify)
|
|
60
|
-
),
|
|
61
|
-
foot: actions(copyButton, drawButton)});
|
|
62
|
-
slot.append(card.node);
|
|
63
|
-
draw();
|
|
64
|
-
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export default { mount };
|