@tekus/design-system 5.35.0 → 5.37.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 (40) hide show
  1. package/fesm2022/tekus-design-system-components-carousel.mjs +32 -7
  2. package/fesm2022/tekus-design-system-components-carousel.mjs.map +1 -1
  3. package/fesm2022/tekus-design-system-components-color-picker.mjs +203 -29
  4. package/fesm2022/tekus-design-system-components-color-picker.mjs.map +1 -1
  5. package/fesm2022/tekus-design-system-components-grid-container.mjs +181 -0
  6. package/fesm2022/tekus-design-system-components-grid-container.mjs.map +1 -0
  7. package/fesm2022/tekus-design-system-components-header.mjs +29 -0
  8. package/fesm2022/tekus-design-system-components-header.mjs.map +1 -0
  9. package/fesm2022/tekus-design-system-components-modal.mjs +49 -29
  10. package/fesm2022/tekus-design-system-components-modal.mjs.map +1 -1
  11. package/fesm2022/tekus-design-system-components-process-steps.mjs +24 -3
  12. package/fesm2022/tekus-design-system-components-process-steps.mjs.map +1 -1
  13. package/fesm2022/tekus-design-system-components-section.mjs +63 -0
  14. package/fesm2022/tekus-design-system-components-section.mjs.map +1 -0
  15. package/fesm2022/tekus-design-system-components-sidebar-layout.mjs +20 -6
  16. package/fesm2022/tekus-design-system-components-sidebar-layout.mjs.map +1 -1
  17. package/fesm2022/tekus-design-system-components-tree-table.mjs +94 -0
  18. package/fesm2022/tekus-design-system-components-tree-table.mjs.map +1 -0
  19. package/fesm2022/tekus-design-system-components-uploader.mjs +1256 -0
  20. package/fesm2022/tekus-design-system-components-uploader.mjs.map +1 -0
  21. package/fesm2022/tekus-design-system-core-types.mjs +22 -7
  22. package/fesm2022/tekus-design-system-core-types.mjs.map +1 -1
  23. package/fesm2022/tekus-design-system-core.mjs +22 -7
  24. package/fesm2022/tekus-design-system-core.mjs.map +1 -1
  25. package/fesm2022/tekus-design-system-directives-gird-item.mjs +1 -1
  26. package/fesm2022/tekus-design-system-directives-gird-item.mjs.map +1 -1
  27. package/package.json +21 -1
  28. package/types/tekus-design-system-components-carousel.d.ts +6 -2
  29. package/types/tekus-design-system-components-color-picker.d.ts +75 -12
  30. package/types/tekus-design-system-components-grid-container.d.ts +123 -0
  31. package/types/tekus-design-system-components-header.d.ts +33 -0
  32. package/types/tekus-design-system-components-modal.d.ts +30 -12
  33. package/types/tekus-design-system-components-process-steps.d.ts +16 -1
  34. package/types/tekus-design-system-components-section.d.ts +45 -0
  35. package/types/tekus-design-system-components-sidebar-layout.d.ts +11 -1
  36. package/types/tekus-design-system-components-tree-table.d.ts +62 -0
  37. package/types/tekus-design-system-components-uploader.d.ts +524 -0
  38. package/types/tekus-design-system-core-types.d.ts +7 -7
  39. package/types/tekus-design-system-core.d.ts +7 -7
  40. package/types/tekus-design-system-directives-gird-item.d.ts +1 -1
