@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 +8 -0
- package/README.md +14 -0
- package/core/cache.js +131 -0
- package/core/index.d.ts +5 -0
- package/frontend/about.html +6 -1
- package/frontend/index.html +5 -1
- package/frontend/js/utils.js +558 -0
- package/package.json +1 -1
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;
|
package/frontend/about.html
CHANGED
|
@@ -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
|
-
|
|
105
|
+
|
|
101
106
|
</body>
|
|
102
107
|
|
|
103
108
|
</html>
|
package/frontend/index.html
CHANGED
|
@@ -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
|
+
});
|