@seip/blue-bird 0.9.9 → 1.0.1

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/core/app.js CHANGED
@@ -160,6 +160,7 @@ class App {
160
160
  if (this.static.path || props.debug)
161
161
  this.app.use(
162
162
  express.static(path.join(__dirname, this.static.path || "frontend"), {
163
+ extensions: ["html"],
163
164
  ...this.static.options,
164
165
  setHeaders: (res) => {
165
166
  res.setHeader("X-Powered-By", "Blue Bird");
package/core/config.js CHANGED
@@ -42,8 +42,10 @@ class Config {
42
42
  port: Number.isNaN(portRaw) ? 3000 : portRaw,
43
43
  jwtSecret: process.env.JWT_SECRET,
44
44
  static: {
45
- path: process.env.STATIC_PATH || "frontend/public",
46
- options: {},
45
+ path: process.env.STATIC_PATH || "frontend",
46
+ options: {
47
+ extensions: ["html"],
48
+ },
47
49
  },
48
50
  };
49
51
  return _cachedProps;
@@ -11,6 +11,11 @@
11
11
  <meta name="keywords" content="">
12
12
  <meta name="author" content="Seip25">
13
13
  <link rel="icon" href="/images/favicon.ico" />
14
+ <script src="/js/tailwind.js"></script>
15
+ <script src="/js/utils.js"></script>
16
+ <style type="text/tailwindcss">
17
+ @custom-variant dark (&:where(.dark, .dark *));
18
+ </style>
14
19
  </head>
15
20
 
16
21
  <body class="min-h-screen bg-slate-950 text-white font-sans antialiased selection:bg-blue-500 selection:text-white">
@@ -97,7 +102,7 @@
97
102
  <p>Powered by Blue Bird Framework. All rights reserved.</p>
98
103
  </div>
99
104
  </footer>
100
- <script src="/js/tailwind.js"></script>
105
+
101
106
  </body>
102
107
 
103
108
  </html>
@@ -11,6 +11,11 @@
11
11
  <meta name="keywords" content="">
12
12
  <meta name="author" content="Seip25">
13
13
  <link rel="icon" href="/images/favicon.ico" />
14
+ <script src="/js/tailwind.js"></script>
15
+ <script src="/js/utils.js"></script>
16
+ <style type="text/tailwindcss">
17
+ @custom-variant dark (&:where(.dark, .dark *));
18
+ </style>
14
19
  </head>
15
20
 
16
21
  <body class="min-h-screen bg-slate-950 text-white font-sans antialiased selection:bg-blue-500 selection:text-white">
@@ -135,7 +140,6 @@
135
140
  <p>Powered by Blue Bird Framework. All rights reserved.</p>
136
141
  </div>
137
142
  </footer>
138
- <script src="/js/tailwind.js"></script>
139
143
  </body>
140
144
 
141
145
  </html>
@@ -0,0 +1,558 @@
1
+ async function Http(url, method = "GET", body = null) {
2
+ const options = {
3
+ method: method,
4
+ headers: {},
5
+ };
6
+
7
+ if (body) {
8
+ if (body instanceof FormData) {
9
+ options.body = body;
10
+ } else {
11
+ options.headers["Content-Type"] = "application/json";
12
+ options.body = JSON.stringify(body);
13
+ }
14
+ }
15
+
16
+ const response = await fetch(url, options);
17
+
18
+ if (response.status === 401 && !url.includes("/api/auth")) {
19
+ if (window.location.pathname.startsWith("/dashboard")) {
20
+ window.location.href = "/login";
21
+ }
22
+ }
23
+
24
+ if (!response.ok) {
25
+ const errorData = await response.json().catch(() => ({}));
26
+ let msg = errorData.msg || errorData.message || errorData.error || "Error en la petición";
27
+ if (errorData.errors && Array.isArray(errorData.errors)) {
28
+ msg = errorData.errors.map((e) => Object.values(e)[0]).join(", ");
29
+ }
30
+ throw new Error(msg);
31
+ }
32
+
33
+ return await response.json();
34
+ }
35
+
36
+ function snackbar({ message, type = "success" }) {
37
+ let container = document.getElementById("snackbar-container");
38
+ if (!container) {
39
+ container = document.createElement("div");
40
+ container.id = "snackbar-container";
41
+ container.className = "fixed bottom-5 right-5 z-50 flex flex-col gap-2 pointer-events-none";
42
+ document.body.appendChild(container);
43
+ }
44
+
45
+ const toast = document.createElement("div");
46
+ const bg = type === "success" ? "bg-emerald-600" : "bg-red-600";
47
+ toast.className = `${bg} text-white px-4 py-3 rounded-xl shadow-lg font-bold text-xs flex items-center gap-2 transition-all transform translate-y-2 opacity-0 duration-300 pointer-events-auto`;
48
+ toast.innerHTML = `<span>${type === "success" ? "✓" : "⚠️"}</span> <span>${message}</span>`;
49
+
50
+ container.appendChild(toast);
51
+ setTimeout(() => {
52
+ toast.classList.remove("translate-y-2", "opacity-0");
53
+ }, 10);
54
+
55
+ setTimeout(() => {
56
+ toast.classList.add("opacity-0");
57
+ setTimeout(() => toast.remove(), 300);
58
+ }, 3500);
59
+ }
60
+
61
+ class ResponsiveDataTable {
62
+ constructor(containerId, options = {}) {
63
+ this.container = document.getElementById(containerId);
64
+ if (!this.container) return;
65
+ this.data = options.data || [];
66
+ this.columns = options.columns || [];
67
+ this.editCallback = options.edit;
68
+ this.deleteCallback = options.delete;
69
+ this.customActions = options.customActions || [];
70
+ this.render();
71
+ }
72
+
73
+ render() {
74
+ if (!this.data || this.data.length === 0) {
75
+ this.container.innerHTML =
76
+ '<p class="text-xs text-slate-400 italic p-4 text-center">No hay datos registrados.</p>';
77
+ return;
78
+ }
79
+
80
+ let html = `
81
+ <!-- Table Desktop -->
82
+ <div class="hidden md:block overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm">
83
+ <table class="w-full text-left border-collapse text-xs">
84
+ <thead>
85
+ <tr class="bg-slate-100 dark:bg-slate-950 text-slate-700 dark:text-slate-300 border-b border-slate-200 dark:border-slate-800 font-bold uppercase tracking-wider">
86
+ ${this.columns
87
+ .map((col) => `<th class="p-3">${col.title}</th>`)
88
+ .join("")}
89
+ ${this.editCallback || this.deleteCallback || this.customActions.length > 0
90
+ ? '<th class="p-3 text-right">Acciones</th>'
91
+ : ""
92
+ }
93
+ </tr>
94
+ </thead>
95
+ <tbody class="divide-y divide-slate-200 dark:divide-slate-800">
96
+ ${this.data
97
+ .map(
98
+ (item, idx) => `
99
+ <tr class="hover:bg-slate-50 dark:hover:bg-slate-950/50 transition-colors">
100
+ ${this.columns
101
+ .map(
102
+ (col) =>
103
+ `<td class="p-3 text-slate-800 dark:text-slate-200 font-medium">${item[col.key] !== undefined && item[col.key] !== null ? item[col.key] : ""
104
+ }</td>`
105
+ )
106
+ .join("")}
107
+ ${this.editCallback || this.deleteCallback || this.customActions.length > 0
108
+ ? `
109
+ <td class="p-3 text-right space-x-1.5 whitespace-nowrap">
110
+ ${this.customActions
111
+ .map(
112
+ (act, aIdx) =>
113
+ `<button onclick="window._dtInstances['${this.container.id}'].handleCustomAction(event, ${idx}, ${aIdx})" class="${act.class || 'px-2 py-1 bg-blue-50 text-blue-600 rounded-lg text-xs font-bold'}">${act.label}</button>`
114
+ )
115
+ .join("")}
116
+ ${this.editCallback
117
+ ? `<button onclick="window._dtInstances['${this.container.id}'].handleAction(event, ${idx}, 'edit')" class="px-2.5 py-1 bg-blue-50 text-blue-600 dark:bg-blue-950/40 dark:text-blue-400 hover:bg-blue-600 hover:text-white rounded-lg font-bold transition-all">Editar</button>`
118
+ : ""
119
+ }
120
+ ${this.deleteCallback
121
+ ? `<button onclick="window._dtInstances['${this.container.id}'].handleAction(event, ${idx}, 'delete')" class="px-2.5 py-1 bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-400 hover:bg-red-600 hover:text-white rounded-lg font-bold transition-all">Eliminar</button>`
122
+ : ""
123
+ }
124
+ </td>
125
+ `
126
+ : ""
127
+ }
128
+ </tr>
129
+ `
130
+ )
131
+ .join("")}
132
+ </tbody>
133
+ </table>
134
+ </div>
135
+
136
+ <!-- Table Mobile (Cards) -->
137
+ <div class="block md:hidden space-y-3">
138
+ ${this.data
139
+ .map(
140
+ (item, idx) => `
141
+ <div class="p-4 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 space-y-2 shadow-sm">
142
+ ${this.columns
143
+ .map(
144
+ (col) => `
145
+ <div class="flex justify-between text-xs gap-2">
146
+ <span class="font-bold text-slate-500">${col.title}:</span>
147
+ <span class="text-slate-800 dark:text-slate-200 font-medium text-right">${item[col.key] !== undefined && item[col.key] !== null ? item[col.key] : ""
148
+ }</span>
149
+ </div>
150
+ `
151
+ )
152
+ .join("")}
153
+ ${this.editCallback || this.deleteCallback || this.customActions.length > 0
154
+ ? `
155
+ <div class="flex flex-wrap justify-end gap-1.5 pt-2 border-t border-slate-200 dark:border-slate-800">
156
+ ${this.customActions
157
+ .map(
158
+ (act, aIdx) =>
159
+ `<button onclick="window._dtInstances['${this.container.id}'].handleCustomAction(event, ${idx}, ${aIdx})" class="${act.class || 'px-2 py-1 bg-blue-600 text-white rounded-lg text-xs font-bold'}">${act.label}</button>`
160
+ )
161
+ .join("")}
162
+ ${this.editCallback
163
+ ? `<button onclick="window._dtInstances['${this.container.id}'].handleAction(event, ${idx}, 'edit')" class="px-3 py-1 bg-blue-600 text-white rounded-lg text-xs font-bold">Editar</button>`
164
+ : ""
165
+ }
166
+ ${this.deleteCallback
167
+ ? `<button onclick="window._dtInstances['${this.container.id}'].handleAction(event, ${idx}, 'delete')" class="px-3 py-1 bg-red-600 text-white rounded-lg text-xs font-bold">Eliminar</button>`
168
+ : ""
169
+ }
170
+ </div>
171
+ `
172
+ : ""
173
+ }
174
+ </div>
175
+ `
176
+ )
177
+ .join("")}
178
+ </div>
179
+ `;
180
+
181
+ this.container.innerHTML = html;
182
+ window._dtInstances = window._dtInstances || {};
183
+ window._dtInstances[this.container.id] = this;
184
+ }
185
+
186
+ handleAction(event, index, type) {
187
+ const item = this.data[index];
188
+ if (type === "edit" && this.editCallback) this.editCallback(event, item);
189
+ if (type === "delete" && this.deleteCallback) this.deleteCallback(event, item);
190
+ }
191
+
192
+ handleCustomAction(event, index, actionIndex) {
193
+ const item = this.data[index];
194
+ const act = this.customActions[actionIndex];
195
+ if (act && typeof act.callback === "function") {
196
+ act.callback(event, item);
197
+ }
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Creates a debounced function that delays execution until wait milliseconds have elapsed.
203
+ * @param {Function} func
204
+ * @param {number} wait
205
+ * @returns {Function}
206
+ */
207
+ function debounce(func, wait = 300) {
208
+ let timeout;
209
+ return function (...args) {
210
+ clearTimeout(timeout);
211
+ timeout = setTimeout(() => func.apply(this, args), wait);
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Autocomplete component supporting static options array or dynamic API search function.
217
+ */
218
+ class Autocomplete {
219
+ /**
220
+ * @param {HTMLElement|string} inputElement
221
+ * @param {Object} options
222
+ */
223
+ constructor(inputElement, options = {}) {
224
+ this.input = typeof inputElement === "string" ? document.getElementById(inputElement) : inputElement;
225
+ if (!this.input) return;
226
+
227
+ this.options = options.options || [];
228
+ this.fetchFn = options.fetch;
229
+ this.onSelect = options.onSelect;
230
+ this.minChars = options.minChars || 1;
231
+ this.debounceTime = options.debounceTime || 300;
232
+ this.placeholder = options.placeholder || "Sin resultados";
233
+ this.renderItem = options.renderItem || ((item) => (typeof item === "object" ? item.label || item.name : item));
234
+
235
+ this.selectedIndex = -1;
236
+ this.items = [];
237
+ this.dropdown = null;
238
+ this.init();
239
+ }
240
+
241
+ init() {
242
+ this.input.setAttribute("autocomplete", "off");
243
+ this.wrapInput();
244
+ this.createDropdown();
245
+ this.bindEvents();
246
+ }
247
+
248
+ wrapInput() {
249
+ if (!this.input.parentElement.classList.contains("relative")) {
250
+ const wrapper = document.createElement("div");
251
+ wrapper.className = "relative w-full";
252
+ this.input.parentNode.insertBefore(wrapper, this.input);
253
+ wrapper.appendChild(this.input);
254
+ }
255
+ }
256
+
257
+ createDropdown() {
258
+ this.dropdown = document.createElement("div");
259
+ this.dropdown.className =
260
+ "hidden absolute z-50 left-0 right-0 mt-1 max-h-60 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-xl transition-all text-xs divide-y divide-slate-100 dark:divide-slate-800/60";
261
+ this.input.parentElement.appendChild(this.dropdown);
262
+ }
263
+
264
+ bindEvents() {
265
+ const debouncedSearch = debounce((query) => this.search(query), this.debounceTime);
266
+
267
+ this.input.addEventListener("input", (e) => {
268
+ const query = e.target.value.trim();
269
+ if (query.length < this.minChars) {
270
+ this.close();
271
+ return;
272
+ }
273
+ debouncedSearch(query);
274
+ });
275
+
276
+ this.input.addEventListener("keydown", (e) => this.handleKeyDown(e));
277
+
278
+ document.addEventListener("click", (e) => {
279
+ if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
280
+ this.close();
281
+ }
282
+ });
283
+ }
284
+
285
+ async search(query) {
286
+ if (this.fetchFn) {
287
+ try {
288
+ this.items = await this.fetchFn(query);
289
+ } catch (err) {
290
+ this.items = [];
291
+ }
292
+ } else {
293
+ const q = query.toLowerCase();
294
+ this.items = this.options.filter((item) => {
295
+ const label = typeof item === "object" ? item.label || item.name || "" : String(item);
296
+ return label.toLowerCase().includes(q);
297
+ });
298
+ }
299
+ this.render();
300
+ }
301
+
302
+ render() {
303
+ this.selectedIndex = -1;
304
+ if (!this.items || this.items.length === 0) {
305
+ this.dropdown.innerHTML = `<div class="p-3 text-slate-400 italic text-center">${this.placeholder}</div>`;
306
+ this.open();
307
+ return;
308
+ }
309
+
310
+ this.dropdown.innerHTML = this.items
311
+ .map((item, index) => {
312
+ const content = this.renderItem(item);
313
+ return `<div data-index="${index}" class="px-3 py-2.5 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-200 transition-colors flex items-center justify-between autocomplete-item">${content}</div>`;
314
+ })
315
+ .join("");
316
+
317
+ this.dropdown.querySelectorAll(".autocomplete-item").forEach((el) => {
318
+ el.addEventListener("click", () => {
319
+ const idx = parseInt(el.getAttribute("data-index"), 10);
320
+ this.selectItem(idx);
321
+ });
322
+ });
323
+
324
+ this.open();
325
+ }
326
+
327
+ handleKeyDown(e) {
328
+ if (this.dropdown.classList.contains("hidden")) return;
329
+ const items = this.dropdown.querySelectorAll(".autocomplete-item");
330
+
331
+ if (e.key === "ArrowDown") {
332
+ e.preventDefault();
333
+ this.selectedIndex = (this.selectedIndex + 1) % items.length;
334
+ this.updateHighlight(items);
335
+ } else if (e.key === "ArrowUp") {
336
+ e.preventDefault();
337
+ this.selectedIndex = (this.selectedIndex - 1 + items.length) % items.length;
338
+ this.updateHighlight(items);
339
+ } else if (e.key === "Enter") {
340
+ if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
341
+ e.preventDefault();
342
+ this.selectItem(this.selectedIndex);
343
+ }
344
+ } else if (e.key === "Escape") {
345
+ this.close();
346
+ }
347
+ }
348
+
349
+ updateHighlight(items) {
350
+ items.forEach((item, idx) => {
351
+ if (idx === this.selectedIndex) {
352
+ item.classList.add("bg-blue-50", "dark:bg-slate-800", "text-blue-600", "dark:text-blue-400");
353
+ item.scrollIntoView({ block: "nearest" });
354
+ } else {
355
+ item.classList.remove("bg-blue-50", "dark:bg-slate-800", "text-blue-600", "dark:text-blue-400");
356
+ }
357
+ });
358
+ }
359
+
360
+ selectItem(index) {
361
+ const item = this.items[index];
362
+ if (!item) return;
363
+ const value = typeof item === "object" ? item.label || item.name || JSON.stringify(item) : String(item);
364
+ this.input.value = value;
365
+ if (typeof this.onSelect === "function") {
366
+ this.onSelect(item);
367
+ }
368
+ this.close();
369
+ }
370
+
371
+ open() {
372
+ this.dropdown.classList.remove("hidden");
373
+ }
374
+
375
+ close() {
376
+ this.dropdown.classList.add("hidden");
377
+ this.selectedIndex = -1;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Manages system theme preference detection, local storage persistence, and Tailwind CSS dark class toggling.
383
+ */
384
+ class ThemeManager {
385
+ /**
386
+ * Initializes theme detection and sets up matchMedia listener for system changes.
387
+ */
388
+ static init() {
389
+ const savedTheme = localStorage.getItem("theme") || "system";
390
+ ThemeManager.setTheme(savedTheme);
391
+
392
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
393
+ const currentSetting = localStorage.getItem("theme") || "system";
394
+ if (currentSetting === "system") {
395
+ ThemeManager.applyTheme("system");
396
+ }
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Applies dark or light theme to html element based on preference.
402
+ * @param {string} theme - 'dark', 'light', or 'system'
403
+ */
404
+ static applyTheme(theme) {
405
+ const isDark =
406
+ theme === "dark" ||
407
+ (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
408
+
409
+ if (isDark) {
410
+ document.documentElement.classList.add("dark");
411
+ } else {
412
+ document.documentElement.classList.remove("dark");
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Sets theme preference, applies theme, and saves to localStorage.
418
+ * @param {string} theme - 'dark', 'light', or 'system'
419
+ */
420
+ static setTheme(theme) {
421
+ localStorage.setItem("theme", theme);
422
+ ThemeManager.applyTheme(theme);
423
+ }
424
+
425
+ /**
426
+ * Toggles between dark and light theme.
427
+ * @returns {string} next theme applied
428
+ */
429
+ static toggleTheme() {
430
+ const currentIsDark = document.documentElement.classList.contains("dark");
431
+ const nextTheme = currentIsDark ? "light" : "dark";
432
+ ThemeManager.setTheme(nextTheme);
433
+ return nextTheme;
434
+ }
435
+
436
+ /**
437
+ * Gets current stored theme setting.
438
+ * @returns {string} 'dark', 'light', or 'system'
439
+ */
440
+ static getTheme() {
441
+ return localStorage.getItem("theme") || "system";
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Displays a Promise-based modal confirmation dialog styled with Tailwind CSS.
447
+ * @param {Object} options
448
+ * @returns {Promise<boolean>}
449
+ */
450
+ function confirmModal({
451
+ title = "Confirmar acción",
452
+ message = "¿Estás seguro de que deseas realizar esta acción?",
453
+ confirmText = "Confirmar",
454
+ cancelText = "Cancelar",
455
+ type = "danger",
456
+ } = {}) {
457
+ return new Promise((resolve) => {
458
+ const overlay = document.createElement("div");
459
+ overlay.className =
460
+ "fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 transition-opacity duration-200";
461
+
462
+ const bgBtn =
463
+ type === "danger" ? "bg-red-600 hover:bg-red-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white";
464
+
465
+ overlay.innerHTML = `
466
+ <div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl p-6 shadow-2xl max-w-md w-full space-y-4 transform transition-all scale-100">
467
+ <h3 class="text-base font-bold text-slate-800 dark:text-slate-100">${title}</h3>
468
+ <p class="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">${message}</p>
469
+ <div class="flex justify-end gap-2 pt-2">
470
+ <button id="modal-cancel-btn" class="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 rounded-xl text-xs font-bold hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors">${cancelText}</button>
471
+ <button id="modal-confirm-btn" class="px-4 py-2 ${bgBtn} rounded-xl text-xs font-bold transition-all shadow-md">${confirmText}</button>
472
+ </div>
473
+ </div>
474
+ `;
475
+
476
+ document.body.appendChild(overlay);
477
+
478
+ const cancelBtn = overlay.querySelector("#modal-cancel-btn");
479
+ const confirmBtn = overlay.querySelector("#modal-confirm-btn");
480
+
481
+ const cleanup = (value) => {
482
+ overlay.classList.add("opacity-0");
483
+ setTimeout(() => overlay.remove(), 200);
484
+ resolve(value);
485
+ };
486
+
487
+ cancelBtn.addEventListener("click", () => cleanup(false));
488
+ confirmBtn.addEventListener("click", () => cleanup(true));
489
+ overlay.addEventListener("click", (e) => {
490
+ if (e.target === overlay) cleanup(false);
491
+ });
492
+ });
493
+ }
494
+
495
+ /**
496
+ * Copies text to clipboard and optionally shows a snackbar feedback.
497
+ * @param {string} text
498
+ * @param {boolean} notify
499
+ * @returns {Promise<boolean>}
500
+ */
501
+ async function copyToClipboard(text, notify = true) {
502
+ try {
503
+ await navigator.clipboard.writeText(text);
504
+ if (notify && typeof snackbar === "function") {
505
+ snackbar({ message: "Copiado al portapapeles", type: "success" });
506
+ }
507
+ return true;
508
+ } catch (err) {
509
+ if (notify && typeof snackbar === "function") {
510
+ snackbar({ message: "Error al copiar", type: "error" });
511
+ }
512
+ return false;
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Formats a numeric value into a currency string.
518
+ * @param {number} amount
519
+ * @param {string} currency
520
+ * @param {string} locale
521
+ * @returns {string}
522
+ */
523
+ function formatCurrency(amount, currency = "USD", locale = "es-AR") {
524
+ return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount || 0);
525
+ }
526
+
527
+ /**
528
+ * Formats a date into a localized date string.
529
+ * @param {string|Date} date
530
+ * @param {Object} options
531
+ * @returns {string}
532
+ */
533
+ function formatDate(date, options = { day: "2-digit", month: "2-digit", year: "numeric" }) {
534
+ if (!date) return "";
535
+ const d = new Date(date);
536
+ return new Intl.DateTimeFormat("es-AR", options).format(d);
537
+ }
538
+
539
+ if (typeof window !== "undefined") {
540
+ document.addEventListener("DOMContentLoaded", () => {
541
+ ThemeManager.init();
542
+ });
543
+ }
544
+
545
+ window.Http = Http;
546
+ window.snackbar = snackbar;
547
+ window.ResponsiveDataTable = ResponsiveDataTable;
548
+ window.debounce = debounce;
549
+ window.Autocomplete = Autocomplete;
550
+ window.ThemeManager = ThemeManager;
551
+ window.confirmModal = confirmModal;
552
+ window.copyToClipboard = copyToClipboard;
553
+ window.formatCurrency = formatCurrency;
554
+ window.formatDate = formatDate;
555
+
556
+ window.addEventListener('DOMContentLoaded', () => {
557
+ ThemeManager.init();
558
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seip/blue-bird",
3
- "version": "0.9.9",
3
+ "version": "1.0.1",
4
4
  "description": "Express opinionated API framework with built-in JWT auth, validation, and caching",
5
5
  "type": "module",
6
6
  "types": "core/index.d.ts",