@@ -0,0 +1,1256 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, Injector, signal, computed, untracked, runInInjectionContext, Injectable, input, output, ChangeDetectionStrategy, Component, viewChild, resource } from '@angular/core';
3
+ import { IconComponent } from '@tekus/design-system/components/icon';
4
+ import { ProcessStepsComponent } from '@tekus/design-system/components/process-steps';
5
+ import { toObservable } from '@angular/core/rxjs-interop';
6
+ import { from, concatMap, catchError, throwError, toArray, map, expand, EMPTY, takeWhile, timer, tap, filter, defaultIfEmpty, last, take, finalize, isObservable, firstValueFrom, of, fromEvent } from 'rxjs';
7
+ import { ButtonComponent } from '@tekus/design-system/components/button';
8
+ import { ProgressBarComponent } from '@tekus/design-system/components/progress-bar';
9
+ import { HttpBackend, HttpClient, HttpEventType } from '@angular/common/http';
10
+
11
+ /** Queue backing store — provided by `provideUploader()`, actively injected by `UploaderQueueService`. */
12
+ const UPLOADER_STORE = new InjectionToken('UPLOADER_STORE');
13
+ /** Client-side preview/metadata generator — provided by `provideUploader()`, actively injected internally. */
14
+ const UPLOADER_PREVIEW_PROVIDER = new InjectionToken('UPLOADER_PREVIEW_PROVIDER');
15
+ /**
16
+ * HTTP transport — provided by `provideUploader()`, but never injected
17
+ * internally. Registered purely so the consumer's own `steps`/
18
+ * `multipart.uploadPart` functions can `inject(UPLOADER_TRANSPORT)` instead
19
+ * of instantiating `HttpClient` themselves.
20
+ */
21
+ const UPLOADER_TRANSPORT = new InjectionToken('UPLOADER_TRANSPORT');
22
+
23
+ /** Runs `config.steps` one after another, reporting progress as each resolves. */
24
+ function runSequentialStepsFlow(params) {
25
+ const { item, payload, context, config, store, handleError, reportProgress } = params;
26
+ const steps = config.steps || [];
27
+ const totalSteps = steps.length;
28
+ return from(steps).pipe(concatMap((step, index) => {
29
+ return executeStep(step, { file: item.file, context, payload, item }, index, totalSteps, store, config, reportProgress).pipe(catchError((err) => {
30
+ handleError(item.id, err, config, context);
31
+ return throwError(() => err);
32
+ }));
33
+ }), toArray(), map(() => context));
34
+ }
35
+ /** Runs `config.multipart`: `init` → chunks sequentially via `uploadPart` → `complete`. */
36
+ function runMultipartFlow(params) {
37
+ const { item, payload, context, config, store, handleError, reportProgress } = params;
38
+ const multipart = config.multipart;
39
+ const chunkSize = multipart.chunkSizeMB * 1024 * 1024;
40
+ const totalChunks = Math.ceil(item.file.size / chunkSize);
41
+ return multipart.init({ file: item.file, payload }).pipe(map((initResult) => {
42
+ Object.assign(context, initResult);
43
+ reportProgress(0);
44
+ return { partNumber: 0 };
45
+ }), expand(({ partNumber }) => {
46
+ if (partNumber >= totalChunks) {
47
+ return EMPTY;
48
+ }
49
+ const start = partNumber * chunkSize;
50
+ const end = Math.min(start + chunkSize, item.file.size);
51
+ const chunk = item.file.slice(start, end);
52
+ return executeChunk(multipart, {
53
+ file: item.file,
54
+ chunk,
55
+ partNumber,
56
+ context,
57
+ payload,
58
+ metadata: item.metadata
59
+ }, totalChunks, store, item.id, config, reportProgress);
60
+ }), takeWhile(({ partNumber }) => partNumber <= totalChunks, true), toArray(), concatMap(() => {
61
+ const scale = 100 / (config.totalSteps || 1);
62
+ reportProgress(Math.round(scale), config.resolveText?.('FINISHING') ?? 'FINISHING');
63
+ return multipart.complete({ context, payload }).pipe(map(() => {
64
+ return context;
65
+ }));
66
+ }), catchError((err) => {
67
+ handleError(item.id, err, config, context);
68
+ return throwError(() => err);
69
+ }));
70
+ }
71
+ /**
72
+ * Runs `config.polling` after the byte upload succeeds: calls `checkStatus`
73
+ * every `intervalMs`, merging `progress`/`statusText`/`steps`/`stepsStatus`/
74
+ * `stepsTitle`/`stepsDescription`/`stepsCountdownSeconds`/`stepsCountdownLabel`
75
+ * into the item as they arrive, until `status` stops being `'loading'`.
76
+ */
77
+ function runPolling(params) {
78
+ const { id, payload, context, config, store, handleError } = params;
79
+ const pollConfig = config.polling;
80
+ const interval = pollConfig.intervalMs || 3000;
81
+ return timer(0, interval).pipe(concatMap(() => pollConfig.checkStatus({ context, payload })), takeWhile((response) => response.status === 'loading', true), map((response) => {
82
+ if (response.status === 'error') {
83
+ throw new Error(response.error || 'POLLING_FAILED');
84
+ }
85
+ const updates = {};
86
+ if (response.progress !== undefined) {
87
+ updates.progress = response.progress;
88
+ }
89
+ if (response.statusText !== undefined) {
90
+ updates.statusText = response.statusText;
91
+ }
92
+ if (response.steps !== undefined) {
93
+ updates.steps = response.steps;
94
+ }
95
+ if (response.stepsStatus !== undefined) {
96
+ updates.stepsStatus = response.stepsStatus;
97
+ }
98
+ if (response.stepsTitle !== undefined) {
99
+ updates.stepsTitle = response.stepsTitle;
100
+ }
101
+ if (response.stepsDescription !== undefined) {
102
+ updates.stepsDescription = response.stepsDescription;
103
+ }
104
+ if (response.stepsCountdownSeconds !== undefined) {
105
+ updates.stepsCountdownSeconds = response.stepsCountdownSeconds;
106
+ }
107
+ if (response.stepsCountdownLabel !== undefined) {
108
+ updates.stepsCountdownLabel = response.stepsCountdownLabel;
109
+ }
110
+ if (Object.keys(updates).length > 0) {
111
+ store.update(id, updates);
112
+ }
113
+ return response;
114
+ }), catchError((err) => {
115
+ handleError(id, err, config, context);
116
+ return throwError(() => err);
117
+ }), toArray(), map((responses) => responses.at(-1)));
118
+ }
119
+ function executeStep(step, params, index, totalSteps, store, config, reportProgress) {
120
+ const scale = 100 / (config.totalSteps || 1);
121
+ return step(params).pipe(map((result) => {
122
+ Object.assign(params.context, result);
123
+ const id = params.item?.id;
124
+ if (id) {
125
+ const progress = Math.round(((index + 1) / totalSteps) * scale);
126
+ reportProgress(progress);
127
+ }
128
+ config.hooks?.onStepComplete?.({
129
+ stepIndex: index,
130
+ context: params.context
131
+ });
132
+ return params.context;
133
+ }));
134
+ }
135
+ function executeChunk(multipart, params, totalChunks, store, itemId, config, reportProgress) {
136
+ const scale = 100 / (config.totalSteps || 1);
137
+ return multipart.uploadPart(params).pipe(tap((val) => {
138
+ if (typeof val === 'number' && totalChunks > 0) {
139
+ const currentPartOffset = (params.partNumber / totalChunks) * scale;
140
+ const chunkContribution = (val / 100) * (scale / totalChunks);
141
+ reportProgress(Math.round(currentPartOffset + chunkContribution));
142
+ }
143
+ }), filter((val) => typeof val === 'object' && val !== null), defaultIfEmpty({}), last(), map((result) => {
144
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
145
+ Object.assign(params.context, result);
146
+ }
147
+ const progress = totalChunks > 0
148
+ ? Math.round(((params.partNumber + 1) / totalChunks) * scale)
149
+ : Math.round(scale);
150
+ reportProgress(progress);
151
+ return { partNumber: params.partNumber + 1 };
152
+ }));
153
+ }
154
+
155
+ /** Fallback values applied by `mergeConfigWithDefaults` when the consumer omits them. */
156
+ const UPLOADER_DEFAULTS = {
157
+ chunkSizeMB: 5,
158
+ concurrencyLimit: 4,
159
+ pollingIntervalMs: 3000
160
+ };
161
+ /** Fills in `UPLOADER_DEFAULTS` for whatever `config` leaves unset — never overrides an explicit value. */
162
+ function mergeConfigWithDefaults(config) {
163
+ let refined = { ...config };
164
+ if (refined.concurrencyLimit === undefined) {
165
+ refined = {
166
+ ...refined,
167
+ concurrencyLimit: UPLOADER_DEFAULTS.concurrencyLimit
168
+ };
169
+ }
170
+ if (refined.multipart) {
171
+ refined = {
172
+ ...refined,
173
+ multipart: {
174
+ ...refined.multipart,
175
+ chunkSizeMB: refined.multipart.chunkSizeMB ?? UPLOADER_DEFAULTS.chunkSizeMB
176
+ }
177
+ };
178
+ }
179
+ if (refined.polling) {
180
+ refined = {
181
+ ...refined,
182
+ polling: {
183
+ ...refined.polling,
184
+ intervalMs: refined.polling.intervalMs ?? UPLOADER_DEFAULTS.pollingIntervalMs
185
+ }
186
+ };
187
+ }
188
+ return refined;
189
+ }
190
+
191
+ /** Checks a file against `UploadFlowConfig.restrictions` before it's queued. */
192
+ class UploaderValidator {
193
+ /**
194
+ * Checks integrity (non-empty) first, then extension/size — `rules` (per-extension
195
+ * `maxSizeMB`) if set, otherwise the legacy `allowedExtensions`/`maxSizeMB` pair.
196
+ */
197
+ static validate(file, restrictions) {
198
+ const integrityResult = this.checkFileIntegrity(file);
199
+ if (!integrityResult.isValid) {
200
+ return integrityResult;
201
+ }
202
+ return restrictions.rules?.length
203
+ ? this.checkGranularRules(file, restrictions.rules)
204
+ : this.checkLegacyRestrictions(file, restrictions);
205
+ }
206
+ static checkFileIntegrity(file) {
207
+ if (file.size === 0) {
208
+ return {
209
+ isValid: false,
210
+ error: 'FILE_CORRUPT',
211
+ detail: { fileName: file.name, fileSize: 0 }
212
+ };
213
+ }
214
+ return { isValid: true };
215
+ }
216
+ static checkGranularRules(file, rules) {
217
+ const fileName = file.name.toLowerCase();
218
+ const matchingRule = rules.find(rule => rule.extensions.some(ext => fileName.endsWith(ext.toLowerCase())));
219
+ return matchingRule
220
+ ? this.checkRuleMaxSize(file, matchingRule)
221
+ : this.buildGranularInvalidFormatResult(fileName, rules);
222
+ }
223
+ static buildGranularInvalidFormatResult(fileName, rules) {
224
+ return {
225
+ isValid: false,
226
+ error: 'INVALID_FORMAT',
227
+ detail: {
228
+ fileName,
229
+ allowedExtensionsByType: rules.map(rule => rule.extensions)
230
+ }
231
+ };
232
+ }
233
+ static checkRuleMaxSize(file, rule) {
234
+ if (rule.maxSizeMB !== undefined && file.size > rule.maxSizeMB * 1024 * 1024) {
235
+ return {
236
+ isValid: false,
237
+ error: 'FILE_TOO_LARGE',
238
+ detail: { fileSize: file.size, maxSizeMB: rule.maxSizeMB }
239
+ };
240
+ }
241
+ return { isValid: true };
242
+ }
243
+ static checkLegacyRestrictions(file, restrictions) {
244
+ const maxSizeResult = this.checkLegacyMaxSize(file, restrictions);
245
+ if (!maxSizeResult.isValid) {
246
+ return maxSizeResult;
247
+ }
248
+ return this.checkLegacyExtension(file, restrictions);
249
+ }
250
+ static checkLegacyMaxSize(file, restrictions) {
251
+ if (restrictions.maxSizeMB &&
252
+ file.size > restrictions.maxSizeMB * 1024 * 1024) {
253
+ return {
254
+ isValid: false,
255
+ error: 'FILE_TOO_LARGE',
256
+ detail: { fileSize: file.size, maxSizeMB: restrictions.maxSizeMB }
257
+ };
258
+ }
259
+ return { isValid: true };
260
+ }
261
+ static checkLegacyExtension(file, restrictions) {
262
+ const fileName = file.name.toLowerCase();
263
+ const isAllowed = !restrictions.allowedExtensions ||
264
+ restrictions.allowedExtensions.some(ext => fileName.endsWith(ext.toLowerCase()));
265
+ return isAllowed
266
+ ? { isValid: true }
267
+ : this.buildLegacyInvalidFormatResult(fileName, restrictions);
268
+ }
269
+ static buildLegacyInvalidFormatResult(fileName, restrictions) {
270
+ return {
271
+ isValid: false,
272
+ error: 'INVALID_FORMAT',
273
+ detail: { fileName, allowedExtensions: restrictions.allowedExtensions }
274
+ };
275
+ }
276
+ }
277
+
278
+ const STATUS_KEY_MAP = {
279
+ loading: 'UPLOAD_IN_PROGRESS',
280
+ success: 'FULL_LOAD',
281
+ error: 'LOAD_ERROR',
282
+ ready: 'READY',
283
+ analyzing: 'ANALYZING',
284
+ transforming: 'TRANSFORMING',
285
+ queued: 'PROCESSING'
286
+ };
287
+ const ERROR_CODES = new Set([
288
+ 'FILE_CORRUPT',
289
+ 'INVALID_FORMAT',
290
+ 'FILE_TOO_LARGE',
291
+ 'ANALYSIS_FAILED',
292
+ 'PROCESS_FAILED',
293
+ 'EXTERNAL_VALIDATION_FAILED',
294
+ 'POLLING_FAILED',
295
+ 'NO_UPLOAD_STRATEGY_CONFIGURED'
296
+ ]);
297
+ /**
298
+ * The engine — orchestrates the whole upload flow (validation, analysis,
299
+ * transform, `steps`/`multipart`, `polling`, lifecycle hooks) driven purely
300
+ * by the `UploadFlowConfig` passed to `addToQueue`. Not `providedIn: 'root'`;
301
+ * `provideUploader()` gives each `tk-uploader` its own instance.
302
+ */
303
+ class UploaderQueueService {
304
+ constructor() {
305
+ this.store = inject(UPLOADER_STORE);
306
+ this.previewProvider = inject(UPLOADER_PREVIEW_PROVIDER);
307
+ this.injector = inject(Injector);
308
+ this.activeSubscriptions = new Map();
309
+ this.isManualTriggered = signal(false, ...(ngDevMode ? [{ debugName: "isManualTriggered" }] : /* istanbul ignore next */ []));
310
+ this.uploadQueue = this.store.uploadQueue;
311
+ /** Every state transition recorded so far — not currently surfaced anywhere by default. */
312
+ this.auditHistory = signal([], ...(ngDevMode ? [{ debugName: "auditHistory" }] : /* istanbul ignore next */ []));
313
+ /** Whether any item is actively `loading` right now. */
314
+ this.isUploading = computed(() => this.uploadQueue().some((i) => i.status === 'loading'), ...(ngDevMode ? [{ debugName: "isUploading" }] : /* istanbul ignore next */ []));
315
+ /** Whether any item is mid-flight (`analyzing`/`transforming`/`loading`) — client-side or network work in progress. */
316
+ this.isBusy = computed(() => this.uploadQueue().some((i) => ['analyzing', 'transforming', 'loading'].includes(i.status)), ...(ngDevMode ? [{ debugName: "isBusy" }] : /* istanbul ignore next */ []));
317
+ /** `!isBusy()` — whether it's safe to close/navigate away without losing in-flight work. */
318
+ this.canSafetyClose = computed(() => !this.isBusy(), ...(ngDevMode ? [{ debugName: "canSafetyClose" }] : /* istanbul ignore next */ []));
319
+ }
320
+ ngOnDestroy() {
321
+ this.clearQueue();
322
+ }
323
+ /**
324
+ * Adds one or more files to the queue and starts them through validation →
325
+ * analysis → transform → (auto-start or wait for `startUpload`/`startAllReady`).
326
+ * `config` becomes `currentConfig`, reused by every later call until the next `addToQueue`.
327
+ */
328
+ addToQueue(files, config) {
329
+ this.isManualTriggered.set(false);
330
+ const mergedConfig = mergeConfigWithDefaults(config);
331
+ this.currentConfig = mergedConfig;
332
+ const fileList = Array.isArray(files) ? files : [files];
333
+ fileList.forEach((file) => {
334
+ const id = crypto.randomUUID();
335
+ const newItem = {
336
+ id,
337
+ file,
338
+ progress: 0,
339
+ status: 'analyzing',
340
+ statusKey: this.getStatusKey('analyzing'),
341
+ statusText: this.getStatusText('analyzing')
342
+ };
343
+ this.addAuditEntry(id, 'none', 'analyzing');
344
+ this.store.add(newItem);
345
+ mergedConfig.hooks?.onFileAdded?.(file);
346
+ untracked(() => {
347
+ if (mergedConfig.restrictions) {
348
+ const result = UploaderValidator.validate(file, mergedConfig.restrictions);
349
+ if (!result.isValid) {
350
+ this.handleError(id, result.error, mergedConfig, undefined, result.detail, 'invalid');
351
+ return;
352
+ }
353
+ }
354
+ this.runAnalysis(id, file, mergedConfig);
355
+ });
356
+ });
357
+ }
358
+ runAnalysis(id, file, config) {
359
+ const analysisResource = runInInjectionContext(this.injector, () => this.previewProvider.generatePreview(file));
360
+ const sub = toObservable(analysisResource.value, {
361
+ injector: this.injector
362
+ })
363
+ .pipe(filter((val) => !!val), take(1), finalize(() => {
364
+ this.activeSubscriptions.delete(`${id}_analysis`);
365
+ analysisResource.destroy();
366
+ }))
367
+ .subscribe((analysis) => {
368
+ if (analysis) {
369
+ this.handleAnalysisResult(id, analysis, config);
370
+ }
371
+ });
372
+ this.activeSubscriptions.set(`${id}_analysis`, sub);
373
+ }
374
+ async handleAnalysisResult(id, analysis, config) {
375
+ if (!this.uploadQueue().some((i) => i.id === id)) {
376
+ // The item was removed while analysis was still in flight — the store never
377
+ // got `previewUrl`, so `removeFromQueue`'s own revoke couldn't find it either.
378
+ if (analysis.previewUrl?.startsWith('blob:')) {
379
+ URL.revokeObjectURL(analysis.previewUrl);
380
+ }
381
+ return;
382
+ }
383
+ if (analysis.analysisError) {
384
+ const errorKey = analysis.technicalError || 'ANALYSIS_FAILED';
385
+ this.handleError(id, errorKey, config, {}, analysis.technicalError);
386
+ return;
387
+ }
388
+ this.patchItem(id, {
389
+ previewUrl: analysis.previewUrl,
390
+ previewBlob: analysis.previewBlob,
391
+ previewBase64: analysis.previewBase64,
392
+ metadata: analysis.metadata
393
+ });
394
+ const isValid = await this.validateExternalRestrictions(id, analysis, config);
395
+ if (!isValid) {
396
+ return;
397
+ }
398
+ await this.runTransformation(id, config);
399
+ }
400
+ async validateExternalRestrictions(id, analysis, config) {
401
+ if (config.hooks?.validate) {
402
+ let validation;
403
+ const validation$ = config.hooks.validate({
404
+ file: this.uploadQueue().find((i) => i.id === id)
405
+ .file,
406
+ metadata: analysis.metadata
407
+ });
408
+ if (isObservable(validation$)) {
409
+ validation = await firstValueFrom(validation$.pipe(catchError(() => of(false))));
410
+ }
411
+ else {
412
+ validation = validation$;
413
+ }
414
+ if (validation !== true) {
415
+ this.handleError(id, typeof validation === 'string' && validation
416
+ ? new Error(validation)
417
+ : new Error('EXTERNAL_VALIDATION_FAILED'), config, {});
418
+ return;
419
+ }
420
+ }
421
+ return true;
422
+ }
423
+ async runTransformation(id, config) {
424
+ if (config.hooks?.onTransform) {
425
+ this.patchItem(id, { status: 'transforming' });
426
+ try {
427
+ const item = this.uploadQueue().find((i) => i.id === id);
428
+ const payload = await config.hooks.onTransform(item);
429
+ this.patchItem(id, { payload });
430
+ }
431
+ catch (err) {
432
+ this.handleError(id, err, config, {});
433
+ return;
434
+ }
435
+ }
436
+ const isAutoStart = typeof config.autoStart === 'function'
437
+ ? config.autoStart()
438
+ : config.autoStart;
439
+ if (isAutoStart !== false || this.isManualTriggered()) {
440
+ this.patchItem(id, { status: 'queued' });
441
+ this.checkConcurrencyAndProcess(config);
442
+ }
443
+ else {
444
+ this.patchItem(id, { status: 'ready' });
445
+ }
446
+ }
447
+ /** Manually starts a single `ready` item (when `autoStart` is `false`). */
448
+ startUpload(id) {
449
+ if (!this.currentConfig) {
450
+ return;
451
+ }
452
+ this.patchItem(id, { status: 'queued' });
453
+ this.checkConcurrencyAndProcess(this.currentConfig);
454
+ }
455
+ /** Manually starts every `ready` item at once (when `autoStart` is `false`). */
456
+ startAllReady() {
457
+ if (!this.currentConfig) {
458
+ return;
459
+ }
460
+ this.isManualTriggered.set(true);
461
+ this.uploadQueue().forEach((item) => {
462
+ if (item.status === 'ready') {
463
+ this.patchItem(item.id, { status: 'queued' });
464
+ }
465
+ });
466
+ this.checkConcurrencyAndProcess(this.currentConfig);
467
+ }
468
+ checkConcurrencyAndProcess(config) {
469
+ const limit = config.concurrencyLimit;
470
+ const activeCount = this.uploadQueue().filter((i) => i.status === 'loading').length;
471
+ if (activeCount < limit) {
472
+ const nextItem = this.uploadQueue().find((i) => i.status === 'queued');
473
+ if (nextItem) {
474
+ this.processUpload(nextItem, config);
475
+ this.checkConcurrencyAndProcess(config);
476
+ }
477
+ }
478
+ }
479
+ processUpload(item, config) {
480
+ const context = {};
481
+ this.startUploadFlow(item, config);
482
+ const subscription = this.getExecutionStream(item, config, context)
483
+ .pipe(concatMap(() => {
484
+ if (config.polling) {
485
+ return runPolling({
486
+ id: item.id,
487
+ payload: item.payload,
488
+ context,
489
+ config,
490
+ store: this.store,
491
+ handleError: this.handleError.bind(this)
492
+ });
493
+ }
494
+ return of(context);
495
+ }), finalize(() => this.finalizeItem(item.id, config)))
496
+ .subscribe({
497
+ next: () => {
498
+ this.patchItem(item.id, { status: 'success', progress: 100 });
499
+ config.hooks?.onSuccess?.({ context, payload: item.payload });
500
+ this.checkAllComplete(config);
501
+ },
502
+ error: () => this.checkAllComplete(config)
503
+ });
504
+ this.activeSubscriptions.set(item.id, subscription);
505
+ }
506
+ startUploadFlow(item, config) {
507
+ this.patchItem(item.id, {
508
+ status: 'loading',
509
+ progress: 0,
510
+ statusText: this.getStatusText('loading')
511
+ });
512
+ config.hooks?.onBeforeStart?.(item.file);
513
+ }
514
+ getExecutionStream(item, config, context) {
515
+ if (!config.multipart && !config.steps?.length) {
516
+ const error = new Error('NO_UPLOAD_STRATEGY_CONFIGURED');
517
+ this.handleError(item.id, error, config, context);
518
+ return throwError(() => error);
519
+ }
520
+ const params = {
521
+ item,
522
+ payload: item.payload,
523
+ context,
524
+ config,
525
+ store: this.store,
526
+ handleError: this.handleError.bind(this),
527
+ reportProgress: (progress, text) => this.patchItem(item.id, { progress, statusText: text })
528
+ };
529
+ return config.multipart
530
+ ? runMultipartFlow(params)
531
+ : runSequentialStepsFlow(params);
532
+ }
533
+ finalizeItem(id, config) {
534
+ this.activeSubscriptions.delete(id);
535
+ this.checkConcurrencyAndProcess(config);
536
+ }
537
+ extractErrorCode(error) {
538
+ if (typeof error === 'string' && ERROR_CODES.has(error)) {
539
+ return error;
540
+ }
541
+ if (error instanceof Error &&
542
+ ERROR_CODES.has(error.message)) {
543
+ return error.message;
544
+ }
545
+ return undefined;
546
+ }
547
+ getErrorText(error) {
548
+ const code = this.extractErrorCode(error);
549
+ if (code) {
550
+ return this.resolveUploaderKeyText(code);
551
+ }
552
+ if (error instanceof Error) {
553
+ return error.message;
554
+ }
555
+ if (typeof error === 'string') {
556
+ return error;
557
+ }
558
+ return this.getStatusText('error');
559
+ }
560
+ resolveUploaderKeyText(key) {
561
+ return this.currentConfig?.resolveText
562
+ ? this.currentConfig.resolveText(key)
563
+ : key;
564
+ }
565
+ handleError(id, error, config, context = undefined, detail, status = 'error') {
566
+ const errorMessage = this.getErrorText(error);
567
+ this.patchItem(id, {
568
+ status,
569
+ error: errorMessage
570
+ }, detail);
571
+ config.hooks?.onError?.({ error, context });
572
+ }
573
+ checkAllComplete(config) {
574
+ const allTerminal = this.uploadQueue().every((i) => ['success', 'error', 'invalid'].includes(i.status));
575
+ if (allTerminal) {
576
+ this.isManualTriggered.set(false);
577
+ config.hooks?.onAllComplete?.();
578
+ }
579
+ }
580
+ /**
581
+ * Removes an item outright — revokes its preview blob URL and cancels any
582
+ * in-flight upload subscription first. This is outside `UploadFlowConfig`'s
583
+ * contract on purpose: `tk-uploader` only ever reports a removal request
584
+ * (see `UploaderRemoveRequest`/`removeRequested`), the consumer decides
585
+ * whether/when to actually call this.
586
+ *
587
+ * Deliberately does NOT cancel a still-pending `${id}_analysis` subscription:
588
+ * doing so would drop the analysis result (and any blob URL it already created)
589
+ * on the floor with nothing left to revoke it. Letting it resolve naturally lets
590
+ * `handleAnalysisResult`'s own "item still in the queue?" check catch it instead.
591
+ */
592
+ removeFromQueue(id) {
593
+ this.revokeItemPreview(id);
594
+ this.cancelSubscription(id);
595
+ this.store.remove(id);
596
+ }
597
+ revokeItemPreview(id) {
598
+ const item = this.uploadQueue().find((i) => i.id === id);
599
+ if (item?.previewUrl?.startsWith('blob:')) {
600
+ URL.revokeObjectURL(item.previewUrl);
601
+ }
602
+ }
603
+ cancelSubscription(key) {
604
+ const sub = this.activeSubscriptions.get(key);
605
+ if (sub) {
606
+ sub.unsubscribe();
607
+ this.activeSubscriptions.delete(key);
608
+ }
609
+ }
610
+ /** Cancels an item's in-flight subscription without removing it — `startUpload` resumes it. */
611
+ pauseUpload(id) {
612
+ this.cancelSubscription(id);
613
+ }
614
+ /** Revokes every preview blob URL, cancels every in-flight subscription, and empties the store. */
615
+ clearQueue() {
616
+ this.uploadQueue().forEach((item) => {
617
+ if (item.previewUrl?.startsWith('blob:')) {
618
+ URL.revokeObjectURL(item.previewUrl);
619
+ }
620
+ });
621
+ this.activeSubscriptions.forEach((sub) => sub.unsubscribe());
622
+ this.activeSubscriptions.clear();
623
+ this.store.clear();
624
+ }
625
+ getStatusKey(status) {
626
+ return STATUS_KEY_MAP[status] || status.toUpperCase();
627
+ }
628
+ getStatusText(status) {
629
+ const key = this.getStatusKey(status);
630
+ if (this.currentConfig?.resolveText) {
631
+ return this.currentConfig.resolveText(key);
632
+ }
633
+ return status;
634
+ }
635
+ patchItem(id, updates, detail) {
636
+ const currentItem = this.uploadQueue().find((i) => i.id === id);
637
+ if (!currentItem) {
638
+ return;
639
+ }
640
+ if (updates.status && updates.status !== currentItem.status) {
641
+ this.addAuditEntry(id, currentItem.status, updates.status, detail);
642
+ updates.statusKey = this.getStatusKey(updates.status);
643
+ updates.statusText ??= this.getStatusText(updates.status);
644
+ }
645
+ const cleanUpdates = { ...updates };
646
+ if (cleanUpdates.statusText === undefined) {
647
+ delete cleanUpdates.statusText;
648
+ }
649
+ this.store.update(id, cleanUpdates);
650
+ }
651
+ addAuditEntry(itemId, from, toStatus, detail) {
652
+ this.auditHistory.update((history) => [
653
+ ...history,
654
+ {
655
+ itemId,
656
+ from,
657
+ to: toStatus,
658
+ timestamp: Date.now(),
659
+ key: this.getStatusKey(toStatus),
660
+ errorDetail: detail
661
+ }
662
+ ]);
663
+ }
664
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
665
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService }); }
666
+ }
667
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderQueueService, decorators: [{
668
+ type: Injectable
669
+ }] });
670
+
671
+ /**
672
+ * Horizontal file card — icon/preview, title, plain progress bar while
673
+ * `status` is `'loading'` (otherwise `statusText`), and a trailing action
674
+ * button. Internal to `tk-uploader`; not part of the public API.
675
+ */
676
+ class UploaderFileCardComponent {
677
+ constructor() {
678
+ this.title = input.required(...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
679
+ this.status = input.required(...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
680
+ /** 0-100, shown only while `status` is `'loading'`. */
681
+ this.progress = input(0, ...(ngDevMode ? [{ debugName: "progress" }] : /* istanbul ignore next */ []));
682
+ this.previewUrl = input(...(ngDevMode ? [undefined, { debugName: "previewUrl" }] : /* istanbul ignore next */ []));
683
+ /** Fallback icon shown when there's no `previewUrl`. */
684
+ this.icon = input('folders', ...(ngDevMode ? [{ debugName: "icon" }] : /* istanbul ignore next */ []));
685
+ /** Shown instead of the progress bar whenever `status` isn't `'loading'`. */
686
+ this.statusText = input.required(...(ngDevMode ? [{ debugName: "statusText" }] : /* istanbul ignore next */ []));
687
+ /**
688
+ * Set by the consumer while it's waiting on its own confirmation for this
689
+ * item's removal — this card never confirms anything itself, it only
690
+ * reflects that a decision is pending by disabling the remove button (no
691
+ * double-clicks while waiting).
692
+ */
693
+ this.isPendingRemoval = input(false, ...(ngDevMode ? [{ debugName: "isPendingRemoval" }] : /* istanbul ignore next */ []));
694
+ /**
695
+ * Icon for the trailing button — defaults to the remove affordance
696
+ * (`trash-can`). `tk-uploader` overrides this for its `placeholder` card,
697
+ * where the same button re-opens the file picker instead of removing
698
+ * anything (there's nothing in the real queue to remove).
699
+ */
700
+ this.trailingIcon = input('trash-can', ...(ngDevMode ? [{ debugName: "trailingIcon" }] : /* istanbul ignore next */ []));
701
+ /** Severity for the trailing button — `danger` reads as destructive, `secondary` doesn't. */
702
+ this.trailingSeverity = input('danger', ...(ngDevMode ? [{ debugName: "trailingSeverity" }] : /* istanbul ignore next */ []));
703
+ /**
704
+ * Tooltip/accessible label for the trailing button — this card has no copy of its own
705
+ * (no i18n coupling, same reasoning as `UploadFlowConfig.resolveText`), so it stays
706
+ * unlabeled unless the consumer supplies text here.
707
+ */
708
+ this.trailingLabel = input(...(ngDevMode ? [undefined, { debugName: "trailingLabel" }] : /* istanbul ignore next */ []));
709
+ /** Emitted on trailing-button click — its meaning depends on `trailingIcon`/context, this card has no opinion on it. */
710
+ this.dismissed = output();
711
+ this.isProcessing = computed(() => this.status() === 'loading', ...(ngDevMode ? [{ debugName: "isProcessing" }] : /* istanbul ignore next */ []));
712
+ }
713
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderFileCardComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
714
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: UploaderFileCardComponent, isStandalone: true, selector: "tk-uploader-file-card", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: true, transformFunction: null }, status: { classPropertyName: "status", publicName: "status", isSignal: true, isRequired: true, transformFunction: null }, progress: { classPropertyName: "progress", publicName: "progress", isSignal: true, isRequired: false, transformFunction: null }, previewUrl: { classPropertyName: "previewUrl", publicName: "previewUrl", isSignal: true, isRequired: false, transformFunction: null }, icon: { classPropertyName: "icon", publicName: "icon", isSignal: true, isRequired: false, transformFunction: null }, statusText: { classPropertyName: "statusText", publicName: "statusText", isSignal: true, isRequired: true, transformFunction: null }, isPendingRemoval: { classPropertyName: "isPendingRemoval", publicName: "isPendingRemoval", isSignal: true, isRequired: false, transformFunction: null }, trailingIcon: { classPropertyName: "trailingIcon", publicName: "trailingIcon", isSignal: true, isRequired: false, transformFunction: null }, trailingSeverity: { classPropertyName: "trailingSeverity", publicName: "trailingSeverity", isSignal: true, isRequired: false, transformFunction: null }, trailingLabel: { classPropertyName: "trailingLabel", publicName: "trailingLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismissed: "dismissed" }, ngImport: i0, template: "<article class=\"tk-uploader-file-card\" [attr.data-status]=\"status()\">\n <div class=\"tk-uploader-file-card__media\">\n @if (previewUrl()) {\n <img\n [src]=\"previewUrl()\"\n alt=\"File preview\"\n class=\"tk-uploader-file-card__preview\" />\n } @else {\n <tk-icon [icon]=\"icon()\" class=\"tk-uploader-file-card__media-icon\" />\n }\n </div>\n\n <div class=\"tk-uploader-file-card__info\">\n <h4 class=\"tk-uploader-file-card__title\">{{ title() }}</h4>\n\n @if (isProcessing()) {\n <!-- statusText is intentionally hidden while loading: tk-progress-bar only shows a\n number, so with multiple files (no per-item tk-process-steps) there's no textual\n feedback during this state \u2014 see uploader.mdx \"Known limitations\". -->\n <div class=\"tk-uploader-file-card__progress\">\n <tk-progress-bar [value]=\"progress()\" size=\"medium\" [showValue]=\"true\" />\n </div>\n } @else {\n <p class=\"tk-uploader-file-card__status-text\">{{ statusText() }}</p>\n }\n </div>\n\n <tk-button\n variant=\"outlined\"\n [severity]=\"trailingSeverity()\"\n size=\"small\"\n [icon]=\"trailingIcon()\"\n [tooltipText]=\"trailingLabel()\"\n class=\"tk-uploader-file-card__remove\"\n [disabled]=\"isPendingRemoval()\"\n (clicked)=\"dismissed.emit()\" />\n</article>\n", styles: [".tk-uploader-file-card{--card-status-color: var(--tk-color-border-default);position:relative;display:flex;align-items:center;gap:var(--tk-spacing-base-150);padding:var(--tk-spacing-base-100);background-color:var(--tk-color-background-soft);border:1px solid var(--card-status-color);border-radius:var(--tk-borderRadius-m);overflow:hidden;width:100%;transition:border-color .2s ease-in-out}.tk-uploader-file-card__media{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:3.5rem;height:3.5rem;border-radius:var(--tk-borderRadius-s);background-color:var(--tk-color-primary-muted);overflow:hidden}.tk-uploader-file-card__media-icon{color:var(--tk-color-primary-default);font-size:1.5rem}.tk-uploader-file-card__preview{width:100%;height:100%;object-fit:cover}.tk-uploader-file-card__info{flex:1 0 0;min-width:0;display:flex;flex-direction:column;gap:var(--tk-spacing-base-50)}.tk-uploader-file-card__title{margin:0;font-size:var(--tk-font-size-paragraph-m);font-weight:600;color:var(--tk-color-text-default);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tk-uploader-file-card__status-text{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default)}.tk-uploader-file-card__progress{display:flex;flex-direction:column}.tk-uploader-file-card__remove{flex-shrink:0}.tk-uploader-file-card[data-status=success]{--card-status-color: var(--tk-color-feedback-success-default)}.tk-uploader-file-card[data-status=error],.tk-uploader-file-card[data-status=invalid]{--card-status-color: var(--tk-color-feedback-danger-default)}\n"], dependencies: [{ kind: "component", type: ButtonComponent, selector: "tk-button", inputs: ["label", "disabled", "type", "severity", "variant", "link", "icon", "iconPosition", "tooltipText", "full", "ariaLabel", "size"], outputs: ["clicked"] }, { kind: "component", type: IconComponent, selector: "tk-icon", inputs: ["icon", "styleIcon", "color", "size", "disabled"] }, { kind: "component", type: ProgressBarComponent, selector: "tk-progress-bar", inputs: ["value", "showValue", "size"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
715
+ }
716
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderFileCardComponent, decorators: [{
717
+ type: Component,
718
+ args: [{ selector: 'tk-uploader-file-card', standalone: true, imports: [ButtonComponent, IconComponent, ProgressBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<article class=\"tk-uploader-file-card\" [attr.data-status]=\"status()\">\n <div class=\"tk-uploader-file-card__media\">\n @if (previewUrl()) {\n <img\n [src]=\"previewUrl()\"\n alt=\"File preview\"\n class=\"tk-uploader-file-card__preview\" />\n } @else {\n <tk-icon [icon]=\"icon()\" class=\"tk-uploader-file-card__media-icon\" />\n }\n </div>\n\n <div class=\"tk-uploader-file-card__info\">\n <h4 class=\"tk-uploader-file-card__title\">{{ title() }}</h4>\n\n @if (isProcessing()) {\n <!-- statusText is intentionally hidden while loading: tk-progress-bar only shows a\n number, so with multiple files (no per-item tk-process-steps) there's no textual\n feedback during this state \u2014 see uploader.mdx \"Known limitations\". -->\n <div class=\"tk-uploader-file-card__progress\">\n <tk-progress-bar [value]=\"progress()\" size=\"medium\" [showValue]=\"true\" />\n </div>\n } @else {\n <p class=\"tk-uploader-file-card__status-text\">{{ statusText() }}</p>\n }\n </div>\n\n <tk-button\n variant=\"outlined\"\n [severity]=\"trailingSeverity()\"\n size=\"small\"\n [icon]=\"trailingIcon()\"\n [tooltipText]=\"trailingLabel()\"\n class=\"tk-uploader-file-card__remove\"\n [disabled]=\"isPendingRemoval()\"\n (clicked)=\"dismissed.emit()\" />\n</article>\n", styles: [".tk-uploader-file-card{--card-status-color: var(--tk-color-border-default);position:relative;display:flex;align-items:center;gap:var(--tk-spacing-base-150);padding:var(--tk-spacing-base-100);background-color:var(--tk-color-background-soft);border:1px solid var(--card-status-color);border-radius:var(--tk-borderRadius-m);overflow:hidden;width:100%;transition:border-color .2s ease-in-out}.tk-uploader-file-card__media{display:flex;align-items:center;justify-content:center;flex-shrink:0;width:3.5rem;height:3.5rem;border-radius:var(--tk-borderRadius-s);background-color:var(--tk-color-primary-muted);overflow:hidden}.tk-uploader-file-card__media-icon{color:var(--tk-color-primary-default);font-size:1.5rem}.tk-uploader-file-card__preview{width:100%;height:100%;object-fit:cover}.tk-uploader-file-card__info{flex:1 0 0;min-width:0;display:flex;flex-direction:column;gap:var(--tk-spacing-base-50)}.tk-uploader-file-card__title{margin:0;font-size:var(--tk-font-size-paragraph-m);font-weight:600;color:var(--tk-color-text-default);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tk-uploader-file-card__status-text{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default)}.tk-uploader-file-card__progress{display:flex;flex-direction:column}.tk-uploader-file-card__remove{flex-shrink:0}.tk-uploader-file-card[data-status=success]{--card-status-color: var(--tk-color-feedback-success-default)}.tk-uploader-file-card[data-status=error],.tk-uploader-file-card[data-status=invalid]{--card-status-color: var(--tk-color-feedback-danger-default)}\n"] }]
719
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: true }] }], status: [{ type: i0.Input, args: [{ isSignal: true, alias: "status", required: true }] }], progress: [{ type: i0.Input, args: [{ isSignal: true, alias: "progress", required: false }] }], previewUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "previewUrl", required: false }] }], icon: [{ type: i0.Input, args: [{ isSignal: true, alias: "icon", required: false }] }], statusText: [{ type: i0.Input, args: [{ isSignal: true, alias: "statusText", required: true }] }], isPendingRemoval: [{ type: i0.Input, args: [{ isSignal: true, alias: "isPendingRemoval", required: false }] }], trailingIcon: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingIcon", required: false }] }], trailingSeverity: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingSeverity", required: false }] }], trailingLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "trailingLabel", required: false }] }], dismissed: [{ type: i0.Output, args: ["dismissed"] }] } });
720
+
721
+ const TERMINAL_STATUSES = new Set(['success', 'error', 'invalid']);
722
+ const DEFAULT_STEPS_TITLE = 'Processing';
723
+ function resolveRemovePhase(status) {
724
+ if (status === 'loading') {
725
+ return 'in-progress';
726
+ }
727
+ return TERMINAL_STATUSES.has(status) ? 'post-upload' : 'pre-upload';
728
+ }
729
+ /**
730
+ * Drag-and-drop upload zone with a queue of file cards. Fully agnostic: it
731
+ * has no opinion on *how* a file gets uploaded — validation, upload
732
+ * mechanics, post-upload polling and every lifecycle callback are supplied
733
+ * by the consumer through `config` (`UploadFlowConfig`). Requires
734
+ * `provideUploader()` in the providers of the hosting component (or a parent).
735
+ */
736
+ class UploaderComponent {
737
+ constructor() {
738
+ this.queueService = inject(UploaderQueueService);
739
+ /** Idle-state heading, shown before any file is selected. */
740
+ this.title = input('Drag and drop your file here', ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
741
+ /** Whether more than one file can be added to the queue at once. */
742
+ this.multiple = input(false, ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
743
+ /** Idle-state helper text. */
744
+ this.subtitle = input('or click to browse', ...(ngDevMode ? [{ debugName: "subtitle" }] : /* istanbul ignore next */ []));
745
+ /** Optional helper line shown below the drop zone while it's empty. */
746
+ this.helperText = input(null, ...(ngDevMode ? [{ debugName: "helperText" }] : /* istanbul ignore next */ []));
747
+ /** Label for the optional link appended to `helperText`. */
748
+ this.helperLinkText = input(null, ...(ngDevMode ? [{ debugName: "helperLinkText" }] : /* istanbul ignore next */ []));
749
+ /** URL for the optional link appended to `helperText`. */
750
+ this.helperLinkUrl = input(null, ...(ngDevMode ? [{ debugName: "helperLinkUrl" }] : /* istanbul ignore next */ []));
751
+ /** Everything this instance needs to run its flow — see `UploadFlowConfig`. */
752
+ this.config = input.required(...(ngDevMode ? [{ debugName: "config" }] : /* istanbul ignore next */ []));
753
+ /**
754
+ * Terminal "existing file" card shown instead of the idle dropzone while
755
+ * the queue is empty (e.g. edit mode: an already-loaded entity) — see
756
+ * `LoaderPlaceholder`.
757
+ */
758
+ this.placeholder = input(null, ...(ngDevMode ? [{ debugName: "placeholder" }] : /* istanbul ignore next */ []));
759
+ /**
760
+ * Ids the consumer is currently waiting on its own confirmation for (see
761
+ * `removeRequested`). Matching cards disable their remove button so the
762
+ * user can't double-trigger a request while a decision is pending — this
763
+ * component still has no opinion on what that confirmation looks like.
764
+ */
765
+ this.pendingRemovalIds = input([], ...(ngDevMode ? [{ debugName: "pendingRemovalIds" }] : /* istanbul ignore next */ []));
766
+ /**
767
+ * Tooltip/accessible label for a real queue item's remove button — `tk-uploader` has no
768
+ * copy of its own (same reasoning as `UploadFlowConfig.resolveText`), so the button stays
769
+ * unlabeled unless the consumer supplies text here.
770
+ */
771
+ this.removeLabel = input(...(ngDevMode ? [undefined, { debugName: "removeLabel" }] : /* istanbul ignore next */ []));
772
+ /** Tooltip/accessible label for the `placeholder` card's trailing (re-upload) button. */
773
+ this.placeholderReplaceLabel = input(...(ngDevMode ? [undefined, { debugName: "placeholderReplaceLabel" }] : /* istanbul ignore next */ []));
774
+ /** Emitted with the raw `File`(s) as soon as they're picked/dropped — `File[]` when `multiple`, a single `File` otherwise. */
775
+ this.fileSelected = output();
776
+ /**
777
+ * Emitted when a file-card's remove button is clicked — `tk-uploader` never
778
+ * removes the item itself. Inspect `phase` to decide whether to confirm
779
+ * (typically for `in-progress`) before calling
780
+ * `UploaderQueueService.removeFromQueue(id)`.
781
+ */
782
+ this.removeRequested = output();
783
+ /**
784
+ * Emitted when an item's `tk-process-steps` countdown (`stepsCountdownSeconds`)
785
+ * reaches zero — `tk-uploader` never acts on it itself (doesn't remove the
786
+ * item, doesn't close anything). The consumer decides what that means: close
787
+ * a modal, remove the item, show a toast, etc.
788
+ */
789
+ this.stepsCountdownFinished = output();
790
+ this.fileInput = viewChild.required('fileInput');
791
+ this.queue = this.queueService.uploadQueue;
792
+ this.isDragging = signal(false, ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
793
+ /**
794
+ * Files already in a terminal state (`success` / `error` / `invalid`) are
795
+ * done — they don't consume capacity anymore even though they stay visible
796
+ * in the queue until manually removed. Capacity is about what's still
797
+ * in-flight, not the size of the visible history.
798
+ */
799
+ this.activeCount = computed(() => this.queue().filter((item) => !TERMINAL_STATUSES.has(item.status)).length, ...(ngDevMode ? [{ debugName: "activeCount" }] : /* istanbul ignore next */ []));
800
+ this.isAtCapacity = computed(() => !this.multiple() && this.activeCount() >= 1, ...(ngDevMode ? [{ debugName: "isAtCapacity" }] : /* istanbul ignore next */ []));
801
+ /**
802
+ * Native `accept` attribute for the hidden `<input type="file">`, derived
803
+ * from `config.restrictions` (`rules[].extensions` or `allowedExtensions`)
804
+ * — the same list that actually validates a file — so there is a single
805
+ * source of truth instead of a separate, independently-settable input that
806
+ * could drift out of sync with it. This is only a hint for the OS file
807
+ * picker: it does not filter drag & drop and does not validate anything.
808
+ */
809
+ this.accept = computed(() => {
810
+ const restrictions = this.config().restrictions;
811
+ if (!restrictions) {
812
+ return '';
813
+ }
814
+ const extensions = restrictions.rules?.length
815
+ ? restrictions.rules.flatMap((rule) => rule.extensions)
816
+ : restrictions.allowedExtensions;
817
+ return extensions?.join(',') ?? '';
818
+ }, ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
819
+ this.dragCounter = 0;
820
+ }
821
+ onDragOver(event) {
822
+ if (this.isAtCapacity()) {
823
+ return;
824
+ }
825
+ event.preventDefault();
826
+ event.stopPropagation();
827
+ }
828
+ onDragEnter(event) {
829
+ if (this.isAtCapacity()) {
830
+ return;
831
+ }
832
+ event.preventDefault();
833
+ event.stopPropagation();
834
+ this.dragCounter++;
835
+ this.isDragging.set(true);
836
+ }
837
+ onDragLeave(event) {
838
+ event.preventDefault();
839
+ event.stopPropagation();
840
+ this.dragCounter--;
841
+ if (this.dragCounter === 0) {
842
+ this.isDragging.set(false);
843
+ }
844
+ }
845
+ onDrop(event) {
846
+ if (this.isAtCapacity()) {
847
+ return;
848
+ }
849
+ event.preventDefault();
850
+ event.stopPropagation();
851
+ this.dragCounter = 0;
852
+ this.isDragging.set(false);
853
+ if (event.dataTransfer?.files) {
854
+ this.handleFiles(event.dataTransfer.files);
855
+ }
856
+ }
857
+ onFileChange(event) {
858
+ if (this.isAtCapacity()) {
859
+ return;
860
+ }
861
+ const input = event.target;
862
+ if (input.files) {
863
+ this.handleFiles(input.files);
864
+ }
865
+ }
866
+ onCancelItem(id) {
867
+ const item = this.queue().find((i) => i.id === id);
868
+ if (!item) {
869
+ return;
870
+ }
871
+ this.removeRequested.emit({ id, phase: resolveRemovePhase(item.status) });
872
+ }
873
+ isPendingRemoval(id) {
874
+ return this.pendingRemovalIds().includes(id);
875
+ }
876
+ stepsTitleFor(stepsTitle) {
877
+ return stepsTitle ?? DEFAULT_STEPS_TITLE;
878
+ }
879
+ /** Opens the OS file picker programmatically — a no-op while `isAtCapacity()`. */
880
+ triggerBrowse() {
881
+ if (this.isAtCapacity()) {
882
+ return;
883
+ }
884
+ this.fileInput().nativeElement.click();
885
+ }
886
+ handleFiles(fileList) {
887
+ const allFiles = Array.from(fileList);
888
+ if (allFiles.length === 0) {
889
+ return;
890
+ }
891
+ const files = this.multiple() ? allFiles : allFiles.slice(0, 1);
892
+ this.queueService.addToQueue(files, this.config());
893
+ this.emitFileSelection(files);
894
+ }
895
+ emitFileSelection(files) {
896
+ if (this.multiple()) {
897
+ this.fileSelected.emit(files);
898
+ }
899
+ else {
900
+ this.fileSelected.emit(files[0]);
901
+ }
902
+ }
903
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
904
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: UploaderComponent, isStandalone: true, selector: "tk-uploader", inputs: { title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, helperLinkText: { classPropertyName: "helperLinkText", publicName: "helperLinkText", isSignal: true, isRequired: false, transformFunction: null }, helperLinkUrl: { classPropertyName: "helperLinkUrl", publicName: "helperLinkUrl", isSignal: true, isRequired: false, transformFunction: null }, config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: true, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, pendingRemovalIds: { classPropertyName: "pendingRemovalIds", publicName: "pendingRemovalIds", isSignal: true, isRequired: false, transformFunction: null }, removeLabel: { classPropertyName: "removeLabel", publicName: "removeLabel", isSignal: true, isRequired: false, transformFunction: null }, placeholderReplaceLabel: { classPropertyName: "placeholderReplaceLabel", publicName: "placeholderReplaceLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { fileSelected: "fileSelected", removeRequested: "removeRequested", stepsCountdownFinished: "stepsCountdownFinished" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n class=\"tk-uploader\"\n [class.tk-uploader--dragging]=\"isDragging()\"\n [class.tk-uploader--has-file]=\"queue().length > 0 || !!placeholder()\"\n [class.tk-uploader--disabled]=\"isAtCapacity()\"\n (dragover)=\"onDragOver($event)\"\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n <input\n #fileInput\n type=\"file\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileChange($event)\"\n hidden />\n\n @if (queue().length === 0) {\n @if (placeholder()) {\n <!-- Stays a div, not a button: tk-uploader-file-card renders its own trailing\n <button> (replace icon), and a <button> cannot contain another <button>. -->\n <div\n class=\"tk-uploader__placeholder\"\n [class.tk-uploader__placeholder--dragging]=\"isDragging()\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"fileInput.click()\"\n (keydown.enter)=\"fileInput.click()\"\n (keydown.space)=\"fileInput.click(); $event.preventDefault()\">\n <tk-uploader-file-card\n [title]=\"placeholder()!.title\"\n [status]=\"placeholder()!.status\"\n [statusText]=\"placeholder()!.statusText\"\n [progress]=\"placeholder()!.progress ?? 0\"\n [previewUrl]=\"placeholder()!.previewUrl\"\n trailingIcon=\"upload\"\n trailingSeverity=\"secondary\"\n [trailingLabel]=\"placeholderReplaceLabel()\"\n (dismissed)=\"fileInput.click()\" />\n </div>\n } @else {\n <!-- Native <button>, not a div with role=\"button\": no nested interactive\n children here, so the accessible, semantic element is the right fit. -->\n <button type=\"button\" class=\"tk-uploader__idle\" (click)=\"fileInput.click()\">\n <div class=\"tk-uploader__illustration\">\n <tk-icon icon=\"upload\" color=\"primary\" />\n </div>\n\n <div class=\"tk-uploader__text\">\n <h3 class=\"tk-uploader__title\">\n {{ title() }}\n </h3>\n <p class=\"tk-uploader__subtitle\">\n {{ subtitle() }}\n </p>\n </div>\n </button>\n }\n } @else {\n <div class=\"tk-uploader__list\">\n @for (item of queue(); track item.id) {\n <tk-uploader-file-card\n [title]=\"item.file.name\"\n [status]=\"item.status\"\n [statusText]=\"item.statusText\"\n [progress]=\"item.progress\"\n [previewUrl]=\"item.previewUrl\"\n [isPendingRemoval]=\"isPendingRemoval(item.id)\"\n [trailingLabel]=\"removeLabel()\"\n (dismissed)=\"onCancelItem(item.id)\" />\n\n <!-- Gated to !multiple: a step-by-step breakdown per item gets noisy with several\n files uploading at once, so multiple falls back to the plain progress bar even\n when checkStatus returns steps. See uploader.mdx \"Known limitations\". -->\n @if (!multiple() && item.steps) {\n <tk-process-steps\n [steps]=\"item.steps\"\n [title]=\"stepsTitleFor(item.stepsTitle)\"\n [description]=\"item.stepsDescription\"\n [status]=\"item.stepsStatus ?? 'progress'\"\n [showIcon]=\"false\"\n [compact]=\"true\"\n [countdownSeconds]=\"item.stepsCountdownSeconds\"\n [countdownLabel]=\"item.stepsCountdownLabel ?? 'Closing in {seconds}s\u2026'\"\n (countdownFinished)=\"stepsCountdownFinished.emit(item.id)\" />\n }\n }\n </div>\n }\n</div>\n\n@if (queue().length === 0 && helperText()) {\n <div class=\"tk-uploader__helper\">\n <span>{{ helperText() }}</span>\n @if (helperLinkText() && helperLinkUrl()) {\n <a\n [href]=\"helperLinkUrl()\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"tk-uploader__helper-link\">\n {{ helperLinkText() }}\n </a>\n }\n </div>\n}\n", styles: [".tk-uploader{position:relative;overflow:hidden;border:1px dashed var(--tk-color-border-default);border-radius:var(--tk-borderRadius-m);background-color:var(--tk-color-background-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:all .2s ease-in-out;width:100%}.tk-uploader--has-file{border-color:transparent;border-radius:0;background-color:var(--tk-color-base-surface-0);justify-content:flex-start;padding:0;overflow:visible}.tk-uploader--dragging{background-color:var(--tk-color-primary-muted);border-color:var(--tk-color-border-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100)}.tk-uploader--dragging>*{pointer-events:none}.tk-uploader--disabled{cursor:default;opacity:.8}.tk-uploader--disabled .tk-uploader__idle,.tk-uploader--disabled .tk-uploader__placeholder{cursor:default;pointer-events:none}.tk-uploader__idle{border:none;background:none;padding:0;margin:0;font:inherit;color:inherit;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--tk-spacing-base-150);text-align:center;width:100%;height:100%}.tk-uploader__illustration{display:flex;align-items:center;justify-content:center}.tk-uploader__text{display:flex;flex-direction:column;align-items:center;gap:var(--tk-spacing-base-100)}.tk-uploader__title{margin:0;font-size:var(--tk-font-size-paragraph-m);color:var(--tk-color-text-default);font-weight:600}.tk-uploader__subtitle{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);font-weight:400}.tk-uploader__list,.tk-uploader__placeholder{width:100%;display:flex;flex-direction:column;gap:var(--tk-spacing-base-100);transition:transform .2s ease-in-out}.tk-uploader__list--dragging,.tk-uploader__placeholder--dragging{filter:brightness(1.05)}.tk-uploader__helper{display:flex;flex-wrap:wrap;gap:var(--tk-spacing-base-25);font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);margin-top:var(--tk-spacing-base-100)}.tk-uploader__helper-link{color:var(--tk-color-primary-default);font-weight:600;text-decoration:none}.tk-uploader__helper-link:hover{text-decoration:underline}\n"], dependencies: [{ kind: "component", type: UploaderFileCardComponent, selector: "tk-uploader-file-card", inputs: ["title", "status", "progress", "previewUrl", "icon", "statusText", "isPendingRemoval", "trailingIcon", "trailingSeverity", "trailingLabel"], outputs: ["dismissed"] }, { kind: "component", type: IconComponent, selector: "tk-icon", inputs: ["icon", "styleIcon", "color", "size", "disabled"] }, { kind: "component", type: ProcessStepsComponent, selector: "tk-process-steps", inputs: ["steps", "title", "description", "icon", "status", "showIcon", "compact", "errors", "countdownSeconds", "countdownLabel"], outputs: ["countdownFinished"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
905
+ }
906
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderComponent, decorators: [{
907
+ type: Component,
908
+ args: [{ selector: 'tk-uploader', standalone: true, imports: [UploaderFileCardComponent, IconComponent, ProcessStepsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div\n class=\"tk-uploader\"\n [class.tk-uploader--dragging]=\"isDragging()\"\n [class.tk-uploader--has-file]=\"queue().length > 0 || !!placeholder()\"\n [class.tk-uploader--disabled]=\"isAtCapacity()\"\n (dragover)=\"onDragOver($event)\"\n (dragenter)=\"onDragEnter($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n <input\n #fileInput\n type=\"file\"\n [accept]=\"accept()\"\n [multiple]=\"multiple()\"\n (change)=\"onFileChange($event)\"\n hidden />\n\n @if (queue().length === 0) {\n @if (placeholder()) {\n <!-- Stays a div, not a button: tk-uploader-file-card renders its own trailing\n <button> (replace icon), and a <button> cannot contain another <button>. -->\n <div\n class=\"tk-uploader__placeholder\"\n [class.tk-uploader__placeholder--dragging]=\"isDragging()\"\n role=\"button\"\n tabindex=\"0\"\n (click)=\"fileInput.click()\"\n (keydown.enter)=\"fileInput.click()\"\n (keydown.space)=\"fileInput.click(); $event.preventDefault()\">\n <tk-uploader-file-card\n [title]=\"placeholder()!.title\"\n [status]=\"placeholder()!.status\"\n [statusText]=\"placeholder()!.statusText\"\n [progress]=\"placeholder()!.progress ?? 0\"\n [previewUrl]=\"placeholder()!.previewUrl\"\n trailingIcon=\"upload\"\n trailingSeverity=\"secondary\"\n [trailingLabel]=\"placeholderReplaceLabel()\"\n (dismissed)=\"fileInput.click()\" />\n </div>\n } @else {\n <!-- Native <button>, not a div with role=\"button\": no nested interactive\n children here, so the accessible, semantic element is the right fit. -->\n <button type=\"button\" class=\"tk-uploader__idle\" (click)=\"fileInput.click()\">\n <div class=\"tk-uploader__illustration\">\n <tk-icon icon=\"upload\" color=\"primary\" />\n </div>\n\n <div class=\"tk-uploader__text\">\n <h3 class=\"tk-uploader__title\">\n {{ title() }}\n </h3>\n <p class=\"tk-uploader__subtitle\">\n {{ subtitle() }}\n </p>\n </div>\n </button>\n }\n } @else {\n <div class=\"tk-uploader__list\">\n @for (item of queue(); track item.id) {\n <tk-uploader-file-card\n [title]=\"item.file.name\"\n [status]=\"item.status\"\n [statusText]=\"item.statusText\"\n [progress]=\"item.progress\"\n [previewUrl]=\"item.previewUrl\"\n [isPendingRemoval]=\"isPendingRemoval(item.id)\"\n [trailingLabel]=\"removeLabel()\"\n (dismissed)=\"onCancelItem(item.id)\" />\n\n <!-- Gated to !multiple: a step-by-step breakdown per item gets noisy with several\n files uploading at once, so multiple falls back to the plain progress bar even\n when checkStatus returns steps. See uploader.mdx \"Known limitations\". -->\n @if (!multiple() && item.steps) {\n <tk-process-steps\n [steps]=\"item.steps\"\n [title]=\"stepsTitleFor(item.stepsTitle)\"\n [description]=\"item.stepsDescription\"\n [status]=\"item.stepsStatus ?? 'progress'\"\n [showIcon]=\"false\"\n [compact]=\"true\"\n [countdownSeconds]=\"item.stepsCountdownSeconds\"\n [countdownLabel]=\"item.stepsCountdownLabel ?? 'Closing in {seconds}s\u2026'\"\n (countdownFinished)=\"stepsCountdownFinished.emit(item.id)\" />\n }\n }\n </div>\n }\n</div>\n\n@if (queue().length === 0 && helperText()) {\n <div class=\"tk-uploader__helper\">\n <span>{{ helperText() }}</span>\n @if (helperLinkText() && helperLinkUrl()) {\n <a\n [href]=\"helperLinkUrl()\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"tk-uploader__helper-link\">\n {{ helperLinkText() }}\n </a>\n }\n </div>\n}\n", styles: [".tk-uploader{position:relative;overflow:hidden;border:1px dashed var(--tk-color-border-default);border-radius:var(--tk-borderRadius-m);background-color:var(--tk-color-background-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100);cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:all .2s ease-in-out;width:100%}.tk-uploader--has-file{border-color:transparent;border-radius:0;background-color:var(--tk-color-base-surface-0);justify-content:flex-start;padding:0;overflow:visible}.tk-uploader--dragging{background-color:var(--tk-color-primary-muted);border-color:var(--tk-color-border-default);padding:var(--tk-spacing-base-200) var(--tk-spacing-base-100)}.tk-uploader--dragging>*{pointer-events:none}.tk-uploader--disabled{cursor:default;opacity:.8}.tk-uploader--disabled .tk-uploader__idle,.tk-uploader--disabled .tk-uploader__placeholder{cursor:default;pointer-events:none}.tk-uploader__idle{border:none;background:none;padding:0;margin:0;font:inherit;color:inherit;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--tk-spacing-base-150);text-align:center;width:100%;height:100%}.tk-uploader__illustration{display:flex;align-items:center;justify-content:center}.tk-uploader__text{display:flex;flex-direction:column;align-items:center;gap:var(--tk-spacing-base-100)}.tk-uploader__title{margin:0;font-size:var(--tk-font-size-paragraph-m);color:var(--tk-color-text-default);font-weight:600}.tk-uploader__subtitle{margin:0;font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);font-weight:400}.tk-uploader__list,.tk-uploader__placeholder{width:100%;display:flex;flex-direction:column;gap:var(--tk-spacing-base-100);transition:transform .2s ease-in-out}.tk-uploader__list--dragging,.tk-uploader__placeholder--dragging{filter:brightness(1.05)}.tk-uploader__helper{display:flex;flex-wrap:wrap;gap:var(--tk-spacing-base-25);font-size:var(--tk-font-size-paragraph-s);color:var(--tk-color-text-default);margin-top:var(--tk-spacing-base-100)}.tk-uploader__helper-link{color:var(--tk-color-primary-default);font-weight:600;text-decoration:none}.tk-uploader__helper-link:hover{text-decoration:underline}\n"] }]
909
+ }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], helperText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperText", required: false }] }], helperLinkText: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperLinkText", required: false }] }], helperLinkUrl: [{ type: i0.Input, args: [{ isSignal: true, alias: "helperLinkUrl", required: false }] }], config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: true }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], pendingRemovalIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "pendingRemovalIds", required: false }] }], removeLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "removeLabel", required: false }] }], placeholderReplaceLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholderReplaceLabel", required: false }] }], fileSelected: [{ type: i0.Output, args: ["fileSelected"] }], removeRequested: [{ type: i0.Output, args: ["removeRequested"] }], stepsCountdownFinished: [{ type: i0.Output, args: ["stepsCountdownFinished"] }], fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }] } });
910
+
911
+ /**
912
+ * Default `IUploaderStore` — a plain in-memory signal, nothing persisted.
913
+ * Registered by `provideUploader()` under `UPLOADER_STORE`; one instance per
914
+ * `tk-uploader` (not `providedIn: 'root'`), so each gets its own queue.
915
+ */
916
+ class UploaderStore {
917
+ constructor() {
918
+ this.queue = signal([], ...(ngDevMode ? [{ debugName: "queue" }] : /* istanbul ignore next */ []));
919
+ this.uploadQueue = this.queue.asReadonly();
920
+ }
921
+ add(item) {
922
+ this.queue.update((items) => [...items, item]);
923
+ }
924
+ update(id, updates) {
925
+ this.queue.update((items) => items.map((item) => (item.id === id ? { ...item, ...updates } : item)));
926
+ }
927
+ remove(id) {
928
+ this.queue.update((items) => items.filter((item) => item.id !== id));
929
+ }
930
+ clear() {
931
+ this.queue.set([]);
932
+ }
933
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
934
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore }); }
935
+ }
936
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderStore, decorators: [{
937
+ type: Injectable
938
+ }] });
939
+
940
+ /**
941
+ * Default `IUploaderPreviewProvider` — generates a client-side preview/
942
+ * metadata pass during `analyzing` (dimensions for images/video, duration
943
+ * for audio/video, a downscaled preview frame), entirely in the browser
944
+ * with no network call. Registered by `provideUploader()` under
945
+ * `UPLOADER_PREVIEW_PROVIDER`.
946
+ */
947
+ class UploaderPreviewProvider {
948
+ /** Kicks off the analysis as an Angular `resource()`, re-run if `file` changes identity. */
949
+ generatePreview(file) {
950
+ return resource({
951
+ params: () => file,
952
+ loader: ({ params: f }) => this.analyzeFile(f)
953
+ });
954
+ }
955
+ async analyzeFile(file) {
956
+ const type = this.normalizeMimeType(file);
957
+ try {
958
+ return await this.dispatchByType(file, type);
959
+ }
960
+ catch (error) {
961
+ return this.createErrorResult(file, type, error);
962
+ }
963
+ }
964
+ dispatchByType(file, type) {
965
+ if (type.startsWith('image/')) {
966
+ return this.processImageFile(file, type);
967
+ }
968
+ if (type.startsWith('video/')) {
969
+ return this.processVideoFile(file, type);
970
+ }
971
+ if (type.startsWith('audio/')) {
972
+ return this.processAudioFile(file, type);
973
+ }
974
+ return Promise.resolve(this.createAnalysisResult(file, type, { size: file.size }));
975
+ }
976
+ createAnalysisResult(file, mimeType, metadata, previews) {
977
+ return {
978
+ previewUrl: previews?.url || '',
979
+ previewBase64: previews?.base64 || '',
980
+ previewBlob: previews?.blob,
981
+ analysisError: false,
982
+ metadata: { ...metadata, mimeType }
983
+ };
984
+ }
985
+ createErrorResult(file, mimeType, error) {
986
+ return {
987
+ previewUrl: '',
988
+ previewBase64: '',
989
+ previewBlob: undefined,
990
+ analysisError: true,
991
+ technicalError: this.resolveTechnicalError(mimeType),
992
+ errorDetail: error,
993
+ metadata: { size: file.size, mimeType }
994
+ };
995
+ }
996
+ resolveTechnicalError(mimeType) {
997
+ const errorMap = {
998
+ 'image/': 'PROCESS_FAILED',
999
+ 'video/': 'PROCESS_FAILED',
1000
+ 'audio/': 'PROCESS_FAILED'
1001
+ };
1002
+ return (Object.entries(errorMap).find(([key]) => mimeType.startsWith(key))?.[1] ||
1003
+ 'ANALYSIS_FAILED');
1004
+ }
1005
+ resolveMimeFromExtension(file) {
1006
+ const extension = file.name.split('.').pop()?.toLowerCase();
1007
+ const mimeMap = {
1008
+ zip: 'application/zip',
1009
+ pdf: 'application/pdf',
1010
+ jpg: 'image/jpeg',
1011
+ jpeg: 'image/jpeg',
1012
+ png: 'image/png',
1013
+ gif: 'image/gif',
1014
+ mp4: 'video/mp4',
1015
+ mp3: 'audio/mpeg'
1016
+ };
1017
+ return extension ? mimeMap[extension] : undefined;
1018
+ }
1019
+ normalizeMimeType(file) {
1020
+ const byExtension = this.resolveMimeFromExtension(file);
1021
+ if (byExtension) {
1022
+ return byExtension;
1023
+ }
1024
+ const type = file.type.toLowerCase();
1025
+ const isZipVariant = type === 'application/x-zip-compressed' ||
1026
+ type === 'application/zip-compressed';
1027
+ return isZipVariant ? 'application/zip' : type || 'application/octet-stream';
1028
+ }
1029
+ async processImageFile(file, type) {
1030
+ const img = new Image();
1031
+ const objectUrl = URL.createObjectURL(file);
1032
+ img.src = objectUrl;
1033
+ try {
1034
+ await this.waitForReady(img, 'load');
1035
+ return await this.buildImageAnalysis(img, file, type);
1036
+ }
1037
+ finally {
1038
+ URL.revokeObjectURL(objectUrl);
1039
+ }
1040
+ }
1041
+ async buildImageAnalysis(img, file, type) {
1042
+ const preview = await this.captureScaledFrame(img, img.naturalWidth, img.naturalHeight);
1043
+ return this.createAnalysisResult(file, type, this.buildImageMetadata(img, file), preview);
1044
+ }
1045
+ buildImageMetadata(img, file) {
1046
+ return {
1047
+ width: img.naturalWidth,
1048
+ height: img.naturalHeight,
1049
+ aspectRatio: img.naturalWidth / img.naturalHeight,
1050
+ size: file.size
1051
+ };
1052
+ }
1053
+ async processAudioFile(file, type) {
1054
+ const audio = new Audio();
1055
+ const objectUrl = URL.createObjectURL(file);
1056
+ audio.src = objectUrl;
1057
+ try {
1058
+ await this.waitForReady(audio, 'loadedmetadata');
1059
+ return this.createAnalysisResult(file, type, {
1060
+ durationMs: audio.duration * 1000,
1061
+ size: file.size
1062
+ });
1063
+ }
1064
+ finally {
1065
+ URL.revokeObjectURL(objectUrl);
1066
+ }
1067
+ }
1068
+ async processVideoFile(file, type) {
1069
+ const video = this.createVideoElement(file);
1070
+ try {
1071
+ await this.waitForReady(video, 'loadedmetadata');
1072
+ await this.seekToTime(video, 1);
1073
+ return await this.buildVideoAnalysis(video, file, type);
1074
+ }
1075
+ finally {
1076
+ URL.revokeObjectURL(video.src);
1077
+ }
1078
+ }
1079
+ async buildVideoAnalysis(video, file, type) {
1080
+ const preview = await this.captureScaledFrame(video, video.videoWidth, video.videoHeight);
1081
+ return this.createAnalysisResult(file, type, this.buildVideoMetadata(video, file), preview);
1082
+ }
1083
+ buildVideoMetadata(video, file) {
1084
+ return {
1085
+ width: video.videoWidth,
1086
+ height: video.videoHeight,
1087
+ durationMs: video.duration * 1000,
1088
+ aspectRatio: video.videoWidth / video.videoHeight,
1089
+ size: file.size
1090
+ };
1091
+ }
1092
+ createVideoElement(file) {
1093
+ const video = document.createElement('video');
1094
+ video.muted = true;
1095
+ video.playsInline = true;
1096
+ video.src = URL.createObjectURL(file);
1097
+ return video;
1098
+ }
1099
+ async captureScaledFrame(source, naturalWidth, naturalHeight) {
1100
+ const { width, height } = this.calculateDimensions(naturalWidth, naturalHeight);
1101
+ return this.capturePreviewFrame(source, width, height);
1102
+ }
1103
+ async capturePreviewFrame(source, width, height) {
1104
+ const canvas = this.prepareCanvas(width, height);
1105
+ const ctx = canvas.getContext('2d');
1106
+ ctx?.drawImage(source, 0, 0, width, height);
1107
+ const base64 = canvas.toDataURL('image/jpeg', 0.8);
1108
+ const blob = (await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8)));
1109
+ const url = URL.createObjectURL(blob);
1110
+ return { url, blob, base64 };
1111
+ }
1112
+ async waitForReady(target, readyEvent) {
1113
+ await Promise.race([
1114
+ firstValueFrom(fromEvent(target, readyEvent)),
1115
+ firstValueFrom(fromEvent(target, 'error')).then(() => {
1116
+ throw new Error('PROCESS_FAILED');
1117
+ })
1118
+ ]);
1119
+ }
1120
+ seekToTime(video, seconds) {
1121
+ video.currentTime = seconds;
1122
+ return firstValueFrom(fromEvent(video, 'seeked'));
1123
+ }
1124
+ prepareCanvas(width, height) {
1125
+ const canvas = document.createElement('canvas');
1126
+ canvas.width = width;
1127
+ canvas.height = height;
1128
+ return canvas;
1129
+ }
1130
+ calculateDimensions(originalWidth, originalHeight, maxSize = 400) {
1131
+ if (originalWidth <= maxSize && originalHeight <= maxSize) {
1132
+ return { width: originalWidth, height: originalHeight };
1133
+ }
1134
+ const ratio = originalWidth / originalHeight;
1135
+ if (originalWidth > originalHeight) {
1136
+ return { width: maxSize, height: maxSize / ratio };
1137
+ }
1138
+ return { width: maxSize * ratio, height: maxSize };
1139
+ }
1140
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1141
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider }); }
1142
+ }
1143
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: UploaderPreviewProvider, decorators: [{
1144
+ type: Injectable
1145
+ }] });
1146
+
1147
+ /**
1148
+ * Default `IUploaderTransport` — a bare `HttpClient` (via `HttpBackend`, so
1149
+ * it bypasses app-level interceptors) that reports upload progress as a
1150
+ * plain 0-100 number. Registered by `provideUploader()` under
1151
+ * `UPLOADER_TRANSPORT`, for the consumer's own `steps`/`multipart.uploadPart`
1152
+ * functions to `inject()` — `tk-uploader` never calls this itself.
1153
+ */
1154
+ class TkUploadTransportService {
1155
+ constructor() {
1156
+ this.handler = inject(HttpBackend);
1157
+ this.http = new HttpClient(this.handler);
1158
+ }
1159
+ upload(url, file, method = 'PUT', headers = {}, reportProgress = true, observe = 'events', withCredentials = false) {
1160
+ const finalHeaders = this.resolveHeaders(headers ?? {}, file);
1161
+ if (observe === 'body') {
1162
+ return this.http.request(method, url, {
1163
+ body: file,
1164
+ headers: finalHeaders,
1165
+ reportProgress,
1166
+ observe: 'body',
1167
+ withCredentials
1168
+ });
1169
+ }
1170
+ return this.http
1171
+ .request(method, url, {
1172
+ body: file,
1173
+ headers: finalHeaders,
1174
+ reportProgress,
1175
+ observe: 'events',
1176
+ withCredentials
1177
+ })
1178
+ .pipe(map((event) => this.mapUploadEvent(event)));
1179
+ }
1180
+ /**
1181
+ * Merges the caller's headers with a normalized `Content-Type`
1182
+ * (defaulting `.zip`-flavored mime types to `application/zip`).
1183
+ * @private
1184
+ */
1185
+ resolveHeaders(headers, file) {
1186
+ const finalHeaders = { ...headers };
1187
+ const contentType = this.resolveContentType(headers, file);
1188
+ if (contentType) {
1189
+ finalHeaders['Content-Type'] = contentType;
1190
+ delete finalHeaders['content-type'];
1191
+ }
1192
+ return finalHeaders;
1193
+ }
1194
+ /**
1195
+ * @private
1196
+ */
1197
+ resolveContentType(headers, file) {
1198
+ const contentType = this.headerValue(headers['Content-Type']) ||
1199
+ this.headerValue(headers['content-type']) ||
1200
+ file.type ||
1201
+ '';
1202
+ const fileName = file instanceof File ? file.name : '';
1203
+ const isZip = fileName.toLowerCase().endsWith('.zip') ||
1204
+ contentType === 'application/x-zip-compressed' ||
1205
+ contentType === 'application/zip-compressed';
1206
+ return isZip ? 'application/zip' : contentType;
1207
+ }
1208
+ /**
1209
+ * @private
1210
+ */
1211
+ headerValue(value) {
1212
+ return Array.isArray(value) ? value.join(',') : (value ?? '');
1213
+ }
1214
+ /**
1215
+ * @private
1216
+ */
1217
+ mapUploadEvent(event) {
1218
+ switch (event.type) {
1219
+ case HttpEventType.UploadProgress:
1220
+ return Math.round((100 * event.loaded) / (event.total || 1));
1221
+ case HttpEventType.Response:
1222
+ return 100;
1223
+ default:
1224
+ return 0;
1225
+ }
1226
+ }
1227
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1228
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService }); }
1229
+ }
1230
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: TkUploadTransportService, decorators: [{
1231
+ type: Injectable
1232
+ }] });
1233
+
1234
+ /**
1235
+ * Registers every service `tk-uploader` depends on (queue, preview,
1236
+ * transport) — add to the `providers` of the component that hosts
1237
+ * `tk-uploader` (or a parent). Each instance gets its own queue.
1238
+ */
1239
+ function provideUploader() {
1240
+ return [
1241
+ UploaderStore,
1242
+ { provide: UPLOADER_STORE, useExisting: UploaderStore },
1243
+ UploaderPreviewProvider,
1244
+ { provide: UPLOADER_PREVIEW_PROVIDER, useExisting: UploaderPreviewProvider },
1245
+ TkUploadTransportService,
1246
+ { provide: UPLOADER_TRANSPORT, useExisting: TkUploadTransportService },
1247
+ UploaderQueueService
1248
+ ];
1249
+ }
1250
+
1251
+ /**
1252
+ * Generated bundle index. Do not edit.
1253
+ */
1254
+
1255
+ export { UPLOADER_PREVIEW_PROVIDER, UPLOADER_STORE, UPLOADER_TRANSPORT, UploaderComponent, UploaderQueueService, provideUploader };
1256
+ //# sourceMappingURL=tekus-design-system-components-uploader.mjs.map