cynx-lib 1.3.80 → 1.3.81
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 +55 -4
- package/utils/utils/storage_manager.js +64 -4
- 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();
|
|
@@ -918,7 +936,7 @@ 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);
|
|
@@ -935,6 +953,7 @@ class StorageManager {
|
|
|
935
953
|
if (req?.headers && req.headers["range"]) {
|
|
936
954
|
requestHeaders["Range"] = req.headers["range"];
|
|
937
955
|
}
|
|
956
|
+
requestHeaders["Accept-Encoding"] = "identity";
|
|
938
957
|
client.get(targetUrl, { agent, headers: requestHeaders }, (cloudRes) => {
|
|
939
958
|
if (cloudRes.statusCode >= 300 && cloudRes.statusCode < 400 && cloudRes.headers.location) {
|
|
940
959
|
const redirectUrl = new URL(cloudRes.headers.location, targetUrl).toString();
|
|
@@ -948,7 +967,36 @@ class StorageManager {
|
|
|
948
967
|
if (cloudRes.statusCode >= 400) {
|
|
949
968
|
return res.status(cloudRes.statusCode).send("Error loading file from storage");
|
|
950
969
|
}
|
|
951
|
-
const
|
|
970
|
+
const rawHeaderType = cloudRes.headers["content-type"];
|
|
971
|
+
const ext = (file.original_name || "").split(".").pop()?.toLowerCase();
|
|
972
|
+
const EXT_MIMES = {
|
|
973
|
+
svg: "image/svg+xml",
|
|
974
|
+
png: "image/png",
|
|
975
|
+
jpg: "image/jpeg",
|
|
976
|
+
jpeg: "image/jpeg",
|
|
977
|
+
webp: "image/webp",
|
|
978
|
+
gif: "image/gif",
|
|
979
|
+
ico: "image/x-icon",
|
|
980
|
+
bmp: "image/bmp",
|
|
981
|
+
avif: "image/avif",
|
|
982
|
+
pdf: "application/pdf",
|
|
983
|
+
json: "application/json",
|
|
984
|
+
txt: "text/plain",
|
|
985
|
+
csv: "text/csv",
|
|
986
|
+
xml: "application/xml",
|
|
987
|
+
zip: "application/zip",
|
|
988
|
+
tar: "application/x-tar",
|
|
989
|
+
gz: "application/gzip",
|
|
990
|
+
mp4: "video/mp4",
|
|
991
|
+
webm: "video/webm",
|
|
992
|
+
mp3: "audio/mpeg",
|
|
993
|
+
wav: "audio/wav"
|
|
994
|
+
};
|
|
995
|
+
const inferredMime = ext ? EXT_MIMES[ext] : null;
|
|
996
|
+
let mimeType = rawHeaderType;
|
|
997
|
+
if (!mimeType || mimeType === "application/octet-stream") {
|
|
998
|
+
mimeType = file.mime_type && file.mime_type !== "application/octet-stream" ? file.mime_type : inferredMime || rawHeaderType || "application/octet-stream";
|
|
999
|
+
}
|
|
952
1000
|
const filename = file.original_name || "file";
|
|
953
1001
|
const safeFilename = filename.replace(/["\r\n]/g, "_");
|
|
954
1002
|
const encodedFilename = encodeURIComponent(filename);
|
|
@@ -961,6 +1009,9 @@ class StorageManager {
|
|
|
961
1009
|
`${disposition}; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`
|
|
962
1010
|
);
|
|
963
1011
|
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
1012
|
+
if (cloudRes.headers["content-encoding"]) {
|
|
1013
|
+
res.setHeader("Content-Encoding", cloudRes.headers["content-encoding"]);
|
|
1014
|
+
}
|
|
964
1015
|
if (cloudRes.headers["content-range"]) {
|
|
965
1016
|
res.setHeader("Content-Range", cloudRes.headers["content-range"]);
|
|
966
1017
|
res.status(206);
|
|
@@ -1020,7 +1071,7 @@ class StorageManager {
|
|
|
1020
1071
|
const cloudPublicId = rf.cloudPublicId || rf.cloud_public_id || fileId;
|
|
1021
1072
|
const storageId = rf.storageId || rf.storage_id || null;
|
|
1022
1073
|
if (this.query) {
|
|
1023
|
-
const hasInspectionCol = this.
|
|
1074
|
+
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1024
1075
|
const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
|
|
1025
1076
|
id, entity_type, entity_id, inspection_id, storage_id,
|
|
1026
1077
|
original_name, file_type, mime_type, file_size, cloud_url, cloud_public_id, created_at, updated_at
|
|
@@ -1077,7 +1128,7 @@ class StorageManager {
|
|
|
1077
1128
|
const fileId = (0, import_uid.genUid)();
|
|
1078
1129
|
const isInspection = entityType === "dot_inspection" || entityType === "inspection";
|
|
1079
1130
|
const inspectionId = isInspection ? entityId : null;
|
|
1080
|
-
const hasInspectionCol = this.
|
|
1131
|
+
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1081
1132
|
const insertSql = hasInspectionCol ? `INSERT INTO ${this.filesTable} (
|
|
1082
1133
|
id, entity_type, entity_id, inspection_id, storage_id,
|
|
1083
1134
|
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();
|
|
@@ -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);
|
|
@@ -1037,6 +1056,9 @@ export class StorageManager {
|
|
|
1037
1056
|
if (req?.headers && req.headers['range']) {
|
|
1038
1057
|
requestHeaders['Range'] = req.headers['range'];
|
|
1039
1058
|
}
|
|
1059
|
+
// Prevent upstream providers (Cloudinary, Vercel, S3) from sending compressed gzip/br streams
|
|
1060
|
+
// which would otherwise be piped raw to clients without correct decompression.
|
|
1061
|
+
requestHeaders['Accept-Encoding'] = 'identity';
|
|
1040
1062
|
|
|
1041
1063
|
client.get(targetUrl, { agent, headers: requestHeaders }, (cloudRes) => {
|
|
1042
1064
|
if (cloudRes.statusCode >= 300 && cloudRes.statusCode < 400 && cloudRes.headers.location) {
|
|
@@ -1054,7 +1076,41 @@ export class StorageManager {
|
|
|
1054
1076
|
return res.status(cloudRes.statusCode).send('Error loading file from storage');
|
|
1055
1077
|
}
|
|
1056
1078
|
|
|
1057
|
-
const
|
|
1079
|
+
const rawHeaderType = cloudRes.headers['content-type'];
|
|
1080
|
+
const ext = (file.original_name || '').split('.').pop()?.toLowerCase();
|
|
1081
|
+
const EXT_MIMES = {
|
|
1082
|
+
svg: 'image/svg+xml',
|
|
1083
|
+
png: 'image/png',
|
|
1084
|
+
jpg: 'image/jpeg',
|
|
1085
|
+
jpeg: 'image/jpeg',
|
|
1086
|
+
webp: 'image/webp',
|
|
1087
|
+
gif: 'image/gif',
|
|
1088
|
+
ico: 'image/x-icon',
|
|
1089
|
+
bmp: 'image/bmp',
|
|
1090
|
+
avif: 'image/avif',
|
|
1091
|
+
pdf: 'application/pdf',
|
|
1092
|
+
json: 'application/json',
|
|
1093
|
+
txt: 'text/plain',
|
|
1094
|
+
csv: 'text/csv',
|
|
1095
|
+
xml: 'application/xml',
|
|
1096
|
+
zip: 'application/zip',
|
|
1097
|
+
tar: 'application/x-tar',
|
|
1098
|
+
gz: 'application/gzip',
|
|
1099
|
+
mp4: 'video/mp4',
|
|
1100
|
+
webm: 'video/webm',
|
|
1101
|
+
mp3: 'audio/mpeg',
|
|
1102
|
+
wav: 'audio/wav',
|
|
1103
|
+
};
|
|
1104
|
+
const inferredMime = ext ? EXT_MIMES[ext] : null;
|
|
1105
|
+
|
|
1106
|
+
let mimeType = rawHeaderType;
|
|
1107
|
+
// If remote returned generic octet-stream, prioritize known DB mime_type or file extension
|
|
1108
|
+
if (!mimeType || mimeType === 'application/octet-stream') {
|
|
1109
|
+
mimeType = (file.mime_type && file.mime_type !== 'application/octet-stream')
|
|
1110
|
+
? file.mime_type
|
|
1111
|
+
: inferredMime || rawHeaderType || 'application/octet-stream';
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1058
1114
|
const filename = file.original_name || 'file';
|
|
1059
1115
|
const safeFilename = filename.replace(/["\r\n]/g, '_');
|
|
1060
1116
|
const encodedFilename = encodeURIComponent(filename);
|
|
@@ -1069,6 +1125,10 @@ export class StorageManager {
|
|
|
1069
1125
|
);
|
|
1070
1126
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
1071
1127
|
|
|
1128
|
+
if (cloudRes.headers['content-encoding']) {
|
|
1129
|
+
res.setHeader('Content-Encoding', cloudRes.headers['content-encoding']);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1072
1132
|
if (cloudRes.headers['content-range']) {
|
|
1073
1133
|
res.setHeader('Content-Range', cloudRes.headers['content-range']);
|
|
1074
1134
|
res.status(206);
|
|
@@ -1140,7 +1200,7 @@ export class StorageManager {
|
|
|
1140
1200
|
const storageId = rf.storageId || rf.storage_id || null;
|
|
1141
1201
|
|
|
1142
1202
|
if (this.query) {
|
|
1143
|
-
const hasInspectionCol = this.
|
|
1203
|
+
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1144
1204
|
const insertSql = hasInspectionCol
|
|
1145
1205
|
? `INSERT INTO ${this.filesTable} (
|
|
1146
1206
|
id, entity_type, entity_id, inspection_id, storage_id,
|
|
@@ -1209,7 +1269,7 @@ export class StorageManager {
|
|
|
1209
1269
|
const fileId = genUid();
|
|
1210
1270
|
const isInspection = entityType === 'dot_inspection' || entityType === 'inspection';
|
|
1211
1271
|
const inspectionId = isInspection ? entityId : null;
|
|
1212
|
-
const hasInspectionCol = this.
|
|
1272
|
+
const hasInspectionCol = await this._hasInspectionColumn();
|
|
1213
1273
|
|
|
1214
1274
|
const insertSql = hasInspectionCol
|
|
1215
1275
|
? `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
|
+
};
|
package/vue/components/Badge.vue
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed } from "vue";
|
|
3
|
-
|
|
4
|
-
const VARIANTS: Record<string, any> = {
|
|
5
|
-
ok: { bg: "var(--success-bg, rgba(39,174,96,.1))", color: "var(--success, #16a34a)", border: "var(--sd, rgba(39,174,96,.2))", dot: "var(--success, #16a34a)" },
|
|
6
|
-
success: { bg: "var(--success-bg, rgba(39,174,96,.1))", color: "var(--success, #16a34a)", border: "var(--sd, rgba(39,174,96,.2))", dot: "var(--success, #16a34a)" },
|
|
7
|
-
err: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
8
|
-
danger: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
9
|
-
error: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
10
|
-
warn: { bg: "var(--accent-bg, rgba(249,158,44,.1))", color: "var(--accent, #f59e0b)", border: "var(--accent-border, rgba(249,158,44,.25))", dot: "var(--accent, #f59e0b)" },
|
|
11
|
-
warning: { bg: "var(--accent-bg, rgba(249,158,44,.1))", color: "var(--accent, #f59e0b)", border: "var(--accent-border, rgba(249,158,44,.25))", dot: "var(--accent, #f59e0b)" },
|
|
12
|
-
blue: { bg: "var(--blue-bg, rgba(39,130,228,.1))", color: "var(--blue, #2563eb)", border: "var(--infd, rgba(39,130,228,.2))", dot: "var(--blue, #2563eb)" },
|
|
13
|
-
info: { bg: "var(--blue-bg, rgba(39,130,228,.1))", color: "var(--blue, #2563eb)", border: "var(--infd, rgba(39,130,228,.2))", dot: "var(--blue, #2563eb)" },
|
|
14
|
-
teal: { bg: "rgba(18,103,93,.1)", color: "var(--accent, #12675D)", border: "rgba(18,103,93,.25)", dot: "var(--accent, #12675D)" },
|
|
15
|
-
primary: { bg: "rgba(18,103,93,.1)", color: "var(--accent, #12675D)", border: "rgba(18,103,93,.25)", dot: "var(--accent, #12675D)" },
|
|
16
|
-
dim: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
17
|
-
neutral: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
18
|
-
secondary: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
19
|
-
ghost: { bg: "transparent", color: "var(--t2, #64748b)", border: "var(--bd, #e2e8f0)", dot: "var(--t3, #94a3b8)" },
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const SIZE_MAP: Record<string, any> = {
|
|
23
|
-
xs: { padding: "1.5px 6px", plainPadding: "1px 0", fontSize: "9.5px", dotSize: 5 },
|
|
24
|
-
sm: { padding: "2px 8px", plainPadding: "2px 0", fontSize: "11px", dotSize: 6 },
|
|
25
|
-
md: { padding: "3px 9px", plainPadding: "2px 0", fontSize: "12px", dotSize: 6.5 },
|
|
26
|
-
lg: { padding: "4px 12px", plainPadding: "3px 0", fontSize: "13px", dotSize: 8 },
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const props = withDefaults(
|
|
30
|
-
defineProps<{
|
|
31
|
-
variant?: string;
|
|
32
|
-
size?: "xs" | "sm" | "md" | "lg";
|
|
33
|
-
dot?: boolean;
|
|
34
|
-
pill?: boolean;
|
|
35
|
-
plain?: boolean;
|
|
36
|
-
minimal?: boolean;
|
|
37
|
-
}>(),
|
|
38
|
-
{
|
|
39
|
-
variant: "dim",
|
|
40
|
-
size: "sm",
|
|
41
|
-
dot: false,
|
|
42
|
-
pill: true,
|
|
43
|
-
plain: false,
|
|
44
|
-
minimal: false,
|
|
45
|
-
}
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const v = computed(() => VARIANTS[props.variant] || VARIANTS.dim);
|
|
49
|
-
const s = computed(() => SIZE_MAP[props.size] || SIZE_MAP.sm);
|
|
50
|
-
const isPlain = computed(() => props.plain || props.minimal);
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<template>
|
|
54
|
-
<span
|
|
55
|
-
:class="[
|
|
56
|
-
'inline-flex items-center gap-1.5 select-none',
|
|
57
|
-
isPlain ? 'font-semibold' : 'font-bold leading-none',
|
|
58
|
-
isPlain ? '' : (pill ? 'rounded-full' : 'rounded-md')
|
|
59
|
-
]"
|
|
60
|
-
:style="{
|
|
61
|
-
background: isPlain ? 'transparent' : v.bg,
|
|
62
|
-
color: v.color,
|
|
63
|
-
border: isPlain ? 'none' : `1px solid ${v.border}`,
|
|
64
|
-
padding: isPlain ? s.plainPadding : s.padding,
|
|
65
|
-
fontSize: s.fontSize,
|
|
66
|
-
}"
|
|
67
|
-
>
|
|
68
|
-
<span
|
|
69
|
-
v-if="dot"
|
|
70
|
-
class="rounded-full flex-shrink-0"
|
|
71
|
-
:style="{
|
|
72
|
-
width: `${s.dotSize}px`,
|
|
73
|
-
height: `${s.dotSize}px`,
|
|
74
|
-
background: v.dot,
|
|
75
|
-
}"
|
|
76
|
-
/>
|
|
77
|
-
<slot />
|
|
78
|
-
</span>
|
|
79
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
const VARIANTS: Record<string, any> = {
|
|
5
|
+
ok: { bg: "var(--success-bg, rgba(39,174,96,.1))", color: "var(--success, #16a34a)", border: "var(--sd, rgba(39,174,96,.2))", dot: "var(--success, #16a34a)" },
|
|
6
|
+
success: { bg: "var(--success-bg, rgba(39,174,96,.1))", color: "var(--success, #16a34a)", border: "var(--sd, rgba(39,174,96,.2))", dot: "var(--success, #16a34a)" },
|
|
7
|
+
err: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
8
|
+
danger: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
9
|
+
error: { bg: "var(--danger-bg, rgba(246,71,71,.1))", color: "var(--danger, #ef4444)", border: "var(--dd, rgba(246,71,71,.2))", dot: "var(--danger, #ef4444)" },
|
|
10
|
+
warn: { bg: "var(--accent-bg, rgba(249,158,44,.1))", color: "var(--accent, #f59e0b)", border: "var(--accent-border, rgba(249,158,44,.25))", dot: "var(--accent, #f59e0b)" },
|
|
11
|
+
warning: { bg: "var(--accent-bg, rgba(249,158,44,.1))", color: "var(--accent, #f59e0b)", border: "var(--accent-border, rgba(249,158,44,.25))", dot: "var(--accent, #f59e0b)" },
|
|
12
|
+
blue: { bg: "var(--blue-bg, rgba(39,130,228,.1))", color: "var(--blue, #2563eb)", border: "var(--infd, rgba(39,130,228,.2))", dot: "var(--blue, #2563eb)" },
|
|
13
|
+
info: { bg: "var(--blue-bg, rgba(39,130,228,.1))", color: "var(--blue, #2563eb)", border: "var(--infd, rgba(39,130,228,.2))", dot: "var(--blue, #2563eb)" },
|
|
14
|
+
teal: { bg: "rgba(18,103,93,.1)", color: "var(--accent, #12675D)", border: "rgba(18,103,93,.25)", dot: "var(--accent, #12675D)" },
|
|
15
|
+
primary: { bg: "rgba(18,103,93,.1)", color: "var(--accent, #12675D)", border: "rgba(18,103,93,.25)", dot: "var(--accent, #12675D)" },
|
|
16
|
+
dim: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
17
|
+
neutral: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
18
|
+
secondary: { bg: "var(--grey-1, #f5f5f8)", color: "var(--grey-4, #64748b)", border: "var(--border, #e2e8f0)", dot: "var(--grey-4, #64748b)" },
|
|
19
|
+
ghost: { bg: "transparent", color: "var(--t2, #64748b)", border: "var(--bd, #e2e8f0)", dot: "var(--t3, #94a3b8)" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const SIZE_MAP: Record<string, any> = {
|
|
23
|
+
xs: { padding: "1.5px 6px", plainPadding: "1px 0", fontSize: "9.5px", dotSize: 5 },
|
|
24
|
+
sm: { padding: "2px 8px", plainPadding: "2px 0", fontSize: "11px", dotSize: 6 },
|
|
25
|
+
md: { padding: "3px 9px", plainPadding: "2px 0", fontSize: "12px", dotSize: 6.5 },
|
|
26
|
+
lg: { padding: "4px 12px", plainPadding: "3px 0", fontSize: "13px", dotSize: 8 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(
|
|
30
|
+
defineProps<{
|
|
31
|
+
variant?: string;
|
|
32
|
+
size?: "xs" | "sm" | "md" | "lg";
|
|
33
|
+
dot?: boolean;
|
|
34
|
+
pill?: boolean;
|
|
35
|
+
plain?: boolean;
|
|
36
|
+
minimal?: boolean;
|
|
37
|
+
}>(),
|
|
38
|
+
{
|
|
39
|
+
variant: "dim",
|
|
40
|
+
size: "sm",
|
|
41
|
+
dot: false,
|
|
42
|
+
pill: true,
|
|
43
|
+
plain: false,
|
|
44
|
+
minimal: false,
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const v = computed(() => VARIANTS[props.variant] || VARIANTS.dim);
|
|
49
|
+
const s = computed(() => SIZE_MAP[props.size] || SIZE_MAP.sm);
|
|
50
|
+
const isPlain = computed(() => props.plain || props.minimal);
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<span
|
|
55
|
+
:class="[
|
|
56
|
+
'inline-flex items-center gap-1.5 select-none',
|
|
57
|
+
isPlain ? 'font-semibold' : 'font-bold leading-none',
|
|
58
|
+
isPlain ? '' : (pill ? 'rounded-full' : 'rounded-md')
|
|
59
|
+
]"
|
|
60
|
+
:style="{
|
|
61
|
+
background: isPlain ? 'transparent' : v.bg,
|
|
62
|
+
color: v.color,
|
|
63
|
+
border: isPlain ? 'none' : `1px solid ${v.border}`,
|
|
64
|
+
padding: isPlain ? s.plainPadding : s.padding,
|
|
65
|
+
fontSize: s.fontSize,
|
|
66
|
+
}"
|
|
67
|
+
>
|
|
68
|
+
<span
|
|
69
|
+
v-if="dot"
|
|
70
|
+
class="rounded-full flex-shrink-0"
|
|
71
|
+
:style="{
|
|
72
|
+
width: `${s.dotSize}px`,
|
|
73
|
+
height: `${s.dotSize}px`,
|
|
74
|
+
background: v.dot,
|
|
75
|
+
}"
|
|
76
|
+
/>
|
|
77
|
+
<slot />
|
|
78
|
+
</span>
|
|
79
|
+
</template>
|