@seip/blue-bird 1.0.0 → 1.0.2

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/AGENTS.md CHANGED
@@ -87,9 +87,17 @@ If an Express route involves heavy processing or database queries, utilize the `
87
87
  ```javascript
88
88
  import Cache from "@seip/blue-bird/core/cache.js";
89
89
 
90
+ // Express route middleware caching
90
91
  router.get("/stats", Cache.middleware(60), (req, res) => {
91
92
  res.json({ ok: true });
92
93
  });
94
+
95
+ // Programmatic cache manipulation
96
+ await Cache.set("custom_key", { data: "value" }, 120);
97
+ const cachedData = await Cache.get("custom_key");
98
+
99
+ // Invalidate route cache manually (e.g. after updating DB)
100
+ await Cache.delete("/api/public/config");
93
101
  ```
94
102
 
95
103
  The Cache module integrates with Redis when `REDIS_HOST` is configured in the environment. If Redis is unavailable or fails, it transparently falls back to an in-memory cache system without interrupting requests.
package/README.md CHANGED
@@ -222,6 +222,20 @@ router.get("/stats", Cache.middleware(60), (req, res) => {
222
222
  });
223
223
  ```
224
224
 
225
+ #### Programmatic Cache Manipulation & Invalidation
226
+
227
+ ```javascript
228
+ // Get / Set keys programmatically
229
+ await Cache.set("custom_key", { data: "value" }, 120);
230
+ const cachedData = await Cache.get("custom_key");
231
+
232
+ // Manually invalidate route cache (e.g. after updating DB)
233
+ await Cache.delete("/api/public/config");
234
+
235
+ // Clear all cache entries
236
+ await Cache.clear();
237
+ ```
238
+
225
239
  #### Custom Database & Data Caching with `getRedisClient()`
226
240
 
227
241
  ```javascript
package/core/cache.js CHANGED
@@ -162,6 +162,137 @@ class Cache {
162
162
  next();
163
163
  };
164
164
  }
165
+
166
+ /**
167
+ * Retrieves cached value by key.
168
+ * @param {string} key - Cache key.
169
+ * @returns {Promise<any|null>} Cached payload or null.
170
+ */
171
+ static async get(key) {
172
+ key = key.trim();
173
+ if (!key) return null;
174
+
175
+ if (redisHost && !redisClient) {
176
+ await initRedis().catch(() => { });
177
+ }
178
+
179
+ if (isRedisConnected && redisClient) {
180
+ try {
181
+ const cachedData = await redisClient.get(key);
182
+ if (cachedData) {
183
+ try {
184
+ const cached = JSON.parse(cachedData);
185
+ return cached && typeof cached === "object" && "data" in cached ? cached.data : cached;
186
+ } catch {
187
+ return cachedData;
188
+ }
189
+ }
190
+ return null;
191
+ } catch (err) {
192
+ isRedisConnected = false;
193
+ }
194
+ }
195
+
196
+ if (CACHE[key]) {
197
+ if (CACHE[key].expiry > Date.now()) {
198
+ const cached = CACHE[key];
199
+ return cached.data !== undefined ? cached.data : cached;
200
+ }
201
+ delete CACHE[key];
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Sets data into cache with a specified TTL in seconds.
209
+ * @param {string} key - Cache key.
210
+ * @param {any} value - Data to cache.
211
+ * @param {number} [seconds=60] - Expiry time in seconds.
212
+ * @returns {Promise<boolean>} True if set successfully.
213
+ */
214
+ static async set(key, value, seconds = 60) {
215
+ key = key.trim();
216
+ if (!key) return false;
217
+
218
+ if (redisHost && !redisClient) {
219
+ await initRedis().catch(() => { });
220
+ }
221
+
222
+ const cacheObject = {
223
+ type: typeof value === "string" ? "html" : "json",
224
+ data: value,
225
+ expiry: Date.now() + seconds * 1000,
226
+ };
227
+
228
+ if (isRedisConnected && redisClient) {
229
+ try {
230
+ await redisClient.set(key, JSON.stringify(cacheObject), {
231
+ EX: seconds,
232
+ });
233
+ } catch (err) {
234
+ CACHE[key] = cacheObject;
235
+ }
236
+ } else {
237
+ CACHE[key] = cacheObject;
238
+ }
239
+
240
+ return true;
241
+ }
242
+
243
+ /**
244
+ * Deletes one or more entries from cache.
245
+ * @param {string|string[]} keys - Single key or array of keys to delete.
246
+ * @returns {Promise<boolean>} True if deleted.
247
+ */
248
+ static async delete(keys) {
249
+ if (!keys) return false;
250
+ const keyList = Array.isArray(keys) ? keys : [keys];
251
+
252
+ if (redisHost && !redisClient) {
253
+ await initRedis().catch(() => { });
254
+ }
255
+
256
+ for (const key of keyList) {
257
+ delete CACHE[key];
258
+ if (isRedisConnected && redisClient) {
259
+ try {
260
+ await redisClient.del(key);
261
+ } catch (err) {
262
+ isRedisConnected = false;
263
+ }
264
+ }
265
+ }
266
+
267
+ return true;
268
+ }
269
+
270
+ /**
271
+ * Alias for delete.
272
+ * @param {string|string[]} keys - Single key or array of keys to delete.
273
+ * @returns {Promise<boolean>} True if deleted.
274
+ */
275
+ static async del(keys) {
276
+ return this.delete(keys);
277
+ }
278
+
279
+ /**
280
+ * Flushes all cached data in memory (and Redis if connected).
281
+ * @returns {Promise<boolean>} True if flushed.
282
+ */
283
+ static async clear() {
284
+ for (const key in CACHE) {
285
+ delete CACHE[key];
286
+ }
287
+ if (isRedisConnected && redisClient) {
288
+ try {
289
+ await redisClient.flushDb();
290
+ } catch (err) {
291
+ isRedisConnected = false;
292
+ }
293
+ }
294
+ return true;
295
+ }
165
296
  }
166
297
 
167
298
  /**
package/core/index.d.ts CHANGED
@@ -112,6 +112,11 @@ export class Auth {
112
112
 
113
113
  export class Cache {
114
114
  static middleware(seconds?: number): (req: Request, res: Response, next: NextFunction) => Promise<any>;
115
+ static get(key: string): Promise<any | null>;
116
+ static set(key: string, value: any, seconds?: number): Promise<boolean>;
117
+ static delete(keys: string | string[]): Promise<boolean>;
118
+ static del(keys: string | string[]): Promise<boolean>;
119
+ static clear(): Promise<boolean>;
115
120
  }
116
121
 
117
122
  export function getRedisClient(): any;
@@ -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": "1.0.0",
3
+ "version": "1.0.2",
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",