@mk-kit/ui 0.55.1 → 0.57.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.
- package/bin/mk-translate.mjs +223 -0
- package/fesm2022/mk-kit-ui-attention.mjs +552 -0
- package/fesm2022/mk-kit-ui-attention.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-core.mjs +16 -0
- package/fesm2022/mk-kit-ui-core.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-locales-de.mjs +16 -0
- package/fesm2022/mk-kit-ui-locales-de.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-locales-es.mjs +16 -0
- package/fesm2022/mk-kit-ui-locales-es.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-locales-fr.mjs +16 -0
- package/fesm2022/mk-kit-ui-locales-fr.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-locales-pl.mjs +16 -0
- package/fesm2022/mk-kit-ui-locales-pl.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-locales-uk.mjs +16 -0
- package/fesm2022/mk-kit-ui-locales-uk.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-media-scanner.mjs +189 -0
- package/fesm2022/mk-kit-ui-media-scanner.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-translate-editor.mjs +157 -0
- package/fesm2022/mk-kit-ui-translate-editor.mjs.map +1 -0
- package/fesm2022/mk-kit-ui.mjs +1 -0
- package/fesm2022/mk-kit-ui.mjs.map +1 -1
- package/package.json +23 -2
- package/types/mk-kit-ui-attention.d.ts +223 -0
- package/types/mk-kit-ui-core.d.ts +19 -0
- package/types/mk-kit-ui-media-scanner.d.ts +74 -0
- package/types/mk-kit-ui-translate-editor.d.ts +76 -0
- package/types/mk-kit-ui.d.ts +1 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, NgZone, signal, Injectable, computed, ChangeDetectionStrategy, Component, Injector, PLATFORM_ID, DestroyRef, effect, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
import { MK_I18N, MK_OVERLAY_DATA, MkOverlayRef } from '@mk-kit/ui/core';
|
|
5
|
+
import { MkButton } from '@mk-kit/ui/button';
|
|
6
|
+
import { MkIcon } from '@mk-kit/ui/icon';
|
|
7
|
+
import { MkDialog, MkDialogService } from '@mk-kit/ui/feedback';
|
|
8
|
+
|
|
9
|
+
const MK_TAB_ATTENTION_CONFIG = new InjectionToken('MK_TAB_ATTENTION_CONFIG');
|
|
10
|
+
/** Register options for {@link MkTabAttention}. Optional — the defaults work. */
|
|
11
|
+
function provideMkTabAttention(config) {
|
|
12
|
+
return { provide: MK_TAB_ATTENTION_CONFIG, useValue: config };
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Messenger-style tab attention: while unhandled work exists the favicon
|
|
16
|
+
* carries a red counter badge, and — only while the tab is hidden — the
|
|
17
|
+
* title alternates with "(N) label" so a pinned tab flashes in the tab strip.
|
|
18
|
+
* Focusing the tab stops the blinking (someone is looking) but keeps the
|
|
19
|
+
* badge until the count reaches zero. SSR-safe: every entry point bails
|
|
20
|
+
* without a `document`; the blink timer runs outside the Angular zone.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* private attention = inject(MkTabAttention);
|
|
24
|
+
* effect(() => this.attention.set(this.pending().length, 'new orders'));
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
class MkTabAttention {
|
|
28
|
+
zone = inject(NgZone);
|
|
29
|
+
config = inject(MK_TAB_ATTENTION_CONFIG, { optional: true }) ?? {};
|
|
30
|
+
label = '';
|
|
31
|
+
originalTitle = '';
|
|
32
|
+
originalFavicon = null;
|
|
33
|
+
blinkTimer = null;
|
|
34
|
+
showingAttention = false;
|
|
35
|
+
listening = false;
|
|
36
|
+
visibilityHandler = () => this.sync();
|
|
37
|
+
/** The count currently shown (0 = nothing pending). */
|
|
38
|
+
count = signal(0, /* @ts-ignore */
|
|
39
|
+
...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
|
|
40
|
+
/** Update the pending count and the label used in the blinking title. */
|
|
41
|
+
set(count, label = '') {
|
|
42
|
+
if (typeof document === 'undefined')
|
|
43
|
+
return;
|
|
44
|
+
this.count.set(Math.max(0, Math.floor(count)));
|
|
45
|
+
this.label = label;
|
|
46
|
+
if (!this.listening) {
|
|
47
|
+
this.listening = true;
|
|
48
|
+
document.addEventListener('visibilitychange', this.visibilityHandler);
|
|
49
|
+
}
|
|
50
|
+
this.sync();
|
|
51
|
+
}
|
|
52
|
+
/** Drop the badge and the blinking entirely and stop listening. */
|
|
53
|
+
clear() {
|
|
54
|
+
if (typeof document === 'undefined')
|
|
55
|
+
return;
|
|
56
|
+
this.count.set(0);
|
|
57
|
+
this.stopBlink();
|
|
58
|
+
this.restoreTitle();
|
|
59
|
+
this.restoreFavicon();
|
|
60
|
+
if (this.listening) {
|
|
61
|
+
this.listening = false;
|
|
62
|
+
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
sync() {
|
|
66
|
+
if (this.count() > 0) {
|
|
67
|
+
this.setFavicon(this.badgeFavicon(this.count()));
|
|
68
|
+
if (document.hidden)
|
|
69
|
+
this.startBlink();
|
|
70
|
+
else {
|
|
71
|
+
this.stopBlink();
|
|
72
|
+
this.restoreTitle();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
this.stopBlink();
|
|
77
|
+
this.restoreTitle();
|
|
78
|
+
this.restoreFavicon();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
startBlink() {
|
|
82
|
+
if (this.blinkTimer)
|
|
83
|
+
return;
|
|
84
|
+
if (!this.originalTitle)
|
|
85
|
+
this.originalTitle = document.title;
|
|
86
|
+
this.showingAttention = false;
|
|
87
|
+
this.toggleTitle();
|
|
88
|
+
this.zone.runOutsideAngular(() => {
|
|
89
|
+
this.blinkTimer = setInterval(() => this.toggleTitle(), this.config.blinkMs ?? 1200);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
stopBlink() {
|
|
93
|
+
if (this.blinkTimer) {
|
|
94
|
+
clearInterval(this.blinkTimer);
|
|
95
|
+
this.blinkTimer = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
toggleTitle() {
|
|
99
|
+
this.showingAttention = !this.showingAttention;
|
|
100
|
+
document.title = this.showingAttention
|
|
101
|
+
? `(${this.count()}) ${this.label}`.trimEnd()
|
|
102
|
+
: this.originalTitle;
|
|
103
|
+
}
|
|
104
|
+
restoreTitle() {
|
|
105
|
+
if (this.originalTitle)
|
|
106
|
+
document.title = this.originalTitle;
|
|
107
|
+
this.showingAttention = false;
|
|
108
|
+
}
|
|
109
|
+
faviconLink() {
|
|
110
|
+
return document.querySelector("link[rel~='icon']");
|
|
111
|
+
}
|
|
112
|
+
setFavicon(href) {
|
|
113
|
+
const link = this.faviconLink();
|
|
114
|
+
if (!link)
|
|
115
|
+
return;
|
|
116
|
+
if (this.originalFavicon === null)
|
|
117
|
+
this.originalFavicon = link.href;
|
|
118
|
+
link.href = href;
|
|
119
|
+
}
|
|
120
|
+
restoreFavicon() {
|
|
121
|
+
const link = this.faviconLink();
|
|
122
|
+
if (link && this.originalFavicon !== null) {
|
|
123
|
+
link.href = this.originalFavicon;
|
|
124
|
+
this.originalFavicon = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Coloured circle + white count, as an inline SVG data URI. */
|
|
128
|
+
badgeFavicon(count) {
|
|
129
|
+
const text = count > 9 ? '9+' : String(count);
|
|
130
|
+
const fill = this.config.badgeColor ?? '#e53935';
|
|
131
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
|
|
132
|
+
`<circle cx="16" cy="16" r="16" fill="${fill}"/>` +
|
|
133
|
+
`<text x="16" y="22" font-family="Arial, sans-serif" font-size="17" font-weight="bold" fill="#fff" text-anchor="middle">${text}</text>` +
|
|
134
|
+
`</svg>`;
|
|
135
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
136
|
+
}
|
|
137
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTabAttention, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
138
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTabAttention, providedIn: 'root' });
|
|
139
|
+
}
|
|
140
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkTabAttention, decorators: [{
|
|
141
|
+
type: Injectable,
|
|
142
|
+
args: [{ providedIn: 'root' }]
|
|
143
|
+
}] });
|
|
144
|
+
|
|
145
|
+
const MK_NOTIFICATION_SOUND_CONFIG = new InjectionToken('MK_NOTIFICATION_SOUND_CONFIG');
|
|
146
|
+
/** Register presets / storage key for {@link MkNotificationSound}. Optional. */
|
|
147
|
+
function provideMkNotificationSound(config) {
|
|
148
|
+
return { provide: MK_NOTIFICATION_SOUND_CONFIG, useValue: config };
|
|
149
|
+
}
|
|
150
|
+
/** The always-available synthesised sound. */
|
|
151
|
+
const MK_CHIME_PRESET = { id: 'chime', label: 'Chime', url: null };
|
|
152
|
+
/**
|
|
153
|
+
* Alert sounds for incoming work (orders, messages, tickets) with the
|
|
154
|
+
* browser's autoplay rules handled: an `AudioContext` stays suspended until a
|
|
155
|
+
* user gesture, so a sound fired by a WebSocket message would be silent. The
|
|
156
|
+
* context is unlocked when the user enables sound (a click) and lazily on the
|
|
157
|
+
* first interaction anywhere in the app. The default sound is synthesised
|
|
158
|
+
* (a short C–E–G chime) — nothing to ship, no CORS, lowest latency; file
|
|
159
|
+
* presets are fetched and decoded once and fall back to the chime when they
|
|
160
|
+
* fail. The on/off preference lives in localStorage under a configurable key.
|
|
161
|
+
*
|
|
162
|
+
* ```ts
|
|
163
|
+
* provideMkNotificationSound({ presets: [MK_CHIME_PRESET, { id: 'ding', label: 'Ding', url: '/assets/ding.wav' }] })
|
|
164
|
+
* sound.primeOnFirstInteraction(); // at app start
|
|
165
|
+
* sound.play(settings.newOrderSound); // on an event, honours the device mute
|
|
166
|
+
* sound.preview('ding'); // settings page test button
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
class MkNotificationSound {
|
|
170
|
+
config = inject(MK_NOTIFICATION_SOUND_CONFIG, { optional: true }) ?? {};
|
|
171
|
+
ctx = null;
|
|
172
|
+
gesturePrimed = false;
|
|
173
|
+
/** Decoded file presets by url; null = fetch/decode failed. */
|
|
174
|
+
buffers = new Map();
|
|
175
|
+
/** The selectable presets (always includes the chime). */
|
|
176
|
+
get presets() {
|
|
177
|
+
const list = this.config.presets ?? [];
|
|
178
|
+
return list.some((p) => p.id === MK_CHIME_PRESET.id) ? list : [MK_CHIME_PRESET, ...list];
|
|
179
|
+
}
|
|
180
|
+
storageKey() {
|
|
181
|
+
return this.config.storageKey?.() ?? 'mk-notification-sound';
|
|
182
|
+
}
|
|
183
|
+
/** Whether the device has sound on. */
|
|
184
|
+
isEnabled() {
|
|
185
|
+
return this.read() === 'true';
|
|
186
|
+
}
|
|
187
|
+
/** Whether the user ever answered the "enable sound?" question on this device. */
|
|
188
|
+
hasBeenAsked() {
|
|
189
|
+
return this.read() !== null;
|
|
190
|
+
}
|
|
191
|
+
/** Persist the preference; call from a click so audio unlocks at once. */
|
|
192
|
+
setEnabled(enabled) {
|
|
193
|
+
try {
|
|
194
|
+
localStorage.setItem(this.storageKey(), String(enabled));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
/* storage unavailable — non-fatal */
|
|
198
|
+
}
|
|
199
|
+
if (enabled)
|
|
200
|
+
this.unlock();
|
|
201
|
+
}
|
|
202
|
+
/** Arm a one-time listener so the first pointer/key interaction unlocks audio. */
|
|
203
|
+
primeOnFirstInteraction() {
|
|
204
|
+
if (this.gesturePrimed || typeof document === 'undefined')
|
|
205
|
+
return;
|
|
206
|
+
this.gesturePrimed = true;
|
|
207
|
+
const handler = () => {
|
|
208
|
+
this.unlock();
|
|
209
|
+
document.removeEventListener('pointerdown', handler);
|
|
210
|
+
document.removeEventListener('keydown', handler);
|
|
211
|
+
};
|
|
212
|
+
document.addEventListener('pointerdown', handler);
|
|
213
|
+
document.addEventListener('keydown', handler);
|
|
214
|
+
}
|
|
215
|
+
/** The default chime, if the device has sound on. */
|
|
216
|
+
chime() {
|
|
217
|
+
this.play(MK_CHIME_PRESET.id);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Play the sound configured for an event: a preset id, `custom` (with
|
|
221
|
+
* `customUrl`) or `none` (silent). Unknown ids and failed loads fall back
|
|
222
|
+
* to the chime. Honours the device mute.
|
|
223
|
+
*/
|
|
224
|
+
play(soundId, customUrl) {
|
|
225
|
+
if (!this.isEnabled())
|
|
226
|
+
return;
|
|
227
|
+
this.playById(soundId, customUrl);
|
|
228
|
+
}
|
|
229
|
+
/** Same as `play()` but ignores the device mute — for settings test buttons. */
|
|
230
|
+
preview(soundId, customUrl) {
|
|
231
|
+
this.playById(soundId, customUrl);
|
|
232
|
+
}
|
|
233
|
+
playById(soundId, customUrl) {
|
|
234
|
+
if (soundId === 'none')
|
|
235
|
+
return;
|
|
236
|
+
const url = soundId === 'custom' ? customUrl || null : (this.presets.find((p) => p.id === soundId)?.url ?? null);
|
|
237
|
+
const ctx = this.ensureCtx();
|
|
238
|
+
if (!ctx)
|
|
239
|
+
return;
|
|
240
|
+
const play = () => (url ? void this.playUrl(ctx, url) : this.playChime(ctx));
|
|
241
|
+
if (ctx.state === 'suspended')
|
|
242
|
+
ctx.resume().then(play).catch(() => undefined);
|
|
243
|
+
else
|
|
244
|
+
play();
|
|
245
|
+
}
|
|
246
|
+
async playUrl(ctx, url) {
|
|
247
|
+
let buffer = this.buffers.get(url);
|
|
248
|
+
if (buffer === undefined) {
|
|
249
|
+
try {
|
|
250
|
+
const res = await fetch(url);
|
|
251
|
+
buffer = await ctx.decodeAudioData(await res.arrayBuffer());
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
buffer = null;
|
|
255
|
+
}
|
|
256
|
+
this.buffers.set(url, buffer);
|
|
257
|
+
}
|
|
258
|
+
if (!buffer) {
|
|
259
|
+
// A missing asset must not mean a missed event — chime instead.
|
|
260
|
+
this.playChime(ctx);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const source = ctx.createBufferSource();
|
|
265
|
+
source.buffer = buffer;
|
|
266
|
+
const gain = ctx.createGain();
|
|
267
|
+
gain.gain.value = this.config.volume ?? 0.8;
|
|
268
|
+
gain.connect(ctx.destination);
|
|
269
|
+
source.connect(gain);
|
|
270
|
+
source.start();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
/* audio is best-effort */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Ascending C5–E5–G5 chime (~0.75 s) with a bell-like timbre. */
|
|
277
|
+
playChime(ctx) {
|
|
278
|
+
const now = ctx.currentTime;
|
|
279
|
+
const master = ctx.createGain();
|
|
280
|
+
master.gain.value = 0.6;
|
|
281
|
+
master.connect(ctx.destination);
|
|
282
|
+
for (const note of [
|
|
283
|
+
{ freq: 523.25, at: 0 },
|
|
284
|
+
{ freq: 659.25, at: 0.13 },
|
|
285
|
+
{ freq: 783.99, at: 0.26 },
|
|
286
|
+
]) {
|
|
287
|
+
this.scheduleNote(ctx, master, note.freq, now + note.at, 0.5);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
scheduleNote(ctx, destination, freq, start, duration) {
|
|
291
|
+
try {
|
|
292
|
+
const env = ctx.createGain();
|
|
293
|
+
env.connect(destination);
|
|
294
|
+
env.gain.setValueAtTime(0.0001, start);
|
|
295
|
+
env.gain.exponentialRampToValueAtTime(0.25, start + 0.015);
|
|
296
|
+
env.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
|
297
|
+
const fundamental = ctx.createOscillator();
|
|
298
|
+
fundamental.type = 'sine';
|
|
299
|
+
fundamental.frequency.value = freq;
|
|
300
|
+
fundamental.connect(env);
|
|
301
|
+
fundamental.start(start);
|
|
302
|
+
fundamental.stop(start + duration);
|
|
303
|
+
const harmonicGain = ctx.createGain();
|
|
304
|
+
harmonicGain.gain.value = 0.35;
|
|
305
|
+
harmonicGain.connect(env);
|
|
306
|
+
const harmonic = ctx.createOscillator();
|
|
307
|
+
harmonic.type = 'sine';
|
|
308
|
+
harmonic.frequency.value = freq * 2;
|
|
309
|
+
harmonic.connect(harmonicGain);
|
|
310
|
+
harmonic.start(start);
|
|
311
|
+
harmonic.stop(start + duration);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
/* audio is best-effort */
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
unlock() {
|
|
318
|
+
const ctx = this.ensureCtx();
|
|
319
|
+
if (ctx && ctx.state === 'suspended')
|
|
320
|
+
void ctx.resume();
|
|
321
|
+
}
|
|
322
|
+
ensureCtx() {
|
|
323
|
+
if (typeof window === 'undefined')
|
|
324
|
+
return null;
|
|
325
|
+
if (!this.ctx) {
|
|
326
|
+
const w = window;
|
|
327
|
+
const AC = w.AudioContext || w.webkitAudioContext;
|
|
328
|
+
if (!AC)
|
|
329
|
+
return null;
|
|
330
|
+
this.ctx = new AC();
|
|
331
|
+
}
|
|
332
|
+
return this.ctx;
|
|
333
|
+
}
|
|
334
|
+
read() {
|
|
335
|
+
try {
|
|
336
|
+
return localStorage.getItem(this.storageKey());
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkNotificationSound, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
343
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkNotificationSound, providedIn: 'root' });
|
|
344
|
+
}
|
|
345
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkNotificationSound, decorators: [{
|
|
346
|
+
type: Injectable,
|
|
347
|
+
args: [{ providedIn: 'root' }]
|
|
348
|
+
}] });
|
|
349
|
+
|
|
350
|
+
const MK_SESSION_EXPIRY_CONFIG = new InjectionToken('MK_SESSION_EXPIRY_CONFIG');
|
|
351
|
+
/**
|
|
352
|
+
* Last call before a session ends: counts down and offers to extend.
|
|
353
|
+
* Reaching zero ends the session, so doing nothing still produces a definite,
|
|
354
|
+
* visible outcome. Opened by {@link MkSessionExpiry}; usable on its own.
|
|
355
|
+
*/
|
|
356
|
+
class MkSessionExpiryDialog {
|
|
357
|
+
i18n = inject(MK_I18N);
|
|
358
|
+
data = inject(MK_OVERLAY_DATA);
|
|
359
|
+
ref = inject(MkOverlayRef);
|
|
360
|
+
zone = inject(NgZone);
|
|
361
|
+
extending = signal(false, /* @ts-ignore */
|
|
362
|
+
...(ngDevMode ? [{ debugName: "extending" }] : /* istanbul ignore next */ []));
|
|
363
|
+
remainingMs = signal(this.data.expiresAt - Date.now(), /* @ts-ignore */
|
|
364
|
+
...(ngDevMode ? [{ debugName: "remainingMs" }] : /* istanbul ignore next */ []));
|
|
365
|
+
/** "1:04" — floored at zero. */
|
|
366
|
+
countdown = computed(() => {
|
|
367
|
+
const total = Math.max(0, Math.ceil(this.remainingMs() / 1000));
|
|
368
|
+
const m = Math.floor(total / 60);
|
|
369
|
+
return `${m}:${String(total - m * 60).padStart(2, '0')}`;
|
|
370
|
+
}, /* @ts-ignore */
|
|
371
|
+
...(ngDevMode ? [{ debugName: "countdown" }] : /* istanbul ignore next */ []));
|
|
372
|
+
ticker = this.zone.runOutsideAngular(() => setInterval(() => {
|
|
373
|
+
const left = this.data.expiresAt - Date.now();
|
|
374
|
+
this.zone.run(() => {
|
|
375
|
+
this.remainingMs.set(left);
|
|
376
|
+
if (left <= 0)
|
|
377
|
+
this.expire();
|
|
378
|
+
});
|
|
379
|
+
}, 1000));
|
|
380
|
+
ngOnDestroy() {
|
|
381
|
+
clearInterval(this.ticker);
|
|
382
|
+
}
|
|
383
|
+
extend() {
|
|
384
|
+
if (this.extending())
|
|
385
|
+
return;
|
|
386
|
+
this.extending.set(true);
|
|
387
|
+
this.data.extend().then(() => this.ref.close('extended'), () => this.expire());
|
|
388
|
+
}
|
|
389
|
+
signOut() {
|
|
390
|
+
this.expire();
|
|
391
|
+
}
|
|
392
|
+
expire() {
|
|
393
|
+
clearInterval(this.ticker);
|
|
394
|
+
this.ref.close('expired');
|
|
395
|
+
this.data.onExpire();
|
|
396
|
+
}
|
|
397
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSessionExpiryDialog, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
398
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "22.0.7", type: MkSessionExpiryDialog, isStandalone: true, selector: "mk-session-expiry-dialog", ngImport: i0, template: `
|
|
399
|
+
<mk-dialog [dialogTitle]="i18n.sessionExpiryTitle" hideClose>
|
|
400
|
+
<div class="mk-session-expiry__body">
|
|
401
|
+
<mk-icon class="mk-session-expiry__icon" name="schedule" [size]="28" />
|
|
402
|
+
<p class="mk-session-expiry__text">{{ i18n.sessionExpiryBody(countdown()) }}</p>
|
|
403
|
+
</div>
|
|
404
|
+
<div mkDialogFooter>
|
|
405
|
+
<button mkButton variant="ghost" tone="neutral" type="button" (click)="signOut()">
|
|
406
|
+
{{ i18n.sessionExpiryLogout }}
|
|
407
|
+
</button>
|
|
408
|
+
<button mkButton tone="primary" type="button" [disabled]="extending()" (click)="extend()">
|
|
409
|
+
{{ extending() ? i18n.sessionExpiryExtending : i18n.sessionExpiryExtend }}
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
</mk-dialog>
|
|
413
|
+
`, isInline: true, styles: [".mk-session-expiry__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-session-expiry__icon{flex:none;color:var(--mk-warning)}.mk-session-expiry__text{margin:0;color:var(--mk-text-muted);font-variant-numeric:tabular-nums}\n"], dependencies: [{ kind: "component", type: MkDialog, selector: "mk-dialog", inputs: ["dialogTitle", "titleId", "hideClose", "draggable", "resizable"] }, { kind: "component", type: MkButton, selector: "button[mkButton], a[mkButton]", inputs: ["variant", "tone", "size", "loading", "fullWidth", "iconOnly", "disabled"] }, { kind: "component", type: MkIcon, selector: "mk-icon", inputs: ["name", "size", "label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
414
|
+
}
|
|
415
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSessionExpiryDialog, decorators: [{
|
|
416
|
+
type: Component,
|
|
417
|
+
args: [{ selector: 'mk-session-expiry-dialog', imports: [MkDialog, MkButton, MkIcon], template: `
|
|
418
|
+
<mk-dialog [dialogTitle]="i18n.sessionExpiryTitle" hideClose>
|
|
419
|
+
<div class="mk-session-expiry__body">
|
|
420
|
+
<mk-icon class="mk-session-expiry__icon" name="schedule" [size]="28" />
|
|
421
|
+
<p class="mk-session-expiry__text">{{ i18n.sessionExpiryBody(countdown()) }}</p>
|
|
422
|
+
</div>
|
|
423
|
+
<div mkDialogFooter>
|
|
424
|
+
<button mkButton variant="ghost" tone="neutral" type="button" (click)="signOut()">
|
|
425
|
+
{{ i18n.sessionExpiryLogout }}
|
|
426
|
+
</button>
|
|
427
|
+
<button mkButton tone="primary" type="button" [disabled]="extending()" (click)="extend()">
|
|
428
|
+
{{ extending() ? i18n.sessionExpiryExtending : i18n.sessionExpiryExtend }}
|
|
429
|
+
</button>
|
|
430
|
+
</div>
|
|
431
|
+
</mk-dialog>
|
|
432
|
+
`, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".mk-session-expiry__body{display:flex;align-items:flex-start;gap:var(--mk-space-3)}.mk-session-expiry__icon{flex:none;color:var(--mk-warning)}.mk-session-expiry__text{margin:0;color:var(--mk-text-muted);font-variant-numeric:tabular-nums}\n"] }]
|
|
433
|
+
}] });
|
|
434
|
+
/**
|
|
435
|
+
* Watches `expiresAt()` and warns BEFORE the session lapses, so a session
|
|
436
|
+
* never ends silently: the dialog offers to extend, or signs out at zero.
|
|
437
|
+
* Re-arms itself whenever `expiresAt()` changes (every token rotation),
|
|
438
|
+
* runs the timer outside the Angular zone and only in the browser. Started
|
|
439
|
+
* automatically by {@link provideMkSessionExpiry}.
|
|
440
|
+
*/
|
|
441
|
+
class MkSessionExpiry {
|
|
442
|
+
config = inject(MK_SESSION_EXPIRY_CONFIG, { optional: true });
|
|
443
|
+
dialog = inject(MkDialogService);
|
|
444
|
+
zone = inject(NgZone);
|
|
445
|
+
injector = inject(Injector);
|
|
446
|
+
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
447
|
+
timer = null;
|
|
448
|
+
started = false;
|
|
449
|
+
/** Whether the dialog is currently open. */
|
|
450
|
+
open = signal(false, /* @ts-ignore */
|
|
451
|
+
...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
|
|
452
|
+
constructor() {
|
|
453
|
+
inject(DestroyRef).onDestroy(() => this.clear());
|
|
454
|
+
}
|
|
455
|
+
/** Begin watching. Idempotent; called by the provider's initializer. */
|
|
456
|
+
start() {
|
|
457
|
+
if (this.started || !this.config)
|
|
458
|
+
return;
|
|
459
|
+
this.started = true;
|
|
460
|
+
effect(() => {
|
|
461
|
+
this.config.expiresAt();
|
|
462
|
+
this.config.enabled?.();
|
|
463
|
+
this.schedule();
|
|
464
|
+
}, { injector: this.injector });
|
|
465
|
+
}
|
|
466
|
+
clear() {
|
|
467
|
+
if (this.timer !== null)
|
|
468
|
+
clearTimeout(this.timer);
|
|
469
|
+
this.timer = null;
|
|
470
|
+
}
|
|
471
|
+
schedule() {
|
|
472
|
+
this.clear();
|
|
473
|
+
if (!this.isBrowser || !this.config)
|
|
474
|
+
return;
|
|
475
|
+
if (this.config.enabled && !this.config.enabled())
|
|
476
|
+
return;
|
|
477
|
+
const expiresAt = this.config.expiresAt();
|
|
478
|
+
if (expiresAt === null)
|
|
479
|
+
return;
|
|
480
|
+
const delay = expiresAt - Date.now() - (this.config.warnBeforeMs ?? 120_000);
|
|
481
|
+
if (delay <= 0) {
|
|
482
|
+
this.warn();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.zone.runOutsideAngular(() => {
|
|
486
|
+
this.timer = setTimeout(() => this.zone.run(() => this.warn()), delay);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
warn() {
|
|
490
|
+
if (this.open() || !this.config)
|
|
491
|
+
return;
|
|
492
|
+
const expiresAt = this.config.expiresAt();
|
|
493
|
+
if (expiresAt === null)
|
|
494
|
+
return;
|
|
495
|
+
// Already lapsed while the tab was suspended: end it cleanly rather than
|
|
496
|
+
// showing a countdown that starts at zero.
|
|
497
|
+
if (expiresAt <= Date.now()) {
|
|
498
|
+
this.config.onExpire();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.open.set(true);
|
|
502
|
+
const data = {
|
|
503
|
+
expiresAt,
|
|
504
|
+
extend: this.config.extend,
|
|
505
|
+
onExpire: this.config.onExpire,
|
|
506
|
+
};
|
|
507
|
+
this.dialog
|
|
508
|
+
.open(MkSessionExpiryDialog, {
|
|
509
|
+
size: 'sm',
|
|
510
|
+
// "Extend" and "sign out" are the only outcomes; a backdrop click
|
|
511
|
+
// would leave a dying session with nothing on screen saying so.
|
|
512
|
+
closeOnBackdropClick: false,
|
|
513
|
+
closeOnEscape: false,
|
|
514
|
+
data,
|
|
515
|
+
})
|
|
516
|
+
.closed$.subscribe(() => {
|
|
517
|
+
this.open.set(false);
|
|
518
|
+
this.schedule();
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSessionExpiry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
522
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSessionExpiry, providedIn: 'root' });
|
|
523
|
+
}
|
|
524
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: MkSessionExpiry, decorators: [{
|
|
525
|
+
type: Injectable,
|
|
526
|
+
args: [{ providedIn: 'root' }]
|
|
527
|
+
}], ctorParameters: () => [] });
|
|
528
|
+
/**
|
|
529
|
+
* Register the session-expiry watcher; it starts with the application.
|
|
530
|
+
*
|
|
531
|
+
* ```ts
|
|
532
|
+
* provideMkSessionExpiry({
|
|
533
|
+
* expiresAt: () => auth.tokenExpiresAt(),
|
|
534
|
+
* extend: () => firstValueFrom(auth.refresh()),
|
|
535
|
+
* onExpire: () => auth.logout(),
|
|
536
|
+
* warnBeforeMs: 2 * 60_000,
|
|
537
|
+
* })
|
|
538
|
+
* ```
|
|
539
|
+
*/
|
|
540
|
+
function provideMkSessionExpiry(config) {
|
|
541
|
+
return makeEnvironmentProviders([
|
|
542
|
+
{ provide: MK_SESSION_EXPIRY_CONFIG, useValue: config },
|
|
543
|
+
provideEnvironmentInitializer(() => inject(MkSessionExpiry).start()),
|
|
544
|
+
]);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Generated bundle index. Do not edit.
|
|
549
|
+
*/
|
|
550
|
+
|
|
551
|
+
export { MK_CHIME_PRESET, MK_NOTIFICATION_SOUND_CONFIG, MK_SESSION_EXPIRY_CONFIG, MK_TAB_ATTENTION_CONFIG, MkNotificationSound, MkSessionExpiry, MkSessionExpiryDialog, MkTabAttention, provideMkNotificationSound, provideMkSessionExpiry, provideMkTabAttention };
|
|
552
|
+
//# sourceMappingURL=mk-kit-ui-attention.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mk-kit-ui-attention.mjs","sources":["../../../projects/mk-kit/attention/tab-attention.ts","../../../projects/mk-kit/attention/notification-sound.ts","../../../projects/mk-kit/attention/session-expiry.ts","../../../projects/mk-kit/attention/mk-kit-ui-attention.ts"],"sourcesContent":["import { inject, Injectable, InjectionToken, NgZone, signal } from '@angular/core';\n\n/** Options for {@link MkTabAttention}, set with {@link provideMkTabAttention}. */\nexport interface MkTabAttentionConfig {\n /** Badge fill colour (any CSS colour). Default `#e53935`. */\n badgeColor?: string;\n /** Title blink period in ms while the tab is hidden. Default `1200`. */\n blinkMs?: number;\n}\n\nexport const MK_TAB_ATTENTION_CONFIG = new InjectionToken<MkTabAttentionConfig>('MK_TAB_ATTENTION_CONFIG');\n\n/** Register options for {@link MkTabAttention}. Optional — the defaults work. */\nexport function provideMkTabAttention(config: MkTabAttentionConfig) {\n return { provide: MK_TAB_ATTENTION_CONFIG, useValue: config };\n}\n\n/**\n * Messenger-style tab attention: while unhandled work exists the favicon\n * carries a red counter badge, and — only while the tab is hidden — the\n * title alternates with \"(N) label\" so a pinned tab flashes in the tab strip.\n * Focusing the tab stops the blinking (someone is looking) but keeps the\n * badge until the count reaches zero. SSR-safe: every entry point bails\n * without a `document`; the blink timer runs outside the Angular zone.\n *\n * ```ts\n * private attention = inject(MkTabAttention);\n * effect(() => this.attention.set(this.pending().length, 'new orders'));\n * ```\n */\n@Injectable({ providedIn: 'root' })\nexport class MkTabAttention {\n private readonly zone = inject(NgZone);\n private readonly config = inject(MK_TAB_ATTENTION_CONFIG, { optional: true }) ?? {};\n private label = '';\n private originalTitle = '';\n private originalFavicon: string | null = null;\n private blinkTimer: ReturnType<typeof setInterval> | null = null;\n private showingAttention = false;\n private listening = false;\n private readonly visibilityHandler = () => this.sync();\n\n /** The count currently shown (0 = nothing pending). */\n readonly count = signal(0);\n\n /** Update the pending count and the label used in the blinking title. */\n set(count: number, label = ''): void {\n if (typeof document === 'undefined') return;\n this.count.set(Math.max(0, Math.floor(count)));\n this.label = label;\n if (!this.listening) {\n this.listening = true;\n document.addEventListener('visibilitychange', this.visibilityHandler);\n }\n this.sync();\n }\n\n /** Drop the badge and the blinking entirely and stop listening. */\n clear(): void {\n if (typeof document === 'undefined') return;\n this.count.set(0);\n this.stopBlink();\n this.restoreTitle();\n this.restoreFavicon();\n if (this.listening) {\n this.listening = false;\n document.removeEventListener('visibilitychange', this.visibilityHandler);\n }\n }\n\n private sync(): void {\n if (this.count() > 0) {\n this.setFavicon(this.badgeFavicon(this.count()));\n if (document.hidden) this.startBlink();\n else {\n this.stopBlink();\n this.restoreTitle();\n }\n } else {\n this.stopBlink();\n this.restoreTitle();\n this.restoreFavicon();\n }\n }\n\n private startBlink(): void {\n if (this.blinkTimer) return;\n if (!this.originalTitle) this.originalTitle = document.title;\n this.showingAttention = false;\n this.toggleTitle();\n this.zone.runOutsideAngular(() => {\n this.blinkTimer = setInterval(() => this.toggleTitle(), this.config.blinkMs ?? 1200);\n });\n }\n\n private stopBlink(): void {\n if (this.blinkTimer) {\n clearInterval(this.blinkTimer);\n this.blinkTimer = null;\n }\n }\n\n private toggleTitle(): void {\n this.showingAttention = !this.showingAttention;\n document.title = this.showingAttention\n ? `(${this.count()}) ${this.label}`.trimEnd()\n : this.originalTitle;\n }\n\n private restoreTitle(): void {\n if (this.originalTitle) document.title = this.originalTitle;\n this.showingAttention = false;\n }\n\n private faviconLink(): HTMLLinkElement | null {\n return document.querySelector<HTMLLinkElement>(\"link[rel~='icon']\");\n }\n\n private setFavicon(href: string): void {\n const link = this.faviconLink();\n if (!link) return;\n if (this.originalFavicon === null) this.originalFavicon = link.href;\n link.href = href;\n }\n\n private restoreFavicon(): void {\n const link = this.faviconLink();\n if (link && this.originalFavicon !== null) {\n link.href = this.originalFavicon;\n this.originalFavicon = null;\n }\n }\n\n /** Coloured circle + white count, as an inline SVG data URI. */\n private badgeFavicon(count: number): string {\n const text = count > 9 ? '9+' : String(count);\n const fill = this.config.badgeColor ?? '#e53935';\n const svg =\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\">` +\n `<circle cx=\"16\" cy=\"16\" r=\"16\" fill=\"${fill}\"/>` +\n `<text x=\"16\" y=\"22\" font-family=\"Arial, sans-serif\" font-size=\"17\" font-weight=\"bold\" fill=\"#fff\" text-anchor=\"middle\">${text}</text>` +\n `</svg>`;\n return `data:image/svg+xml,${encodeURIComponent(svg)}`;\n }\n}\n","import { inject, Injectable, InjectionToken } from '@angular/core';\n\n/** One selectable alert sound. `url: null` = the synthesised chime. */\nexport interface MkSoundPreset {\n id: string;\n label: string;\n url: string | null;\n}\n\n/** Options for {@link MkNotificationSound}. */\nexport interface MkNotificationSoundConfig {\n /** Selectable sounds; the ids `custom` and `none` are reserved. Default: the chime only. */\n presets?: MkSoundPreset[];\n /** localStorage key for the on/off preference — a function so it can vary per tenant / user. */\n storageKey?: () => string;\n /** Output gain for file presets (0–1). Default `0.8`. */\n volume?: number;\n}\n\nexport const MK_NOTIFICATION_SOUND_CONFIG = new InjectionToken<MkNotificationSoundConfig>('MK_NOTIFICATION_SOUND_CONFIG');\n\n/** Register presets / storage key for {@link MkNotificationSound}. Optional. */\nexport function provideMkNotificationSound(config: MkNotificationSoundConfig) {\n return { provide: MK_NOTIFICATION_SOUND_CONFIG, useValue: config };\n}\n\n/** The always-available synthesised sound. */\nexport const MK_CHIME_PRESET: MkSoundPreset = { id: 'chime', label: 'Chime', url: null };\n\n/**\n * Alert sounds for incoming work (orders, messages, tickets) with the\n * browser's autoplay rules handled: an `AudioContext` stays suspended until a\n * user gesture, so a sound fired by a WebSocket message would be silent. The\n * context is unlocked when the user enables sound (a click) and lazily on the\n * first interaction anywhere in the app. The default sound is synthesised\n * (a short C–E–G chime) — nothing to ship, no CORS, lowest latency; file\n * presets are fetched and decoded once and fall back to the chime when they\n * fail. The on/off preference lives in localStorage under a configurable key.\n *\n * ```ts\n * provideMkNotificationSound({ presets: [MK_CHIME_PRESET, { id: 'ding', label: 'Ding', url: '/assets/ding.wav' }] })\n * sound.primeOnFirstInteraction(); // at app start\n * sound.play(settings.newOrderSound); // on an event, honours the device mute\n * sound.preview('ding'); // settings page test button\n * ```\n */\n@Injectable({ providedIn: 'root' })\nexport class MkNotificationSound {\n private readonly config = inject(MK_NOTIFICATION_SOUND_CONFIG, { optional: true }) ?? {};\n private ctx: AudioContext | null = null;\n private gesturePrimed = false;\n /** Decoded file presets by url; null = fetch/decode failed. */\n private readonly buffers = new Map<string, AudioBuffer | null>();\n\n /** The selectable presets (always includes the chime). */\n get presets(): MkSoundPreset[] {\n const list = this.config.presets ?? [];\n return list.some((p) => p.id === MK_CHIME_PRESET.id) ? list : [MK_CHIME_PRESET, ...list];\n }\n\n private storageKey(): string {\n return this.config.storageKey?.() ?? 'mk-notification-sound';\n }\n\n /** Whether the device has sound on. */\n isEnabled(): boolean {\n return this.read() === 'true';\n }\n\n /** Whether the user ever answered the \"enable sound?\" question on this device. */\n hasBeenAsked(): boolean {\n return this.read() !== null;\n }\n\n /** Persist the preference; call from a click so audio unlocks at once. */\n setEnabled(enabled: boolean): void {\n try {\n localStorage.setItem(this.storageKey(), String(enabled));\n } catch {\n /* storage unavailable — non-fatal */\n }\n if (enabled) this.unlock();\n }\n\n /** Arm a one-time listener so the first pointer/key interaction unlocks audio. */\n primeOnFirstInteraction(): void {\n if (this.gesturePrimed || typeof document === 'undefined') return;\n this.gesturePrimed = true;\n const handler = () => {\n this.unlock();\n document.removeEventListener('pointerdown', handler);\n document.removeEventListener('keydown', handler);\n };\n document.addEventListener('pointerdown', handler);\n document.addEventListener('keydown', handler);\n }\n\n /** The default chime, if the device has sound on. */\n chime(): void {\n this.play(MK_CHIME_PRESET.id);\n }\n\n /**\n * Play the sound configured for an event: a preset id, `custom` (with\n * `customUrl`) or `none` (silent). Unknown ids and failed loads fall back\n * to the chime. Honours the device mute.\n */\n play(soundId: string, customUrl?: string | null): void {\n if (!this.isEnabled()) return;\n this.playById(soundId, customUrl);\n }\n\n /** Same as `play()` but ignores the device mute — for settings test buttons. */\n preview(soundId: string, customUrl?: string | null): void {\n this.playById(soundId, customUrl);\n }\n\n private playById(soundId: string, customUrl?: string | null): void {\n if (soundId === 'none') return;\n const url =\n soundId === 'custom' ? customUrl || null : (this.presets.find((p) => p.id === soundId)?.url ?? null);\n const ctx = this.ensureCtx();\n if (!ctx) return;\n const play = () => (url ? void this.playUrl(ctx, url) : this.playChime(ctx));\n if (ctx.state === 'suspended') ctx.resume().then(play).catch(() => undefined);\n else play();\n }\n\n private async playUrl(ctx: AudioContext, url: string): Promise<void> {\n let buffer = this.buffers.get(url);\n if (buffer === undefined) {\n try {\n const res = await fetch(url);\n buffer = await ctx.decodeAudioData(await res.arrayBuffer());\n } catch {\n buffer = null;\n }\n this.buffers.set(url, buffer);\n }\n if (!buffer) {\n // A missing asset must not mean a missed event — chime instead.\n this.playChime(ctx);\n return;\n }\n try {\n const source = ctx.createBufferSource();\n source.buffer = buffer;\n const gain = ctx.createGain();\n gain.gain.value = this.config.volume ?? 0.8;\n gain.connect(ctx.destination);\n source.connect(gain);\n source.start();\n } catch {\n /* audio is best-effort */\n }\n }\n\n /** Ascending C5–E5–G5 chime (~0.75 s) with a bell-like timbre. */\n private playChime(ctx: AudioContext): void {\n const now = ctx.currentTime;\n const master = ctx.createGain();\n master.gain.value = 0.6;\n master.connect(ctx.destination);\n for (const note of [\n { freq: 523.25, at: 0 },\n { freq: 659.25, at: 0.13 },\n { freq: 783.99, at: 0.26 },\n ]) {\n this.scheduleNote(ctx, master, note.freq, now + note.at, 0.5);\n }\n }\n\n private scheduleNote(ctx: AudioContext, destination: AudioNode, freq: number, start: number, duration: number): void {\n try {\n const env = ctx.createGain();\n env.connect(destination);\n env.gain.setValueAtTime(0.0001, start);\n env.gain.exponentialRampToValueAtTime(0.25, start + 0.015);\n env.gain.exponentialRampToValueAtTime(0.0001, start + duration);\n const fundamental = ctx.createOscillator();\n fundamental.type = 'sine';\n fundamental.frequency.value = freq;\n fundamental.connect(env);\n fundamental.start(start);\n fundamental.stop(start + duration);\n const harmonicGain = ctx.createGain();\n harmonicGain.gain.value = 0.35;\n harmonicGain.connect(env);\n const harmonic = ctx.createOscillator();\n harmonic.type = 'sine';\n harmonic.frequency.value = freq * 2;\n harmonic.connect(harmonicGain);\n harmonic.start(start);\n harmonic.stop(start + duration);\n } catch {\n /* audio is best-effort */\n }\n }\n\n private unlock(): void {\n const ctx = this.ensureCtx();\n if (ctx && ctx.state === 'suspended') void ctx.resume();\n }\n\n private ensureCtx(): AudioContext | null {\n if (typeof window === 'undefined') return null;\n if (!this.ctx) {\n const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };\n const AC = w.AudioContext || w.webkitAudioContext;\n if (!AC) return null;\n this.ctx = new AC();\n }\n return this.ctx;\n }\n\n private read(): string | null {\n try {\n return localStorage.getItem(this.storageKey());\n } catch {\n return null;\n }\n }\n}\n","import {\n ChangeDetectionStrategy,\n Component,\n computed,\n DestroyRef,\n effect,\n EnvironmentProviders,\n inject,\n Injectable,\n InjectionToken,\n Injector,\n makeEnvironmentProviders,\n NgZone,\n OnDestroy,\n PLATFORM_ID,\n provideEnvironmentInitializer,\n signal,\n} from '@angular/core';\nimport { isPlatformBrowser } from '@angular/common';\nimport { MK_I18N, MK_OVERLAY_DATA, MkOverlayRef } from '@mk-kit/ui/core';\nimport { MkButton } from '@mk-kit/ui/button';\nimport { MkIcon } from '@mk-kit/ui/icon';\nimport { MkDialog, MkDialogService } from '@mk-kit/ui/feedback';\n\n/** Options for {@link provideMkSessionExpiry}. */\nexport interface MkSessionExpiryConfig {\n /** Epoch ms when the session lapses, or `null` when there is none. Read reactively. */\n expiresAt: () => number | null;\n /** How long before the lapse the dialog appears. Default 2 minutes. */\n warnBeforeMs?: number;\n /** Extend the session (refresh the token). Resolve = extended, reject = nothing to extend. */\n extend: () => Promise<unknown>;\n /** End the session (sign out, navigate). */\n onExpire: () => void;\n /** Read reactively; `false` suspends the watcher (a kiosk / PIN mode, say). */\n enabled?: () => boolean;\n}\n\nexport const MK_SESSION_EXPIRY_CONFIG = new InjectionToken<MkSessionExpiryConfig>('MK_SESSION_EXPIRY_CONFIG');\n\n/** Data handed to {@link MkSessionExpiryDialog}. */\nexport interface MkSessionExpiryDialogData {\n expiresAt: number;\n extend: () => Promise<unknown>;\n onExpire: () => void;\n}\n\n/**\n * Last call before a session ends: counts down and offers to extend.\n * Reaching zero ends the session, so doing nothing still produces a definite,\n * visible outcome. Opened by {@link MkSessionExpiry}; usable on its own.\n */\n@Component({\n selector: 'mk-session-expiry-dialog',\n imports: [MkDialog, MkButton, MkIcon],\n template: `\n <mk-dialog [dialogTitle]=\"i18n.sessionExpiryTitle\" hideClose>\n <div class=\"mk-session-expiry__body\">\n <mk-icon class=\"mk-session-expiry__icon\" name=\"schedule\" [size]=\"28\" />\n <p class=\"mk-session-expiry__text\">{{ i18n.sessionExpiryBody(countdown()) }}</p>\n </div>\n <div mkDialogFooter>\n <button mkButton variant=\"ghost\" tone=\"neutral\" type=\"button\" (click)=\"signOut()\">\n {{ i18n.sessionExpiryLogout }}\n </button>\n <button mkButton tone=\"primary\" type=\"button\" [disabled]=\"extending()\" (click)=\"extend()\">\n {{ extending() ? i18n.sessionExpiryExtending : i18n.sessionExpiryExtend }}\n </button>\n </div>\n </mk-dialog>\n `,\n styles: `\n .mk-session-expiry__body {\n display: flex;\n align-items: flex-start;\n gap: var(--mk-space-3);\n }\n .mk-session-expiry__icon {\n flex: none;\n color: var(--mk-warning);\n }\n .mk-session-expiry__text {\n margin: 0;\n color: var(--mk-text-muted);\n font-variant-numeric: tabular-nums;\n }\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class MkSessionExpiryDialog implements OnDestroy {\n protected readonly i18n = inject(MK_I18N);\n private readonly data = inject<MkSessionExpiryDialogData>(MK_OVERLAY_DATA);\n private readonly ref = inject(MkOverlayRef);\n private readonly zone = inject(NgZone);\n\n protected readonly extending = signal(false);\n private readonly remainingMs = signal(this.data.expiresAt - Date.now());\n /** \"1:04\" — floored at zero. */\n protected readonly countdown = computed(() => {\n const total = Math.max(0, Math.ceil(this.remainingMs() / 1000));\n const m = Math.floor(total / 60);\n return `${m}:${String(total - m * 60).padStart(2, '0')}`;\n });\n\n private readonly ticker = this.zone.runOutsideAngular(() =>\n setInterval(() => {\n const left = this.data.expiresAt - Date.now();\n this.zone.run(() => {\n this.remainingMs.set(left);\n if (left <= 0) this.expire();\n });\n }, 1000),\n );\n\n ngOnDestroy(): void {\n clearInterval(this.ticker);\n }\n\n protected extend(): void {\n if (this.extending()) return;\n this.extending.set(true);\n this.data.extend().then(\n () => this.ref.close('extended'),\n () => this.expire(),\n );\n }\n\n protected signOut(): void {\n this.expire();\n }\n\n private expire(): void {\n clearInterval(this.ticker);\n this.ref.close('expired');\n this.data.onExpire();\n }\n}\n\n/**\n * Watches `expiresAt()` and warns BEFORE the session lapses, so a session\n * never ends silently: the dialog offers to extend, or signs out at zero.\n * Re-arms itself whenever `expiresAt()` changes (every token rotation),\n * runs the timer outside the Angular zone and only in the browser. Started\n * automatically by {@link provideMkSessionExpiry}.\n */\n@Injectable({ providedIn: 'root' })\nexport class MkSessionExpiry {\n private readonly config = inject(MK_SESSION_EXPIRY_CONFIG, { optional: true });\n private readonly dialog = inject(MkDialogService);\n private readonly zone = inject(NgZone);\n private readonly injector = inject(Injector);\n private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));\n private timer: ReturnType<typeof setTimeout> | null = null;\n private started = false;\n /** Whether the dialog is currently open. */\n readonly open = signal(false);\n\n constructor() {\n inject(DestroyRef).onDestroy(() => this.clear());\n }\n\n /** Begin watching. Idempotent; called by the provider's initializer. */\n start(): void {\n if (this.started || !this.config) return;\n this.started = true;\n effect(\n () => {\n this.config!.expiresAt();\n this.config!.enabled?.();\n this.schedule();\n },\n { injector: this.injector },\n );\n }\n\n private clear(): void {\n if (this.timer !== null) clearTimeout(this.timer);\n this.timer = null;\n }\n\n private schedule(): void {\n this.clear();\n if (!this.isBrowser || !this.config) return;\n if (this.config.enabled && !this.config.enabled()) return;\n const expiresAt = this.config.expiresAt();\n if (expiresAt === null) return;\n const delay = expiresAt - Date.now() - (this.config.warnBeforeMs ?? 120_000);\n if (delay <= 0) {\n this.warn();\n return;\n }\n this.zone.runOutsideAngular(() => {\n this.timer = setTimeout(() => this.zone.run(() => this.warn()), delay);\n });\n }\n\n private warn(): void {\n if (this.open() || !this.config) return;\n const expiresAt = this.config.expiresAt();\n if (expiresAt === null) return;\n // Already lapsed while the tab was suspended: end it cleanly rather than\n // showing a countdown that starts at zero.\n if (expiresAt <= Date.now()) {\n this.config.onExpire();\n return;\n }\n this.open.set(true);\n const data: MkSessionExpiryDialogData = {\n expiresAt,\n extend: this.config.extend,\n onExpire: this.config.onExpire,\n };\n this.dialog\n .open(MkSessionExpiryDialog, {\n size: 'sm',\n // \"Extend\" and \"sign out\" are the only outcomes; a backdrop click\n // would leave a dying session with nothing on screen saying so.\n closeOnBackdropClick: false,\n closeOnEscape: false,\n data,\n })\n .closed$.subscribe(() => {\n this.open.set(false);\n this.schedule();\n });\n }\n}\n\n/**\n * Register the session-expiry watcher; it starts with the application.\n *\n * ```ts\n * provideMkSessionExpiry({\n * expiresAt: () => auth.tokenExpiresAt(),\n * extend: () => firstValueFrom(auth.refresh()),\n * onExpire: () => auth.logout(),\n * warnBeforeMs: 2 * 60_000,\n * })\n * ```\n */\nexport function provideMkSessionExpiry(config: MkSessionExpiryConfig): EnvironmentProviders {\n return makeEnvironmentProviders([\n { provide: MK_SESSION_EXPIRY_CONFIG, useValue: config },\n provideEnvironmentInitializer(() => inject(MkSessionExpiry).start()),\n ]);\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
|
|
@@ -1294,6 +1294,22 @@ const MK_DEFAULT_I18N = {
|
|
|
1294
1294
|
noOptions: 'No options',
|
|
1295
1295
|
noResults: 'No results',
|
|
1296
1296
|
noData: 'No data to display',
|
|
1297
|
+
translationEditorSearch: 'Search keys and text',
|
|
1298
|
+
translationEditorAll: 'All',
|
|
1299
|
+
translationEditorOverridden: 'Edited',
|
|
1300
|
+
translationEditorMissing: 'Missing',
|
|
1301
|
+
translationEditorKey: 'Key',
|
|
1302
|
+
translationEditorReset: 'Restore the original text',
|
|
1303
|
+
translationEditorExport: 'Export CSV',
|
|
1304
|
+
translationEditorKeys: 'keys',
|
|
1305
|
+
sessionExpiryTitle: 'Your session is about to end',
|
|
1306
|
+
sessionExpiryExtend: 'Stay signed in',
|
|
1307
|
+
sessionExpiryExtending: 'Extending…',
|
|
1308
|
+
sessionExpiryLogout: 'Sign out now',
|
|
1309
|
+
sessionExpiryBody: (countdown) => `For security you will be signed out in ${countdown}. Unsaved changes will be lost.`,
|
|
1310
|
+
scannerTitle: 'Scan a code',
|
|
1311
|
+
scannerHint: 'Point the camera at a barcode or QR code. It is read automatically.',
|
|
1312
|
+
scannerCameraError: 'The camera could not be started. Check the browser permissions.',
|
|
1297
1313
|
resultsCount: (count) => (count === 1 ? '1 result' : `${count} results`),
|
|
1298
1314
|
previousPage: 'Go to previous page',
|
|
1299
1315
|
nextPage: 'Go to next page',
|