UNPKG

wacom

Version:

Module which has common services, pipes, directives and interfaces which can be used on all projects.

1,247 lines (1,228 loc) 156 kB
import * as i0 from '@angular/core'; import { InjectionToken, Inject, Optional, Injectable, signal, inject, ChangeDetectorRef, output, ElementRef, DestroyRef, Directive, input, effect, Pipe, isSignal, ApplicationRef, EnvironmentInjector, createComponent, makeEnvironmentProviders, NgModule } from '@angular/core'; import * as i1 from '@angular/router'; import * as i2 from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser'; import { take, firstValueFrom, Subject, skip, takeUntil, share, filter, map, Observable, merge, combineLatest, timeout, ReplaySubject, EMPTY } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; import * as i1$1 from '@angular/common/http'; import { HttpHeaders, HttpErrorResponse, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { first, catchError } from 'rxjs/operators'; import * as i1$2 from '@angular/common'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; const CONFIG_TOKEN = new InjectionToken('config'); const DEFAULT_CONFIG = { store: { prefix: 'waStore', }, meta: { useTitleSuffix: false, warnMissingGuard: false, defaults: { links: {} }, }, socket: false, http: { url: '', headers: {}, }, }; const DEFAULT_HTTP_CONFIG = { headers: {}, url: '', }; const DEFAULT_NETWORK_CONFIG = { endpoints: [ 'https://api.webart.work/status', // Opaque but useful reachability fallbacks: 'https://www.google.com/generate_204', 'https://www.gstatic.com/generate_204', 'https://www.cloudflare.com/cdn-cgi/trace', ], intervalMs: 30_000, timeoutMs: 2_500, goodLatencyMs: 300, maxConsecutiveFails: 3, }; const NETWORK_CONFIG = new InjectionToken('NETWORK_CONFIG', { factory: () => DEFAULT_NETWORK_CONFIG, }); const isDefined = (val) => typeof val !== 'undefined'; class MetaService { constructor(config, router, meta, titleService) { this.config = config; this.router = router; this.meta = meta; this.titleService = titleService; this.config = this.config || DEFAULT_CONFIG; this._meta = this.config.meta || {}; this._warnMissingGuard(); } /** * Sets the default meta tags. * * @param defaults - The default meta tags. */ setDefaults(defaults) { this._meta.defaults = { ...this._meta.defaults, ...defaults, }; } /** * Sets the title and optional title suffix. * * @param title - The title to set. * @param titleSuffix - The title suffix to append. * @returns The MetaService instance. */ setTitle(title, titleSuffix) { let titleContent = isDefined(title) ? title || '' : this._meta.defaults?.['title'] || ''; if (this._meta.useTitleSuffix) { titleContent += isDefined(titleSuffix) ? titleSuffix : this._meta.defaults?.['titleSuffix'] || ''; } this._updateMetaTag('title', titleContent); this._updateMetaTag('og:title', titleContent); this._updateMetaTag('twitter:title', titleContent); this.titleService.setTitle(titleContent); return this; } /** * Sets link tags. * * @param links - The links to set. * @returns The MetaService instance. */ setLink(links) { Object.keys(links).forEach((rel) => { let link = document.createElement('link'); link.setAttribute('rel', rel); link.setAttribute('href', links[rel]); document.head.appendChild(link); }); return this; } /** * Sets a meta tag. * * @param tag - The meta tag name. * @param value - The meta tag value. * @param prop - The meta tag property. */ setTag(tag, value, prop) { if (tag === 'title' || tag === 'titleSuffix') { throw new Error(`Attempt to set ${tag} through 'setTag': 'title' and 'titleSuffix' are reserved. Use 'MetaService.setTitle' instead.`); } const content = (isDefined(value) ? value || '' : this._meta.defaults?.[tag] || '') + ''; this._updateMetaTag(tag, content, prop); if (tag === 'description') { this._updateMetaTag('og:description', content, prop); this._updateMetaTag('twitter:description', content, prop); } } /** * Updates a meta tag. * * @param tag - The meta tag name. * @param value - The meta tag value. * @param prop - The meta tag property. */ _updateMetaTag(tag, value, prop) { prop = prop || (tag.startsWith('og:') || tag.startsWith('twitter:') ? 'property' : 'name'); this.meta.updateTag({ [prop]: tag, content: value }); } /** * Removes a meta tag. * * @param tag - The meta tag name. * @param prop - The meta tag property. */ removeTag(tag, prop) { prop = prop || (tag.startsWith('og:') || tag.startsWith('twitter:') ? 'property' : 'name'); this.meta.removeTag(`${prop}="${tag}"`); } /** * Warns about missing meta guards in routes. */ _warnMissingGuard() { if (isDefined(this._meta.warnMissingGuard) && !this._meta.warnMissingGuard) { return; } const hasDefaultMeta = !!Object.keys(this._meta.defaults ?? {}).length; const hasMetaGuardInArr = (it) => it && it.IDENTIFIER === 'MetaGuard'; let hasShownWarnings = false; const checkRoute = (route) => { const hasRouteMeta = route.data && route.data['meta']; const showWarning = !isDefined(route.redirectTo) && (hasDefaultMeta || hasRouteMeta) && !(route.canActivate || []).some(hasMetaGuardInArr); if (showWarning) { console.warn(`Route with path "${route.path}" has ${hasRouteMeta ? '' : 'default '}meta tags, but does not use MetaGuard. Please add MetaGuard to the canActivate array in your route configuration`); hasShownWarnings = true; } (route.children || []).forEach(checkRoute); }; this.router.config.forEach(checkRoute); if (hasShownWarnings) { console.warn(`To disable these warnings, set metaConfig.warnMissingGuard: false in your MetaConfig passed to MetaModule.forRoot()`); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, deps: [{ token: CONFIG_TOKEN, optional: true }, { token: i1.Router }, { token: i2.Meta }, { token: i2.Title }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Inject, args: [CONFIG_TOKEN] }, { type: Optional }] }, { type: i1.Router }, { type: i2.Meta }, { type: i2.Title }] }); class MetaGuard { static { this.IDENTIFIER = 'MetaGuard'; } constructor(metaService, config) { this.metaService = metaService; this.config = config; if (!this.config) this.config = DEFAULT_CONFIG; this._meta = this.config.meta || {}; this._meta.defaults = this._meta.defaults || {}; } canActivate(route, state) { this._processRouteMetaTags(route.data && route.data['meta']); return true; } _processRouteMetaTags(meta = {}) { if (meta.disableUpdate) { return; } if (meta.title) { this.metaService.setTitle(meta.title, meta.titleSuffix); } if (meta.links && Object.keys(meta.links).length) { this.metaService.setLink(meta.links); } if (this._meta.defaults?.links && Object.keys(this._meta.defaults.links).length) { this.metaService.setLink(this._meta.defaults.links); } Object.keys(meta).forEach((prop) => { if (prop === 'title' || prop === 'titleSuffix' || prop === 'links') { return; } Object.keys(meta[prop]).forEach((key) => { this.metaService.setTag(key, meta[prop][key], prop); }); }); Object.keys(this._meta.defaults).forEach((key) => { if (key in meta || key === 'title' || key === 'titleSuffix' || key === 'links') { return; } this.metaService.setTag(key, this._meta.defaults[key]); }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, deps: [{ token: MetaService }, { token: CONFIG_TOKEN, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MetaGuard, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: MetaService }, { type: undefined, decorators: [{ type: Inject, args: [CONFIG_TOKEN] }, { type: Optional }] }] }); // Core utilities and helpers for the Wacom app // Add capitalize method to String prototype if it doesn't already exist if (!String.prototype.capitalize) { String.prototype.capitalize = function () { if (this.length > 0) { return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase(); } return ''; }; } class CoreService { constructor() { this.deviceID = localStorage.getItem('deviceID') || (typeof crypto?.randomUUID === 'function' ? crypto.randomUUID() : this.UUID()); // After While this._afterWhile = {}; // Device management this.device = ''; // Version management this.version = '1.0.0'; this.appVersion = ''; this.dateVersion = ''; // Locking management this._locked = {}; this._unlockResolvers = {}; localStorage.setItem('deviceID', this.deviceID); this.detectDevice(); } /** * Generates a UUID (Universally Unique Identifier) version 4. * * This implementation uses `Math.random()` to generate random values, * making it suitable for general-purpose identifiers, but **not** for * cryptographic or security-sensitive use cases. * * The format follows the UUID v4 standard: `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` * where: * - `x` is a random hexadecimal digit (0–f) * - `4` indicates UUID version 4 * - `y` is one of 8, 9, A, or B * * Example: `f47ac10b-58cc-4372-a567-0e02b2c3d479` * * @returns A string containing a UUID v4. */ UUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** * Converts an object to an array. Optionally holds keys instead of values. * * @param {any} obj - The object to be converted. * @param {boolean} [holder=false] - If true, the keys will be held in the array; otherwise, the values will be held. * @returns {any[]} The resulting array. */ ota(obj, holder = false) { if (Array.isArray(obj)) return obj; if (typeof obj !== 'object' || obj === null) return []; const arr = []; for (const each in obj) { if (obj.hasOwnProperty(each) && (obj[each] || typeof obj[each] === 'number' || typeof obj[each] === 'boolean')) { if (holder) { arr.push(each); } else { arr.push(obj[each]); } } } return arr; } /** * Removes elements from `fromArray` that are present in `removeArray` based on a comparison field. * * @param {any[]} removeArray - The array of elements to remove. * @param {any[]} fromArray - The array from which to remove elements. * @param {string} [compareField='_id'] - The field to use for comparison. * @returns {any[]} The modified `fromArray` with elements removed. */ splice(removeArray, fromArray, compareField = '_id') { if (!Array.isArray(removeArray) || !Array.isArray(fromArray)) { return fromArray; } const removeSet = new Set(removeArray.map((item) => item[compareField])); return fromArray.filter((item) => !removeSet.has(item[compareField])); } /** * Unites multiple _id values into a single unique _id. * The resulting _id is unique regardless of the order of the input _id values. * * @param {...string[]} args - The _id values to be united. * @returns {string} The unique combined _id. */ ids2id(...args) { args.sort((a, b) => { if (Number(a.toString().substring(0, 8)) > Number(b.toString().substring(0, 8))) { return 1; } return -1; }); return args.join(); } /** * Delays the execution of a callback function for a specified amount of time. * If called again within that time, the timer resets. * * @param {string | object | (() => void)} doc - A unique identifier for the timer, an object to host the timer, or the callback function. * @param {() => void} [cb] - The callback function to execute after the delay. * @param {number} [time=1000] - The delay time in milliseconds. */ afterWhile(doc, cb, time = 1000) { if (typeof doc === 'function') { cb = doc; doc = 'common'; } if (typeof cb === 'function' && typeof time === 'number') { if (typeof doc === 'string') { clearTimeout(this._afterWhile[doc]); this._afterWhile[doc] = window.setTimeout(cb, time); } else if (typeof doc === 'object') { clearTimeout(doc.__afterWhile); doc.__afterWhile = window.setTimeout(cb, time); } else { console.warn('badly configured after while'); } } } /** * Recursively copies properties from one object to another. * Handles nested objects, arrays, and Date instances appropriately. * * @param from - The source object from which properties are copied. * @param to - The target object to which properties are copied. */ copy(from, to) { for (const each in from) { if (typeof from[each] !== 'object' || from[each] instanceof Date || Array.isArray(from[each]) || from[each] === null) { to[each] = from[each]; } else { if (typeof to[each] !== 'object' || to[each] instanceof Date || Array.isArray(to[each]) || to[each] === null) { to[each] = {}; } this.copy(from[each], to[each]); } } } /** * Detects the device type based on the user agent. */ detectDevice() { const userAgent = navigator.userAgent || navigator.vendor || window.opera; if (/windows phone/i.test(userAgent)) { this.device = 'Windows Phone'; } else if (/android/i.test(userAgent)) { this.device = 'Android'; } else if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) { this.device = 'iOS'; } else { this.device = 'Web'; } } /** * Checks if the device is a mobile device. * @returns {boolean} - Returns true if the device is a mobile device. */ isMobile() { return (this.device === 'Windows Phone' || this.device === 'Android' || this.device === 'iOS'); } /** * Checks if the device is a tablet. * @returns {boolean} - Returns true if the device is a tablet. */ isTablet() { return this.device === 'iOS' && /iPad/.test(navigator.userAgent); } /** * Checks if the device is a web browser. * @returns {boolean} - Returns true if the device is a web browser. */ isWeb() { return this.device === 'Web'; } /** * Checks if the device is an Android device. * @returns {boolean} - Returns true if the device is an Android device. */ isAndroid() { return this.device === 'Android'; } /** * Checks if the device is an iOS device. * @returns {boolean} - Returns true if the device is an iOS device. */ isIos() { return this.device === 'iOS'; } /** * Sets the combined version string based on appVersion and dateVersion. */ setVersion() { this.version = this.appVersion || ''; this.version += this.version && this.dateVersion ? ' ' : ''; this.version += this.dateVersion || ''; } /** * Sets the app version and updates the combined version string. * * @param {string} appVersion - The application version to set. */ setAppVersion(appVersion) { this.appVersion = appVersion; this.setVersion(); } /** * Sets the date version and updates the combined version string. * * @param {string} dateVersion - The date version to set. */ setDateVersion(dateVersion) { this.dateVersion = dateVersion; this.setVersion(); } /** * Locks a resource to prevent concurrent access. * @param which - The resource to lock, identified by a string. */ lock(which) { this._locked[which] = true; if (!this._unlockResolvers[which]) { this._unlockResolvers[which] = []; } } /** * Unlocks a resource, allowing access. * @param which - The resource to unlock, identified by a string. */ unlock(which) { this._locked[which] = false; if (this._unlockResolvers[which]) { this._unlockResolvers[which].forEach((resolve) => resolve()); this._unlockResolvers[which] = []; } } /** * Returns a Promise that resolves when the specified resource is unlocked. * @param which - The resource to watch for unlocking, identified by a string. * @returns A Promise that resolves when the resource is unlocked. */ onUnlock(which) { if (!this._locked[which]) { return Promise.resolve(); } return new Promise((resolve) => { if (!this._unlockResolvers[which]) { this._unlockResolvers[which] = []; } this._unlockResolvers[which].push(resolve); }); } /** * Checks if a resource is locked. * @param which - The resource to check, identified by a string. * @returns True if the resource is locked, false otherwise. */ locked(which) { return !!this._locked[which]; } // Angular Signals // /** * Converts a plain object into a signal-wrapped object. * Optionally wraps specific fields of the object as individual signals, * and merges them into the returned signal for fine-grained reactivity. * * @template Document - The type of the object being wrapped. * @param {Document} document - The plain object to wrap into a signal. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map where each key is a field name and the value is a function * to extract the initial value for that field. These fields will be wrapped * as separate signals and embedded in the returned object. * * @returns {WritableSignal<Document>} A signal-wrapped object, possibly containing * nested field signals for more granular control. * * @example * const user = { _id: '1', name: 'Alice', score: 42 }; * const sig = toSignal(user, { score: (u) => u.score }); * console.log(sig().name); // 'Alice' * console.log(sig().score()); // 42 — field is now a signal */ toSignal(document, signalFields = {}) { if (Object.keys(signalFields).length) { const fields = {}; for (const key in signalFields) { fields[key] = signal(signalFields[key](document)); } return signal({ ...document, ...fields }); } else { return signal(document); } } /** * Converts an array of objects into an array of Angular signals. * Optionally wraps specific fields of each object as individual signals. * * @template Document - The type of each object in the array. * @param {Document[]} arr - Array of plain objects to convert into signals. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map where keys are field names and values are functions that extract the initial value * from the object. These fields will be turned into separate signals. * * @returns {WritableSignal<Document>[]} An array where each item is a signal-wrapped object, * optionally with individual fields also wrapped in signals. * * @example * toSignalsArray(users, { * name: (u) => u.name, * score: (u) => u.score, * }); */ toSignalsArray(arr, signalFields = {}) { return arr.map((obj) => this.toSignal(obj, signalFields)); } /** * Adds a new object to the signals array. * Optionally wraps specific fields of the object as individual signals before wrapping the whole object. * * @template Document - The type of the object being added. * @param {WritableSignal<Document>[]} signals - The signals array to append to. * @param {Document} item - The object to wrap and push as a signal. * @param {Record<string, (doc: Document) => unknown>} [signalFields={}] - * Optional map of fields to be wrapped as signals within the object. * * @returns {void} */ pushSignal(signals, item, signalFields = {}) { signals.push(this.toSignal(item, signalFields)); } /** * Removes the first signal from the array whose object's field matches the provided value. * @template Document * @param {WritableSignal<Document>[]} signals - The signals array to modify. * @param {unknown} value - The value to match. * @param {string} [field='_id'] - The object field to match against. * @returns {void} */ removeSignalByField(signals, value, field = '_id') { const idx = signals.findIndex((sig) => sig()[field] === value); if (idx > -1) signals.splice(idx, 1); } /** * Returns a generic trackBy function for *ngFor, tracking by the specified object field. * @template Document * @param {string} field - The object field to use for tracking (e.g., '_id'). * @returns {(index: number, sig: Signal<Document>) => unknown} TrackBy function for Angular. */ trackBySignalField(field) { return (_, sig) => sig()[field]; } /** * Finds the first signal in the array whose object's field matches the provided value. * @template Document * @param {Signal<Document>[]} signals - Array of signals to search. * @param {unknown} value - The value to match. * @param {string} [field='_id'] - The object field to match against. * @returns {Signal<Document> | undefined} The found signal or undefined if not found. */ findSignalByField(signals, value, field = '_id') { return signals.find((sig) => sig()[field] === value); } /** * Updates the first writable signal in the array whose object's field matches the provided value. * @template Document * @param {WritableSignal<Document>[]} signals - Array of writable signals to search. * @param {unknown} value - The value to match. * @param {(val: Document) => Document} updater - Function to produce the updated object. * @param {string} field - The object field to match against. * @returns {void} */ updateSignalByField(signals, value, updater, field) { const sig = this.findSignalByField(signals, value, field); if (sig) sig.update(updater); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: CoreService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [] }); /** * Abstract reusable base class for CRUD list views. * It encapsulates pagination, modals, and document handling logic. * * @template Service - A service implementing CrudServiceInterface for a specific document type * @template Document - The data model extending CrudDocument */ class CrudComponent { /** * Constructor * * @param formConfig - Object describing form title and its component structure * @param formService - Any service that conforms to FormServiceInterface (usually casted) * @param crudService - CRUD service implementing get/create/update/delete */ constructor(formConfig, formService, crudService, module = '') { /** The array of documents currently loaded and shown */ this.documents = signal([], ...(ngDevMode ? [{ debugName: "documents" }] : [])); /** Current pagination page */ this.page = 1; /** CoreService handles timing and copying helpers */ this.__core = inject(CoreService); /** ChangeDetectorRef handles on push strategy */ this.__cdr = inject(ChangeDetectorRef); /** Fields considered when performing bulk updates. */ this.updatableFields = ['_id', 'name', 'description', 'data']; /** Data source mode used for document retrieval. */ this.configType = 'server'; /** Number of documents fetched per page when paginating. */ this.perPage = 20; /** Name of the collection or module used for contextual actions. */ this._module = ''; const form = formConfig; this.__form = formService; this.form = form; this.crudService = crudService; this._module = module; } /** * Loads documents for a given page. */ setDocuments(page = this.page, query = '') { return new Promise((resolve) => { if (this.configType === 'server') { this.page = page; this.__core.afterWhile(this, () => { this.crudService .get({ page, query }, this.getOptions()) .subscribe((docs) => { this.documents.update(() => docs.map((doc) => this.crudService.getSignal(doc))); resolve(); this.__cdr.markForCheck(); }); }, 250); } else { this.documents.update(() => this.crudService .getDocs() .map((doc) => this.crudService.getSignal(doc))); this.crudService.loaded.pipe(take(1)).subscribe(() => { resolve(); this.__cdr.markForCheck(); }); } }); } /** * Clears temporary metadata before document creation. */ preCreate(doc) { delete doc.__creating; } /** * Funciton which controls whether the create functionality is available. */ allowCreate() { return true; } /** * Funciton which controls whether the update and delete functionality is available. */ allowMutate() { return true; } /** * Funciton which controls whether the unique url functionality is available. */ allowUrl() { return true; } /** Determines whether manual sorting controls are available. */ allowSort() { return false; } /** * Funciton which prepare get crud options. */ getOptions() { return {}; } /** * Handles bulk creation and updating of documents. * In creation mode, adds new documents. * In update mode, syncs changes and deletes removed entries. */ bulkManagement(isCreateFlow = true) { return () => { this.__form .modalDocs(isCreateFlow ? [] : this.documents().map((obj) => Object.fromEntries(this.updatableFields.map((key) => [ key, obj()[key], ])))) .then(async (docs) => { if (isCreateFlow) { for (const doc of docs) { this.preCreate(doc); await firstValueFrom(this.crudService.create(doc)); } } else { for (const document of this.documents()) { if (!docs.find((d) => d._id === document()._id)) { await firstValueFrom(this.crudService.delete(document())); } } for (const doc of docs) { const local = this.documents().find((document) => document()._id === doc._id); if (local) { local.update((document) => { this.__core.copy(doc, document); return document; }); await firstValueFrom(this.crudService.update(local())); } else { this.preCreate(doc); await firstValueFrom(this.crudService.create(doc)); } } } this.setDocuments(); }); }; } /** Opens a modal to create a new document. */ create() { this.__form.modal(this.form, { label: 'Create', click: async (created, close) => { close(); this.preCreate(created); await firstValueFrom(this.crudService.create(created)); this.setDocuments(); }, }); } /** Displays a modal to edit an existing document. */ update(doc) { this.__form.modal(this.form, { label: 'Update', click: (updated, close) => { close(); this.__core.copy(updated, doc); this.crudService.update(doc); this.__cdr.markForCheck(); }, }, doc); } /** Requests confirmation before deleting the provided document. */ async delete(doc) { this.crudService.delete(doc).subscribe(() => { this.setDocuments(); }); } /** Opens a modal to edit the document's unique URL. */ mutateUrl(doc) { this.__form.modalUnique(this._module, 'url', doc); } /** Moves the given document one position up and updates ordering. */ moveUp(doc) { const index = this.documents().findIndex((document) => document()._id === doc._id); if (index) { this.documents.update((documents) => { documents.splice(index, 1); documents.splice(index - 1, 0, this.crudService.getSignal(doc)); return documents; }); } for (let i = 0; i < this.documents().length; i++) { if (this.documents()[i]().order !== i) { this.documents()[i]().order = i; this.crudService.update(this.documents()[i]()); } } this.__cdr.markForCheck(); } /** * Configuration object used by the UI for rendering table and handling actions. */ getConfig() { const config = { create: this.allowCreate() ? () => { this.create(); } : null, update: this.allowMutate() ? (doc) => { this.update(doc); } : null, delete: this.allowMutate() ? (doc) => { this.delete(doc); } : null, buttons: [], headerButtons: [], allDocs: true, }; if (this.allowUrl()) { config.buttons.push({ icon: 'cloud_download', click: (doc) => { this.mutateUrl(doc); }, }); } if (this.allowSort()) { config.buttons.push({ icon: 'arrow_upward', click: (doc) => { this.moveUp(doc); }, }); } if (this.allowCreate()) { config.headerButtons.push({ icon: 'playlist_add', click: this.bulkManagement(), class: 'playlist', }); } if (this.allowMutate()) { config.headerButtons.push({ icon: 'edit_note', click: this.bulkManagement(false), class: 'edit', }); } return this.configType === 'server' ? { ...config, paginate: this.setDocuments.bind(this), perPage: this.perPage, setPerPage: this.crudService.setPerPage?.bind(this.crudService), allDocs: false, } : config; } } /** * Stand-alone “click outside” directive (zoneless-safe). * * Usage: * <div (clickOutside)="close()">…</div> */ class ClickOutsideDirective { constructor() { this.clickOutside = output(); this._host = inject((ElementRef)); this._cdr = inject(ChangeDetectorRef); this._dref = inject(DestroyRef); this.handler = (e) => { if (!this._host.nativeElement.contains(e.target)) { this.clickOutside.emit(e); // notify parent this._cdr.markForCheck(); // trigger CD for OnPush comps } }; document.addEventListener('pointerdown', this.handler, true); // cleanup this._dref.onDestroy(() => document.removeEventListener('pointerdown', this.handler, true)); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ClickOutsideDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.3", type: ClickOutsideDirective, isStandalone: true, selector: "[clickOutside]", outputs: { clickOutside: "clickOutside" }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ClickOutsideDirective, decorators: [{ type: Directive, args: [{ selector: '[clickOutside]', }] }], ctorParameters: () => [], propDecorators: { clickOutside: [{ type: i0.Output, args: ["clickOutside"] }] } }); class ManualDisabledDirective { constructor() { this.el = inject(ElementRef); // Bind as: [manualDisabled]="isDisabled" this.manualDisabled = input(null, { ...(ngDevMode ? { debugName: "manualDisabled" } : {}), alias: 'manualDisabled' }); this.syncDisabledEffect = effect(() => { const disabled = this.manualDisabled(); if (disabled == null) return; const native = this.el.nativeElement; if (!native) return; native.disabled = !!disabled; }, ...(ngDevMode ? [{ debugName: "syncDisabledEffect" }] : [])); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualDisabledDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualDisabledDirective, isStandalone: true, selector: "input[manualDisabled], textarea[manualDisabled]", inputs: { manualDisabled: { classPropertyName: "manualDisabled", publicName: "manualDisabled", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualDisabledDirective, decorators: [{ type: Directive, args: [{ selector: 'input[manualDisabled], textarea[manualDisabled]', }] }], propDecorators: { manualDisabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualDisabled", required: false }] }] } }); class ManualNameDirective { constructor() { this.el = inject(ElementRef); // Bind as: manualName="email" or [manualName]="expr" this.manualName = input(null, { ...(ngDevMode ? { debugName: "manualName" } : {}), alias: 'manualName' }); this.syncNameEffect = effect(() => { const name = this.manualName(); if (name == null) return; const native = this.el.nativeElement; if (!native) return; if (native.name !== name) { native.name = name; } }, ...(ngDevMode ? [{ debugName: "syncNameEffect" }] : [])); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualNameDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualNameDirective, isStandalone: true, selector: "input[manualName], textarea[manualName]", inputs: { manualName: { classPropertyName: "manualName", publicName: "manualName", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualNameDirective, decorators: [{ type: Directive, args: [{ selector: 'input[manualName], textarea[manualName]', }] }], propDecorators: { manualName: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualName", required: false }] }] } }); class ManualReadonlyDirective { constructor() { this.el = inject(ElementRef); // Bind as: [manualReadonly]="true" this.manualReadonly = input(null, { ...(ngDevMode ? { debugName: "manualReadonly" } : {}), alias: 'manualReadonly' }); this.syncReadonlyEffect = effect(() => { const readonly = this.manualReadonly(); if (readonly == null) return; const native = this.el.nativeElement; if (!native) return; native.readOnly = !!readonly; }, ...(ngDevMode ? [{ debugName: "syncReadonlyEffect" }] : [])); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualReadonlyDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualReadonlyDirective, isStandalone: true, selector: "input[manualReadonly], textarea[manualReadonly]", inputs: { manualReadonly: { classPropertyName: "manualReadonly", publicName: "manualReadonly", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualReadonlyDirective, decorators: [{ type: Directive, args: [{ selector: 'input[manualReadonly], textarea[manualReadonly]', }] }], propDecorators: { manualReadonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualReadonly", required: false }] }] } }); class ManualTypeDirective { constructor() { this.el = inject(ElementRef); // Bind as: manualType="password" or [manualType]="expr" this.manualType = input(null, { ...(ngDevMode ? { debugName: "manualType" } : {}), alias: 'manualType' }); this.syncTypeEffect = effect(() => { const t = this.manualType(); if (!t) return; const native = this.el.nativeElement; if (!native) return; if (native.type !== t) { native.type = t; } }, ...(ngDevMode ? [{ debugName: "syncTypeEffect" }] : [])); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualTypeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.0.3", type: ManualTypeDirective, isStandalone: true, selector: "input[manualType], textarea[manualType]", inputs: { manualType: { classPropertyName: "manualType", publicName: "manualType", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ManualTypeDirective, decorators: [{ type: Directive, args: [{ selector: 'input[manualType], textarea[manualType]', }] }], propDecorators: { manualType: [{ type: i0.Input, args: [{ isSignal: true, alias: "manualType", required: false }] }] } }); class ArrPipe { transform(data, type, refresh) { if (!data) { return []; } if (typeof data == 'string') return data.split(type || ' '); if (Array.isArray(data)) { return data; } if (typeof data != 'object') { return []; } let arr = []; for (let each in data) { if (!data[each]) continue; if (type == 'prop') { arr.push(each); } else if (type == 'value') { arr.push(data[each]); } else { arr.push({ prop: each, value: data[each], }); } } return arr; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, isStandalone: true, name: "arr" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: ArrPipe, decorators: [{ type: Pipe, args: [{ name: 'arr', }] }] }); class MongodatePipe { transform(_id) { if (!_id) return new Date(); let timestamp = _id.toString().substring(0, 8); return new Date(parseInt(timestamp, 16) * 1000); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, isStandalone: true, name: "mongodate" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: MongodatePipe, decorators: [{ type: Pipe, args: [{ name: 'mongodate', }] }] }); class NumberPipe { transform(value) { const result = Number(value); // Convert value to a number return isNaN(result) ? 0 : result; // Return 0 if conversion fails } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, isStandalone: true, name: "number" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: NumberPipe, decorators: [{ type: Pipe, args: [{ name: 'number', }] }] }); class PaginationPipe { transform(arr, config, sort, search = '') { if (!Array.isArray(arr)) return []; arr = arr.slice(); for (let i = 0; i < arr.length; i++) { arr[i].num = i + 1; } if (sort.direction) { arr.sort((a, b) => { if (a[sort.title] < b[sort.title]) { return sort.direction == 'desc' ? 1 : -1; } if (a[sort.title] > b[sort.title]) { return sort.direction == 'desc' ? -1 : 1; } return 0; }); } return arr.slice((config.page - 1) * config.perPage, config.page * config.perPage); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, isStandalone: true, name: "page", pure: false }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: PaginationPipe, decorators: [{ type: Pipe, args: [{ name: 'page', pure: false, }] }] }); class SafePipe { constructor() { this._sanitizer = inject(DomSanitizer); } transform(html) { return this._sanitizer.bypassSecurityTrustResourceUrl(html); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); } static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, isStandalone: true, name: "safe" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.3", ngImport: i0, type: SafePipe, decorators: [{ type: Pipe, args: [{ name: 'safe', }] }] }); class SearchPipe { transform(items, query, fields, limit, ignore = false, _reload) { /* unwrap signals */ const q = isSignal(query) ? query() : query; let f = isSignal(fields) ? fields() : fields; /* allow “fields” to be a number (=limit) */ if (typeof f === 'number') { limit = f; f = undefined; } const docs = Array.isArray(items) ? items : Object.values(items); if (ignore || !q) return limit ? docs.slice(0, limit) : docs; /* normalise fields */ const paths = !f ? ['name'] : Array.isArray(f) ? f : f.trim().split(/\s+/); /* normalise query */ const needles = Array.isArray(q) ? q.map((s) => s.toLowerCase()) : typeof q === 'object' ? Object.keys(q) .filter((k) => q[k]) .map((k) => k.toLowerCase()) : [q.toLowerCase()]; const txtMatches = (val) => { if (val == null) return false; const hay = val.toString().toLowerCase(); return needles.some((n) => hay.includes(n) || n.includes(hay)); }; const walk = (obj, parts) => { if (!obj) return false;