@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 +1 -0
- package/core/config.js +4 -2
- 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/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
|
|
46
|
-
options: {
|
|
45
|
+
path: process.env.STATIC_PATH || "frontend",
|
|
46
|
+
options: {
|
|
47
|
+
extensions: ["html"],
|
|
48
|
+
},
|
|
47
49
|
},
|
|
48
50
|
};
|
|
49
51
|
return _cachedProps;
|
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
|
+
});
|