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.
- package/gen.js +77 -77
- package/package.json +1 -1
- package/ui/components/Drawer.jsx +208 -208
- package/ui/components/Layout.jsx +138 -138
- package/ui/components/LoginPage.jsx +405 -405
- package/ui/components/Pagination.jsx +145 -145
- package/ui/components/PrivacyBlur.jsx +603 -603
- package/ui/components/Sidebar.jsx +820 -820
- package/utils/index.js +20 -20
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1634 -1634
- package/utils/utils/session.js +58 -58
- package/utils/utils/storage.js +393 -393
- package/utils/utils/storage_manager.cjs +84 -7
- package/utils/utils/storage_manager.js +95 -8
- package/utils/utils/ui.js +111 -111
- package/vue/components/Badge.vue +79 -79
- package/vue/components/Button.vue +209 -209
- package/vue/components/Checkbox.vue +103 -103
- package/vue/components/CircularProgress.vue +383 -383
- package/vue/components/Divider.vue +94 -94
- package/vue/components/Drawer.vue +243 -243
- package/vue/components/InfoBadge.vue +223 -223
- package/vue/components/Input.vue +341 -341
- package/vue/components/LinearProgress.vue +268 -268
- package/vue/components/Modal.vue +226 -226
- package/vue/components/Pagination.vue +199 -199
- package/vue/components/Select.vue +713 -713
- package/vue/components/Sidebar.vue +955 -955
- package/vue/components/Skeleton.vue +91 -91
- package/vue/components/Spinner.vue +263 -263
- package/vue/components/StatusTag.vue +89 -89
- package/vue/components/TabBar.vue +176 -176
- package/vue/components/Table.vue +388 -388
- package/vue/components/TimePicker.vue +399 -399
- package/vue/components/Toggle.vue +115 -115
- package/vue/components/XTable.vue +119 -119
- package/vue/composables/useAutoSkeletonRows.ts +71 -71
- package/vue/index.d.ts +67 -67
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
1171
|
-
: [fileId, entityType, entityId,
|
|
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.
|
|
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
|
+
};
|