cynx-lib 1.3.80 → 1.3.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/gen.js +77 -77
  2. package/package.json +1 -1
  3. package/ui/components/Drawer.jsx +208 -208
  4. package/ui/components/Layout.jsx +138 -138
  5. package/ui/components/LoginPage.jsx +405 -405
  6. package/ui/components/Pagination.jsx +145 -145
  7. package/ui/components/PrivacyBlur.jsx +603 -603
  8. package/ui/components/Sidebar.jsx +820 -820
  9. package/utils/index.js +20 -20
  10. package/utils/utils/duration.js +139 -139
  11. package/utils/utils/oidc.js +214 -214
  12. package/utils/utils/realtime.js +1634 -1634
  13. package/utils/utils/session.js +58 -58
  14. package/utils/utils/storage.js +393 -393
  15. package/utils/utils/storage_manager.cjs +84 -7
  16. package/utils/utils/storage_manager.js +95 -8
  17. package/utils/utils/ui.js +111 -111
  18. package/vue/components/Badge.vue +79 -79
  19. package/vue/components/Button.vue +209 -209
  20. package/vue/components/Checkbox.vue +103 -103
  21. package/vue/components/CircularProgress.vue +383 -383
  22. package/vue/components/Divider.vue +94 -94
  23. package/vue/components/Drawer.vue +243 -243
  24. package/vue/components/InfoBadge.vue +223 -223
  25. package/vue/components/Input.vue +341 -341
  26. package/vue/components/LinearProgress.vue +268 -268
  27. package/vue/components/Modal.vue +226 -226
  28. package/vue/components/Pagination.vue +199 -199
  29. package/vue/components/Select.vue +713 -713
  30. package/vue/components/Sidebar.vue +955 -955
  31. package/vue/components/Skeleton.vue +91 -91
  32. package/vue/components/Spinner.vue +263 -263
  33. package/vue/components/StatusTag.vue +89 -89
  34. package/vue/components/TabBar.vue +176 -176
  35. package/vue/components/Table.vue +388 -388
  36. package/vue/components/TimePicker.vue +399 -399
  37. package/vue/components/Toggle.vue +115 -115
  38. package/vue/components/XTable.vue +119 -119
  39. package/vue/composables/useAutoSkeletonRows.ts +71 -71
  40. package/vue/index.d.ts +67 -67
  41. package/vue/index.js +86 -86
