@xosue-utils/services 0.1.0

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.
Files changed (30) hide show
  1. package/README.md +70 -0
  2. package/config/services-config.d.ts +46 -0
  3. package/fesm2022/xosue-utils-services.mjs +1657 -0
  4. package/fesm2022/xosue-utils-services.mjs.map +1 -0
  5. package/index.d.ts +5 -0
  6. package/lib/ai-autofill-overlay-service/ai-autofill-overlay-service.component.d.ts +13 -0
  7. package/lib/ai-autofill-overlay-service/ai-autofill-overlay.service.d.ts +19 -0
  8. package/lib/alert-service/alert-service.component.d.ts +17 -0
  9. package/lib/alert-service/alert.service.d.ts +25 -0
  10. package/lib/browser-notification/browser-notification.service.d.ts +42 -0
  11. package/lib/context-menu-service/context-menu-service.component.d.ts +37 -0
  12. package/lib/context-menu-service/context-menu.service.d.ts +63 -0
  13. package/lib/field-help-tooltip/field-help-tooltip.component.d.ts +9 -0
  14. package/lib/global-loading-service/global-loading-service.component.d.ts +14 -0
  15. package/lib/global-loading-service/global-loading.service.d.ts +22 -0
  16. package/lib/index.d.ts +29 -0
  17. package/lib/media-viewer-service/items/audio-player/audio-player.component.d.ts +14 -0
  18. package/lib/media-viewer-service/items/image-viewer/image-viewer.component.d.ts +19 -0
  19. package/lib/media-viewer-service/items/video-player/video-player.component.d.ts +18 -0
  20. package/lib/media-viewer-service/media-viewer-service.component.d.ts +62 -0
  21. package/lib/media-viewer-service/media-viewer.service.d.ts +34 -0
  22. package/lib/navigation-loading/navigation-loading-service.component.d.ts +9 -0
  23. package/lib/navigation-loading/navigation-loading.service.d.ts +24 -0
  24. package/lib/session-request-service/session-request-service.component.d.ts +15 -0
  25. package/lib/session-request-service/session-request.service.d.ts +21 -0
  26. package/lib/toast-service/toast-service.component.d.ts +16 -0
  27. package/lib/toast-service/toast.service.d.ts +22 -0
  28. package/package.json +47 -0
  29. package/public-api.d.ts +4 -0
  30. package/vendor/lucide-icon/lucide-icon.component.d.ts +26 -0
