@masters-union/union-stack 0.3.5 → 0.3.7
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/dist/cdn/loader.v1.global.js +3 -2
- package/dist/cdn/loader.v1.global.js.map +1 -1
- package/dist/{chunk-ZUVGFBHM.js → chunk-J6UVDGJO.js} +3 -3
- package/dist/{chunk-ZUVGFBHM.js.map → chunk-J6UVDGJO.js.map} +1 -1
- package/dist/{chunk-HDI26HSZ.cjs → chunk-M4COFGFO.cjs} +3 -3
- package/dist/{chunk-HDI26HSZ.cjs.map → chunk-M4COFGFO.cjs.map} +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.js +2 -2
- package/dist/picker.cjs +185 -15
- package/dist/picker.cjs.map +1 -1
- package/dist/picker.d.cts +7 -0
- package/dist/picker.d.ts +7 -0
- package/dist/picker.js +185 -15
- package/dist/picker.js.map +1 -1
- package/dist/react.cjs +2 -2
- package/dist/react.js +1 -1
- package/package.json +1 -1
package/dist/picker.d.cts
CHANGED
|
@@ -27,20 +27,26 @@ declare class Picker {
|
|
|
27
27
|
private resolved;
|
|
28
28
|
private resolvePromise;
|
|
29
29
|
private donePromise;
|
|
30
|
+
private previouslyFocused;
|
|
31
|
+
private noticeTimer;
|
|
30
32
|
constructor(client: UnionStackClient, opts: PickerOptions);
|
|
31
33
|
open(): Promise<PickResponse>;
|
|
32
34
|
close(): void;
|
|
33
35
|
cancel(): void;
|
|
34
36
|
private $summary;
|
|
37
|
+
private get acceptStr();
|
|
35
38
|
private mount;
|
|
39
|
+
private trapFocus;
|
|
36
40
|
private resolvedSources;
|
|
37
41
|
private renderSourceTabs;
|
|
42
|
+
private onTabKeydown;
|
|
38
43
|
private activateSource;
|
|
39
44
|
private renderUrlSource;
|
|
40
45
|
private handleUrlAdd;
|
|
41
46
|
private setUrlHint;
|
|
42
47
|
private renderDropzone;
|
|
43
48
|
private unmount;
|
|
49
|
+
private fileMatchesAccept;
|
|
44
50
|
private addFiles;
|
|
45
51
|
private renderItem;
|
|
46
52
|
private replaceItemFile;
|
|
@@ -50,6 +56,7 @@ declare class Picker {
|
|
|
50
56
|
private setItemState;
|
|
51
57
|
private updateSummary;
|
|
52
58
|
private refreshConfirm;
|
|
59
|
+
private flashNotice;
|
|
53
60
|
private startUpload;
|
|
54
61
|
private resolveResult;
|
|
55
62
|
}
|
package/dist/picker.d.ts
CHANGED
|
@@ -27,20 +27,26 @@ declare class Picker {
|
|
|
27
27
|
private resolved;
|
|
28
28
|
private resolvePromise;
|
|
29
29
|
private donePromise;
|
|
30
|
+
private previouslyFocused;
|
|
31
|
+
private noticeTimer;
|
|
30
32
|
constructor(client: UnionStackClient, opts: PickerOptions);
|
|
31
33
|
open(): Promise<PickResponse>;
|
|
32
34
|
close(): void;
|
|
33
35
|
cancel(): void;
|
|
34
36
|
private $summary;
|
|
37
|
+
private get acceptStr();
|
|
35
38
|
private mount;
|
|
39
|
+
private trapFocus;
|
|
36
40
|
private resolvedSources;
|
|
37
41
|
private renderSourceTabs;
|
|
42
|
+
private onTabKeydown;
|
|
38
43
|
private activateSource;
|
|
39
44
|
private renderUrlSource;
|
|
40
45
|
private handleUrlAdd;
|
|
41
46
|
private setUrlHint;
|
|
42
47
|
private renderDropzone;
|
|
43
48
|
private unmount;
|
|
49
|
+
private fileMatchesAccept;
|
|
44
50
|
private addFiles;
|
|
45
51
|
private renderItem;
|
|
46
52
|
private replaceItemFile;
|
|
@@ -50,6 +56,7 @@ declare class Picker {
|
|
|
50
56
|
private setItemState;
|
|
51
57
|
private updateSummary;
|
|
52
58
|
private refreshConfirm;
|
|
59
|
+
private flashNotice;
|
|
53
60
|
private startUpload;
|
|
54
61
|
private resolveResult;
|
|
55
62
|
}
|
package/dist/picker.js
CHANGED
|
@@ -332,6 +332,7 @@ var BASE_CSS = `
|
|
|
332
332
|
font-size: 11px; color: var(--us-muted);
|
|
333
333
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
334
334
|
}
|
|
335
|
+
.us-actions-summary[data-tone="warn"] { color: var(--us-danger); }
|
|
335
336
|
.us-actions-buttons { display: flex; gap: 8px; }
|
|
336
337
|
|
|
337
338
|
.us-btn {
|
|
@@ -1068,6 +1069,7 @@ function iconForMime(mime) {
|
|
|
1068
1069
|
if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
|
|
1069
1070
|
return ICON2.file;
|
|
1070
1071
|
}
|
|
1072
|
+
var FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
1071
1073
|
var Picker = class {
|
|
1072
1074
|
constructor(client, opts) {
|
|
1073
1075
|
this.client = client;
|
|
@@ -1090,6 +1092,10 @@ var Picker = class {
|
|
|
1090
1092
|
this.abortCtrl = new AbortController();
|
|
1091
1093
|
this.uploadStarted = false;
|
|
1092
1094
|
this.resolved = false;
|
|
1095
|
+
// Element focused before the modal opened, restored on close (a11y).
|
|
1096
|
+
this.previouslyFocused = null;
|
|
1097
|
+
// Auto-clears the transient notice line in the actions bar.
|
|
1098
|
+
this.noticeTimer = null;
|
|
1093
1099
|
// ---- mount / dom --------------------------------------------------------
|
|
1094
1100
|
this.$summary = null;
|
|
1095
1101
|
this.donePromise = new Promise((res) => {
|
|
@@ -1120,7 +1126,13 @@ var Picker = class {
|
|
|
1120
1126
|
this.opts.onCancel?.();
|
|
1121
1127
|
this.close();
|
|
1122
1128
|
}
|
|
1129
|
+
// Normalized accept string (callers may pass a string or string[]).
|
|
1130
|
+
get acceptStr() {
|
|
1131
|
+
const a = this.opts.accept;
|
|
1132
|
+
return Array.isArray(a) ? a.join(",") : a ?? "";
|
|
1133
|
+
}
|
|
1123
1134
|
mount() {
|
|
1135
|
+
this.previouslyFocused = document.activeElement ?? null;
|
|
1124
1136
|
const root = document.createElement("div");
|
|
1125
1137
|
root.className = "us-picker-backdrop";
|
|
1126
1138
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
@@ -1130,6 +1142,17 @@ var Picker = class {
|
|
|
1130
1142
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1131
1143
|
});
|
|
1132
1144
|
const panel = el2("div", "us-picker");
|
|
1145
|
+
this.$panel = panel;
|
|
1146
|
+
const titleId = `us-picker-title-${cryptoId()}`;
|
|
1147
|
+
panel.setAttribute("role", "dialog");
|
|
1148
|
+
panel.setAttribute("aria-modal", "true");
|
|
1149
|
+
panel.setAttribute("aria-labelledby", titleId);
|
|
1150
|
+
panel.addEventListener("keydown", (e) => {
|
|
1151
|
+
if (e.key === "Escape" && !this.uploadStarted) {
|
|
1152
|
+
e.preventDefault();
|
|
1153
|
+
this.cancel();
|
|
1154
|
+
} else if (e.key === "Tab") this.trapFocus(e);
|
|
1155
|
+
});
|
|
1133
1156
|
const header = el2("div", "us-picker-header");
|
|
1134
1157
|
const logoWrap = el2("div", "us-picker-header-logo");
|
|
1135
1158
|
if (this.opts.branding?.logoUrl) {
|
|
@@ -1142,6 +1165,7 @@ var Picker = class {
|
|
|
1142
1165
|
}
|
|
1143
1166
|
header.appendChild(logoWrap);
|
|
1144
1167
|
const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
|
|
1168
|
+
title.id = titleId;
|
|
1145
1169
|
header.appendChild(title);
|
|
1146
1170
|
this.$closeBtn = document.createElement("button");
|
|
1147
1171
|
this.$closeBtn.type = "button";
|
|
@@ -1171,6 +1195,8 @@ var Picker = class {
|
|
|
1171
1195
|
const autoUpload = this.opts.autoUpload === true;
|
|
1172
1196
|
const actions = el2("div", "us-actions");
|
|
1173
1197
|
this.$summary = el2("div", "us-actions-summary", "");
|
|
1198
|
+
this.$summary.setAttribute("role", "status");
|
|
1199
|
+
this.$summary.setAttribute("aria-live", "polite");
|
|
1174
1200
|
actions.appendChild(this.$summary);
|
|
1175
1201
|
const buttons = el2("div", "us-actions-buttons");
|
|
1176
1202
|
this.$cancel = document.createElement("button");
|
|
@@ -1198,7 +1224,26 @@ var Picker = class {
|
|
|
1198
1224
|
root.appendChild(panel);
|
|
1199
1225
|
(this.opts.container ?? document.body).appendChild(root);
|
|
1200
1226
|
this.$backdrop = root;
|
|
1201
|
-
this.$panel
|
|
1227
|
+
const target = panel.querySelector('.us-source-tab[data-active="true"]') ?? (this.$deviceSource?.style.display !== "none" ? panel.querySelector(".us-dropzone") : panel.querySelector(".us-url-input")) ?? this.$closeBtn;
|
|
1228
|
+
target?.focus();
|
|
1229
|
+
}
|
|
1230
|
+
// Keeps Tab focus cycling within the modal (focus trap).
|
|
1231
|
+
trapFocus(e) {
|
|
1232
|
+
if (!this.$panel) return;
|
|
1233
|
+
const focusable = Array.from(
|
|
1234
|
+
this.$panel.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1235
|
+
).filter((node) => !node.hasAttribute("disabled") && node.offsetParent !== null);
|
|
1236
|
+
if (focusable.length === 0) return;
|
|
1237
|
+
const first = focusable[0];
|
|
1238
|
+
const last = focusable[focusable.length - 1];
|
|
1239
|
+
const active = document.activeElement;
|
|
1240
|
+
if (e.shiftKey && active === first) {
|
|
1241
|
+
e.preventDefault();
|
|
1242
|
+
last.focus();
|
|
1243
|
+
} else if (!e.shiftKey && active === last) {
|
|
1244
|
+
e.preventDefault();
|
|
1245
|
+
first.focus();
|
|
1246
|
+
}
|
|
1202
1247
|
}
|
|
1203
1248
|
// Resolves which sources to show, honoring opts.fromSources, defaulting to
|
|
1204
1249
|
// both. Empty arrays fall back to ['device'] — we never want to leave a
|
|
@@ -1211,18 +1256,34 @@ var Picker = class {
|
|
|
1211
1256
|
renderSourceTabs(sources) {
|
|
1212
1257
|
const tabs = el2("div", "us-source-tabs");
|
|
1213
1258
|
tabs.setAttribute("role", "tablist");
|
|
1214
|
-
|
|
1259
|
+
tabs.setAttribute("aria-label", "Upload source");
|
|
1260
|
+
sources.forEach((src, i) => {
|
|
1215
1261
|
const btn = document.createElement("button");
|
|
1216
1262
|
btn.type = "button";
|
|
1217
1263
|
btn.className = "us-source-tab";
|
|
1218
1264
|
btn.setAttribute("role", "tab");
|
|
1265
|
+
btn.setAttribute("aria-selected", "false");
|
|
1266
|
+
btn.tabIndex = -1;
|
|
1219
1267
|
btn.dataset.source = src;
|
|
1220
1268
|
btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
|
|
1221
1269
|
btn.onclick = () => this.activateSource(src);
|
|
1270
|
+
btn.onkeydown = (e) => this.onTabKeydown(e, sources, i);
|
|
1222
1271
|
tabs.appendChild(btn);
|
|
1223
|
-
}
|
|
1272
|
+
});
|
|
1224
1273
|
return tabs;
|
|
1225
1274
|
}
|
|
1275
|
+
// Arrow/Home/End navigation across the source tabs.
|
|
1276
|
+
onTabKeydown(e, sources, index) {
|
|
1277
|
+
let next = index;
|
|
1278
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (index + 1) % sources.length;
|
|
1279
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (index - 1 + sources.length) % sources.length;
|
|
1280
|
+
else if (e.key === "Home") next = 0;
|
|
1281
|
+
else if (e.key === "End") next = sources.length - 1;
|
|
1282
|
+
else return;
|
|
1283
|
+
e.preventDefault();
|
|
1284
|
+
this.activateSource(sources[next]);
|
|
1285
|
+
this.$panel?.querySelector(`.us-source-tab[data-source="${sources[next]}"]`)?.focus();
|
|
1286
|
+
}
|
|
1226
1287
|
activateSource(source) {
|
|
1227
1288
|
if (this.$deviceSource) {
|
|
1228
1289
|
const active = source === "device";
|
|
@@ -1233,7 +1294,10 @@ var Picker = class {
|
|
|
1233
1294
|
}
|
|
1234
1295
|
const tabs = this.$panel?.querySelectorAll(".us-source-tab");
|
|
1235
1296
|
tabs?.forEach((t) => {
|
|
1236
|
-
|
|
1297
|
+
const isActive = t.dataset.source === source;
|
|
1298
|
+
t.dataset.active = isActive ? "true" : "false";
|
|
1299
|
+
t.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
1300
|
+
t.tabIndex = isActive ? 0 : -1;
|
|
1237
1301
|
});
|
|
1238
1302
|
}
|
|
1239
1303
|
renderUrlSource() {
|
|
@@ -1310,20 +1374,32 @@ var Picker = class {
|
|
|
1310
1374
|
dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
|
|
1311
1375
|
const constraintBits = [];
|
|
1312
1376
|
if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
|
|
1313
|
-
if (this.
|
|
1314
|
-
|
|
1315
|
-
const
|
|
1316
|
-
|
|
1317
|
-
|
|
1377
|
+
if (this.acceptStr) {
|
|
1378
|
+
const labels = [];
|
|
1379
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1380
|
+
for (const part of this.acceptStr.split(",")) {
|
|
1381
|
+
const raw = part.trim();
|
|
1382
|
+
if (!raw || raw === "*/*") continue;
|
|
1383
|
+
const label = mimeLabel(raw);
|
|
1384
|
+
if (label && !seen.has(label)) {
|
|
1385
|
+
seen.add(label);
|
|
1386
|
+
labels.push(label);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (labels.length > 0) {
|
|
1390
|
+
const MAX = 5;
|
|
1391
|
+
constraintBits.push(
|
|
1392
|
+
labels.length > MAX ? `${labels.slice(0, MAX).join(", ")} +${labels.length - MAX}` : labels.join(", ")
|
|
1393
|
+
);
|
|
1318
1394
|
}
|
|
1319
1395
|
}
|
|
1320
1396
|
if (constraintBits.length > 0) {
|
|
1321
|
-
dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join("
|
|
1397
|
+
dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
|
|
1322
1398
|
}
|
|
1323
1399
|
const input = document.createElement("input");
|
|
1324
1400
|
input.type = "file";
|
|
1325
1401
|
input.multiple = (this.opts.maxFiles ?? 10) > 1;
|
|
1326
|
-
if (this.
|
|
1402
|
+
if (this.acceptStr) input.accept = this.acceptStr;
|
|
1327
1403
|
input.style.display = "none";
|
|
1328
1404
|
input.onchange = () => {
|
|
1329
1405
|
if (input.files) this.addFiles(Array.from(input.files));
|
|
@@ -1338,13 +1414,20 @@ var Picker = class {
|
|
|
1338
1414
|
input.click();
|
|
1339
1415
|
}
|
|
1340
1416
|
};
|
|
1341
|
-
|
|
1417
|
+
let dragDepth = 0;
|
|
1418
|
+
dz.addEventListener("dragenter", (e) => {
|
|
1342
1419
|
e.preventDefault();
|
|
1420
|
+
dragDepth++;
|
|
1343
1421
|
dz.setAttribute("data-drag", "over");
|
|
1344
1422
|
});
|
|
1345
|
-
dz.addEventListener("
|
|
1423
|
+
dz.addEventListener("dragover", (e) => e.preventDefault());
|
|
1424
|
+
dz.addEventListener("dragleave", () => {
|
|
1425
|
+
dragDepth = Math.max(0, dragDepth - 1);
|
|
1426
|
+
if (dragDepth === 0) dz.removeAttribute("data-drag");
|
|
1427
|
+
});
|
|
1346
1428
|
dz.addEventListener("drop", (e) => {
|
|
1347
1429
|
e.preventDefault();
|
|
1430
|
+
dragDepth = 0;
|
|
1348
1431
|
dz.removeAttribute("data-drag");
|
|
1349
1432
|
const dropped = e.dataTransfer?.files;
|
|
1350
1433
|
if (dropped) this.addFiles(Array.from(dropped));
|
|
@@ -1356,20 +1439,50 @@ var Picker = class {
|
|
|
1356
1439
|
this.editor.close();
|
|
1357
1440
|
this.editor = null;
|
|
1358
1441
|
}
|
|
1442
|
+
if (this.noticeTimer) {
|
|
1443
|
+
clearTimeout(this.noticeTimer);
|
|
1444
|
+
this.noticeTimer = null;
|
|
1445
|
+
}
|
|
1359
1446
|
if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
|
|
1360
1447
|
this.$backdrop = null;
|
|
1361
1448
|
this.$panel = null;
|
|
1362
1449
|
for (const item of this.items) {
|
|
1363
1450
|
if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
|
|
1364
1451
|
}
|
|
1452
|
+
this.previouslyFocused?.focus?.();
|
|
1453
|
+
this.previouslyFocused = null;
|
|
1365
1454
|
this.opts.onClose?.();
|
|
1366
1455
|
}
|
|
1367
1456
|
// ---- file selection -----------------------------------------------------
|
|
1457
|
+
// Mirrors the native <input accept> matching: exact MIME, "type/*"
|
|
1458
|
+
// wildcards, and ".ext" suffixes. Drag-and-drop bypasses the input filter,
|
|
1459
|
+
// so we re-check here to keep both entry points consistent.
|
|
1460
|
+
fileMatchesAccept(file) {
|
|
1461
|
+
const tokens = this.acceptStr.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1462
|
+
if (tokens.length === 0 || tokens.includes("*/*")) return true;
|
|
1463
|
+
const mime = (file.type || "").toLowerCase();
|
|
1464
|
+
const name = file.name.toLowerCase();
|
|
1465
|
+
return tokens.some((tok) => {
|
|
1466
|
+
if (tok.startsWith(".")) return name.endsWith(tok);
|
|
1467
|
+
if (tok.endsWith("/*")) return mime.startsWith(tok.slice(0, -1));
|
|
1468
|
+
return mime === tok;
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1368
1471
|
addFiles(files) {
|
|
1369
1472
|
const cap = this.opts.maxFiles ?? Infinity;
|
|
1473
|
+
let rejectedType = 0;
|
|
1474
|
+
const eligible = files.filter((f) => {
|
|
1475
|
+
if (this.fileMatchesAccept(f)) return true;
|
|
1476
|
+
rejectedType++;
|
|
1477
|
+
return false;
|
|
1478
|
+
});
|
|
1370
1479
|
const remaining = cap - this.items.length;
|
|
1371
|
-
|
|
1372
|
-
const
|
|
1480
|
+
const chosen = remaining > 0 ? eligible.slice(0, remaining) : [];
|
|
1481
|
+
const overCap = eligible.length - chosen.length;
|
|
1482
|
+
const notices = [];
|
|
1483
|
+
if (rejectedType > 0) notices.push(`${rejectedType} file${rejectedType > 1 ? "s" : ""} skipped \u2014 unsupported type`);
|
|
1484
|
+
if (overCap > 0) notices.push(`${overCap} file${overCap > 1 ? "s" : ""} skipped \u2014 max ${cap} allowed`);
|
|
1485
|
+
if (notices.length) this.flashNotice(notices.join(" \xB7 "));
|
|
1373
1486
|
for (const file of chosen) {
|
|
1374
1487
|
if (this.opts.maxFileSize && file.size > this.opts.maxFileSize) {
|
|
1375
1488
|
const item2 = {
|
|
@@ -1601,6 +1714,20 @@ var Picker = class {
|
|
|
1601
1714
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1602
1715
|
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1603
1716
|
}
|
|
1717
|
+
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1718
|
+
flashNotice(text) {
|
|
1719
|
+
if (!this.$summary) return;
|
|
1720
|
+
this.$summary.textContent = text;
|
|
1721
|
+
this.$summary.dataset.tone = "warn";
|
|
1722
|
+
if (this.noticeTimer) clearTimeout(this.noticeTimer);
|
|
1723
|
+
this.noticeTimer = setTimeout(() => {
|
|
1724
|
+
if (this.$summary) {
|
|
1725
|
+
this.$summary.textContent = "";
|
|
1726
|
+
delete this.$summary.dataset.tone;
|
|
1727
|
+
}
|
|
1728
|
+
this.noticeTimer = null;
|
|
1729
|
+
}, 4e3);
|
|
1730
|
+
}
|
|
1604
1731
|
// ---- upload -------------------------------------------------------------
|
|
1605
1732
|
async startUpload() {
|
|
1606
1733
|
if (this.uploadStarted) return;
|
|
@@ -1704,6 +1831,49 @@ function el2(tag, className, text) {
|
|
|
1704
1831
|
if (text !== void 0) node.textContent = text;
|
|
1705
1832
|
return node;
|
|
1706
1833
|
}
|
|
1834
|
+
var MIME_LABELS = {
|
|
1835
|
+
"application/pdf": "PDF",
|
|
1836
|
+
"application/msword": "DOC",
|
|
1837
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
|
|
1838
|
+
"application/vnd.ms-excel": "XLS",
|
|
1839
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
|
|
1840
|
+
"application/vnd.ms-powerpoint": "PPT",
|
|
1841
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "PPTX",
|
|
1842
|
+
"application/zip": "ZIP",
|
|
1843
|
+
"application/json": "JSON",
|
|
1844
|
+
"application/rtf": "RTF",
|
|
1845
|
+
"text/plain": "TXT",
|
|
1846
|
+
"text/csv": "CSV",
|
|
1847
|
+
"text/html": "HTML",
|
|
1848
|
+
"text/markdown": "MD",
|
|
1849
|
+
"image/jpeg": "JPG",
|
|
1850
|
+
"image/png": "PNG",
|
|
1851
|
+
"image/gif": "GIF",
|
|
1852
|
+
"image/webp": "WEBP",
|
|
1853
|
+
"image/svg+xml": "SVG",
|
|
1854
|
+
"image/heic": "HEIC",
|
|
1855
|
+
"video/mp4": "MP4",
|
|
1856
|
+
"audio/mpeg": "MP3"
|
|
1857
|
+
};
|
|
1858
|
+
var MIME_GROUP_LABELS = {
|
|
1859
|
+
image: "Images",
|
|
1860
|
+
video: "Video",
|
|
1861
|
+
audio: "Audio",
|
|
1862
|
+
text: "Text",
|
|
1863
|
+
font: "Fonts"
|
|
1864
|
+
};
|
|
1865
|
+
function mimeLabel(raw) {
|
|
1866
|
+
const type = raw.trim().toLowerCase();
|
|
1867
|
+
if (!type) return "";
|
|
1868
|
+
if (type.endsWith("/*") || !type.includes("/")) {
|
|
1869
|
+
const group = type.replace("/*", "");
|
|
1870
|
+
return MIME_GROUP_LABELS[group] ?? group.charAt(0).toUpperCase() + group.slice(1);
|
|
1871
|
+
}
|
|
1872
|
+
if (MIME_LABELS[type]) return MIME_LABELS[type];
|
|
1873
|
+
if (type.startsWith(".")) return type.slice(1).toUpperCase();
|
|
1874
|
+
const subtype = type.split("/")[1] ?? type;
|
|
1875
|
+
return (subtype.split("+")[0].split(".").pop() ?? subtype).toUpperCase();
|
|
1876
|
+
}
|
|
1707
1877
|
function formatBytes(n) {
|
|
1708
1878
|
if (n < 1024) return `${n} B`;
|
|
1709
1879
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|