@@ -224,6 +224,24 @@ class StorageManager {
224
224
  invalidateStorageCache() {
225
225
  this.activeStorageCache = { data: null, ts: 0 };
226
226
  }
227
+ async _hasInspectionColumn() {
228
+ if (this._hasInspectionCol !== void 0) return this._hasInspectionCol;
229
+ if (!this.query) {
230
+ this._hasInspectionCol = false;
231
+ return false;
232
+ }
233
+ try {
234
+ const res = await this.query(
235
+ `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'inspection_id' LIMIT 1`,
236
+ [this.filesTable]
237
+ );
238
+ this._hasInspectionCol = res.rows && res.rows.length > 0;
239
+ return this._hasInspectionCol;
240
+ } catch {
241
+ this._hasInspectionCol = false;
242
+ return false;
243
+ }
244
+ }
227
245
  async getCachedFileRecord(fileId) {
228
246
  if (!fileId) return null;
229
247
  const now = Date.now();
@@ -256,7 +274,7 @@ class StorageManager {
256
274
  fileType,
257
275
  mimeType,
258
276
  fileSize: Number(f.file_size || 0),
259
- // cloudUrl: f.cloud_url,
277
+ cloudUrl: f.cloud_url || f.cloudUrl || null,
260
278
  viewUrl: `${base}/cdn/${fileId}/view`,
261
279
  downloadUrl: `${base}/cdn/${fileId}/download`,
262
280
  url: `${base}/cdn/${fileId}`,
@@ -918,13 +936,19 @@ class StorageManager {
918
936
  this._streamHttp(file, req, res, isInline, redirectCount);
919
937
  }
920
938
  _streamHttp(file, req, res, isInline, redirectCount = 0, knownProvider = null) {
921
- let targetUrl = file.cloud_url;
939
+ let targetUrl = file.cloud_url || file.url || file.view_url;
922
940
  if (!targetUrl) {
923
941
  if (file.storage_id && file.cloud_public_id) {
924
942
  return this._streamFromS3(file, req, res, isInline);
925
943
  }
926
944
  return res.status(404).send("File URL not found");
927
945
  }
946
+ if (targetUrl.startsWith("/")) {
947
+ const serverBase = (this.storageServerUrl || process.env?.STORAGE_SERVER_URL || "").replace(/\/+$/, "");
948
+ if (serverBase) {
949
+ targetUrl = `${serverBase}${targetUrl}`;
950
+ }
951
+ }
928
952
  if (!/^https?:\/\//i.test(targetUrl)) {
929
953
  targetUrl = `https://${targetUrl}`;
930
954
  }
@@ -935,6 +959,7 @@ class StorageManager {
935
959
  if (req?.headers && req.headers["range"]) {
936
960
  requestHeaders["Range"] = req.headers["range"];
937
961
  }
962
+ requestHeaders["Accept-Encoding"] = "identity";
938
963
  client.get(targetUrl, { agent, headers: requestHeaders }, (cloudRes) => {
939
964
  if (cloudRes.statusCode >= 300 && cloudRes.statusCode < 400 && cloudRes.headers.location) {
940
965
  const redirectUrl = new URL(cloudRes.headers.location, targetUrl).toString();
@@ -948,7 +973,36 @@ class StorageManager {
948
973
  if (cloudRes.statusCode >= 400) {
949
974
  return res.status(cloudRes.statusCode).send("Error loading file from storage");
950
975
  }
951
- const mimeType = cloudRes.headers["content-type"] || file.mime_type || "application/octet-stream";
976
+ const rawHeaderType = cloudRes.headers["content-type"];
977
+ const ext = (file.original_name || "").split(".").pop()?.toLowerCase();
978
+ const EXT_MIMES = {
979
+ svg: "image/svg+xml",
980
+ png: "image/png",
981
+ jpg: "image/jpeg",
982
+ jpeg: "image/jpeg",
983
+ webp: "image/webp",
984
+ gif: "image/gif",
985
+ ico: "image/x-icon",
986
+ bmp: "image/bmp",
987
+ avif: "image/avif",
988
+ pdf: "application/pdf",
989
+ json: "application/json",
990
+ txt: "text/plain",
991
+ csv: "text/csv",
992
+ xml: "application/xml",
993
+ zip: "application/zip",
994
+ tar: "application/x-tar",
995
+ gz: "application/gzip",
996
+ mp4: "video/mp4",
997
+ webm: "video/webm",
998
+ mp3: "audio/mpeg",
999
+ wav: "audio/wav"
1000
+ };
1001
+ const inferredMime = ext ? EXT_MIMES[ext] : null;
1002
+ let mimeType = rawHeaderType;
1003
+ if (!mimeType || mimeType === "application/octet-stream") {
1004
+ mimeType = file.mime_type && file.mime_type !== "application/octet-stream" ? file.mime_type : inferredMime || rawHeaderType || "application/octet-stream";
1005
+ }
952
1006
  const filename = file.original_name || "file";
953
1007
  const safeFilename = filename.replace(/["\r\n]/g, "_");
954
1008
  const encodedFilename = encodeURIComponent(filename);
@@ -961,6 +1015,9 @@ class StorageManager {
961
1015
  `${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
962
1016
  );
963
1017
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
1018
+ if (cloudRes.headers["content-encoding"]) {
1019
+ res.setHeader("Content-Encoding", cloudRes.headers["content-encoding"]);
1020
+ }
964
1021
  if (cloudRes.headers["content-range"]) {
965
1022
  res.setHeader("Content-Range", cloudRes.headers["content-range"]);
966
1023
  res.status(206);
@@ -1016,11 +1073,31 @@ class StorageManager {
1016
1073
  const fileType = rf.fileType || rf.file_type || (0, import_storage.getFileTypeCategory)(mimeType, rf.originalName || rf.original_name);
1017
1074
  const originalName = rf.originalName || rf.original_name || "file";
1018
1075
  const fileSize = Number(rf.fileSize || rf.file_size || 0);
1019
- const cloudUrl = rf.cloudUrl || rf.cloud_url || rf.url || (this.storageServerUrl ? `${this.storageServerUrl}/cdn/${fileId}` : "");
1076
+ const rawUrl = rf.cloudUrl || rf.cloud_url || rf.url || "";
1077
+ let cloudUrl = rawUrl;
1078
+ if (this.storageServerUrl) {
1079
+ if (rawUrl.startsWith("http")) {
1080
+ cloudUrl = this.storageServerUrl.startsWith("https://") ? rawUrl.replace(/^http:\/\//, "https://") : rawUrl;
1081
+ } else if (rawUrl.startsWith("/")) {
1082
+ cloudUrl = `${this.storageServerUrl}${rawUrl}`;
1083
+ } else {
1084
+ cloudUrl = `${this.storageServerUrl}/cdn/${fileId}`;
1085
+ }
1086
+ }
1020
1087
  const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
1021
1088
  const storageId = rf.storageId || rf.storage_id || null;
1022
1089
  if (this.query) {
1023
- const hasInspectionCol = this.filesTable === "storage_files";
1090
+ let validStorageId = null;
1091
+ if (storageId) {
1092
+ try {
1093
+ const sCheck = await this.query(`SELECT 1 FROM ${this.storagesTable} WHERE id = $1 LIMIT 1`, [storageId]);
1094
+ if (sCheck && sCheck.rows && sCheck.rows.length > 0) {
1095
+ validStorageId = storageId;
1096
+ }
1097
+ } catch {
1098
+ }
1099
+ }
1100
+ const hasInspectionCol = await this._hasInspectionColumn();
1024
1101
  const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
1025
1102
  id, entity_type, entity_id, inspection_id, storage_id,
1026
1103
  original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id, created_at, updated_at
@@ -1043,7 +1120,7 @@ class StorageManager {
1043
1120
  cloud_public_id = EXCLUDED.cloud_public_id,
1044
1121
  updated_at = NOW()
1045
1122
  RETURNING *`;
1046
- const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId] : [fileId, entityType, entityId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
1123
+ const params = hasInspectionCol ? [fileId, entityType, entityId, inspectionId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId] : [fileId, entityType, entityId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
1047
1124
  const r = await this.query(insertSql, params);
1048
1125
  this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
1049
1126
  return this.formatFileRecord(r.rows[0]);
@@ -1077,7 +1154,7 @@ class StorageManager {
1077
1154
  const fileId = (0, import_uid.genUid)();
1078
1155
  const isInspection = entityType === "dot_inspection" || entityType === "inspection";
1079
1156
  const inspectionId = isInspection ? entityId : null;
1080
- const hasInspectionCol = this.filesTable === "storage_files";
1157
+ const hasInspectionCol = await this._hasInspectionColumn();
1081
1158
  const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
1082
1159
  id, entity_type, entity_id, inspection_id, storage_id,
1083
1160
  original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id
@@ -229,6 +229,25 @@ export class StorageManager {
229
229
  this.activeStorageCache = { data: null, ts: 0 };
230
230
  }
231
231
 
232
+ async _hasInspectionColumn() {
233
+ if (this._hasInspectionCol !== undefined) return this._hasInspectionCol;
234
+ if (!this.query) {
235
+ this._hasInspectionCol = false;
236
+ return false;
237
+ }
238
+ try {
239
+ const res = await this.query(
240
+ `SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'inspection_id' LIMIT 1`,
241
+ [this.filesTable]
242
+ );
243
+ this._hasInspectionCol = res.rows && res.rows.length > 0;
244
+ return this._hasInspectionCol;
245
+ } catch {
246
+ this._hasInspectionCol = false;
247
+ return false;
248
+ }
249
+ }
250
+
232
251
  async getCachedFileRecord(fileId) {
233
252
  if (!fileId) return null;
234
253
  const now = Date.now();
@@ -265,7 +284,7 @@ export class StorageManager {
265
284
  fileType,
266
285
  mimeType,
267
286
  fileSize: Number(f.file_size || 0),
268
- // cloudUrl: f.cloud_url,
287
+ cloudUrl: f.cloud_url || f.cloudUrl || null,
269
288
  viewUrl: `${base}/cdn/${fileId}/view`,
270
289
  downloadUrl: `${base}/cdn/${fileId}/download`,
271
290
  url: `${base}/cdn/${fileId}`,
@@ -1017,7 +1036,7 @@ export class StorageManager {
1017
1036
  }
1018
1037
 
1019
1038
  _streamHttp(file, req, res, isInline, redirectCount = 0, knownProvider = null) {
1020
- let targetUrl = file.cloud_url;
1039
+ let targetUrl = file.cloud_url || file.url || file.view_url;
1021
1040
  if (!targetUrl) {
1022
1041
  if (file.storage_id && file.cloud_public_id) {
1023
1042
  return this._streamFromS3(file, req, res, isInline);
@@ -1025,6 +1044,13 @@ export class StorageManager {
1025
1044
  return res.status(404).send('File URL not found');
1026
1045
  }
1027
1046
 
1047
+ if (targetUrl.startsWith('/')) {
1048
+ const serverBase = (this.storageServerUrl || process.env?.STORAGE_SERVER_URL || '').replace(/\/+$/, '');
1049
+ if (serverBase) {
1050
+ targetUrl = `${serverBase}${targetUrl}`;
1051
+ }
1052
+ }
1053
+
1028
1054
  if (!/^https?:\/\//i.test(targetUrl)) {
1029
1055
  targetUrl = `https://${targetUrl}`;
1030
1056
  }
@@ -1037,6 +1063,9 @@ export class StorageManager {
1037
1063
  if (req?.headers && req.headers['range']) {
1038
1064
  requestHeaders['Range'] = req.headers['range'];
1039
1065
  }
1066
+ // Prevent upstream providers (Cloudinary, Vercel, S3) from sending compressed gzip/br streams
1067
+ // which would otherwise be piped raw to clients without correct decompression.
1068
+ requestHeaders['Accept-Encoding'] = 'identity';
1040
1069
 
1041
1070
  client.get(targetUrl, { agent, headers: requestHeaders }, (cloudRes) => {
1042
1071
  if (cloudRes.statusCode >= 300 && cloudRes.statusCode < 400 && cloudRes.headers.location) {
@@ -1054,7 +1083,41 @@ export class StorageManager {
1054
1083
  return res.status(cloudRes.statusCode).send('Error loading file from storage');
1055
1084
  }
1056
1085
 
1057
- const mimeType = cloudRes.headers['content-type'] || file.mime_type || 'application/octet-stream';
1086
+ const rawHeaderType = cloudRes.headers['content-type'];
1087
+ const ext = (file.original_name || '').split('.').pop()?.toLowerCase();
1088
+ const EXT_MIMES = {
1089
+ svg: 'image/svg+xml',
1090
+ png: 'image/png',
1091
+ jpg: 'image/jpeg',
1092
+ jpeg: 'image/jpeg',
1093
+ webp: 'image/webp',
1094
+ gif: 'image/gif',
1095
+ ico: 'image/x-icon',
1096
+ bmp: 'image/bmp',
1097
+ avif: 'image/avif',
1098
+ pdf: 'application/pdf',
1099
+ json: 'application/json',
1100
+ txt: 'text/plain',
1101
+ csv: 'text/csv',
1102
+ xml: 'application/xml',
1103
+ zip: 'application/zip',
1104
+ tar: 'application/x-tar',
1105
+ gz: 'application/gzip',
1106
+ mp4: 'video/mp4',
1107
+ webm: 'video/webm',
1108
+ mp3: 'audio/mpeg',
1109
+ wav: 'audio/wav',
1110
+ };
1111
+ const inferredMime = ext ? EXT_MIMES[ext] : null;
1112
+
1113
+ let mimeType = rawHeaderType;
1114
+ // If remote returned generic octet-stream, prioritize known DB mime_type or file extension
1115
+ if (!mimeType || mimeType === 'application/octet-stream') {
1116
+ mimeType = (file.mime_type && file.mime_type !== 'application/octet-stream')
1117
+ ? file.mime_type
1118
+ : inferredMime || rawHeaderType || 'application/octet-stream';
1119
+ }
1120
+
1058
1121
  const filename = file.original_name || 'file';
1059
1122
  const safeFilename = filename.replace(/["\r\n]/g, '_');
1060
1123
  const encodedFilename = encodeURIComponent(filename);
@@ -1069,6 +1132,10 @@ export class StorageManager {
1069
1132
  );
1070
1133
  res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1071
1134
 
1135
+ if (cloudRes.headers['content-encoding']) {
1136
+ res.setHeader('Content-Encoding', cloudRes.headers['content-encoding']);
1137
+ }
1138
+
1072
1139
  if (cloudRes.headers['content-range']) {
1073
1140
  res.setHeader('Content-Range', cloudRes.headers['content-range']);
1074
1141
  res.status(206);
@@ -1135,12 +1202,32 @@ export class StorageManager {
1135
1202
  const fileType = rf.fileType || rf.file_type || getFileTypeCategory(mimeType, rf.originalName || rf.original_name);
1136
1203
  const originalName = rf.originalName || rf.original_name || 'file';
1137
1204
  const fileSize = Number(rf.fileSize || rf.file_size || 0);
1138
- const cloudUrl = rf.cloudUrl || rf.cloud_url || rf.url || (this.storageServerUrl ? `${this.storageServerUrl}/cdn/${fileId}` : '');
1205
+ const rawUrl = rf.cloudUrl || rf.cloud_url || rf.url || '';
1206
+ let cloudUrl = rawUrl;
1207
+ if (this.storageServerUrl) {
1208
+ if (rawUrl.startsWith('http')) {
1209
+ cloudUrl = this.storageServerUrl.startsWith('https://') ? rawUrl.replace(/^http:\/\//, 'https://') : rawUrl;
1210
+ } else if (rawUrl.startsWith('/')) {
1211
+ cloudUrl = `${this.storageServerUrl}${rawUrl}`;
1212
+ } else {
1213
+ cloudUrl = `${this.storageServerUrl}/cdn/${fileId}`;
1214
+ }
1215
+ }
1139
1216
  const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
1140
1217
  const storageId = rf.storageId || rf.storage_id || null;
1141
1218
 
1142
1219
  if (this.query) {
1143
- const hasInspectionCol = this.filesTable === 'storage_files';
1220
+ let validStorageId = null;
1221
+ if (storageId) {
1222
+ try {
1223
+ const sCheck = await this.query(`SELECT 1 FROM ${this.storagesTable} WHERE id = $1 LIMIT 1`, [storageId]);
1224
+ if (sCheck && sCheck.rows && sCheck.rows.length > 0) {
1225
+ validStorageId = storageId;
1226
+ }
1227
+ } catch {}
1228
+ }
1229
+
1230
+ const hasInspectionCol = await this._hasInspectionColumn();
1144
1231
  const insertSql = hasInspectionCol
1145
1232
  ? `INSERT INTO ${this.filesTable} (
1146
1233
  id, entity_type, entity_id, inspection_id, storage_id,
@@ -1167,8 +1254,8 @@ export class StorageManager {
1167
1254
  RETURNING *`;
1168
1255
 
1169
1256
  const params = hasInspectionCol
1170
- ? [fileId, entityType, entityId, inspectionId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId]
1171
- : [fileId, entityType, entityId, storageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
1257
+ ? [fileId, entityType, entityId, inspectionId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId]
1258
+ : [fileId, entityType, entityId, validStorageId, originalName, fileType, mimeType, fileSize, cloudUrl, cloudPublicId];
1172
1259
 
1173
1260
  const r = await this.query(insertSql, params);
1174
1261
  this.fileMetadataCache.set(fileId, { data: r.rows[0], ts: Date.now() });
@@ -1209,7 +1296,7 @@ export class StorageManager {
1209
1296
  const fileId = genUid();
1210
1297
  const isInspection = entityType === 'dot_inspection' || entityType === 'inspection';
1211
1298
  const inspectionId = isInspection ? entityId : null;
1212
- const hasInspectionCol = this.filesTable === 'storage_files';
1299
+ const hasInspectionCol = await this._hasInspectionColumn();
1213
1300
 
1214
1301
  const insertSql = hasInspectionCol
1215
1302
  ? `INSERT INTO ${this.filesTable} (
package/utils/utils/ui.js CHANGED
@@ -1,111 +1,111 @@
1
- /**
2
- * cynx/utils — UI and Formatting Helpers (ui.js)
3
- *
4
- * Universal classname merger (cn), clipboard helper, and friendly date/byte formatters.
5
- */
6
-
7
- /**
8
- * Universal class names combiner / merger (zero-dependency clsx replacement).
9
- * Handles strings, arrays, objects, booleans, and null/undefined values.
10
- *
11
- * @param {...any} inputs
12
- * @returns {string} Combined class string
13
- */
14
- export function cn(...inputs) {
15
- const classes = [];
16
-
17
- for (let i = 0; i < inputs.length; i++) {
18
- const arg = inputs[i];
19
- if (!arg) continue;
20
-
21
- const argType = typeof arg;
22
- if (argType === 'string' || argType === 'number') {
23
- classes.push(arg);
24
- } else if (Array.isArray(arg)) {
25
- if (arg.length) {
26
- const inner = cn(...arg);
27
- if (inner) classes.push(inner);
28
- }
29
- } else if (argType === 'object') {
30
- for (const key in arg) {
31
- if (Object.prototype.hasOwnProperty.call(arg, key) && arg[key]) {
32
- classes.push(key);
33
- }
34
- }
35
- }
36
- }
37
-
38
- return classes.join(' ');
39
- }
40
-
41
- /**
42
- * Safely copy text to clipboard with modern Clipboard API and legacy fallback.
43
- *
44
- * @param {string} text
45
- * @returns {Promise<boolean>}
46
- */
47
- export async function copyToClipboard(text) {
48
- if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
49
- try {
50
- await navigator.clipboard.writeText(text);
51
- return true;
52
- } catch {}
53
- }
54
-
55
- if (typeof document !== 'undefined') {
56
- try {
57
- const el = document.createElement('textarea');
58
- el.value = text;
59
- el.setAttribute('readonly', '');
60
- el.style.position = 'absolute';
61
- el.style.left = '-9999px';
62
- document.body.appendChild(el);
63
- el.select();
64
- const successful = document.execCommand('copy');
65
- document.body.removeChild(el);
66
- return successful;
67
- } catch {}
68
- }
69
-
70
- return false;
71
- }
72
-
73
- /**
74
- * Format relative time (e.g. "5s ago", "12m ago", "3h ago", "2d ago").
75
- *
76
- * @param {Date|string|number} d
77
- * @returns {string}
78
- */
79
- export function fmtAgo(d) {
80
- if (!d) return '—';
81
- const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000);
82
- if (isNaN(s)) return '—';
83
- if (s < 60) return `${Math.max(0, s)}s ago`;
84
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
85
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
86
- return `${Math.floor(s / 86400)}d ago`;
87
- }
88
-
89
- /**
90
- * Format localized date or return fallback dash.
91
- *
92
- * @param {Date|string|number} d
93
- * @param {string} [locale='ru-RU']
94
- * @returns {string}
95
- */
96
- export function fmtDate(d, locale = 'ru-RU') {
97
- if (!d) return '—';
98
- try {
99
- const date = new Date(d);
100
- return isNaN(date.getTime()) ? '—' : date.toLocaleString(locale);
101
- } catch {
102
- return '—';
103
- }
104
- }
105
-
106
- export default {
107
- cn,
108
- copyToClipboard,
109
- fmtAgo,
110
- fmtDate,
111
- };
1
+ /**
2
+ * cynx/utils — UI and Formatting Helpers (ui.js)
3
+ *
4
+ * Universal classname merger (cn), clipboard helper, and friendly date/byte formatters.
5
+ */
6
+
7
+ /**
8
+ * Universal class names combiner / merger (zero-dependency clsx replacement).
9
+ * Handles strings, arrays, objects, booleans, and null/undefined values.
10
+ *
11
+ * @param {...any} inputs
12
+ * @returns {string} Combined class string
13
+ */
14
+ export function cn(...inputs) {
15
+ const classes = [];
16
+
17
+ for (let i = 0; i < inputs.length; i++) {
18
+ const arg = inputs[i];
19
+ if (!arg) continue;
20
+
21
+ const argType = typeof arg;
22
+ if (argType === 'string' || argType === 'number') {
23
+ classes.push(arg);
24
+ } else if (Array.isArray(arg)) {
25
+ if (arg.length) {
26
+ const inner = cn(...arg);
27
+ if (inner) classes.push(inner);
28
+ }
29
+ } else if (argType === 'object') {
30
+ for (const key in arg) {
31
+ if (Object.prototype.hasOwnProperty.call(arg, key) && arg[key]) {
32
+ classes.push(key);
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ return classes.join(' ');
39
+ }
40
+
41
+ /**
42
+ * Safely copy text to clipboard with modern Clipboard API and legacy fallback.
43
+ *
44
+ * @param {string} text
45
+ * @returns {Promise<boolean>}
46
+ */
47
+ export async function copyToClipboard(text) {
48
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
49
+ try {
50
+ await navigator.clipboard.writeText(text);
51
+ return true;
52
+ } catch {}
53
+ }
54
+
55
+ if (typeof document !== 'undefined') {
56
+ try {
57
+ const el = document.createElement('textarea');
58
+ el.value = text;
59
+ el.setAttribute('readonly', '');
60
+ el.style.position = 'absolute';
61
+ el.style.left = '-9999px';
62
+ document.body.appendChild(el);
63
+ el.select();
64
+ const successful = document.execCommand('copy');
65
+ document.body.removeChild(el);
66
+ return successful;
67
+ } catch {}
68
+ }
69
+
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Format relative time (e.g. "5s ago", "12m ago", "3h ago", "2d ago").
75
+ *
76
+ * @param {Date|string|number} d
77
+ * @returns {string}
78
+ */
79
+ export function fmtAgo(d) {
80
+ if (!d) return '—';
81
+ const s = Math.floor((Date.now() - new Date(d).getTime()) / 1000);
82
+ if (isNaN(s)) return '—';
83
+ if (s < 60) return `${Math.max(0, s)}s ago`;
84
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
85
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
86
+ return `${Math.floor(s / 86400)}d ago`;
87
+ }
88
+
89
+ /**
90
+ * Format localized date or return fallback dash.
91
+ *
92
+ * @param {Date|string|number} d
93
+ * @param {string} [locale='ru-RU']
94
+ * @returns {string}
95
+ */
96
+ export function fmtDate(d, locale = 'ru-RU') {
97
+ if (!d) return '—';
98
+ try {
99
+ const date = new Date(d);
100
+ return isNaN(date.getTime()) ? '—' : date.toLocaleString(locale);
101
+ } catch {
102
+ return '—';
103
+ }
104
+ }
105
+
106
+ export default {
107
+ cn,
108
+ copyToClipboard,
109
+ fmtAgo,
110
+ fmtDate,
111
+ };