@@ -0,0 +1,1657 @@
1
+ import * as i0 from '@angular/core';
2
+ import { PLATFORM_ID, Inject, Injectable, input, booleanAttribute, computed, Component, InjectionToken, inject, ChangeDetectorRef, HostListener, ViewChild, Input, ChangeDetectionStrategy, SimpleChange, signal } from '@angular/core';
3
+ import * as i2 from '@angular/common';
4
+ import { isPlatformBrowser, CommonModule } from '@angular/common';
5
+ import { Subject, BehaviorSubject } from 'rxjs';
6
+ import { trigger, transition, style, animate } from '@angular/animations';
7
+ import { LucideDynamicIcon } from '@lucide/angular';
8
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
+ import { DomSanitizer } from '@angular/platform-browser';
10
+ import * as i3 from '@angular/router';
11
+ import { RouterModule, Router, NavigationStart, NavigationEnd, NavigationCancel, NavigationError } from '@angular/router';
12
+
13
+ class AlertService {
14
+ platformId;
15
+ alertSubject = new Subject();
16
+ alert$ = this.alertSubject.asObservable();
17
+ idCounter = 0;
18
+ isBrowser;
19
+ constructor(platformId) {
20
+ this.platformId = platformId;
21
+ this.isBrowser = isPlatformBrowser(this.platformId);
22
+ }
23
+ showAlert(message) {
24
+ if (!this.isBrowser)
25
+ return Promise.resolve(false);
26
+ return new Promise((resolve) => {
27
+ const alert = {
28
+ id: ++this.idCounter,
29
+ message,
30
+ mode: 'normal',
31
+ resolve,
32
+ };
33
+ this.alertSubject.next(alert);
34
+ });
35
+ }
36
+ showConfirm(message, options) {
37
+ if (!this.isBrowser)
38
+ return Promise.resolve(false);
39
+ return new Promise((resolve) => {
40
+ const alert = {
41
+ id: ++this.idCounter,
42
+ message,
43
+ mode: 'actions',
44
+ resolve,
45
+ onAcceptClick: options?.onAcceptClick,
46
+ };
47
+ this.alertSubject.next(alert);
48
+ });
49
+ }
50
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AlertService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
51
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AlertService, providedIn: 'root' });
52
+ }
53
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AlertService, decorators: [{
54
+ type: Injectable,
55
+ args: [{ providedIn: 'root' }]
56
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
57
+ type: Inject,
58
+ args: [PLATFORM_ID]
59
+ }] }] });
60
+
61
+ /**
62
+ * Icono Lucide.
63
+ *
64
+ * HTML:
65
+ * <lucide-icon name="star"></lucide-icon>
66
+ * <lucide-icon name="heart" filled></lucide-icon> // relleno + trazo
67
+ *
68
+ * SASS — variables CSS (heredan del padre):
69
+ * --lucide-stroke: var(--primary)
70
+ * --lucide-fill: var(--accent)
71
+ * --lucide-stroke-width: 2
72
+ */
73
+ class LucideIconComponent {
74
+ name = input.required();
75
+ size = input(undefined);
76
+ spin = input(false, { transform: booleanAttribute });
77
+ /** Activa relleno (--lucide-fill); el trazo se conserva. */
78
+ filled = input(false, { transform: booleanAttribute });
79
+ dimStyle = computed(() => {
80
+ const s = this.size();
81
+ if (s === undefined || s === null || s === '')
82
+ return null;
83
+ if (typeof s === 'number') {
84
+ return { width: `${s}px`, height: `${s}px` };
85
+ }
86
+ const trimmed = String(s).trim();
87
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
88
+ return { width: `${trimmed}px`, height: `${trimmed}px` };
89
+ }
90
+ return { width: trimmed, height: trimmed };
91
+ });
92
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: LucideIconComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
93
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.11", type: LucideIconComponent, isStandalone: true, selector: "lucide-icon", inputs: { name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: true, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, spin: { classPropertyName: "spin", publicName: "spin", isSignal: true, isRequired: false, transformFunction: null }, filled: { classPropertyName: "filled", publicName: "filled", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.lucide-icon--filled": "filled()", "attr.filled": "filled() ? \"\" : null" } }, ngImport: i0, template: `
94
+ <svg
95
+ class="lucide-svg inline-block shrink-0 align-middle"
96
+ [class.lucide-svg--filled]="filled()"
97
+ [class.animate-spin]="spin()"
98
+ [style.width]="dimStyle()?.width"
99
+ [style.height]="dimStyle()?.height"
100
+ [lucideIcon]="name()"
101
+ aria-hidden="true"
102
+ />
103
+ `, isInline: true, dependencies: [{ kind: "component", type: LucideDynamicIcon, selector: "svg[lucideIcon]", inputs: ["lucideIcon"] }] });
104
+ }
105
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: LucideIconComponent, decorators: [{
106
+ type: Component,
107
+ args: [{
108
+ selector: 'lucide-icon',
109
+ standalone: true,
110
+ imports: [LucideDynamicIcon],
111
+ host: {
112
+ '[class.lucide-icon--filled]': 'filled()',
113
+ '[attr.filled]': 'filled() ? "" : null',
114
+ },
115
+ template: `
116
+ <svg
117
+ class="lucide-svg inline-block shrink-0 align-middle"
118
+ [class.lucide-svg--filled]="filled()"
119
+ [class.animate-spin]="spin()"
120
+ [style.width]="dimStyle()?.width"
121
+ [style.height]="dimStyle()?.height"
122
+ [lucideIcon]="name()"
123
+ aria-hidden="true"
124
+ />
125
+ `,
126
+ }]
127
+ }] });
128
+
129
+ const XOSUE_UTILS_SERVICES_DEFAULTS = {
130
+ alertAcceptLabel: 'Aceptar',
131
+ alertCancelLabel: 'Cancelar',
132
+ loadingDefaultMessage: 'Cargando',
133
+ sessionRegisterPath: '/auth/register',
134
+ sessionLoginPath: '/auth/login',
135
+ sessionRegisterLabel: 'Registrarse',
136
+ sessionLoginLabel: 'Iniciar Sesión',
137
+ toastSoundUrl: '/sounds/toast.mp3',
138
+ toastErrorSoundUrl: '/sounds/error-toast.mp3',
139
+ toastSuccessGifUrl: '/gifs/success-toast.gif',
140
+ toastDurationsMs: {
141
+ success: 1000,
142
+ info: 1000,
143
+ warning: 5000,
144
+ error: 5000,
145
+ },
146
+ mediaFileNameFallback: 'Archivo',
147
+ browserNotificationIconUrl: '/favicon.ico',
148
+ browserNotificationSoundUrl: '/sounds/toast.mp3',
149
+ browserNotificationPermissionPrompt: '¿Permitir notificaciones del navegador para avisarte cuando no estés en la pestaña?',
150
+ };
151
+ const XOSUE_UTILS_SERVICES_CONFIG = new InjectionToken('XOSUE_UTILS_SERVICES_CONFIG', {
152
+ providedIn: 'root',
153
+ factory: () => ({ ...XOSUE_UTILS_SERVICES_DEFAULTS }),
154
+ });
155
+ function resolveXosueUtilsServicesConfig(config) {
156
+ return {
157
+ ...XOSUE_UTILS_SERVICES_DEFAULTS,
158
+ ...config,
159
+ toastDurationsMs: {
160
+ ...XOSUE_UTILS_SERVICES_DEFAULTS.toastDurationsMs,
161
+ ...config?.toastDurationsMs,
162
+ },
163
+ };
164
+ }
165
+ /**
166
+ * Provide overlay services configuration in the host app.
167
+ *
168
+ * ```ts
169
+ * providers: [
170
+ * provideXosueUtilsServices({
171
+ * sessionLoginPath: '/login',
172
+ * toastSoundUrl: '/assets/sounds/toast.mp3',
173
+ * }),
174
+ * ]
175
+ * ```
176
+ */
177
+ function provideXosueUtilsServices(config) {
178
+ return [
179
+ {
180
+ provide: XOSUE_UTILS_SERVICES_CONFIG,
181
+ useValue: resolveXosueUtilsServicesConfig(config),
182
+ },
183
+ ];
184
+ }
185
+
186
+ class AlertServiceComponent {
187
+ alertService;
188
+ alerts = [];
189
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
190
+ constructor(alertService) {
191
+ this.alertService = alertService;
192
+ }
193
+ ngOnInit() {
194
+ this.alertService.alert$.subscribe((alert) => {
195
+ this.alerts.push(alert);
196
+ });
197
+ }
198
+ removeAlert(id) {
199
+ this.alerts = this.alerts.filter((a) => a.id !== id);
200
+ }
201
+ accept(alert) {
202
+ try {
203
+ alert.onAcceptClick?.();
204
+ }
205
+ catch {
206
+ /* ignore */
207
+ }
208
+ alert.resolve(true);
209
+ this.removeAlert(alert.id);
210
+ }
211
+ cancel(alert) {
212
+ alert.resolve(false);
213
+ this.removeAlert(alert.id);
214
+ }
215
+ trackById(_index, item) {
216
+ return item.id;
217
+ }
218
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AlertServiceComponent, deps: [{ token: AlertService }], target: i0.ɵɵFactoryTarget.Component });
219
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: AlertServiceComponent, isStandalone: true, selector: "alert-service", ngImport: i0, template: "<div class=\"AlertOverlay\" *ngIf=\"alerts.length > 0\">\r\n <div\r\n class=\"Alert\"\r\n *ngFor=\"let alert of alerts; trackBy: trackById\"\r\n [@alertAnimation]\r\n >\r\n <div class=\"AlertContent\">\r\n <div class=\"AlertIcon\">\r\n <lucide-icon name=\"circle-alert\"></lucide-icon>\r\n </div>\r\n <div class=\"AlertMessage\">\r\n {{ alert.message }}\r\n </div>\r\n <div class=\"AlertActions\">\r\n <button\r\n *ngIf=\"alert.mode === 'normal'\"\r\n type=\"button\"\r\n class=\"AlertButton AcceptButton\"\r\n (click)=\"accept(alert)\"\r\n >\r\n {{ config.alertAcceptLabel }}\r\n </button>\r\n <ng-container *ngIf=\"alert.mode === 'actions'\">\r\n <button\r\n type=\"button\"\r\n class=\"AlertButton CancelButton\"\r\n (click)=\"cancel(alert)\"\r\n >\r\n {{ config.alertCancelLabel }}\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"AlertButton AcceptButton\"\r\n (click)=\"accept(alert)\"\r\n >\r\n {{ config.alertAcceptLabel }}\r\n </button>\r\n </ng-container>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".AlertOverlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);display:flex;align-items:center;justify-content:center;z-index:10000;pointer-events:auto;padding:12px}.Alert{background-color:var(--surface);border-radius:var(--radius-lg);box-shadow:0 10px 25px color-mix(in srgb,var(--text-base) 20%,transparent);max-width:400px;width:100%;margin:12px;pointer-events:auto}.AlertContent{padding:12px;display:flex;flex-direction:column;gap:8px}.AlertIcon{display:flex;justify-content:center}.AlertIcon lucide-icon{--lucide-stroke: var(--icon-muted);font-size:2rem}.AlertIcon lucide-icon .lucide-svg{width:2rem;height:2rem}.AlertMessage{text-align:center;font-size:1rem;color:var(--text-1, var(--text-base));line-height:1.5}.AlertActions{display:flex;gap:4px;justify-content:flex-end;margin-top:4px}.AlertButton{padding:8px 16px;border:none;border-radius:var(--radius-md);font-size:.875rem;font-weight:500;cursor:pointer}.AcceptButton{background-color:var(--primary);color:var(--text-inverted)}.CancelButton{background-color:var(--bg, var(--surface-secondary, var(--surface-2)));color:var(--text-base)}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], animations: [
220
+ trigger('alertAnimation', [
221
+ transition(':enter', [
222
+ style({ opacity: 0, transform: 'scale(0.9)' }),
223
+ animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
224
+ ]),
225
+ transition(':leave', [
226
+ animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.9)' })),
227
+ ]),
228
+ ]),
229
+ ] });
230
+ }
231
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AlertServiceComponent, decorators: [{
232
+ type: Component,
233
+ args: [{ selector: 'alert-service', standalone: true, imports: [LucideIconComponent, CommonModule], animations: [
234
+ trigger('alertAnimation', [
235
+ transition(':enter', [
236
+ style({ opacity: 0, transform: 'scale(0.9)' }),
237
+ animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
238
+ ]),
239
+ transition(':leave', [
240
+ animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.9)' })),
241
+ ]),
242
+ ]),
243
+ ], template: "<div class=\"AlertOverlay\" *ngIf=\"alerts.length > 0\">\r\n <div\r\n class=\"Alert\"\r\n *ngFor=\"let alert of alerts; trackBy: trackById\"\r\n [@alertAnimation]\r\n >\r\n <div class=\"AlertContent\">\r\n <div class=\"AlertIcon\">\r\n <lucide-icon name=\"circle-alert\"></lucide-icon>\r\n </div>\r\n <div class=\"AlertMessage\">\r\n {{ alert.message }}\r\n </div>\r\n <div class=\"AlertActions\">\r\n <button\r\n *ngIf=\"alert.mode === 'normal'\"\r\n type=\"button\"\r\n class=\"AlertButton AcceptButton\"\r\n (click)=\"accept(alert)\"\r\n >\r\n {{ config.alertAcceptLabel }}\r\n </button>\r\n <ng-container *ngIf=\"alert.mode === 'actions'\">\r\n <button\r\n type=\"button\"\r\n class=\"AlertButton CancelButton\"\r\n (click)=\"cancel(alert)\"\r\n >\r\n {{ config.alertCancelLabel }}\r\n </button>\r\n <button\r\n type=\"button\"\r\n class=\"AlertButton AcceptButton\"\r\n (click)=\"accept(alert)\"\r\n >\r\n {{ config.alertAcceptLabel }}\r\n </button>\r\n </ng-container>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".AlertOverlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);display:flex;align-items:center;justify-content:center;z-index:10000;pointer-events:auto;padding:12px}.Alert{background-color:var(--surface);border-radius:var(--radius-lg);box-shadow:0 10px 25px color-mix(in srgb,var(--text-base) 20%,transparent);max-width:400px;width:100%;margin:12px;pointer-events:auto}.AlertContent{padding:12px;display:flex;flex-direction:column;gap:8px}.AlertIcon{display:flex;justify-content:center}.AlertIcon lucide-icon{--lucide-stroke: var(--icon-muted);font-size:2rem}.AlertIcon lucide-icon .lucide-svg{width:2rem;height:2rem}.AlertMessage{text-align:center;font-size:1rem;color:var(--text-1, var(--text-base));line-height:1.5}.AlertActions{display:flex;gap:4px;justify-content:flex-end;margin-top:4px}.AlertButton{padding:8px 16px;border:none;border-radius:var(--radius-md);font-size:.875rem;font-weight:500;cursor:pointer}.AcceptButton{background-color:var(--primary);color:var(--text-inverted)}.CancelButton{background-color:var(--bg, var(--surface-secondary, var(--surface-2)));color:var(--text-base)}\n"] }]
244
+ }], ctorParameters: () => [{ type: AlertService }] });
245
+
246
+ class ToastService {
247
+ platformId;
248
+ ngZone;
249
+ toastSubject = new Subject();
250
+ toast$ = this.toastSubject.asObservable();
251
+ idCounter = 0;
252
+ isBrowser;
253
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
254
+ constructor(platformId, ngZone) {
255
+ this.platformId = platformId;
256
+ this.ngZone = ngZone;
257
+ this.isBrowser = isPlatformBrowser(this.platformId);
258
+ }
259
+ showToast(type, message) {
260
+ if (!this.isBrowser)
261
+ return;
262
+ const toast = { id: ++this.idCounter, type, message };
263
+ this.toastSubject.next(toast);
264
+ this.ngZone.runOutsideAngular(() => this.playSound(type));
265
+ }
266
+ playSound(type) {
267
+ if (type === 'info')
268
+ return;
269
+ try {
270
+ const src = type === 'error' ? this.config.toastErrorSoundUrl : this.config.toastSoundUrl;
271
+ const audio = new Audio(src);
272
+ void audio.play().catch(() => { });
273
+ }
274
+ catch {
275
+ /* ignore */
276
+ }
277
+ }
278
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ToastService, deps: [{ token: PLATFORM_ID }, { token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
279
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ToastService, providedIn: 'root' });
280
+ }
281
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ToastService, decorators: [{
282
+ type: Injectable,
283
+ args: [{ providedIn: 'root' }]
284
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
285
+ type: Inject,
286
+ args: [PLATFORM_ID]
287
+ }] }, { type: i0.NgZone }] });
288
+
289
+ class ToastServiceComponent {
290
+ toastService;
291
+ toasts = [];
292
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
293
+ constructor(toastService) {
294
+ this.toastService = toastService;
295
+ }
296
+ ngOnInit() {
297
+ this.toastService.toast$.subscribe((toast) => {
298
+ this.toasts.push(toast);
299
+ const durationMs = this.config.toastDurationsMs[toast.type] ?? 5000;
300
+ setTimeout(() => this.removeToast(toast.id), durationMs);
301
+ });
302
+ }
303
+ successGifSrc(toast) {
304
+ const base = this.config.toastSuccessGifUrl;
305
+ const sep = base.includes('?') ? '&' : '?';
306
+ return `${base}${sep}t=${toast.id}`;
307
+ }
308
+ removeToast(id) {
309
+ this.toasts = this.toasts.filter((t) => t.id !== id);
310
+ }
311
+ trackById(_index, item) {
312
+ return item.id;
313
+ }
314
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ToastServiceComponent, deps: [{ token: ToastService }], target: i0.ɵɵFactoryTarget.Component });
315
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: ToastServiceComponent, isStandalone: true, selector: "toast-service", ngImport: i0, template: "<div class=\"ToastOverlay\">\r\n <div class=\"ToastContainer\">\r\n <div\r\n class=\"Toast\"\r\n *ngFor=\"let toast of toasts; trackBy: trackById\"\r\n [@toastAnimation]\r\n [ngClass]=\"toast.type\"\r\n (click)=\"removeToast(toast.id)\"\r\n >\r\n <div class=\"Icon\" [ngSwitch]=\"toast.type\">\r\n <lucide-icon name=\"info\" *ngSwitchCase=\"'info'\"></lucide-icon>\r\n <lucide-icon name=\"circle-check\" *ngSwitchCase=\"'success'\"></lucide-icon>\r\n <lucide-icon name=\"circle-alert\" *ngSwitchCase=\"'error'\"></lucide-icon>\r\n <lucide-icon name=\"triangle-alert\" *ngSwitchCase=\"'warning'\"></lucide-icon>\r\n <lucide-icon name=\"bell\" *ngSwitchDefault></lucide-icon>\r\n </div>\r\n\r\n <div class=\"Text\">\r\n {{ toast.message }}\r\n </div>\r\n\r\n <img\r\n *ngIf=\"toast.type === 'success' && config.toastSuccessGifUrl\"\r\n class=\"SuccessToastGif\"\r\n [attr.src]=\"successGifSrc(toast)\"\r\n alt=\"\"\r\n aria-hidden=\"true\"\r\n />\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".ToastOverlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:9999}.ToastContainer{display:flex;width:calc(100% - 20px);max-width:500px;flex-direction:column;gap:8px;margin:0 auto;padding:16px;pointer-events:none;align-items:center}.Toast{padding:12px;border:1px solid;border-radius:var(--radius-lg);cursor:pointer;font-weight:400;display:flex;gap:8px;pointer-events:auto;position:relative;overflow:visible}.SuccessToastGif{position:absolute;right:-5px;bottom:0;transform:translate(40%,10px);width:72px;height:72px;object-fit:contain;pointer-events:none}.Toast.success{background:var(--success-bg);color:var(--success-text);border-color:var(--success-border)}.Toast.warning{background:var(--warning-bg);color:var(--warning-text);border-color:var(--warning-border)}.Toast.error{background:var(--error-bg);color:var(--error-text);border-color:var(--error-border)}.Toast.info{background:var(--info-bg);color:var(--info-text);border-color:var(--info-border)}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgSwitch, selector: "[ngSwitch]", inputs: ["ngSwitch"] }, { kind: "directive", type: i2.NgSwitchCase, selector: "[ngSwitchCase]", inputs: ["ngSwitchCase"] }, { kind: "directive", type: i2.NgSwitchDefault, selector: "[ngSwitchDefault]" }], animations: [
316
+ trigger('toastAnimation', [
317
+ transition(':enter', [
318
+ style({ opacity: 0, transform: 'translateY(-20px)' }),
319
+ animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })),
320
+ ]),
321
+ transition(':leave', [
322
+ animate('300ms ease-in', style({ opacity: 0, transform: 'translateY(-20px)' })),
323
+ ]),
324
+ ]),
325
+ ] });
326
+ }
327
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ToastServiceComponent, decorators: [{
328
+ type: Component,
329
+ args: [{ selector: 'toast-service', standalone: true, imports: [LucideIconComponent, CommonModule], animations: [
330
+ trigger('toastAnimation', [
331
+ transition(':enter', [
332
+ style({ opacity: 0, transform: 'translateY(-20px)' }),
333
+ animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' })),
334
+ ]),
335
+ transition(':leave', [
336
+ animate('300ms ease-in', style({ opacity: 0, transform: 'translateY(-20px)' })),
337
+ ]),
338
+ ]),
339
+ ], template: "<div class=\"ToastOverlay\">\r\n <div class=\"ToastContainer\">\r\n <div\r\n class=\"Toast\"\r\n *ngFor=\"let toast of toasts; trackBy: trackById\"\r\n [@toastAnimation]\r\n [ngClass]=\"toast.type\"\r\n (click)=\"removeToast(toast.id)\"\r\n >\r\n <div class=\"Icon\" [ngSwitch]=\"toast.type\">\r\n <lucide-icon name=\"info\" *ngSwitchCase=\"'info'\"></lucide-icon>\r\n <lucide-icon name=\"circle-check\" *ngSwitchCase=\"'success'\"></lucide-icon>\r\n <lucide-icon name=\"circle-alert\" *ngSwitchCase=\"'error'\"></lucide-icon>\r\n <lucide-icon name=\"triangle-alert\" *ngSwitchCase=\"'warning'\"></lucide-icon>\r\n <lucide-icon name=\"bell\" *ngSwitchDefault></lucide-icon>\r\n </div>\r\n\r\n <div class=\"Text\">\r\n {{ toast.message }}\r\n </div>\r\n\r\n <img\r\n *ngIf=\"toast.type === 'success' && config.toastSuccessGifUrl\"\r\n class=\"SuccessToastGif\"\r\n [attr.src]=\"successGifSrc(toast)\"\r\n alt=\"\"\r\n aria-hidden=\"true\"\r\n />\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".ToastOverlay{position:fixed;top:0;left:0;width:100%;pointer-events:none;z-index:9999}.ToastContainer{display:flex;width:calc(100% - 20px);max-width:500px;flex-direction:column;gap:8px;margin:0 auto;padding:16px;pointer-events:none;align-items:center}.Toast{padding:12px;border:1px solid;border-radius:var(--radius-lg);cursor:pointer;font-weight:400;display:flex;gap:8px;pointer-events:auto;position:relative;overflow:visible}.SuccessToastGif{position:absolute;right:-5px;bottom:0;transform:translate(40%,10px);width:72px;height:72px;object-fit:contain;pointer-events:none}.Toast.success{background:var(--success-bg);color:var(--success-text);border-color:var(--success-border)}.Toast.warning{background:var(--warning-bg);color:var(--warning-text);border-color:var(--warning-border)}.Toast.error{background:var(--error-bg);color:var(--error-text);border-color:var(--error-border)}.Toast.info{background:var(--info-bg);color:var(--info-text);border-color:var(--info-border)}\n"] }]
340
+ }], ctorParameters: () => [{ type: ToastService }] });
341
+
342
+ class ContextMenuService {
343
+ platformId;
344
+ requestSubject = new Subject();
345
+ request$ = this.requestSubject.asObservable();
346
+ closeSubject = new Subject();
347
+ close$ = this.closeSubject.asObservable();
348
+ idCounter = 0;
349
+ isBrowser;
350
+ constructor(platformId) {
351
+ this.platformId = platformId;
352
+ this.isBrowser = isPlatformBrowser(this.platformId);
353
+ }
354
+ /** Menú contextual en el punto del clic. Solo uno activo a la vez. */
355
+ openMenu(event, options, menuOptions) {
356
+ if (!this.isBrowser || options.length === 0) {
357
+ return Promise.resolve(null);
358
+ }
359
+ if (event instanceof MouseEvent && menuOptions?.preventDefault !== false) {
360
+ event.preventDefault();
361
+ event.stopPropagation();
362
+ }
363
+ const { x, y } = this.pointFromEvent(event);
364
+ return new Promise((resolve) => {
365
+ const request = {
366
+ id: ++this.idCounter,
367
+ kind: 'menu',
368
+ x,
369
+ y,
370
+ title: menuOptions?.title,
371
+ options,
372
+ resolve,
373
+ };
374
+ this.requestSubject.next(request);
375
+ });
376
+ }
377
+ /** Globo informativo en el punto del clic. */
378
+ openInfo(event, info) {
379
+ if (!this.isBrowser)
380
+ return Promise.resolve();
381
+ const { x, y } = this.pointFromEvent(event);
382
+ return new Promise((resolve) => {
383
+ const request = {
384
+ id: ++this.idCounter,
385
+ kind: 'info',
386
+ x,
387
+ y,
388
+ info,
389
+ resolve,
390
+ };
391
+ this.requestSubject.next(request);
392
+ });
393
+ }
394
+ /** Cierra el menú contextual activo desde código. */
395
+ close() {
396
+ if (!this.isBrowser)
397
+ return;
398
+ this.closeSubject.next();
399
+ }
400
+ pointFromEvent(event) {
401
+ if ('clientX' in event && 'clientY' in event) {
402
+ return { x: event.clientX, y: event.clientY };
403
+ }
404
+ return { x: 0, y: 0 };
405
+ }
406
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ContextMenuService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
407
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ContextMenuService, providedIn: 'root' });
408
+ }
409
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ContextMenuService, decorators: [{
410
+ type: Injectable,
411
+ args: [{ providedIn: 'root' }]
412
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
413
+ type: Inject,
414
+ args: [PLATFORM_ID]
415
+ }] }] });
416
+
417
+ class ContextMenuServiceComponent {
418
+ contextMenuService;
419
+ platformId;
420
+ static VIEWPORT_EDGE_INSET = 5;
421
+ static ANCHOR_OFFSET = 8;
422
+ cdr = inject(ChangeDetectorRef);
423
+ panelRef;
424
+ active = null;
425
+ position = { x: 0, y: 0 };
426
+ panelVisible = false;
427
+ anchor = { x: 0, y: 0 };
428
+ /** Timestamp hasta el cual ignoramos cierres (evita que el gesto de apertura cierre el menú). */
429
+ ignoreOutsideUntil = 0;
430
+ sub;
431
+ closeSub;
432
+ scrollListener = () => {
433
+ if (this.active)
434
+ this.dismissCurrent(null);
435
+ };
436
+ outsidePointerListener = (event) => {
437
+ this.handleOutsideEvent(event);
438
+ };
439
+ outsideContextMenuListener = (event) => {
440
+ this.handleOutsideEvent(event);
441
+ };
442
+ constructor(contextMenuService, platformId) {
443
+ this.contextMenuService = contextMenuService;
444
+ this.platformId = platformId;
445
+ }
446
+ ngOnInit() {
447
+ this.sub = this.contextMenuService.request$.subscribe((request) => {
448
+ this.dismissCurrent(null);
449
+ this.active = request;
450
+ this.anchor = { x: request.x, y: request.y };
451
+ this.position = {
452
+ x: request.x + ContextMenuServiceComponent.ANCHOR_OFFSET,
453
+ y: request.y + ContextMenuServiceComponent.ANCHOR_OFFSET,
454
+ };
455
+ this.panelVisible = true;
456
+ // Ignorar el mismo gesto de apertura y el click sintético post-contextmenu.
457
+ this.ignoreOutsideUntil = performance.now() + 180;
458
+ this.cdr.detectChanges();
459
+ this.applyClampedPosition();
460
+ });
461
+ this.closeSub = this.contextMenuService.close$.subscribe(() => {
462
+ this.dismissCurrent(null);
463
+ });
464
+ if (isPlatformBrowser(this.platformId)) {
465
+ document.addEventListener('scroll', this.scrollListener, true);
466
+ // Captura: no depende de que el click burbujee hasta document.
467
+ document.addEventListener('pointerdown', this.outsidePointerListener, true);
468
+ document.addEventListener('contextmenu', this.outsideContextMenuListener, true);
469
+ }
470
+ }
471
+ ngOnDestroy() {
472
+ this.dismissCurrent(null);
473
+ this.sub?.unsubscribe();
474
+ this.closeSub?.unsubscribe();
475
+ if (isPlatformBrowser(this.platformId)) {
476
+ document.removeEventListener('scroll', this.scrollListener, true);
477
+ document.removeEventListener('pointerdown', this.outsidePointerListener, true);
478
+ document.removeEventListener('contextmenu', this.outsideContextMenuListener, true);
479
+ }
480
+ }
481
+ onWindowResize() {
482
+ if (this.active && this.panelVisible) {
483
+ this.applyClampedPosition();
484
+ }
485
+ }
486
+ onEscape(event) {
487
+ if (event.key === 'Escape' && this.active) {
488
+ this.dismissCurrent(null);
489
+ }
490
+ }
491
+ selectOption(option) {
492
+ if (!this.active || this.active.kind !== 'menu' || option.disabled)
493
+ return;
494
+ const request = this.active;
495
+ this.clearMenu();
496
+ this.cdr.detectChanges();
497
+ request.resolve(option);
498
+ }
499
+ handleOutsideEvent(event) {
500
+ if (!this.active)
501
+ return;
502
+ if (performance.now() < this.ignoreOutsideUntil)
503
+ return;
504
+ const target = event.target;
505
+ const panel = this.panelRef?.nativeElement;
506
+ if (panel && target && panel.contains(target))
507
+ return;
508
+ this.dismissCurrent(null);
509
+ }
510
+ dismissCurrent(value) {
511
+ if (!this.active)
512
+ return;
513
+ const request = this.active;
514
+ this.clearMenu();
515
+ this.cdr.detectChanges();
516
+ if (request.kind === 'menu') {
517
+ request.resolve(value);
518
+ }
519
+ else {
520
+ request.resolve();
521
+ }
522
+ }
523
+ clearMenu() {
524
+ this.active = null;
525
+ this.panelVisible = false;
526
+ this.ignoreOutsideUntil = 0;
527
+ }
528
+ applyClampedPosition() {
529
+ const panel = this.panelRef?.nativeElement;
530
+ if (!panel)
531
+ return;
532
+ const inset = ContextMenuServiceComponent.VIEWPORT_EDGE_INSET;
533
+ const gap = ContextMenuServiceComponent.ANCHOR_OFFSET;
534
+ const viewportW = document.documentElement.clientWidth;
535
+ const viewportH = document.documentElement.clientHeight;
536
+ const w = panel.offsetWidth || 220;
537
+ const h = panel.offsetHeight || 120;
538
+ const minX = inset;
539
+ const maxX = Math.max(inset, viewportW - inset - w);
540
+ const minY = inset;
541
+ const maxY = Math.max(inset, viewportH - inset - h);
542
+ let x = this.anchor.x + gap;
543
+ let y = this.anchor.y + gap;
544
+ if (x > maxX) {
545
+ x = this.anchor.x - w - gap;
546
+ }
547
+ if (y > maxY) {
548
+ y = this.anchor.y - h - gap;
549
+ }
550
+ this.position = {
551
+ x: Math.max(minX, Math.min(x, maxX)),
552
+ y: Math.max(minY, Math.min(y, maxY)),
553
+ };
554
+ this.cdr.detectChanges();
555
+ }
556
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ContextMenuServiceComponent, deps: [{ token: ContextMenuService }, { token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Component });
557
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.11", type: ContextMenuServiceComponent, isStandalone: true, selector: "context-menu-service", host: { listeners: { "window:resize": "onWindowResize()", "document:keydown": "onEscape($event)" } }, viewQueries: [{ propertyName: "panelRef", first: true, predicate: ["panel"], descendants: true }], ngImport: i0, template: "@if (active) {\r\n <div class=\"ContextMenuOverlay\" aria-hidden=\"true\">\r\n <div\r\n #panel\r\n class=\"ContextMenuPanel\"\r\n [class.ContextMenuPanel--visible]=\"panelVisible\"\r\n [class.ContextMenuPanel--menu]=\"active.kind === 'menu'\"\r\n [class.ContextMenuPanel--info]=\"active.kind === 'info'\"\r\n [style.left.px]=\"position.x\"\r\n [style.top.px]=\"position.y\"\r\n role=\"menu\"\r\n [attr.aria-label]=\"active.kind === 'info' ? active.info.title : (active.title || 'Opciones')\"\r\n [attr.aria-hidden]=\"!panelVisible\"\r\n (contextmenu)=\"$event.preventDefault()\"\r\n >\r\n @if (active.kind === 'menu') {\r\n @if (active.title) {\r\n <p class=\"ContextMenuMenuTitle\">{{ active.title }}</p>\r\n }\r\n @for (option of active.options; track $index) {\r\n @if (option.separatorBefore) {\r\n <div class=\"ContextMenuMenuSep\" aria-hidden=\"true\"></div>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"ContextMenuMenuItem\"\r\n [class.ContextMenuMenuItem--danger]=\"option.danger\"\r\n [class.ContextMenuMenuItem--disabled]=\"option.disabled\"\r\n [disabled]=\"option.disabled\"\r\n role=\"menuitem\"\r\n (click)=\"selectOption(option)\"\r\n >\r\n <lucide-icon [name]=\"option.icon\" [size]=\"14\"></lucide-icon>\r\n <span>{{ option.name }}</span>\r\n </button>\r\n }\r\n } @else {\r\n <div class=\"ContextMenuInfo\">\r\n <div class=\"ContextMenuInfoHeader\">\r\n <lucide-icon [name]=\"active.info.icon\" [size]=\"18\"></lucide-icon>\r\n <strong>{{ active.info.title }}</strong>\r\n </div>\r\n <p class=\"ContextMenuInfoBody\">{{ active.info.info }}</p>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n}\r\n", styles: [".ContextMenuOverlay{position:fixed;inset:5px;pointer-events:none;z-index:10001}.ContextMenuPanel{position:fixed;pointer-events:none;visibility:hidden;opacity:0;min-width:10rem;max-width:min(18rem,100vw - 10px);max-height:calc(100vh - 10px);overflow-x:hidden;overflow-y:auto;background-color:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 8px 24px var(--shadow-color);transition:opacity .1s ease-out}.ContextMenuPanel--visible{visibility:visible;opacity:1;pointer-events:auto}.ContextMenuPanel--menu{min-width:220px;max-width:min(280px,100vw - 16px);padding:4px;border-radius:12px;border:1px solid var(--border);background:var(--surface);box-shadow:0 12px 40px var(--shadow-color),0 0 0 .5px var(--border);display:flex;flex-direction:column;gap:2px;animation:cupertino-menu-in .1s ease-out}.ContextMenuMenuTitle{margin:0;padding:4px 8px 2px;font-size:.7rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ContextMenuMenuSep{height:1px;margin:2px 4px;background:var(--surface-2, var(--border))}.ContextMenuMenuItem{display:flex;align-items:center;gap:8px;width:100%;padding:4px 8px;border:none;border-radius:8px;background:transparent;color:var(--text-base);font:inherit;font-size:.875rem;text-align:left;cursor:pointer}.ContextMenuMenuItem lucide-icon{flex-shrink:0;--lucide-stroke: var(--icon-base)}.ContextMenuMenuItem:hover,.ContextMenuMenuItem:focus-visible{outline:none;background:var(--info-bg, var(--primary-soft, var(--surface-2)))}.ContextMenuMenuItem--danger{color:var(--error-text)}.ContextMenuMenuItem--danger lucide-icon{--lucide-stroke: var(--error-text)}.ContextMenuMenuItem--danger:hover,.ContextMenuMenuItem--danger:focus-visible{background:var(--error-bg)}.ContextMenuMenuItem--disabled,.ContextMenuMenuItem:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.ContextMenuPanel--info{padding:0}.ContextMenuInfo{padding:12px;display:flex;flex-direction:column;gap:8px}.ContextMenuInfoHeader{display:flex;align-items:center;gap:8px;color:var(--text-base)}.ContextMenuInfoHeader strong{font-size:.9rem;font-weight:600}.ContextMenuInfoHeader lucide-icon{flex-shrink:0;--lucide-stroke: var(--primary)}.ContextMenuInfoBody{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-label)}@keyframes cupertino-menu-in{0%{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }] });
558
+ }
559
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ContextMenuServiceComponent, decorators: [{
560
+ type: Component,
561
+ args: [{ selector: 'context-menu-service', standalone: true, imports: [CommonModule, LucideIconComponent], template: "@if (active) {\r\n <div class=\"ContextMenuOverlay\" aria-hidden=\"true\">\r\n <div\r\n #panel\r\n class=\"ContextMenuPanel\"\r\n [class.ContextMenuPanel--visible]=\"panelVisible\"\r\n [class.ContextMenuPanel--menu]=\"active.kind === 'menu'\"\r\n [class.ContextMenuPanel--info]=\"active.kind === 'info'\"\r\n [style.left.px]=\"position.x\"\r\n [style.top.px]=\"position.y\"\r\n role=\"menu\"\r\n [attr.aria-label]=\"active.kind === 'info' ? active.info.title : (active.title || 'Opciones')\"\r\n [attr.aria-hidden]=\"!panelVisible\"\r\n (contextmenu)=\"$event.preventDefault()\"\r\n >\r\n @if (active.kind === 'menu') {\r\n @if (active.title) {\r\n <p class=\"ContextMenuMenuTitle\">{{ active.title }}</p>\r\n }\r\n @for (option of active.options; track $index) {\r\n @if (option.separatorBefore) {\r\n <div class=\"ContextMenuMenuSep\" aria-hidden=\"true\"></div>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"ContextMenuMenuItem\"\r\n [class.ContextMenuMenuItem--danger]=\"option.danger\"\r\n [class.ContextMenuMenuItem--disabled]=\"option.disabled\"\r\n [disabled]=\"option.disabled\"\r\n role=\"menuitem\"\r\n (click)=\"selectOption(option)\"\r\n >\r\n <lucide-icon [name]=\"option.icon\" [size]=\"14\"></lucide-icon>\r\n <span>{{ option.name }}</span>\r\n </button>\r\n }\r\n } @else {\r\n <div class=\"ContextMenuInfo\">\r\n <div class=\"ContextMenuInfoHeader\">\r\n <lucide-icon [name]=\"active.info.icon\" [size]=\"18\"></lucide-icon>\r\n <strong>{{ active.info.title }}</strong>\r\n </div>\r\n <p class=\"ContextMenuInfoBody\">{{ active.info.info }}</p>\r\n </div>\r\n }\r\n </div>\r\n </div>\r\n}\r\n", styles: [".ContextMenuOverlay{position:fixed;inset:5px;pointer-events:none;z-index:10001}.ContextMenuPanel{position:fixed;pointer-events:none;visibility:hidden;opacity:0;min-width:10rem;max-width:min(18rem,100vw - 10px);max-height:calc(100vh - 10px);overflow-x:hidden;overflow-y:auto;background-color:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 8px 24px var(--shadow-color);transition:opacity .1s ease-out}.ContextMenuPanel--visible{visibility:visible;opacity:1;pointer-events:auto}.ContextMenuPanel--menu{min-width:220px;max-width:min(280px,100vw - 16px);padding:4px;border-radius:12px;border:1px solid var(--border);background:var(--surface);box-shadow:0 12px 40px var(--shadow-color),0 0 0 .5px var(--border);display:flex;flex-direction:column;gap:2px;animation:cupertino-menu-in .1s ease-out}.ContextMenuMenuTitle{margin:0;padding:4px 8px 2px;font-size:.7rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ContextMenuMenuSep{height:1px;margin:2px 4px;background:var(--surface-2, var(--border))}.ContextMenuMenuItem{display:flex;align-items:center;gap:8px;width:100%;padding:4px 8px;border:none;border-radius:8px;background:transparent;color:var(--text-base);font:inherit;font-size:.875rem;text-align:left;cursor:pointer}.ContextMenuMenuItem lucide-icon{flex-shrink:0;--lucide-stroke: var(--icon-base)}.ContextMenuMenuItem:hover,.ContextMenuMenuItem:focus-visible{outline:none;background:var(--info-bg, var(--primary-soft, var(--surface-2)))}.ContextMenuMenuItem--danger{color:var(--error-text)}.ContextMenuMenuItem--danger lucide-icon{--lucide-stroke: var(--error-text)}.ContextMenuMenuItem--danger:hover,.ContextMenuMenuItem--danger:focus-visible{background:var(--error-bg)}.ContextMenuMenuItem--disabled,.ContextMenuMenuItem:disabled{opacity:.45;cursor:not-allowed;pointer-events:none}.ContextMenuPanel--info{padding:0}.ContextMenuInfo{padding:12px;display:flex;flex-direction:column;gap:8px}.ContextMenuInfoHeader{display:flex;align-items:center;gap:8px;color:var(--text-base)}.ContextMenuInfoHeader strong{font-size:.9rem;font-weight:600}.ContextMenuInfoHeader lucide-icon{flex-shrink:0;--lucide-stroke: var(--primary)}.ContextMenuInfoBody{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-label)}@keyframes cupertino-menu-in{0%{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}\n"] }]
562
+ }], ctorParameters: () => [{ type: ContextMenuService }, { type: undefined, decorators: [{
563
+ type: Inject,
564
+ args: [PLATFORM_ID]
565
+ }] }], propDecorators: { panelRef: [{
566
+ type: ViewChild,
567
+ args: ['panel']
568
+ }], onWindowResize: [{
569
+ type: HostListener,
570
+ args: ['window:resize']
571
+ }], onEscape: [{
572
+ type: HostListener,
573
+ args: ['document:keydown', ['$event']]
574
+ }] } });
575
+
576
+ class FieldHelpTooltipComponent {
577
+ contextMenuService = inject(ContextMenuService);
578
+ text = '';
579
+ title = 'Información del campo';
580
+ showHelp(event) {
581
+ event.preventDefault();
582
+ event.stopPropagation();
583
+ void this.contextMenuService.openInfo(event, {
584
+ icon: 'info',
585
+ title: this.title,
586
+ info: this.text,
587
+ });
588
+ }
589
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: FieldHelpTooltipComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
590
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: FieldHelpTooltipComponent, isStandalone: true, selector: "field-help-tooltip", inputs: { text: "text", title: "title" }, ngImport: i0, template: "<button\r\n type=\"button\"\r\n class=\"HelpButton\"\r\n (click)=\"showHelp($event)\"\r\n aria-label=\"Informaci\u00F3n del campo\"\r\n>\r\n <lucide-icon name=\"info\"></lucide-icon>\r\n</button>\r\n", styles: [":host{display:inline-flex;flex-shrink:0}.HelpButton{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:none;border-radius:50%;background:transparent;color:var(--text-muted);cursor:pointer}.HelpButton lucide-icon{width:15px;height:15px}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }] });
591
+ }
592
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: FieldHelpTooltipComponent, decorators: [{
593
+ type: Component,
594
+ args: [{ selector: 'field-help-tooltip', standalone: true, imports: [LucideIconComponent], template: "<button\r\n type=\"button\"\r\n class=\"HelpButton\"\r\n (click)=\"showHelp($event)\"\r\n aria-label=\"Informaci\u00F3n del campo\"\r\n>\r\n <lucide-icon name=\"info\"></lucide-icon>\r\n</button>\r\n", styles: [":host{display:inline-flex;flex-shrink:0}.HelpButton{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:none;border-radius:50%;background:transparent;color:var(--text-muted);cursor:pointer}.HelpButton lucide-icon{width:15px;height:15px}\n"] }]
595
+ }], propDecorators: { text: [{
596
+ type: Input,
597
+ args: [{ required: true }]
598
+ }], title: [{
599
+ type: Input
600
+ }] } });
601
+
602
+ class MediaViewerService {
603
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
604
+ closedState = () => ({
605
+ open: false,
606
+ mode: 'image',
607
+ type: undefined,
608
+ url: null,
609
+ urlSafe: null,
610
+ downloadUrl: null,
611
+ fileName: this.config.mediaFileNameFallback,
612
+ downloadContext: null,
613
+ autoDetectType: true,
614
+ });
615
+ state = new BehaviorSubject(this.closedState());
616
+ state$ = this.state.asObservable();
617
+ onDownloadRequestedCallback = null;
618
+ /**
619
+ * Opens the viewer with already-resolved URLs.
620
+ * Host is responsible for CDN/crypto resolution before calling `open()`.
621
+ */
622
+ open(params) {
623
+ const { onDownloadRequested, type, downloadContext, autoDetectType, fileName, ...rest } = params;
624
+ this.onDownloadRequestedCallback = onDownloadRequested ?? null;
625
+ this.state.next({
626
+ open: true,
627
+ type: type ?? null,
628
+ downloadContext: downloadContext ?? null,
629
+ autoDetectType: autoDetectType ?? true,
630
+ mode: rest.mode,
631
+ url: rest.url,
632
+ urlSafe: rest.urlSafe,
633
+ downloadUrl: rest.downloadUrl,
634
+ fileName: fileName?.trim() || this.config.mediaFileNameFallback,
635
+ });
636
+ }
637
+ close() {
638
+ this.onDownloadRequestedCallback = null;
639
+ this.state.next(this.closedState());
640
+ }
641
+ handleDownloadRequested(context) {
642
+ this.onDownloadRequestedCallback?.(context);
643
+ }
644
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: MediaViewerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
645
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: MediaViewerService, providedIn: 'root' });
646
+ }
647
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: MediaViewerService, decorators: [{
648
+ type: Injectable,
649
+ args: [{ providedIn: 'root' }]
650
+ }] });
651
+
652
+ /** Resoluciones usadas en el visor (referencia para otros componentes). */
653
+ const IMAGE_MULTIRES_WIDTHS = [150, 400, 800];
654
+ /**
655
+ * Visor de imagen: una sola carga del original, sin progresividad (sin versión pequeña previa).
656
+ */
657
+ class ImageViewerComponent {
658
+ cdr;
659
+ src = null;
660
+ ready = false;
661
+ loadError = false;
662
+ constructor(cdr) {
663
+ this.cdr = cdr;
664
+ }
665
+ ngOnChanges(changes) {
666
+ if (changes['src']) {
667
+ this.ready = false;
668
+ this.loadError = false;
669
+ this.cdr.markForCheck();
670
+ }
671
+ }
672
+ onLoad() {
673
+ this.ready = true;
674
+ this.cdr.markForCheck();
675
+ }
676
+ onError() {
677
+ this.loadError = true;
678
+ this.cdr.markForCheck();
679
+ }
680
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ImageViewerComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
681
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: ImageViewerComponent, isStandalone: true, selector: "app-image-viewer", inputs: { src: "src" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"ImageViewer\" *ngIf=\"src\">\r\n <div class=\"RainbowLoader\" *ngIf=\"!ready && !loadError\"></div>\r\n <img\r\n *ngIf=\"src\"\r\n class=\"Layer\"\r\n [class.visible]=\"ready\"\r\n [src]=\"src\"\r\n [alt]=\"\"\r\n decoding=\"async\"\r\n (load)=\"onLoad()\"\r\n (error)=\"onError()\"\r\n />\r\n <div *ngIf=\"loadError\" class=\"FallbackNote\">\r\n <span>Error al cargar la imagen</span>\r\n </div>\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%;min-height:120px;filter:none;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.ImageViewer{position:relative;width:100%;height:100%;min-height:120px;overflow:hidden;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.RainbowLoader{position:absolute;inset:0;background:color-mix(in srgb,var(--primary) 35%,var(--surface-2));animation:rainbowShift 1.2s ease infinite alternate}@keyframes rainbowShift{0%{opacity:.55}to{opacity:1}}.Layer{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;opacity:0;filter:none;transform:none;image-rendering:pixelated;image-rendering:crisp-edges;transition-property:opacity;transition-duration:.4s;transition-timing-function:ease;pointer-events:none}.Layer.visible{opacity:1}.FallbackNote{position:absolute;bottom:0;left:0;right:0;padding:4px 8px;background:color-mix(in srgb,var(--text-base) 50%,transparent);color:var(--text-inverted);font-size:.8rem;text-align:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
682
+ }
683
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: ImageViewerComponent, decorators: [{
684
+ type: Component,
685
+ args: [{ selector: 'app-image-viewer', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"ImageViewer\" *ngIf=\"src\">\r\n <div class=\"RainbowLoader\" *ngIf=\"!ready && !loadError\"></div>\r\n <img\r\n *ngIf=\"src\"\r\n class=\"Layer\"\r\n [class.visible]=\"ready\"\r\n [src]=\"src\"\r\n [alt]=\"\"\r\n decoding=\"async\"\r\n (load)=\"onLoad()\"\r\n (error)=\"onError()\"\r\n />\r\n <div *ngIf=\"loadError\" class=\"FallbackNote\">\r\n <span>Error al cargar la imagen</span>\r\n </div>\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%;min-height:120px;filter:none;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.ImageViewer{position:relative;width:100%;height:100%;min-height:120px;overflow:hidden;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.RainbowLoader{position:absolute;inset:0;background:color-mix(in srgb,var(--primary) 35%,var(--surface-2));animation:rainbowShift 1.2s ease infinite alternate}@keyframes rainbowShift{0%{opacity:.55}to{opacity:1}}.Layer{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;opacity:0;filter:none;transform:none;image-rendering:pixelated;image-rendering:crisp-edges;transition-property:opacity;transition-duration:.4s;transition-timing-function:ease;pointer-events:none}.Layer.visible{opacity:1}.FallbackNote{position:absolute;bottom:0;left:0;right:0;padding:4px 8px;background:color-mix(in srgb,var(--text-base) 50%,transparent);color:var(--text-inverted);font-size:.8rem;text-align:center}\n"] }]
686
+ }], ctorParameters: () => [{ type: i0.ChangeDetectorRef }], propDecorators: { src: [{
687
+ type: Input
688
+ }] } });
689
+
690
+ /**
691
+ * Lightweight native video player (no hls.js).
692
+ * HLS (.m3u8) relies on browser Safari support; other browsers may need host-side remux.
693
+ */
694
+ class VideoPlayerComponent {
695
+ cdr;
696
+ src = null;
697
+ /** Informational; native player only. */
698
+ videoFormat = null;
699
+ loadError = false;
700
+ constructor(cdr) {
701
+ this.cdr = cdr;
702
+ }
703
+ ngOnChanges(changes) {
704
+ if (changes['src']) {
705
+ this.loadError = false;
706
+ this.cdr.markForCheck();
707
+ }
708
+ }
709
+ onError() {
710
+ this.loadError = true;
711
+ this.cdr.markForCheck();
712
+ }
713
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: VideoPlayerComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
714
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: VideoPlayerComponent, isStandalone: true, selector: "app-video-player", inputs: { src: "src", videoFormat: "videoFormat" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"VideoPlayer\" *ngIf=\"src\">\r\n <video\r\n class=\"VideoPlayerEl\"\r\n [src]=\"src\"\r\n controls\r\n playsinline\r\n (error)=\"onError()\"\r\n ></video>\r\n <p *ngIf=\"loadError\" class=\"VideoPlayerError\">No se pudo reproducir el video.</p>\r\n <p *ngIf=\"videoFormat === 'hls'\" class=\"VideoPlayerHint\">\r\n HLS: requiere soporte nativo del navegador.\r\n </p>\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%}.VideoPlayer{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:8px}.VideoPlayerEl{width:100%;height:100%;max-height:100%;object-fit:contain;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.VideoPlayerError,.VideoPlayerHint{margin:0;font-size:.85rem;color:var(--text-inverted);text-align:center}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
715
+ }
716
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: VideoPlayerComponent, decorators: [{
717
+ type: Component,
718
+ args: [{ selector: 'app-video-player', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"VideoPlayer\" *ngIf=\"src\">\r\n <video\r\n class=\"VideoPlayerEl\"\r\n [src]=\"src\"\r\n controls\r\n playsinline\r\n (error)=\"onError()\"\r\n ></video>\r\n <p *ngIf=\"loadError\" class=\"VideoPlayerError\">No se pudo reproducir el video.</p>\r\n <p *ngIf=\"videoFormat === 'hls'\" class=\"VideoPlayerHint\">\r\n HLS: requiere soporte nativo del navegador.\r\n </p>\r\n</div>\r\n", styles: [":host{display:block;width:100%;height:100%}.VideoPlayer{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:8px}.VideoPlayerEl{width:100%;height:100%;max-height:100%;object-fit:contain;background:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.VideoPlayerError,.VideoPlayerHint{margin:0;font-size:.85rem;color:var(--text-inverted);text-align:center}\n"] }]
719
+ }], ctorParameters: () => [{ type: i0.ChangeDetectorRef }], propDecorators: { src: [{
720
+ type: Input
721
+ }], videoFormat: [{
722
+ type: Input
723
+ }] } });
724
+
725
+ /** Lightweight native audio player (no wavesurfer / hls.js). */
726
+ class AudioPlayerComponent {
727
+ cdr;
728
+ src = null;
729
+ name = null;
730
+ loadError = false;
731
+ constructor(cdr) {
732
+ this.cdr = cdr;
733
+ }
734
+ ngOnChanges(changes) {
735
+ if (changes['src']) {
736
+ this.loadError = false;
737
+ this.cdr.markForCheck();
738
+ }
739
+ }
740
+ onError() {
741
+ this.loadError = true;
742
+ this.cdr.markForCheck();
743
+ }
744
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AudioPlayerComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
745
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: AudioPlayerComponent, isStandalone: true, selector: "app-audio-player", inputs: { src: "src", name: "name" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"AudioPlayer\" *ngIf=\"src\">\r\n <p *ngIf=\"name\" class=\"AudioPlayerName\">{{ name }}</p>\r\n <audio class=\"AudioPlayerEl\" [src]=\"src\" controls (error)=\"onError()\"></audio>\r\n <p *ngIf=\"loadError\" class=\"AudioPlayerError\">No se pudo reproducir el audio.</p>\r\n</div>\r\n", styles: [":host{display:block;width:100%}.AudioPlayer{display:flex;flex-direction:column;gap:8px;align-items:stretch;padding:12px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border)}.AudioPlayerName{margin:0;font-size:.9rem;font-weight:600;color:var(--text-base);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.AudioPlayerEl{width:100%}.AudioPlayerError{margin:0;font-size:.85rem;color:var(--error-text)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
746
+ }
747
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AudioPlayerComponent, decorators: [{
748
+ type: Component,
749
+ args: [{ selector: 'app-audio-player', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"AudioPlayer\" *ngIf=\"src\">\r\n <p *ngIf=\"name\" class=\"AudioPlayerName\">{{ name }}</p>\r\n <audio class=\"AudioPlayerEl\" [src]=\"src\" controls (error)=\"onError()\"></audio>\r\n <p *ngIf=\"loadError\" class=\"AudioPlayerError\">No se pudo reproducir el audio.</p>\r\n</div>\r\n", styles: [":host{display:block;width:100%}.AudioPlayer{display:flex;flex-direction:column;gap:8px;align-items:stretch;padding:12px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border)}.AudioPlayerName{margin:0;font-size:.9rem;font-weight:600;color:var(--text-base);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.AudioPlayerEl{width:100%}.AudioPlayerError{margin:0;font-size:.85rem;color:var(--error-text)}\n"] }]
750
+ }], ctorParameters: () => [{ type: i0.ChangeDetectorRef }], propDecorators: { src: [{
751
+ type: Input
752
+ }], name: [{
753
+ type: Input
754
+ }] } });
755
+
756
+ const DOCUMENT_LOAD_TIMEOUT_MS = 10000;
757
+ const VIEWER_DIFF_KEYS = ['open', 'mode', 'type', 'url', 'urlSafe', 'autoDetectType'];
758
+ /**
759
+ * Host singleton media viewer.
760
+ * Expects already-resolved public URLs from the host (no crypto / CDN decrypt).
761
+ */
762
+ class MediaViewerServiceComponent {
763
+ platformId = inject(PLATFORM_ID);
764
+ viewer = inject(MediaViewerService);
765
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
766
+ cdr = inject(ChangeDetectorRef);
767
+ sanitizer = inject(DomSanitizer);
768
+ _prevVm = null;
769
+ open = false;
770
+ mode = 'image';
771
+ type = undefined;
772
+ url = null;
773
+ urlSafe = null;
774
+ autoDetectType = true;
775
+ downloadUrl = null;
776
+ fileName = 'Archivo';
777
+ downloadContext = null;
778
+ detectedMode = null;
779
+ detectingOrLoading = false;
780
+ detectError = false;
781
+ documentBlobUrl = null;
782
+ documentRenderFailed = false;
783
+ resolvedMediaUrl = null;
784
+ blobUrlsToRevoke = [];
785
+ documentLoadTimeout = null;
786
+ cachedDocumentUrlRaw = null;
787
+ cachedDocumentUrlSafe = null;
788
+ constructor() {
789
+ this.viewer.state$.pipe(takeUntilDestroyed()).subscribe((vm) => {
790
+ const changes = this.diffViewerState(this._prevVm, vm);
791
+ this.applyVmFields(vm);
792
+ this._prevVm = vm;
793
+ this.onViewerInputsChanged(changes);
794
+ });
795
+ }
796
+ diffViewerState(prev, curr) {
797
+ const out = {};
798
+ for (const k of VIEWER_DIFF_KEYS) {
799
+ const p = prev?.[k];
800
+ const c = curr[k];
801
+ if (p !== c) {
802
+ out[k] = new SimpleChange(p, c, prev === null);
803
+ }
804
+ }
805
+ return out;
806
+ }
807
+ applyVmFields(vm) {
808
+ this.open = vm.open;
809
+ this.mode = vm.mode;
810
+ this.type = vm.type;
811
+ this.url = vm.url;
812
+ this.urlSafe = vm.urlSafe;
813
+ this.autoDetectType = vm.autoDetectType;
814
+ this.downloadUrl = vm.downloadUrl;
815
+ this.fileName = vm.fileName || this.config.mediaFileNameFallback;
816
+ this.downloadContext = vm.downloadContext;
817
+ }
818
+ get effectiveMode() {
819
+ return this.detectedMode ?? this.mode;
820
+ }
821
+ get videoPlayerFormat() {
822
+ const u = (this.mediaSrc ?? '').trim().toLowerCase();
823
+ return u.includes('.m3u8') ? 'hls' : null;
824
+ }
825
+ get typeLabel() {
826
+ if (this.detectingOrLoading && !this.detectedMode)
827
+ return 'Cargando...';
828
+ const labels = {
829
+ image: 'Imagen',
830
+ video: 'Video',
831
+ audio: 'Audio',
832
+ document: 'Documento',
833
+ download: 'Archivo',
834
+ };
835
+ return labels[this.effectiveMode] ?? this.config.mediaFileNameFallback;
836
+ }
837
+ get loadingMessage() {
838
+ if (!this.detectedMode)
839
+ return 'Detectando tipo...';
840
+ const labels = {
841
+ image: 'Imagen',
842
+ video: 'Video',
843
+ audio: 'Audio',
844
+ document: 'Documento',
845
+ download: 'Archivo',
846
+ };
847
+ const name = labels[this.detectedMode] ?? 'archivo';
848
+ return `Cargando ${name.toLowerCase()}...`;
849
+ }
850
+ get mediaSrc() {
851
+ if (this.resolvedMediaUrl)
852
+ return this.resolvedMediaUrl;
853
+ return this.url;
854
+ }
855
+ get documentUrlSafe() {
856
+ if (this.urlSafe)
857
+ return this.urlSafe;
858
+ const url = this.documentBlobUrl ?? this.url ?? null;
859
+ if (!url || this.effectiveMode !== 'document') {
860
+ this.cachedDocumentUrlRaw = null;
861
+ this.cachedDocumentUrlSafe = null;
862
+ return null;
863
+ }
864
+ if (this.cachedDocumentUrlRaw === url && this.cachedDocumentUrlSafe) {
865
+ return this.cachedDocumentUrlSafe;
866
+ }
867
+ this.cachedDocumentUrlRaw = url;
868
+ this.cachedDocumentUrlSafe = this.sanitizer.bypassSecurityTrustResourceUrl(url);
869
+ return this.cachedDocumentUrlSafe;
870
+ }
871
+ onViewerInputsChanged(changes) {
872
+ const docRelevant = (changes['open'] ??
873
+ changes['mode'] ??
874
+ changes['type'] ??
875
+ changes['url'] ??
876
+ changes['urlSafe'] ??
877
+ changes['autoDetectType']) != null;
878
+ if (!docRelevant)
879
+ return;
880
+ if (changes['url'] ?? changes['urlSafe']) {
881
+ this.documentBlobUrl = null;
882
+ this.cachedDocumentUrlRaw = null;
883
+ this.cachedDocumentUrlSafe = null;
884
+ }
885
+ if (!this.open || !this.url) {
886
+ this.documentBlobUrl = null;
887
+ this.cachedDocumentUrlRaw = null;
888
+ this.cachedDocumentUrlSafe = null;
889
+ this.revokeBlobUrls();
890
+ this.resolvedMediaUrl = null;
891
+ this.detectedMode = null;
892
+ this.detectingOrLoading = false;
893
+ this.detectError = false;
894
+ }
895
+ if (this.open && this.url) {
896
+ const hasExplicitType = this.type != null && this.type !== undefined;
897
+ if (hasExplicitType) {
898
+ this.detectedMode = this.type;
899
+ this.detectError = false;
900
+ this.detectingOrLoading = false;
901
+ this.resolvedMediaUrl = this.url;
902
+ if (this.type === 'document') {
903
+ void this.fetchAsBlob(this.url).then((blobUrl) => {
904
+ if (blobUrl) {
905
+ this.documentBlobUrl = blobUrl;
906
+ this.blobUrlsToRevoke.push(blobUrl);
907
+ }
908
+ this.cdr.markForCheck();
909
+ });
910
+ }
911
+ this.cdr.markForCheck();
912
+ }
913
+ else if (this.autoDetectType) {
914
+ this.detectAndLoadFromUrl(this.url);
915
+ }
916
+ else {
917
+ this.detectedMode = this.mode;
918
+ this.resolvedMediaUrl = this.url;
919
+ this.detectingOrLoading = false;
920
+ this.cdr.markForCheck();
921
+ }
922
+ }
923
+ else {
924
+ this.resolvedMediaUrl = null;
925
+ }
926
+ this.clearDocumentLoadTimeout();
927
+ if (this.open &&
928
+ this.effectiveMode === 'document' &&
929
+ (this.documentBlobUrl || this.url || this.urlSafe)) {
930
+ this.documentRenderFailed = false;
931
+ this.documentLoadTimeout = setTimeout(() => {
932
+ this.documentLoadTimeout = null;
933
+ this.documentRenderFailed = true;
934
+ this.cdr.markForCheck();
935
+ }, DOCUMENT_LOAD_TIMEOUT_MS);
936
+ }
937
+ else {
938
+ this.documentRenderFailed = false;
939
+ }
940
+ this.cdr.markForCheck();
941
+ }
942
+ ngOnDestroy() {
943
+ this.clearDocumentLoadTimeout();
944
+ this.revokeBlobUrls();
945
+ }
946
+ detectAndLoadFromUrl(url) {
947
+ this.revokeBlobUrls();
948
+ this.resolvedMediaUrl = null;
949
+ this.documentBlobUrl = null;
950
+ this.detectedMode = null;
951
+ this.detectError = false;
952
+ this.detectingOrLoading = true;
953
+ this.cdr.markForCheck();
954
+ this.fetchContentType(url)
955
+ .then((contentType) => this.detectModeFromContentType(url, contentType))
956
+ .then((mode) => {
957
+ this.detectedMode = mode;
958
+ this.cdr.markForCheck();
959
+ if (mode === 'image' || mode === 'document') {
960
+ return this.fetchAsBlob(url).then((blobUrl) => {
961
+ if (mode === 'image')
962
+ this.resolvedMediaUrl = blobUrl ?? url;
963
+ else
964
+ this.documentBlobUrl = blobUrl;
965
+ if (blobUrl)
966
+ this.blobUrlsToRevoke.push(blobUrl);
967
+ this.detectingOrLoading = false;
968
+ this.cdr.markForCheck();
969
+ });
970
+ }
971
+ this.resolvedMediaUrl = url;
972
+ this.detectingOrLoading = false;
973
+ this.cdr.markForCheck();
974
+ return undefined;
975
+ })
976
+ .catch(() => {
977
+ this.detectError = true;
978
+ this.detectingOrLoading = false;
979
+ this.detectedMode = this.mode;
980
+ this.resolvedMediaUrl = url;
981
+ this.cdr.markForCheck();
982
+ });
983
+ }
984
+ fetchContentType(url) {
985
+ const readCt = (r) => (r.headers.get('content-type') ?? '').trim();
986
+ const tryHead = () => fetch(url, { method: 'HEAD', mode: 'cors' }).then((r) => {
987
+ if (!r.ok)
988
+ return '';
989
+ return readCt(r);
990
+ });
991
+ const tryGetRange = () => fetch(url, {
992
+ method: 'GET',
993
+ mode: 'cors',
994
+ headers: { Range: 'bytes=0-0' },
995
+ }).then((r) => {
996
+ if (!(r.ok || r.status === 206))
997
+ return '';
998
+ return readCt(r);
999
+ });
1000
+ return tryHead()
1001
+ .then((ct) => ct || tryGetRange())
1002
+ .catch(() => tryGetRange())
1003
+ .catch(() => '');
1004
+ }
1005
+ urlPathname(absUrl) {
1006
+ try {
1007
+ const base = typeof window !== 'undefined' && window.location?.origin
1008
+ ? window.location.origin
1009
+ : 'https://invalid.local';
1010
+ return new URL(absUrl, base).pathname;
1011
+ }
1012
+ catch {
1013
+ return absUrl.split(/[?#]/)[0];
1014
+ }
1015
+ }
1016
+ pathnameExtensionFromUrl(absUrl) {
1017
+ const path = this.urlPathname(absUrl);
1018
+ const base = path.split('/').pop() ?? '';
1019
+ const dot = base.lastIndexOf('.');
1020
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
1021
+ }
1022
+ detectModeFromUrlPathOnly(absUrl) {
1023
+ const pathLower = this.urlPathname(absUrl).toLowerCase();
1024
+ if (pathLower.endsWith('.m3u8') || pathLower.includes('/master.m3u8')) {
1025
+ return 'video';
1026
+ }
1027
+ const ext = this.pathnameExtensionFromUrl(absUrl);
1028
+ if (['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'bmp', 'svg'].includes(ext))
1029
+ return 'image';
1030
+ if (['webm', 'mp4', 'ogg', 'ogv', 'mov', 'm4v', 'mkv'].includes(ext))
1031
+ return 'video';
1032
+ if (['mp3', 'wav', 'oga', 'm4a', 'aac', 'flac', 'opus'].includes(ext))
1033
+ return 'audio';
1034
+ if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp'].includes(ext)) {
1035
+ return 'document';
1036
+ }
1037
+ return null;
1038
+ }
1039
+ detectModeFromContentType(url, contentType) {
1040
+ const type = (contentType.split(';')[0] ?? '').trim().toLowerCase();
1041
+ const fromPath = this.detectModeFromUrlPathOnly(url);
1042
+ if (type.startsWith('image/'))
1043
+ return 'image';
1044
+ if (type.startsWith('video/'))
1045
+ return 'video';
1046
+ if (type.startsWith('audio/'))
1047
+ return 'audio';
1048
+ if (type === 'application/pdf' || type.includes('pdf'))
1049
+ return 'document';
1050
+ if (type.includes('document') ||
1051
+ type.includes('msword') ||
1052
+ type.includes('excel') ||
1053
+ type.includes('powerpoint') ||
1054
+ type.includes('officedocument') ||
1055
+ type.includes('opendocument')) {
1056
+ return 'document';
1057
+ }
1058
+ if (fromPath)
1059
+ return fromPath;
1060
+ return 'download';
1061
+ }
1062
+ fetchAsBlob(url) {
1063
+ return fetch(url, { mode: 'cors' })
1064
+ .then((r) => {
1065
+ if (!r.ok)
1066
+ throw new Error('Fetch failed');
1067
+ return r.blob();
1068
+ })
1069
+ .then((blob) => URL.createObjectURL(blob))
1070
+ .catch(() => null);
1071
+ }
1072
+ revokeBlobUrls() {
1073
+ const toRevoke = [...this.blobUrlsToRevoke];
1074
+ this.blobUrlsToRevoke = [];
1075
+ if (this.resolvedMediaUrl && toRevoke.includes(this.resolvedMediaUrl)) {
1076
+ this.resolvedMediaUrl = null;
1077
+ }
1078
+ this.documentBlobUrl = null;
1079
+ for (const u of toRevoke) {
1080
+ try {
1081
+ URL.revokeObjectURL(u);
1082
+ }
1083
+ catch {
1084
+ /* ignore */
1085
+ }
1086
+ }
1087
+ }
1088
+ clearDocumentLoadTimeout() {
1089
+ if (this.documentLoadTimeout != null) {
1090
+ clearTimeout(this.documentLoadTimeout);
1091
+ this.documentLoadTimeout = null;
1092
+ }
1093
+ }
1094
+ onDocumentIframeLoad() {
1095
+ this.clearDocumentLoadTimeout();
1096
+ this.cdr.markForCheck();
1097
+ }
1098
+ onBackdropClick() {
1099
+ this.viewer.close();
1100
+ }
1101
+ onContentClick(event) {
1102
+ event.stopPropagation();
1103
+ }
1104
+ close() {
1105
+ this.viewer.close();
1106
+ }
1107
+ triggerDownload() {
1108
+ const url = this.downloadUrl ?? this.url;
1109
+ if (url && typeof url === 'string' && url.trim().startsWith('http')) {
1110
+ if (isPlatformBrowser(this.platformId)) {
1111
+ window.open(url.trim(), '_blank');
1112
+ }
1113
+ }
1114
+ else if (this.downloadContext != null) {
1115
+ this.viewer.handleDownloadRequested(this.downloadContext);
1116
+ }
1117
+ this.cdr.markForCheck();
1118
+ }
1119
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: MediaViewerServiceComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1120
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: MediaViewerServiceComponent, isStandalone: true, selector: "media-viewer-service", ngImport: i0, template: "<div class=\"GeneralViewerBackdrop\" *ngIf=\"open\" (click)=\"onBackdropClick()\">\r\n <div class=\"GeneralViewerContent\" (click)=\"onContentClick($event)\">\r\n <div class=\"GeneralViewerHeader\">\r\n <span class=\"GeneralViewerTypeLabel\">{{ typeLabel }}</span>\r\n <button type=\"button\" class=\"GeneralViewerClose\" (click)=\"close()\" aria-label=\"Cerrar\">\r\n <lucide-icon name=\"x\"></lucide-icon>\r\n </button>\r\n </div>\r\n <div class=\"GeneralViewerBody\" [class.GeneralViewerBody--document]=\"effectiveMode === 'document'\">\r\n <div *ngIf=\"detectingOrLoading\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\r\n <p>{{ loadingMessage }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && detectError && !resolvedMediaUrl && effectiveMode === 'download'\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"triangle-alert\"></lucide-icon>\r\n <p>No se pudo detectar el tipo de archivo.</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"url\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">Descargar</button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <app-image-viewer\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'image' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n ></app-image-viewer>\r\n\r\n <app-video-player\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'video' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n [videoFormat]=\"videoPlayerFormat\"\r\n ></app-video-player>\r\n\r\n <app-audio-player\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'audio' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n [name]=\"fileName\"\r\n ></app-audio-player>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'document' && documentUrlSafe && !documentRenderFailed\" class=\"GeneralViewerDocument\">\r\n <iframe\r\n [src]=\"documentUrlSafe\"\r\n frameborder=\"0\"\r\n class=\"GeneralViewerIframe\"\r\n [attr.title]=\"fileName\"\r\n (load)=\"onDocumentIframeLoad()\"\r\n ></iframe>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'document' && documentRenderFailed\" class=\"GeneralViewerDownload\">\r\n <p class=\"GeneralViewerDownloadMessage\">\r\n No se pudo mostrar el documento. Puede descargarlo con el bot\u00F3n o cerrar.\r\n </p>\r\n <p class=\"GeneralViewerDownloadFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"downloadUrl || url || downloadContext\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">\r\n Descargar\r\n </button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">\r\n Listo, Cerrar\r\n </button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'download' && !detectError\" class=\"GeneralViewerDownload\">\r\n <p class=\"GeneralViewerDownloadMessage\">\r\n {{ (downloadUrl || url || downloadContext) ? 'Este archivo no se puede previsualizar. Puede descargarlo con el bot\u00F3n o cerrar.' : 'No se pudo cargar la URL del archivo.' }}\r\n </p>\r\n <p class=\"GeneralViewerDownloadFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"downloadUrl || url || downloadContext\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">\r\n Descargar\r\n </button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">\r\n Listo, Cerrar\r\n </button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && open && effectiveMode === 'document' && !documentUrlSafe && !documentRenderFailed\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\r\n <p>Cargando documento...</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && open && effectiveMode !== 'download' && effectiveMode !== 'document' && !mediaSrc && !documentUrlSafe\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"file\"></lucide-icon>\r\n <p>No se pudo cargar el archivo</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Listo, Cerrar</button>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".GeneralViewerBackdrop{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);display:flex;align-items:center;justify-content:center;z-index:2000;padding:0;overflow:hidden}.GeneralViewerContent{position:relative;background-color:var(--media-viewer-bg, var(--surface-inverse, var(--text-base)));width:100%;height:100%;max-width:100vw;max-height:100vh;display:flex;flex-direction:column;overflow:hidden}.GeneralViewerHeader{position:absolute;top:0;left:0;z-index:1;width:100%;height:48px;display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:linear-gradient(to bottom,color-mix(in srgb,var(--text-base) 60%,transparent),transparent)}.GeneralViewerTypeLabel{margin-right:12px;padding:4px 8px;border-radius:var(--radius-md);font-size:.875rem;font-weight:600;color:var(--text-inverted);background-color:color-mix(in srgb,var(--text-base) 40%,transparent)}.GeneralViewerFileName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:12px;padding:4px 8px;border-radius:var(--radius-md);font-size:.875rem;color:var(--text-inverted);max-width:70%;background-color:color-mix(in srgb,var(--text-base) 40%,transparent)}.GeneralViewerClose{width:40px;height:40px;display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;cursor:pointer;font-size:1.25rem;color:var(--text-inverted);background-color:color-mix(in srgb,var(--text-base) 40%,transparent);flex-shrink:0}.GeneralViewerClose:hover{background-color:color-mix(in srgb,var(--text-inverted) 15%,transparent)}.GeneralViewerBody{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;overflow-y:auto;min-height:0;background-color:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.GeneralViewerBody.GeneralViewerBody--document{padding-top:48px;align-items:flex-start;justify-content:center}.GeneralViewerBody app-image-viewer,.GeneralViewerBody app-video-player{width:100%;height:100%;min-height:200px}.GeneralViewerBody app-audio-player{width:100%;max-width:560px;padding:16px}.GeneralViewerNoContent,.GeneralViewerDownload{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px;color:var(--text-inverted);text-align:center}.GeneralViewerDocument{width:100%;height:100%;min-height:0}.GeneralViewerIframe{width:100%;height:100%;border:none;background:var(--surface)}.GeneralViewerDownloadMessage,.GeneralViewerDownloadFileName{margin:0;color:var(--text-inverted)}.GeneralViewerBtnDownload,.GeneralViewerBtnClose{padding:8px 16px;border:none;border-radius:var(--radius-md);font-size:.875rem;font-weight:500;cursor:pointer}.GeneralViewerBtnDownload{background-color:var(--primary);color:var(--text-inverted)}.GeneralViewerBtnClose{background-color:color-mix(in srgb,var(--text-inverted) 20%,transparent);color:var(--text-inverted)}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: ImageViewerComponent, selector: "app-image-viewer", inputs: ["src"] }, { kind: "component", type: VideoPlayerComponent, selector: "app-video-player", inputs: ["src", "videoFormat"] }, { kind: "component", type: AudioPlayerComponent, selector: "app-audio-player", inputs: ["src", "name"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1121
+ }
1122
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: MediaViewerServiceComponent, decorators: [{
1123
+ type: Component,
1124
+ args: [{ selector: 'media-viewer-service', standalone: true, imports: [
1125
+ LucideIconComponent,
1126
+ CommonModule,
1127
+ ImageViewerComponent,
1128
+ VideoPlayerComponent,
1129
+ AudioPlayerComponent,
1130
+ ], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"GeneralViewerBackdrop\" *ngIf=\"open\" (click)=\"onBackdropClick()\">\r\n <div class=\"GeneralViewerContent\" (click)=\"onContentClick($event)\">\r\n <div class=\"GeneralViewerHeader\">\r\n <span class=\"GeneralViewerTypeLabel\">{{ typeLabel }}</span>\r\n <button type=\"button\" class=\"GeneralViewerClose\" (click)=\"close()\" aria-label=\"Cerrar\">\r\n <lucide-icon name=\"x\"></lucide-icon>\r\n </button>\r\n </div>\r\n <div class=\"GeneralViewerBody\" [class.GeneralViewerBody--document]=\"effectiveMode === 'document'\">\r\n <div *ngIf=\"detectingOrLoading\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\r\n <p>{{ loadingMessage }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && detectError && !resolvedMediaUrl && effectiveMode === 'download'\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"triangle-alert\"></lucide-icon>\r\n <p>No se pudo detectar el tipo de archivo.</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"url\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">Descargar</button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <app-image-viewer\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'image' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n ></app-image-viewer>\r\n\r\n <app-video-player\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'video' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n [videoFormat]=\"videoPlayerFormat\"\r\n ></app-video-player>\r\n\r\n <app-audio-player\r\n *ngIf=\"!detectingOrLoading && effectiveMode === 'audio' && mediaSrc\"\r\n [src]=\"mediaSrc\"\r\n [name]=\"fileName\"\r\n ></app-audio-player>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'document' && documentUrlSafe && !documentRenderFailed\" class=\"GeneralViewerDocument\">\r\n <iframe\r\n [src]=\"documentUrlSafe\"\r\n frameborder=\"0\"\r\n class=\"GeneralViewerIframe\"\r\n [attr.title]=\"fileName\"\r\n (load)=\"onDocumentIframeLoad()\"\r\n ></iframe>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'document' && documentRenderFailed\" class=\"GeneralViewerDownload\">\r\n <p class=\"GeneralViewerDownloadMessage\">\r\n No se pudo mostrar el documento. Puede descargarlo con el bot\u00F3n o cerrar.\r\n </p>\r\n <p class=\"GeneralViewerDownloadFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"downloadUrl || url || downloadContext\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">\r\n Descargar\r\n </button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">\r\n Listo, Cerrar\r\n </button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && effectiveMode === 'download' && !detectError\" class=\"GeneralViewerDownload\">\r\n <p class=\"GeneralViewerDownloadMessage\">\r\n {{ (downloadUrl || url || downloadContext) ? 'Este archivo no se puede previsualizar. Puede descargarlo con el bot\u00F3n o cerrar.' : 'No se pudo cargar la URL del archivo.' }}\r\n </p>\r\n <p class=\"GeneralViewerDownloadFileName\">{{ fileName }}</p>\r\n <button *ngIf=\"downloadUrl || url || downloadContext\" type=\"button\" class=\"GeneralViewerBtnDownload\" (click)=\"triggerDownload()\">\r\n Descargar\r\n </button>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">\r\n Listo, Cerrar\r\n </button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && open && effectiveMode === 'document' && !documentUrlSafe && !documentRenderFailed\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\r\n <p>Cargando documento...</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Cerrar</button>\r\n </div>\r\n\r\n <div *ngIf=\"!detectingOrLoading && open && effectiveMode !== 'download' && effectiveMode !== 'document' && !mediaSrc && !documentUrlSafe\" class=\"GeneralViewerNoContent\">\r\n <lucide-icon name=\"file\"></lucide-icon>\r\n <p>No se pudo cargar el archivo</p>\r\n <p class=\"GeneralViewerFileName\">{{ fileName }}</p>\r\n <button type=\"button\" class=\"GeneralViewerBtnClose\" (click)=\"close()\">Listo, Cerrar</button>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".GeneralViewerBackdrop{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);display:flex;align-items:center;justify-content:center;z-index:2000;padding:0;overflow:hidden}.GeneralViewerContent{position:relative;background-color:var(--media-viewer-bg, var(--surface-inverse, var(--text-base)));width:100%;height:100%;max-width:100vw;max-height:100vh;display:flex;flex-direction:column;overflow:hidden}.GeneralViewerHeader{position:absolute;top:0;left:0;z-index:1;width:100%;height:48px;display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:linear-gradient(to bottom,color-mix(in srgb,var(--text-base) 60%,transparent),transparent)}.GeneralViewerTypeLabel{margin-right:12px;padding:4px 8px;border-radius:var(--radius-md);font-size:.875rem;font-weight:600;color:var(--text-inverted);background-color:color-mix(in srgb,var(--text-base) 40%,transparent)}.GeneralViewerFileName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:12px;padding:4px 8px;border-radius:var(--radius-md);font-size:.875rem;color:var(--text-inverted);max-width:70%;background-color:color-mix(in srgb,var(--text-base) 40%,transparent)}.GeneralViewerClose{width:40px;height:40px;display:flex;align-items:center;justify-content:center;border:none;border-radius:50%;cursor:pointer;font-size:1.25rem;color:var(--text-inverted);background-color:color-mix(in srgb,var(--text-base) 40%,transparent);flex-shrink:0}.GeneralViewerClose:hover{background-color:color-mix(in srgb,var(--text-inverted) 15%,transparent)}.GeneralViewerBody{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;overflow-y:auto;min-height:0;background-color:var(--media-viewer-body-bg, var(--surface-inverse, var(--text-base)))}.GeneralViewerBody.GeneralViewerBody--document{padding-top:48px;align-items:flex-start;justify-content:center}.GeneralViewerBody app-image-viewer,.GeneralViewerBody app-video-player{width:100%;height:100%;min-height:200px}.GeneralViewerBody app-audio-player{width:100%;max-width:560px;padding:16px}.GeneralViewerNoContent,.GeneralViewerDownload{display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px;color:var(--text-inverted);text-align:center}.GeneralViewerDocument{width:100%;height:100%;min-height:0}.GeneralViewerIframe{width:100%;height:100%;border:none;background:var(--surface)}.GeneralViewerDownloadMessage,.GeneralViewerDownloadFileName{margin:0;color:var(--text-inverted)}.GeneralViewerBtnDownload,.GeneralViewerBtnClose{padding:8px 16px;border:none;border-radius:var(--radius-md);font-size:.875rem;font-weight:500;cursor:pointer}.GeneralViewerBtnDownload{background-color:var(--primary);color:var(--text-inverted)}.GeneralViewerBtnClose{background-color:color-mix(in srgb,var(--text-inverted) 20%,transparent);color:var(--text-inverted)}\n"] }]
1131
+ }], ctorParameters: () => [] });
1132
+
1133
+ class SessionRequestService {
1134
+ platformId;
1135
+ sessionRequestSubject = new Subject();
1136
+ sessionRequest$ = this.sessionRequestSubject.asObservable();
1137
+ idCounter = 0;
1138
+ isBrowser;
1139
+ constructor(platformId) {
1140
+ this.platformId = platformId;
1141
+ this.isBrowser = isPlatformBrowser(this.platformId);
1142
+ }
1143
+ /**
1144
+ * Muestra un overlay solicitando sesión.
1145
+ * @param lucideIcon Nombre del icono Lucide en kebab-case (ej: shopping-cart).
1146
+ */
1147
+ showSessionRequest(lucideIcon, message) {
1148
+ if (!this.isBrowser)
1149
+ return;
1150
+ const sessionRequest = {
1151
+ id: ++this.idCounter,
1152
+ lucideIcon,
1153
+ message,
1154
+ };
1155
+ this.sessionRequestSubject.next(sessionRequest);
1156
+ }
1157
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: SessionRequestService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
1158
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: SessionRequestService, providedIn: 'root' });
1159
+ }
1160
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: SessionRequestService, decorators: [{
1161
+ type: Injectable,
1162
+ args: [{ providedIn: 'root' }]
1163
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
1164
+ type: Inject,
1165
+ args: [PLATFORM_ID]
1166
+ }] }] });
1167
+
1168
+ class SessionRequestServiceComponent {
1169
+ sessionRequestService;
1170
+ sessionRequests = [];
1171
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
1172
+ constructor(sessionRequestService) {
1173
+ this.sessionRequestService = sessionRequestService;
1174
+ }
1175
+ ngOnInit() {
1176
+ this.sessionRequestService.sessionRequest$.subscribe((sessionRequest) => {
1177
+ this.sessionRequests.push(sessionRequest);
1178
+ });
1179
+ }
1180
+ closeSessionRequest(id) {
1181
+ this.sessionRequests = this.sessionRequests.filter((sr) => sr.id !== id);
1182
+ }
1183
+ trackById(_index, item) {
1184
+ return item.id;
1185
+ }
1186
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: SessionRequestServiceComponent, deps: [{ token: SessionRequestService }], target: i0.ɵɵFactoryTarget.Component });
1187
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: SessionRequestServiceComponent, isStandalone: true, selector: "session-request-service", ngImport: i0, template: "<div class=\"SessionRequestOverlay\" *ngIf=\"sessionRequests.length > 0\">\r\n <div\r\n class=\"SessionRequest\"\r\n *ngFor=\"let sessionRequest of sessionRequests; trackBy: trackById\"\r\n [@sessionRequestAnimation]\r\n >\r\n <button\r\n type=\"button\"\r\n class=\"CloseButton\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n title=\"Cerrar\"\r\n >\r\n <lucide-icon name=\"x\"></lucide-icon>\r\n </button>\r\n <div class=\"SessionRequestContent\">\r\n <div class=\"SessionRequestIcon\">\r\n <lucide-icon [name]=\"sessionRequest.lucideIcon\"></lucide-icon>\r\n </div>\r\n <div class=\"SessionRequestMessage\">\r\n {{ sessionRequest.message }}\r\n </div>\r\n <div class=\"SessionRequestActions\">\r\n <a\r\n class=\"SessionRequestButton RegisterButton\"\r\n [routerLink]=\"config.sessionRegisterPath\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n >\r\n <lucide-icon name=\"user-plus\"></lucide-icon>\r\n {{ config.sessionRegisterLabel }}\r\n </a>\r\n <a\r\n class=\"SessionRequestButton LoginButton\"\r\n [routerLink]=\"config.sessionLoginPath\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n >\r\n <lucide-icon name=\"log-in\"></lucide-icon>\r\n {{ config.sessionLoginLabel }}\r\n </a>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".SessionRequestOverlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:10000;pointer-events:auto;padding:4px}.SessionRequest{position:relative;background-color:var(--surface);border-radius:var(--radius-xl, var(--radius-lg));max-width:450px;width:100%;margin:12px;pointer-events:auto}.CloseButton{position:absolute;top:4px;right:4px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;background-color:transparent;border:none;border-radius:50%;cursor:pointer;color:var(--text-muted);z-index:10}.CloseButton lucide-icon .lucide-svg{width:1.3rem;height:1.3rem}.SessionRequestContent{padding:12px;display:flex;flex-direction:column;gap:8px;align-items:center}.SessionRequestIcon{display:flex;justify-content:center;margin-top:12px}.SessionRequestIcon lucide-icon{--lucide-stroke: var(--primary)}.SessionRequestIcon lucide-icon .lucide-svg{width:2.5rem;height:2.5rem}.SessionRequestMessage{text-align:center;color:var(--text-base)}.SessionRequestActions{display:flex;gap:4px;width:100%;margin-top:8px}.SessionRequestButton{flex:1;padding:8px 16px;border:none;border-radius:var(--radius-lg);font-size:.9375rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none}.SessionRequestButton lucide-icon .lucide-svg{width:1rem;height:1rem}.LoginButton{background-color:var(--primary);color:var(--text-inverted)}.RegisterButton{background-color:var(--surface-secondary, var(--surface-2));color:var(--text-base)}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i3.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }], animations: [
1188
+ trigger('sessionRequestAnimation', [
1189
+ transition(':enter', [
1190
+ style({ opacity: 0, transform: 'scale(0.9)' }),
1191
+ animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
1192
+ ]),
1193
+ transition(':leave', [
1194
+ animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.9)' })),
1195
+ ]),
1196
+ ]),
1197
+ ] });
1198
+ }
1199
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: SessionRequestServiceComponent, decorators: [{
1200
+ type: Component,
1201
+ args: [{ selector: 'session-request-service', standalone: true, imports: [LucideIconComponent, CommonModule, RouterModule], animations: [
1202
+ trigger('sessionRequestAnimation', [
1203
+ transition(':enter', [
1204
+ style({ opacity: 0, transform: 'scale(0.9)' }),
1205
+ animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
1206
+ ]),
1207
+ transition(':leave', [
1208
+ animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.9)' })),
1209
+ ]),
1210
+ ]),
1211
+ ], template: "<div class=\"SessionRequestOverlay\" *ngIf=\"sessionRequests.length > 0\">\r\n <div\r\n class=\"SessionRequest\"\r\n *ngFor=\"let sessionRequest of sessionRequests; trackBy: trackById\"\r\n [@sessionRequestAnimation]\r\n >\r\n <button\r\n type=\"button\"\r\n class=\"CloseButton\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n title=\"Cerrar\"\r\n >\r\n <lucide-icon name=\"x\"></lucide-icon>\r\n </button>\r\n <div class=\"SessionRequestContent\">\r\n <div class=\"SessionRequestIcon\">\r\n <lucide-icon [name]=\"sessionRequest.lucideIcon\"></lucide-icon>\r\n </div>\r\n <div class=\"SessionRequestMessage\">\r\n {{ sessionRequest.message }}\r\n </div>\r\n <div class=\"SessionRequestActions\">\r\n <a\r\n class=\"SessionRequestButton RegisterButton\"\r\n [routerLink]=\"config.sessionRegisterPath\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n >\r\n <lucide-icon name=\"user-plus\"></lucide-icon>\r\n {{ config.sessionRegisterLabel }}\r\n </a>\r\n <a\r\n class=\"SessionRequestButton LoginButton\"\r\n [routerLink]=\"config.sessionLoginPath\"\r\n (click)=\"closeSessionRequest(sessionRequest.id)\"\r\n >\r\n <lucide-icon name=\"log-in\"></lucide-icon>\r\n {{ config.sessionLoginLabel }}\r\n </a>\r\n </div>\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".SessionRequestOverlay{position:fixed;top:0;left:0;width:100%;height:100%;background-color:color-mix(in srgb,var(--text-base) 50%,transparent);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:10000;pointer-events:auto;padding:4px}.SessionRequest{position:relative;background-color:var(--surface);border-radius:var(--radius-xl, var(--radius-lg));max-width:450px;width:100%;margin:12px;pointer-events:auto}.CloseButton{position:absolute;top:4px;right:4px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;background-color:transparent;border:none;border-radius:50%;cursor:pointer;color:var(--text-muted);z-index:10}.CloseButton lucide-icon .lucide-svg{width:1.3rem;height:1.3rem}.SessionRequestContent{padding:12px;display:flex;flex-direction:column;gap:8px;align-items:center}.SessionRequestIcon{display:flex;justify-content:center;margin-top:12px}.SessionRequestIcon lucide-icon{--lucide-stroke: var(--primary)}.SessionRequestIcon lucide-icon .lucide-svg{width:2.5rem;height:2.5rem}.SessionRequestMessage{text-align:center;color:var(--text-base)}.SessionRequestActions{display:flex;gap:4px;width:100%;margin-top:8px}.SessionRequestButton{flex:1;padding:8px 16px;border:none;border-radius:var(--radius-lg);font-size:.9375rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none}.SessionRequestButton lucide-icon .lucide-svg{width:1rem;height:1rem}.LoginButton{background-color:var(--primary);color:var(--text-inverted)}.RegisterButton{background-color:var(--surface-secondary, var(--surface-2));color:var(--text-base)}\n"] }]
1212
+ }], ctorParameters: () => [{ type: SessionRequestService }] });
1213
+
1214
+ /**
1215
+ * Capa global de carga: un mismo backdrop desde el primer `push` hasta el último `pop`.
1216
+ * Varios procesos concurrentes se listan con su mensaje.
1217
+ */
1218
+ class GlobalLoadingService {
1219
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
1220
+ get defaultMessage() {
1221
+ return this.config.loadingDefaultMessage;
1222
+ }
1223
+ seq = 0;
1224
+ processes$ = new BehaviorSubject([]);
1225
+ get snapshot() {
1226
+ return this.processes$.getValue();
1227
+ }
1228
+ processes = this.processes$.asObservable();
1229
+ push(message) {
1230
+ const id = `gl-${++this.seq}`;
1231
+ const msg = message != null && String(message).trim() !== ''
1232
+ ? String(message).trim()
1233
+ : this.defaultMessage;
1234
+ const next = [...this.processes$.getValue(), { id, message: msg }];
1235
+ this.processes$.next(next);
1236
+ return id;
1237
+ }
1238
+ setMessage(id, message) {
1239
+ const msg = String(message).trim() || this.defaultMessage;
1240
+ const cur = this.processes$.getValue();
1241
+ const idx = cur.findIndex((p) => p.id === id);
1242
+ if (idx < 0)
1243
+ return;
1244
+ const next = [...cur];
1245
+ next[idx] = { ...next[idx], message: msg };
1246
+ this.processes$.next(next);
1247
+ }
1248
+ pop(id) {
1249
+ const cur = this.processes$.getValue();
1250
+ const next = cur.filter((p) => p.id !== id);
1251
+ if (next.length === cur.length)
1252
+ return;
1253
+ this.processes$.next(next);
1254
+ }
1255
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: GlobalLoadingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1256
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: GlobalLoadingService, providedIn: 'root' });
1257
+ }
1258
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: GlobalLoadingService, decorators: [{
1259
+ type: Injectable,
1260
+ args: [{ providedIn: 'root' }]
1261
+ }] });
1262
+
1263
+ class GlobalLoadingServiceComponent {
1264
+ globalLoading;
1265
+ processes = [];
1266
+ sub;
1267
+ constructor(globalLoading) {
1268
+ this.globalLoading = globalLoading;
1269
+ }
1270
+ ngOnInit() {
1271
+ this.sub = this.globalLoading.processes.subscribe((list) => {
1272
+ this.processes = list;
1273
+ });
1274
+ }
1275
+ ngOnDestroy() {
1276
+ this.sub?.unsubscribe();
1277
+ }
1278
+ trackById(_index, item) {
1279
+ return item.id;
1280
+ }
1281
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: GlobalLoadingServiceComponent, deps: [{ token: GlobalLoadingService }], target: i0.ɵɵFactoryTarget.Component });
1282
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: GlobalLoadingServiceComponent, isStandalone: true, selector: "global-loading-service", ngImport: i0, template: "<div\r\n *ngIf=\"processes.length > 0\"\r\n class=\"GlobalLoadingOverlay\"\r\n aria-live=\"polite\"\r\n aria-busy=\"true\"\r\n role=\"alertdialog\"\r\n aria-modal=\"true\"\r\n>\r\n <div class=\"GlobalLoadingCard\">\r\n <div class=\"GlobalLoadingSpinner\" aria-hidden=\"true\"></div>\r\n <ul class=\"GlobalLoadingList\">\r\n <li *ngFor=\"let p of processes; trackBy: trackById\" class=\"GlobalLoadingItem\">\r\n {{ p.message }}\r\n </li>\r\n </ul>\r\n </div>\r\n</div>\r\n", styles: [".GlobalLoadingOverlay{position:fixed;inset:0;z-index:10050;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 45%,transparent);backdrop-filter:blur(5px);pointer-events:auto}.GlobalLoadingCard{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:420px;width:100%;padding:16px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border);box-shadow:0 12px 40px color-mix(in srgb,var(--text-base) 15%,transparent)}.GlobalLoadingSpinner{width:48px;height:48px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:GlobalLoadingSpin .75s linear infinite}.GlobalLoadingList{list-style:none;margin:0;padding:0;width:100%;text-align:center}.GlobalLoadingItem{font-size:.95rem;font-weight:500;color:var(--text-primary, var(--text-base));line-height:1.45}.GlobalLoadingItem+.GlobalLoadingItem{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}@keyframes GlobalLoadingSpin{to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
1283
+ }
1284
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: GlobalLoadingServiceComponent, decorators: [{
1285
+ type: Component,
1286
+ args: [{ selector: 'global-loading-service', standalone: true, imports: [CommonModule], template: "<div\r\n *ngIf=\"processes.length > 0\"\r\n class=\"GlobalLoadingOverlay\"\r\n aria-live=\"polite\"\r\n aria-busy=\"true\"\r\n role=\"alertdialog\"\r\n aria-modal=\"true\"\r\n>\r\n <div class=\"GlobalLoadingCard\">\r\n <div class=\"GlobalLoadingSpinner\" aria-hidden=\"true\"></div>\r\n <ul class=\"GlobalLoadingList\">\r\n <li *ngFor=\"let p of processes; trackBy: trackById\" class=\"GlobalLoadingItem\">\r\n {{ p.message }}\r\n </li>\r\n </ul>\r\n </div>\r\n</div>\r\n", styles: [".GlobalLoadingOverlay{position:fixed;inset:0;z-index:10050;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 45%,transparent);backdrop-filter:blur(5px);pointer-events:auto}.GlobalLoadingCard{display:flex;flex-direction:column;align-items:center;gap:8px;max-width:420px;width:100%;padding:16px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border);box-shadow:0 12px 40px color-mix(in srgb,var(--text-base) 15%,transparent)}.GlobalLoadingSpinner{width:48px;height:48px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:GlobalLoadingSpin .75s linear infinite}.GlobalLoadingList{list-style:none;margin:0;padding:0;width:100%;text-align:center}.GlobalLoadingItem{font-size:.95rem;font-weight:500;color:var(--text-primary, var(--text-base));line-height:1.45}.GlobalLoadingItem+.GlobalLoadingItem{margin-top:8px;padding-top:8px;border-top:1px solid var(--border)}@keyframes GlobalLoadingSpin{to{transform:rotate(360deg)}}\n"] }]
1287
+ }], ctorParameters: () => [{ type: GlobalLoadingService }] });
1288
+
1289
+ const SHOW_DELAY_MS = 120;
1290
+ const COMPLETE_HIDE_MS = 320;
1291
+ const PROGRESS_TICK_MS = 180;
1292
+ const MAX_SIMULATED_PROGRESS = 92;
1293
+ class NavigationLoadingService {
1294
+ router = inject(Router);
1295
+ platformId = inject(PLATFORM_ID);
1296
+ /** Barra visible (solo tras superar el delay en navegaciones lentas). */
1297
+ active = signal(false);
1298
+ /** Progreso 0–100 para la barra. */
1299
+ progress = signal(0);
1300
+ routerBound = false;
1301
+ navigationGeneration = 0;
1302
+ showDelayTimer = null;
1303
+ progressTimer = null;
1304
+ hideTimer = null;
1305
+ /** Llamar una vez desde el host (evita suscribirse en el constructor). */
1306
+ bindToRouter() {
1307
+ if (this.routerBound || !isPlatformBrowser(this.platformId)) {
1308
+ return;
1309
+ }
1310
+ this.routerBound = true;
1311
+ this.router.events.subscribe((event) => {
1312
+ if (event instanceof NavigationStart) {
1313
+ this.onNavigationStart();
1314
+ return;
1315
+ }
1316
+ if (event instanceof NavigationEnd ||
1317
+ event instanceof NavigationCancel ||
1318
+ event instanceof NavigationError) {
1319
+ this.onNavigationEnd();
1320
+ }
1321
+ });
1322
+ }
1323
+ onNavigationStart() {
1324
+ this.navigationGeneration += 1;
1325
+ const generation = this.navigationGeneration;
1326
+ this.clearHideTimer();
1327
+ this.clearProgressTimer();
1328
+ this.clearShowDelayTimer();
1329
+ this.showDelayTimer = setTimeout(() => {
1330
+ if (generation !== this.navigationGeneration) {
1331
+ return;
1332
+ }
1333
+ this.active.set(true);
1334
+ this.progress.set(12);
1335
+ this.startProgressSimulation(generation);
1336
+ }, SHOW_DELAY_MS);
1337
+ }
1338
+ onNavigationEnd() {
1339
+ this.navigationGeneration += 1;
1340
+ const generation = this.navigationGeneration;
1341
+ this.clearShowDelayTimer();
1342
+ this.clearProgressTimer();
1343
+ if (!this.active()) {
1344
+ this.progress.set(0);
1345
+ return;
1346
+ }
1347
+ this.progress.set(100);
1348
+ this.clearHideTimer();
1349
+ this.hideTimer = setTimeout(() => {
1350
+ if (generation !== this.navigationGeneration) {
1351
+ return;
1352
+ }
1353
+ this.active.set(false);
1354
+ this.progress.set(0);
1355
+ }, COMPLETE_HIDE_MS);
1356
+ }
1357
+ startProgressSimulation(generation) {
1358
+ this.clearProgressTimer();
1359
+ this.progressTimer = setInterval(() => {
1360
+ if (generation !== this.navigationGeneration) {
1361
+ return;
1362
+ }
1363
+ const current = this.progress();
1364
+ if (current >= MAX_SIMULATED_PROGRESS) {
1365
+ return;
1366
+ }
1367
+ const step = current < 40 ? 14 : current < 70 ? 8 : 4;
1368
+ this.progress.set(Math.min(MAX_SIMULATED_PROGRESS, current + step));
1369
+ }, PROGRESS_TICK_MS);
1370
+ }
1371
+ clearShowDelayTimer() {
1372
+ if (this.showDelayTimer !== null) {
1373
+ clearTimeout(this.showDelayTimer);
1374
+ this.showDelayTimer = null;
1375
+ }
1376
+ }
1377
+ clearProgressTimer() {
1378
+ if (this.progressTimer !== null) {
1379
+ clearInterval(this.progressTimer);
1380
+ this.progressTimer = null;
1381
+ }
1382
+ }
1383
+ clearHideTimer() {
1384
+ if (this.hideTimer !== null) {
1385
+ clearTimeout(this.hideTimer);
1386
+ this.hideTimer = null;
1387
+ }
1388
+ }
1389
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: NavigationLoadingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1390
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: NavigationLoadingService, providedIn: 'root' });
1391
+ }
1392
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: NavigationLoadingService, decorators: [{
1393
+ type: Injectable,
1394
+ args: [{ providedIn: 'root' }]
1395
+ }] });
1396
+
1397
+ class NavigationLoadingServiceComponent {
1398
+ navLoading = inject(NavigationLoadingService);
1399
+ ngOnInit() {
1400
+ this.navLoading.bindToRouter();
1401
+ }
1402
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: NavigationLoadingServiceComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1403
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.11", type: NavigationLoadingServiceComponent, isStandalone: true, selector: "navigation-loading-service", ngImport: i0, template: "@if (navLoading.active()) {\n <div\n class=\"NavigationLoadingBackdrop\"\n role=\"status\"\n aria-live=\"polite\"\n aria-busy=\"true\"\n aria-label=\"Navegando\"\n >\n <div class=\"NavigationLoadingCard\">\n <div class=\"NavigationLoadingIconWrap\" aria-hidden=\"true\">\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\n </div>\n <div class=\"NavigationLoadingCopy\">\n <p class=\"NavigationLoadingTitle\">Navegando...</p>\n <p class=\"NavigationLoadingHint\">Preparando la vista</p>\n </div>\n <div class=\"NavigationLoadingProgressTrack\" aria-hidden=\"true\">\n <div class=\"NavigationLoadingProgressFill\" [style.width.%]=\"navLoading.progress()\"></div>\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}.NavigationLoadingBackdrop{position:fixed;inset:0;z-index:150;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 45%,transparent);backdrop-filter:blur(8px) saturate(120%);-webkit-backdrop-filter:blur(8px) saturate(120%);pointer-events:auto;animation:NavigationBackdropIn .22s ease-out}.NavigationLoadingCard{display:flex;flex-direction:column;align-items:center;width:min(100%,280px);padding:24px 20px 16px;background-color:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 20px 50px color-mix(in srgb,var(--text-base) 18%,transparent);animation:NavigationCardIn .28s cubic-bezier(.22,1,.36,1);overflow:hidden}.NavigationLoadingIconWrap{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin-bottom:4px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border)}.NavigationLoadingIconWrap lucide-icon .lucide-svg{width:1.75rem;height:1.75rem;color:var(--primary)}.NavigationLoadingCopy{display:flex;flex-direction:column;align-items:center;gap:4px;text-align:center;width:100%}.NavigationLoadingTitle{margin:0;font-size:1rem;font-weight:600;letter-spacing:-.01em;color:var(--text-base)}.NavigationLoadingHint{margin:0;font-size:.8125rem;font-weight:400;color:var(--text-muted)}.NavigationLoadingProgressTrack{width:100%;height:3px;margin-top:16px;border-radius:999px;overflow:hidden;background-color:var(--surface-2)}.NavigationLoadingProgressFill{height:100%;width:0;border-radius:inherit;background:var(--primary);transition:width .25s ease-out}@keyframes NavigationBackdropIn{0%{opacity:0}to{opacity:1}}@keyframes NavigationCardIn{0%{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}\n"], dependencies: [{ kind: "component", type: LucideIconComponent, selector: "lucide-icon", inputs: ["name", "size", "spin", "filled"] }] });
1404
+ }
1405
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: NavigationLoadingServiceComponent, decorators: [{
1406
+ type: Component,
1407
+ args: [{ selector: 'navigation-loading-service', standalone: true, imports: [LucideIconComponent], template: "@if (navLoading.active()) {\n <div\n class=\"NavigationLoadingBackdrop\"\n role=\"status\"\n aria-live=\"polite\"\n aria-busy=\"true\"\n aria-label=\"Navegando\"\n >\n <div class=\"NavigationLoadingCard\">\n <div class=\"NavigationLoadingIconWrap\" aria-hidden=\"true\">\n <lucide-icon name=\"loader-circle\" [spin]=\"true\"></lucide-icon>\n </div>\n <div class=\"NavigationLoadingCopy\">\n <p class=\"NavigationLoadingTitle\">Navegando...</p>\n <p class=\"NavigationLoadingHint\">Preparando la vista</p>\n </div>\n <div class=\"NavigationLoadingProgressTrack\" aria-hidden=\"true\">\n <div class=\"NavigationLoadingProgressFill\" [style.width.%]=\"navLoading.progress()\"></div>\n </div>\n </div>\n </div>\n}\n", styles: [":host{display:contents}.NavigationLoadingBackdrop{position:fixed;inset:0;z-index:150;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 45%,transparent);backdrop-filter:blur(8px) saturate(120%);-webkit-backdrop-filter:blur(8px) saturate(120%);pointer-events:auto;animation:NavigationBackdropIn .22s ease-out}.NavigationLoadingCard{display:flex;flex-direction:column;align-items:center;width:min(100%,280px);padding:24px 20px 16px;background-color:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:0 20px 50px color-mix(in srgb,var(--text-base) 18%,transparent);animation:NavigationCardIn .28s cubic-bezier(.22,1,.36,1);overflow:hidden}.NavigationLoadingIconWrap{display:flex;align-items:center;justify-content:center;width:56px;height:56px;margin-bottom:4px;border-radius:50%;background:var(--surface-2);border:1px solid var(--border)}.NavigationLoadingIconWrap lucide-icon .lucide-svg{width:1.75rem;height:1.75rem;color:var(--primary)}.NavigationLoadingCopy{display:flex;flex-direction:column;align-items:center;gap:4px;text-align:center;width:100%}.NavigationLoadingTitle{margin:0;font-size:1rem;font-weight:600;letter-spacing:-.01em;color:var(--text-base)}.NavigationLoadingHint{margin:0;font-size:.8125rem;font-weight:400;color:var(--text-muted)}.NavigationLoadingProgressTrack{width:100%;height:3px;margin-top:16px;border-radius:999px;overflow:hidden;background-color:var(--surface-2)}.NavigationLoadingProgressFill{height:100%;width:0;border-radius:inherit;background:var(--primary);transition:width .25s ease-out}@keyframes NavigationBackdropIn{0%{opacity:0}to{opacity:1}}@keyframes NavigationCardIn{0%{opacity:0;transform:scale(.94) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}\n"] }]
1408
+ }] });
1409
+
1410
+ class AiAutofillOverlayService {
1411
+ seq = 0;
1412
+ state$ = new BehaviorSubject(null);
1413
+ state = this.state$.asObservable();
1414
+ get snapshot() {
1415
+ return this.state$.getValue();
1416
+ }
1417
+ start(title = 'Autocompletando con IA...') {
1418
+ const id = `ai-${++this.seq}`;
1419
+ this.state$.next({
1420
+ id,
1421
+ title: String(title).trim() || 'Autocompletando con IA...',
1422
+ logs: [],
1423
+ startedAt: Date.now(),
1424
+ });
1425
+ return id;
1426
+ }
1427
+ log(id, line) {
1428
+ const cur = this.state$.getValue();
1429
+ if (!cur || cur.id !== id)
1430
+ return;
1431
+ const msg = String(line ?? '').trim();
1432
+ if (!msg)
1433
+ return;
1434
+ this.state$.next({ ...cur, logs: [...cur.logs, msg] });
1435
+ }
1436
+ replaceLogs(id, logs) {
1437
+ const cur = this.state$.getValue();
1438
+ if (!cur || cur.id !== id)
1439
+ return;
1440
+ this.state$.next({ ...cur, logs: Array.isArray(logs) ? logs.map((x) => String(x)) : [] });
1441
+ }
1442
+ end(id) {
1443
+ const cur = this.state$.getValue();
1444
+ if (!cur || cur.id !== id)
1445
+ return;
1446
+ this.state$.next(null);
1447
+ }
1448
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AiAutofillOverlayService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1449
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AiAutofillOverlayService, providedIn: 'root' });
1450
+ }
1451
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AiAutofillOverlayService, decorators: [{
1452
+ type: Injectable,
1453
+ args: [{ providedIn: 'root' }]
1454
+ }] });
1455
+
1456
+ class AiAutofillOverlayServiceComponent {
1457
+ aiOverlay;
1458
+ state = null;
1459
+ sub;
1460
+ constructor(aiOverlay) {
1461
+ this.aiOverlay = aiOverlay;
1462
+ }
1463
+ ngOnInit() {
1464
+ this.sub = this.aiOverlay.state.subscribe((s) => {
1465
+ this.state = s;
1466
+ });
1467
+ }
1468
+ ngOnDestroy() {
1469
+ this.sub?.unsubscribe();
1470
+ }
1471
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AiAutofillOverlayServiceComponent, deps: [{ token: AiAutofillOverlayService }], target: i0.ɵɵFactoryTarget.Component });
1472
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.11", type: AiAutofillOverlayServiceComponent, isStandalone: true, selector: "ai-autofill-overlay-service", ngImport: i0, template: "<div\r\n *ngIf=\"state\"\r\n class=\"AiAutofillOverlay\"\r\n aria-live=\"polite\"\r\n aria-busy=\"true\"\r\n role=\"alertdialog\"\r\n aria-modal=\"true\"\r\n>\r\n <div class=\"AiAutofillCard\">\r\n <div class=\"AiAutofillHeader\">\r\n <div class=\"AiAutofillSpinner\" aria-hidden=\"true\"></div>\r\n <div class=\"AiAutofillTitle\">{{ state.title }}</div>\r\n </div>\r\n\r\n <div class=\"AiAutofillLogs\" aria-label=\"Logs de IA en tiempo real\">\r\n <pre class=\"AiAutofillPre\">{{ state.logs.join('\\n') }}</pre>\r\n </div>\r\n </div>\r\n</div>\r\n\r\n", styles: [".AiAutofillOverlay{position:fixed;inset:0;z-index:10060;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 55%,transparent);backdrop-filter:blur(6px);pointer-events:auto}.AiAutofillCard{display:flex;flex-direction:column;gap:8px;width:min(920px,100vw - 32px);max-height:min(78vh,720px);padding:16px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border);box-shadow:0 12px 40px color-mix(in srgb,var(--text-base) 18%,transparent)}.AiAutofillHeader{display:flex;align-items:center;gap:8px}.AiAutofillTitle{font-size:1rem;font-weight:700;color:var(--text-primary, var(--text-base))}.AiAutofillSpinner{width:22px;height:22px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:AiAutofillSpin .75s linear infinite}.AiAutofillLogs{flex:1;overflow:auto;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--surface-2)}.AiAutofillPre{margin:0;padding:8px 12px;font-size:.85rem;line-height:1.4;color:var(--text-primary, var(--text-base));white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}@keyframes AiAutofillSpin{to{transform:rotate(360deg)}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
1473
+ }
1474
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: AiAutofillOverlayServiceComponent, decorators: [{
1475
+ type: Component,
1476
+ args: [{ selector: 'ai-autofill-overlay-service', standalone: true, imports: [CommonModule], template: "<div\r\n *ngIf=\"state\"\r\n class=\"AiAutofillOverlay\"\r\n aria-live=\"polite\"\r\n aria-busy=\"true\"\r\n role=\"alertdialog\"\r\n aria-modal=\"true\"\r\n>\r\n <div class=\"AiAutofillCard\">\r\n <div class=\"AiAutofillHeader\">\r\n <div class=\"AiAutofillSpinner\" aria-hidden=\"true\"></div>\r\n <div class=\"AiAutofillTitle\">{{ state.title }}</div>\r\n </div>\r\n\r\n <div class=\"AiAutofillLogs\" aria-label=\"Logs de IA en tiempo real\">\r\n <pre class=\"AiAutofillPre\">{{ state.logs.join('\\n') }}</pre>\r\n </div>\r\n </div>\r\n</div>\r\n\r\n", styles: [".AiAutofillOverlay{position:fixed;inset:0;z-index:10060;display:flex;align-items:center;justify-content:center;padding:16px;background-color:color-mix(in srgb,var(--text-base) 55%,transparent);backdrop-filter:blur(6px);pointer-events:auto}.AiAutofillCard{display:flex;flex-direction:column;gap:8px;width:min(920px,100vw - 32px);max-height:min(78vh,720px);padding:16px;background:var(--surface);border-radius:var(--radius-lg);border:1px solid var(--border);box-shadow:0 12px 40px color-mix(in srgb,var(--text-base) 18%,transparent)}.AiAutofillHeader{display:flex;align-items:center;gap:8px}.AiAutofillTitle{font-size:1rem;font-weight:700;color:var(--text-primary, var(--text-base))}.AiAutofillSpinner{width:22px;height:22px;border:3px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:AiAutofillSpin .75s linear infinite}.AiAutofillLogs{flex:1;overflow:auto;border-radius:var(--radius-lg);border:1px solid var(--border);background:var(--surface-2)}.AiAutofillPre{margin:0;padding:8px 12px;font-size:.85rem;line-height:1.4;color:var(--text-primary, var(--text-base));white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}@keyframes AiAutofillSpin{to{transform:rotate(360deg)}}\n"] }]
1477
+ }], ctorParameters: () => [{ type: AiAutofillOverlayService }] });
1478
+
1479
+ /**
1480
+ * Thin wrapper around the Web Notifications API.
1481
+ * Host-agnostic: no chat/app coupling. Optional AlertService for permission prompt.
1482
+ */
1483
+ class BrowserNotificationService {
1484
+ isBrowser;
1485
+ config = inject(XOSUE_UTILS_SERVICES_CONFIG);
1486
+ alert = inject(AlertService, { optional: true });
1487
+ permissionRequestInFlight = null;
1488
+ pendingQueue = [];
1489
+ tracked = new Map();
1490
+ constructor(platformId) {
1491
+ this.isBrowser = isPlatformBrowser(platformId);
1492
+ }
1493
+ get isSupported() {
1494
+ return this.isBrowser && typeof Notification !== 'undefined';
1495
+ }
1496
+ get permission() {
1497
+ if (!this.isSupported)
1498
+ return 'denied';
1499
+ return Notification.permission;
1500
+ }
1501
+ get canShow() {
1502
+ return this.isSupported && this.permission === 'granted';
1503
+ }
1504
+ queuePending(payload) {
1505
+ this.pendingQueue.push(payload);
1506
+ if (this.pendingQueue.length > 8) {
1507
+ this.pendingQueue.shift();
1508
+ }
1509
+ }
1510
+ /** Call from a user gesture (click) so the browser allows requestPermission. */
1511
+ beginPermissionRequest() {
1512
+ if (!this.isSupported || Notification.permission !== 'default')
1513
+ return;
1514
+ if (!this.permissionRequestInFlight) {
1515
+ this.permissionRequestInFlight = Notification.requestPermission().catch(() => 'denied');
1516
+ }
1517
+ }
1518
+ async completePermissionRequest() {
1519
+ if (!this.isSupported)
1520
+ return false;
1521
+ if (Notification.permission === 'granted')
1522
+ return true;
1523
+ if (Notification.permission === 'denied')
1524
+ return false;
1525
+ if (!this.permissionRequestInFlight)
1526
+ return false;
1527
+ const perm = await this.permissionRequestInFlight;
1528
+ return perm === 'granted';
1529
+ }
1530
+ /**
1531
+ * Optional UX helper: confirm via AlertService then request permission on Accept click.
1532
+ * Returns whether permission was granted.
1533
+ */
1534
+ async requestPermissionWithPrompt(promptMessage) {
1535
+ if (!this.isSupported)
1536
+ return false;
1537
+ if (this.permission === 'granted')
1538
+ return true;
1539
+ if (this.permission === 'denied')
1540
+ return false;
1541
+ if (!this.alert) {
1542
+ this.beginPermissionRequest();
1543
+ return this.completePermissionRequest();
1544
+ }
1545
+ const accepted = await this.alert.showConfirm(promptMessage ?? this.config.browserNotificationPermissionPrompt, { onAcceptClick: () => this.beginPermissionRequest() });
1546
+ if (!accepted)
1547
+ return false;
1548
+ return this.completePermissionRequest();
1549
+ }
1550
+ drainPendingQueue() {
1551
+ const batch = [...this.pendingQueue];
1552
+ this.pendingQueue.length = 0;
1553
+ let shown = 0;
1554
+ for (const payload of batch) {
1555
+ if (this.show(payload))
1556
+ shown += 1;
1557
+ }
1558
+ return shown;
1559
+ }
1560
+ showOrQueue(payload) {
1561
+ if (!this.isSupported)
1562
+ return false;
1563
+ if (this.canShow)
1564
+ return this.show(payload);
1565
+ if (Notification.permission === 'denied')
1566
+ return false;
1567
+ this.queuePending(payload);
1568
+ return false;
1569
+ }
1570
+ show(payload) {
1571
+ if (!this.canShow)
1572
+ return false;
1573
+ try {
1574
+ const icon = this.resolveIconUrl();
1575
+ const notification = new Notification(payload.title, {
1576
+ body: payload.body,
1577
+ tag: payload.tag,
1578
+ icon,
1579
+ badge: icon,
1580
+ requireInteraction: false,
1581
+ silent: false,
1582
+ data: payload.data,
1583
+ });
1584
+ this.tracked.set(payload.tag, notification);
1585
+ notification.onclose = () => this.tracked.delete(payload.tag);
1586
+ notification.onclick = (ev) => {
1587
+ ev.preventDefault();
1588
+ try {
1589
+ window.focus();
1590
+ }
1591
+ catch {
1592
+ /* ignore */
1593
+ }
1594
+ payload.onClick?.();
1595
+ notification.close();
1596
+ };
1597
+ return true;
1598
+ }
1599
+ catch {
1600
+ return false;
1601
+ }
1602
+ }
1603
+ closeByTag(tag) {
1604
+ const n = this.tracked.get(tag);
1605
+ if (n) {
1606
+ try {
1607
+ n.close();
1608
+ }
1609
+ catch {
1610
+ /* ignore */
1611
+ }
1612
+ this.tracked.delete(tag);
1613
+ }
1614
+ }
1615
+ playSound() {
1616
+ if (!this.isBrowser || !this.canShow)
1617
+ return;
1618
+ try {
1619
+ const audio = new Audio(this.config.browserNotificationSoundUrl);
1620
+ audio.volume = 0.45;
1621
+ void audio.play().catch(() => { });
1622
+ }
1623
+ catch {
1624
+ /* ignore */
1625
+ }
1626
+ }
1627
+ resolveIconUrl() {
1628
+ if (!this.isBrowser)
1629
+ return undefined;
1630
+ try {
1631
+ return new URL(this.config.browserNotificationIconUrl, window.location.origin).href;
1632
+ }
1633
+ catch {
1634
+ return this.config.browserNotificationIconUrl;
1635
+ }
1636
+ }
1637
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: BrowserNotificationService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
1638
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: BrowserNotificationService, providedIn: 'root' });
1639
+ }
1640
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.11", ngImport: i0, type: BrowserNotificationService, decorators: [{
1641
+ type: Injectable,
1642
+ args: [{ providedIn: 'root' }]
1643
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
1644
+ type: Inject,
1645
+ args: [PLATFORM_ID]
1646
+ }] }] });
1647
+
1648
+ /*
1649
+ * Public API Surface of @xosue-utils/services
1650
+ */
1651
+
1652
+ /**
1653
+ * Generated bundle index. Do not edit.
1654
+ */
1655
+
1656
+ export { AiAutofillOverlayService, AiAutofillOverlayServiceComponent, AlertService, AlertServiceComponent, AudioPlayerComponent, BrowserNotificationService, ContextMenuService, ContextMenuServiceComponent, FieldHelpTooltipComponent, GlobalLoadingService, GlobalLoadingServiceComponent, ImageViewerComponent, LucideIconComponent, MediaViewerService, MediaViewerServiceComponent, NavigationLoadingService, NavigationLoadingServiceComponent, SessionRequestService, SessionRequestServiceComponent, ToastService, ToastServiceComponent, VideoPlayerComponent, XOSUE_UTILS_SERVICES_CONFIG, XOSUE_UTILS_SERVICES_DEFAULTS, provideXosueUtilsServices, resolveXosueUtilsServicesConfig };
1657
+ //# sourceMappingURL=xosue-utils-services.mjs.map