@mintplayer/ng-spark 22.31.0 → 22.33.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 (75) hide show
  1. package/README.md +101 -1
  2. package/fesm2022/mintplayer-ng-spark-attribute-description.mjs +3 -3
  3. package/fesm2022/mintplayer-ng-spark-auth-account.mjs +18 -18
  4. package/fesm2022/mintplayer-ng-spark-auth-auth-bar.mjs +16 -8
  5. package/fesm2022/mintplayer-ng-spark-auth-auth-bar.mjs.map +1 -1
  6. package/fesm2022/mintplayer-ng-spark-auth-confirm-email.mjs +3 -3
  7. package/fesm2022/mintplayer-ng-spark-auth-core.mjs +6 -6
  8. package/fesm2022/mintplayer-ng-spark-auth-external-login.mjs +3 -3
  9. package/fesm2022/mintplayer-ng-spark-auth-forgot-password.mjs +3 -3
  10. package/fesm2022/mintplayer-ng-spark-auth-login.mjs +3 -3
  11. package/fesm2022/mintplayer-ng-spark-auth-pipes.mjs +3 -3
  12. package/fesm2022/mintplayer-ng-spark-auth-register.mjs +3 -3
  13. package/fesm2022/mintplayer-ng-spark-auth-reset-password.mjs +3 -3
  14. package/fesm2022/mintplayer-ng-spark-auth-sign-in.mjs +3 -3
  15. package/fesm2022/mintplayer-ng-spark-auth-two-factor.mjs +3 -3
  16. package/fesm2022/mintplayer-ng-spark-client-operations.mjs +24 -24
  17. package/fesm2022/mintplayer-ng-spark-column-filter.mjs +3 -3
  18. package/fesm2022/mintplayer-ng-spark-contributions.mjs +9 -9
  19. package/fesm2022/mintplayer-ng-spark-grid.mjs +220 -49
  20. package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
  21. package/fesm2022/mintplayer-ng-spark-history.mjs +9 -9
  22. package/fesm2022/mintplayer-ng-spark-icon.mjs +3 -3
  23. package/fesm2022/mintplayer-ng-spark-identity-provider-connected-applications.mjs +3 -3
  24. package/fesm2022/mintplayer-ng-spark-identity-provider-core.mjs +9 -9
  25. package/fesm2022/mintplayer-ng-spark-identity-provider-developers.mjs +6 -6
  26. package/fesm2022/mintplayer-ng-spark-identity-provider-management.mjs +3 -3
  27. package/fesm2022/mintplayer-ng-spark-models.mjs +44 -1
  28. package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
  29. package/fesm2022/mintplayer-ng-spark-moderation.mjs +21 -21
  30. package/fesm2022/mintplayer-ng-spark-notifications.mjs +687 -0
  31. package/fesm2022/mintplayer-ng-spark-notifications.mjs.map +1 -0
  32. package/fesm2022/mintplayer-ng-spark-organizations-accept.mjs +179 -0
  33. package/fesm2022/mintplayer-ng-spark-organizations-accept.mjs.map +1 -0
  34. package/fesm2022/mintplayer-ng-spark-organizations.mjs +202 -0
  35. package/fesm2022/mintplayer-ng-spark-organizations.mjs.map +1 -0
  36. package/fesm2022/mintplayer-ng-spark-pipes.mjs +125 -85
  37. package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
  38. package/fesm2022/mintplayer-ng-spark-po-create.mjs +3 -3
  39. package/fesm2022/mintplayer-ng-spark-po-detail.mjs +90 -26
  40. package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
  41. package/fesm2022/mintplayer-ng-spark-po-edit.mjs +84 -12
  42. package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
  43. package/fesm2022/mintplayer-ng-spark-po-form.mjs +9 -9
  44. package/fesm2022/mintplayer-ng-spark-pwa.mjs +74 -6
  45. package/fesm2022/mintplayer-ng-spark-pwa.mjs.map +1 -1
  46. package/fesm2022/mintplayer-ng-spark-query-list.mjs +10 -109
  47. package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
  48. package/fesm2022/mintplayer-ng-spark-renderers.mjs +3 -3
  49. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +3 -3
  50. package/fesm2022/mintplayer-ng-spark-score-renderers.mjs +801 -0
  51. package/fesm2022/mintplayer-ng-spark-score-renderers.mjs.map +1 -0
  52. package/fesm2022/mintplayer-ng-spark-services.mjs +424 -72
  53. package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
  54. package/fesm2022/mintplayer-ng-spark-share-page.mjs +334 -0
  55. package/fesm2022/mintplayer-ng-spark-share-page.mjs.map +1 -0
  56. package/fesm2022/mintplayer-ng-spark-share.mjs +10 -0
  57. package/fesm2022/mintplayer-ng-spark-share.mjs.map +1 -0
  58. package/fesm2022/mintplayer-ng-spark-shell.mjs +38 -36
  59. package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
  60. package/fesm2022/mintplayer-ng-spark-soft-delete.mjs +9 -9
  61. package/package.json +25 -1
  62. package/types/mintplayer-ng-spark-grid.d.ts +77 -26
  63. package/types/mintplayer-ng-spark-models.d.ts +150 -17
  64. package/types/mintplayer-ng-spark-notifications.d.ts +188 -0
  65. package/types/mintplayer-ng-spark-organizations-accept.d.ts +40 -0
  66. package/types/mintplayer-ng-spark-organizations.d.ts +72 -0
  67. package/types/mintplayer-ng-spark-pipes.d.ts +10 -0
  68. package/types/mintplayer-ng-spark-po-detail.d.ts +26 -0
  69. package/types/mintplayer-ng-spark-po-edit.d.ts +37 -0
  70. package/types/mintplayer-ng-spark-pwa.d.ts +48 -2
  71. package/types/mintplayer-ng-spark-query-list.d.ts +8 -23
  72. package/types/mintplayer-ng-spark-score-renderers.d.ts +340 -0
  73. package/types/mintplayer-ng-spark-services.d.ts +146 -6
  74. package/types/mintplayer-ng-spark-share-page.d.ts +67 -0
  75. package/types/mintplayer-ng-spark-share.d.ts +26 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mintplayer-ng-spark-notifications.mjs","names":[],"sources":["../../notifications/src/spark-notification.ts","../../notifications/src/spark-notifications.service.ts","../../notifications/src/spark-push-toggle.component.ts","../../notifications/src/relative-time.ts","../../notifications/src/spark-notification-bell.component.ts"],"sourcesContent":["import { InjectionToken, Provider, inject } from '@angular/core';\nimport { DOCUMENT } from '@angular/common';\nimport { QueryResultItem, valueFor } from '@mintplayer/ng-spark/models';\n\n/** The `Notification` entity type of `MintPlayer.Spark.Notifications` (`App_Data/Model/Notification.json`). */\nexport const SPARK_NOTIFICATION_TYPE_ID = '411e3be6-5da0-5d84-bc21-001888fafc6d';\n/** `MyNotifications` (alias `notifications`): the caller's notifications, newest first. Live. */\nexport const SPARK_MY_NOTIFICATIONS_QUERY_ID = '7123c201-9aed-50f9-9d9a-ede2491af7f9';\n/** `UnreadNotifications` (alias `unread-notifications`): the caller's unread ones. Live. */\nexport const SPARK_UNREAD_NOTIFICATIONS_QUERY_ID = 'a205d3e5-ff6e-5260-ac9e-e5c6b16644c9';\n\n/** One notification as the bell lists it: a row of `MyNotifications`. */\nexport interface SparkNotification {\n id: string;\n title: string;\n body: string | null;\n /**\n * The link, when the row carries it. `Url` is a detail-only attribute in the shipped model, so a row\n * usually does not, and {@link SparkNotificationsService.open} loads it from the object instead.\n */\n url: string | null;\n isRead: boolean;\n createdAt: Date | null;\n}\n\n/** A query row of `MyNotifications` as a {@link SparkNotification}. */\nexport function toSparkNotification(item: QueryResultItem): SparkNotification {\n const text = (name: string): string | null => {\n const value = valueFor(item, name)?.value;\n return value == null || value === '' ? null : String(value);\n };\n const created = text('CreatedAtUtc');\n const createdAt = created ? new Date(created) : null;\n return {\n id: item.id,\n title: text('Title') ?? item.breadcrumb ?? '',\n body: text('Body'),\n url: text('Url'),\n isRead: valueFor(item, 'IsRead')?.value === true,\n createdAt: createdAt && !Number.isNaN(createdAt.getTime()) ? createdAt : null,\n };\n}\n\n/** Options of the notification bell and service. */\nexport interface SparkNotificationsOptions {\n /** How many of the latest notifications the bell lists. Default 8. */\n recentCount: number;\n /** Where \"All notifications\" goes: a router link. Default `['/query', 'notifications']` (the `MyNotifications` alias). */\n listLink: string | readonly unknown[];\n}\n\nexport const SPARK_NOTIFICATIONS_OPTIONS = new InjectionToken<SparkNotificationsOptions>('SPARK_NOTIFICATIONS_OPTIONS', {\n providedIn: 'root',\n factory: () => ({ recentCount: 8, listLink: ['/query', 'notifications'] }),\n});\n\n/** Overrides {@link SparkNotificationsOptions}; anything left out keeps its default. */\nexport function provideSparkNotifications(options: Partial<SparkNotificationsOptions> = {}): Provider {\n return {\n provide: SPARK_NOTIFICATIONS_OPTIONS,\n useValue: { recentCount: 8, listLink: ['/query', 'notifications'], ...options } satisfies SparkNotificationsOptions,\n };\n}\n\n/** The browser's Notification API, as far as push needs it. */\nexport interface SparkNotificationPermissions {\n readonly permission: NotificationPermission;\n requestPermission(): Promise<NotificationPermission>;\n}\n\n/**\n * What the browser offers for Web Push: the Notification API, or `null` when there is no Notification,\n * no `PushManager` or no service worker. That is the case in Safari on iPhone and iPad until the app is\n * added to the Home Screen, so `null` reads as \"install the app first\". Replaceable for tests.\n */\nexport const SPARK_PUSH_PERMISSIONS = new InjectionToken<SparkNotificationPermissions | null>('SPARK_PUSH_PERMISSIONS', {\n providedIn: 'root',\n factory: () => {\n const window = inject(DOCUMENT).defaultView as (Window & { Notification?: SparkNotificationPermissions; PushManager?: unknown }) | null;\n if (!window || !window.Notification || !window.PushManager || !window.navigator?.serviceWorker) return null;\n return window.Notification;\n },\n});\n","import { DOCUMENT, Location, isPlatformBrowser } from '@angular/common';\nimport { HttpClient, HttpErrorResponse } from '@angular/common/http';\nimport { DestroyRef, Injectable, PLATFORM_ID, Signal, WritableSignal, computed, effect, inject, signal, untracked } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { SwPush } from '@angular/service-worker';\nimport { Subscription, firstValueFrom } from 'rxjs';\nimport { SPARK_AUTH_STATE, SPARK_CONFIG } from '@mintplayer/ng-spark';\nimport { LiveQueryRequest, QueryResult } from '@mintplayer/ng-spark/models';\nimport { SparkLiveService, SparkService } from '@mintplayer/ng-spark/services';\nimport { sparkInAppUrl } from '@mintplayer/ng-spark/pwa';\nimport {\n SPARK_MY_NOTIFICATIONS_QUERY_ID,\n SPARK_NOTIFICATIONS_OPTIONS,\n SPARK_NOTIFICATION_TYPE_ID,\n SPARK_PUSH_PERMISSIONS,\n SPARK_UNREAD_NOTIFICATIONS_QUERY_ID,\n SparkNotification,\n toSparkNotification,\n} from './spark-notification';\n\n/**\n * Web Push on this device: `unsupported` (no service worker, no PushManager or no Notification API:\n * an iPhone/iPad needs the app on its Home Screen), `denied` (blocked in the browser's settings),\n * `off` or `on`.\n */\nexport type SparkPushState = 'unsupported' | 'denied' | 'off' | 'on';\n\n/**\n * The client of `MintPlayer.Spark.Notifications` (`docs/seo_analyzer_PRD.md` X10, D20, D30).\n *\n * - **In-app**: {@link unreadCount} and {@link recent} follow the live queries `UnreadNotifications`\n * (one row, for its `totalItems`) and `MyNotifications` (the latest few) over the ONE `/spark/live`\n * socket of {@link SparkLiveService}. Nothing polls and there is no second channel: a new, read or\n * deleted notification arrives as a patch. The subscriptions start again whenever `SPARK_AUTH_STATE`\n * changes (sign-in, sign-out), so a page opened before signing in still counts afterwards.\n * - **Web Push**: {@link enablePush} / {@link disablePush} subscribe this device through Angular's\n * `SwPush` and register it at `/spark/notifications/push/*`. It needs `provideSparkServiceWorker()`\n * (or `provideServiceWorker`) with the worker enabled; without it {@link pushState} is `unsupported`.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationsService {\n private readonly http = inject(HttpClient);\n private readonly live = inject(SparkLiveService);\n private readonly spark = inject(SparkService);\n private readonly router = inject(Router, { optional: true });\n private readonly location = inject(Location, { optional: true });\n private readonly document = inject(DOCUMENT);\n private readonly swPush = inject(SwPush, { optional: true });\n private readonly permissions = inject(SPARK_PUSH_PERMISSIONS);\n private readonly options = inject(SPARK_NOTIFICATIONS_OPTIONS);\n private readonly baseUrl = `${inject(SPARK_CONFIG, { optional: true })?.baseUrl ?? '/spark'}/notifications`;\n private readonly browser = isPlatformBrowser(inject(PLATFORM_ID));\n\n private readonly unread = signal<QueryResult | null>(null);\n private readonly latest = signal<QueryResult | null>(null);\n\n /** Unread notifications of the signed-in user; 0 until the first result and while signed out. */\n readonly unreadCount: Signal<number> = computed(() => this.unread()?.totalItems ?? 0);\n /** The latest notifications (read or not), newest first; empty until the first result. */\n readonly recent: Signal<SparkNotification[]> = computed(() => (this.latest()?.items ?? []).map(toSparkNotification));\n /** Whether {@link recent} holds a result yet. */\n readonly loaded: Signal<boolean> = computed(() => this.latest() !== null);\n\n // --- Web Push ----------------------------------------------------------------------------------\n\n /** Whether this browser can receive Web Push here at all (see {@link SparkPushState}). */\n readonly pushSupported: boolean = this.browser && !!this.swPush?.isEnabled && this.permissions !== null;\n\n private readonly permission = signal<NotificationPermission>(this.permissions?.permission ?? 'default');\n private readonly subscribed = signal(false);\n private readonly configured = signal<boolean | null>(null);\n private readonly busy = signal(false);\n private publicKey: Promise<string | null> | null = null;\n\n /** Web Push on this device. */\n readonly pushState: Signal<SparkPushState> = computed(() => {\n if (!this.pushSupported) return 'unsupported';\n if (this.permission() === 'denied') return 'denied';\n return this.subscribed() && this.permission() === 'granted' ? 'on' : 'off';\n });\n /**\n * Whether the server sends Web Push (`GET /spark/notifications/push/key` answered a key): `null` while\n * unknown, `false` when Web Push is not configured or nobody is signed in. Offer no push UI unless `true`.\n */\n readonly pushConfigured: Signal<boolean | null> = this.configured.asReadonly();\n /** True while {@link enablePush} or {@link disablePush} runs. */\n readonly pushBusy: Signal<boolean> = this.busy.asReadonly();\n\n constructor() {\n if (!this.browser) return;\n const authState = inject(SPARK_AUTH_STATE, { optional: true });\n\n effect(onCleanup => {\n authState?.();\n untracked(() => {\n const subscriptions = new Subscription();\n subscriptions.add(this.follow({ queryId: SPARK_UNREAD_NOTIFICATIONS_QUERY_ID, skip: 0, take: 1 }, this.unread));\n subscriptions.add(this.follow({ queryId: SPARK_MY_NOTIFICATIONS_QUERY_ID, skip: 0, take: this.options.recentCount }, this.latest));\n // Whether push is configured is asked again per user: anonymous callers get a 401.\n this.publicKey = null;\n void this.loadPublicKey();\n onCleanup(() => {\n subscriptions.unsubscribe();\n this.unread.set(null);\n this.latest.set(null);\n });\n });\n });\n\n if (this.swPush?.isEnabled) {\n const subscription = this.swPush.subscription.subscribe(current => this.subscribed.set(!!current));\n // A clicked push is read: its payload carries `data.notificationId` (the server's Angular-format payload).\n // The navigation itself is `SparkNotificationClicks`' (pwa), which does not know about notifications.\n subscription.add(this.swPush.notificationClicks.subscribe(({ notification }) => {\n const id = (notification?.data as { notificationId?: unknown } | undefined)?.notificationId;\n if (typeof id === 'string' && id) this.markRead([id]).catch(() => undefined);\n }));\n inject(DestroyRef).onDestroy(() => subscription.unsubscribe());\n }\n }\n\n /**\n * Marks notifications read: the given ids, or every unread one of the caller when `ids` is omitted.\n * Resolves to how many changed. The live queries then deliver the new state; nothing is set locally.\n */\n async markRead(ids?: readonly string[]): Promise<number> {\n const body = ids ? { ids: [...ids] } : {};\n const response = await firstValueFrom(this.http.post<{ marked: number }>(`${this.baseUrl}/read`, body));\n return response?.marked ?? 0;\n }\n\n /**\n * Opens a notification: marks it read when it is not, then follows its link, in-app through the router\n * for a URL on this origin, as a page load for another `http(s)` URL. A row without its `Url` (a\n * detail-only attribute in the shipped model) is loaded once for it.\n */\n async open(notification: SparkNotification): Promise<void> {\n const url = notification.url ?? await this.loadUrl(notification.id);\n if (!notification.isRead) {\n try {\n await this.markRead([notification.id]);\n } catch {\n // Following the link matters more than the read flag; the list still shows it unread.\n }\n }\n this.navigate(url);\n }\n\n /**\n * Turns Web Push on for this device. Call it straight from a click handler: the permission prompt is\n * requested before anything is awaited, because Safari only grants it to a user gesture. Resolves to\n * whether the device is now subscribed; a refusal, a missing key or a subscription the server rejects\n * (400: a push service outside its allow-list) is `false`.\n */\n async enablePush(): Promise<boolean> {\n const permissions = this.permissions;\n const swPush = this.swPush;\n if (!this.pushSupported || !permissions || !swPush) return false;\n\n const asked = permissions.requestPermission();\n this.busy.set(true);\n try {\n const key = this.loadPublicKey();\n const permission = await asked;\n this.permission.set(permission);\n if (permission !== 'granted') return false;\n\n const serverPublicKey = await key;\n if (!serverPublicKey) return false;\n\n const subscription = await swPush.requestSubscription({ serverPublicKey });\n try {\n await firstValueFrom(this.http.post(`${this.baseUrl}/push/subscribe`, subscription.toJSON()));\n } catch {\n // The server refused this device: do not leave a subscription nobody sends to.\n await swPush.unsubscribe().catch(() => undefined);\n this.subscribed.set(false);\n return false;\n }\n this.subscribed.set(true);\n return true;\n } catch {\n return false;\n } finally {\n this.busy.set(false);\n }\n }\n\n /** Turns Web Push off for this device: the server forgets it, then the browser unsubscribes. */\n async disablePush(): Promise<void> {\n const swPush = this.swPush;\n if (!swPush?.isEnabled) return;\n this.busy.set(true);\n try {\n const current = await firstValueFrom(swPush.subscription);\n if (current) {\n await firstValueFrom(this.http.post(`${this.baseUrl}/push/unsubscribe`, { endpoint: current.endpoint })).catch(() => undefined);\n await swPush.unsubscribe().catch(() => undefined);\n }\n this.subscribed.set(false);\n } finally {\n this.busy.set(false);\n }\n }\n\n // --- internals ---------------------------------------------------------------------------------\n\n private follow(request: LiveQueryRequest, target: WritableSignal<QueryResult | null>): Subscription {\n return this.live.watchQuery(request).subscribe(event => target.set(event.type === 'result' ? event.result : null));\n }\n\n private loadPublicKey(): Promise<string | null> {\n this.publicKey ??= firstValueFrom(this.http.get<{ publicKey: string }>(`${this.baseUrl}/push/key`)).then(\n response => {\n const key = response?.publicKey || null;\n this.configured.set(!!key);\n return key;\n },\n (error: HttpErrorResponse) => {\n this.configured.set(false);\n // A network failure may pass; 404 (not configured) and 401 (signed out) last until the next user.\n if (!(error instanceof HttpErrorResponse) || error.status === 0) this.publicKey = null;\n return null;\n },\n );\n return this.publicKey;\n }\n\n private async loadUrl(id: string): Promise<string | null> {\n try {\n const po = await this.spark.get(SPARK_NOTIFICATION_TYPE_ID, id);\n const value = po?.attributes?.find(a => a.name === 'Url')?.value;\n return typeof value === 'string' && value ? value : null;\n } catch {\n return null;\n }\n }\n\n private navigate(url: string | null): void {\n if (!url) return;\n let parsed: URL;\n try {\n parsed = new URL(url, this.document.baseURI);\n } catch {\n return;\n }\n const inApp = sparkInAppUrl(url, this.document, this.location);\n if (inApp && this.router) {\n // A same-origin link the router has no route for is the server's (a PDF download, /bot): load it as a page.\n this.router.navigateByUrl(inApp).catch(() => this.assign(parsed.href));\n return;\n }\n if (parsed.protocol === 'http:' || parsed.protocol === 'https:') this.assign(parsed.href);\n }\n\n /** A full page load (overridden in specs: jsdom cannot navigate). */\n protected assign(href: string): void {\n this.document.defaultView?.location.assign(href);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkLanguageService } from '@mintplayer/ng-spark/services';\nimport { SparkNotificationsService } from './spark-notifications.service';\n\n/** English fallbacks of `notifications.spa.*`, shown until the server's translations arrive. */\nexport const SPARK_NOTIFICATION_TEXTS: Record<string, string> = {\n title: 'Notifications',\n empty: 'No notifications',\n markAllRead: 'Mark all as read',\n showAll: 'All notifications',\n pushEnable: 'Turn on push notifications',\n pushDisable: 'Turn off push notifications on this device',\n pushDenied: 'Notifications are blocked for this site in your browser settings.',\n pushUnsupported: 'This browser cannot receive push notifications here. On iPhone and iPad, add the app to your Home Screen first.',\n};\n\n/**\n * The Web Push opt-in for this device, on its own (an account or settings page); the notification bell\n * shows the same control. Renders nothing unless the server sends Web Push. Otherwise: a button to turn\n * it on or off, or why it cannot be (blocked in the browser, or a browser without push: on iPhone and\n * iPad the app must be on the Home Screen first).\n */\n@Component({\n selector: 'spark-push-toggle',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (notifications.pushConfigured()) {\n @switch (notifications.pushState()) {\n @case ('unsupported') {\n <p class=\"spark-push-unsupported small text-body-secondary mb-0\">{{ t('pushUnsupported') }}</p>\n }\n @case ('denied') {\n <p class=\"spark-push-denied small text-body-secondary mb-0\">{{ t('pushDenied') }}</p>\n }\n @case ('on') {\n <button type=\"button\" class=\"btn btn-sm btn-outline-secondary spark-push-disable\"\n [disabled]=\"notifications.pushBusy()\" (click)=\"disable()\">{{ t('pushDisable') }}</button>\n }\n @default {\n <button type=\"button\" class=\"btn btn-sm btn-outline-primary spark-push-enable\"\n [disabled]=\"notifications.pushBusy()\" (click)=\"enable()\">{{ t('pushEnable') }}</button>\n }\n }\n }\n `,\n})\nexport class SparkPushToggleComponent {\n protected readonly notifications = inject(SparkNotificationsService);\n private readonly language = inject(SparkLanguageService);\n\n protected t(key: string): string {\n return this.language.t('notifications.spa.' + key, SPARK_NOTIFICATION_TEXTS[key]);\n }\n\n /** Synchronous up to the permission prompt: this is the click handler the prompt needs. */\n protected enable(): void {\n void this.notifications.enablePush();\n }\n\n protected disable(): void {\n void this.notifications.disablePush();\n }\n}\n","const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [\n ['year', 365 * 24 * 60 * 60],\n ['month', 30 * 24 * 60 * 60],\n ['week', 7 * 24 * 60 * 60],\n ['day', 24 * 60 * 60],\n ['hour', 60 * 60],\n ['minute', 60],\n];\n\n/**\n * `date` relative to `now` in `language` (\"5 minutes ago\", \"il y a 5 minutes\", \"5 minuten geleden\"),\n * through `Intl.RelativeTimeFormat`; \"now\" under a minute. Empty for a missing date.\n */\nexport function sparkRelativeTime(date: Date | null | undefined, now: number, language: string): string {\n if (!date) return '';\n const seconds = Math.round((date.getTime() - now) / 1000);\n let format: Intl.RelativeTimeFormat;\n try {\n format = new Intl.RelativeTimeFormat(language || 'en', { numeric: 'auto' });\n } catch {\n format = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });\n }\n for (const [unit, size] of UNITS) {\n if (Math.abs(seconds) >= size) return format.format(Math.trunc(seconds / size), unit);\n }\n return format.format(0, 'second');\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';\nimport { CdkConnectedOverlay, CdkOverlayOrigin, type ConnectedPosition } from '@angular/cdk/overlay';\nimport { RouterLink } from '@angular/router';\nimport { Color } from '@mintplayer/ng-bootstrap';\nimport { BsBadgeComponent } from '@mintplayer/ng-bootstrap/badge';\nimport { BsDropdownDividerDirective, BsDropdownHeaderDirective, BsDropdownItemDirective, BsDropdownMenuComponent } from '@mintplayer/ng-bootstrap/dropdown-menu';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\nimport { SparkLanguageService } from '@mintplayer/ng-spark/services';\nimport { sparkRelativeTime } from './relative-time';\nimport { SPARK_NOTIFICATIONS_OPTIONS, SparkNotification } from './spark-notification';\nimport { SparkNotificationsService } from './spark-notifications.service';\nimport { SPARK_NOTIFICATION_TEXTS, SparkPushToggleComponent } from './spark-push-toggle.component';\n\n/**\n * The notification bell for an app's top bar (`docs/seo_analyzer_PRD.md` X10): the unread count as a\n * badge, live; a menu with the latest notifications (title, message, relative time), \"Mark all as read\",\n * a link to the full list and, where this browser can receive it, the Web Push opt-in. Choosing a\n * notification marks it read and follows its link.\n */\n@Component({\n selector: 'spark-notification-bell',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [\n RouterLink, CdkOverlayOrigin, CdkConnectedOverlay, BsBadgeComponent, BsDropdownMenuComponent, BsDropdownItemDirective,\n BsDropdownHeaderDirective, BsDropdownDividerDirective, SparkIconComponent, SparkPushToggleComponent,\n ],\n template: `\n <button type=\"button\" cdkOverlayOrigin #origin=\"cdkOverlayOrigin\" #toggle\n class=\"btn btn-link position-relative spark-notification-bell-toggle\"\n aria-haspopup=\"menu\" [attr.aria-expanded]=\"open()\" [attr.aria-label]=\"ariaLabel()\"\n (click)=\"open.set(!open())\">\n <spark-icon name=\"bell\" />\n @if (notifications.unreadCount() > 0) {\n <bs-badge [type]=\"colors.danger\" [decorative]=\"true\"\n class=\"position-absolute top-0 start-100 translate-middle spark-notification-count\">{{ countText() }}</bs-badge>\n }\n </button>\n <ng-template cdkConnectedOverlay\n [cdkConnectedOverlayOrigin]=\"origin\"\n [cdkConnectedOverlayOpen]=\"open()\"\n [cdkConnectedOverlayPositions]=\"positions\"\n (overlayOutsideClick)=\"onOutsideClick($event, toggle)\"\n (overlayKeydown)=\"$event.key === 'Escape' && close()\"\n (detach)=\"close()\">\n <bs-dropdown-menu class=\"spark-notification-menu\">\n <li bsDropdownHeader class=\"d-flex align-items-center justify-content-between gap-3\">\n <span>{{ t('title') }}</span>\n @if (notifications.unreadCount() > 0) {\n <button type=\"button\" class=\"btn btn-link btn-sm p-0 spark-notification-mark-all\" (click)=\"markAllRead()\">{{ t('markAllRead') }}</button>\n }\n </li>\n @for (notification of notifications.recent(); track notification.id) {\n <li bsDropdownItem class=\"spark-notification\" [class.fw-semibold]=\"!notification.isRead\"\n [class.spark-notification-unread]=\"!notification.isRead\" [attr.data-id]=\"notification.id\"\n (click)=\"choose(notification)\">\n <div class=\"spark-notification-title text-wrap\">{{ notification.title }}</div>\n @if (notification.body) {\n <div class=\"spark-notification-body small fw-normal text-body-secondary text-wrap\">{{ notification.body }}</div>\n }\n <div class=\"spark-notification-time small fw-normal text-body-secondary\">{{ when(notification) }}</div>\n </li>\n } @empty {\n <li class=\"dropdown-item-text text-body-secondary spark-notification-empty\">{{ t('empty') }}</li>\n }\n <li bsDropdownDivider></li>\n <li bsDropdownItem class=\"spark-notification-all\">\n <a [routerLink]=\"listLink()\" (click)=\"close()\">{{ t('showAll') }}</a>\n </li>\n @if (notifications.pushConfigured() && notifications.pushState() !== 'unsupported') {\n <li class=\"dropdown-item-text spark-notification-push\"><spark-push-toggle /></li>\n }\n </bs-dropdown-menu>\n </ng-template>\n `,\n styles: [`\n :host { display: inline-block; }\n .spark-notification-count { font-size: .65rem; }\n `],\n})\nexport class SparkNotificationBellComponent {\n protected readonly notifications = inject(SparkNotificationsService);\n private readonly language = inject(SparkLanguageService);\n private readonly options = inject(SPARK_NOTIFICATIONS_OPTIONS);\n protected readonly colors = Color;\n\n /** Where \"All notifications\" goes. Default: `provideSparkNotifications({ listLink })`, else the `notifications` query page. */\n readonly allLink = input<string | readonly unknown[] | null>(null);\n\n readonly open = signal(false);\n\n protected readonly listLink = computed(() => this.allLink() ?? this.options.listLink);\n protected readonly countText = computed(() => {\n const count = this.notifications.unreadCount();\n return count > 99 ? '99+' : String(count);\n });\n protected readonly ariaLabel = computed(() => {\n const count = this.notifications.unreadCount();\n return count > 0 ? `${this.t('title')} (${count})` : this.t('title');\n });\n\n readonly positions: ConnectedPosition[] = [\n { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top' },\n { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom' },\n ];\n\n protected t(key: string): string {\n return this.language.t('notifications.spa.' + key, SPARK_NOTIFICATION_TEXTS[key]);\n }\n\n protected when(notification: SparkNotification): string {\n return sparkRelativeTime(notification.createdAt, Date.now(), this.language.language());\n }\n\n close(): void {\n this.open.set(false);\n }\n\n /** A click outside closes the menu, except on the bell itself, whose own handler toggles it. */\n onOutsideClick(event: MouseEvent, toggle: HTMLElement): void {\n if (event.target instanceof Node && toggle.contains(event.target)) return;\n this.close();\n }\n\n async choose(notification: SparkNotification): Promise<void> {\n this.close();\n await this.notifications.open(notification);\n }\n\n async markAllRead(): Promise<void> {\n await this.notifications.markRead();\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,179 @@
1
+ import { HttpClient } from "@angular/common/http";
2
+ import * as i0 from "@angular/core";
3
+ import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
4
+ import { ActivatedRoute, RouterLink } from "@angular/router";
5
+ import { firstValueFrom } from "rxjs";
6
+ import { Color } from "@mintplayer/ng-bootstrap";
7
+ import { BsAlertComponent } from "@mintplayer/ng-bootstrap/alert";
8
+ import { BsCardComponent, BsCardHeaderComponent } from "@mintplayer/ng-bootstrap/card";
9
+ import { BsSpinnerComponent } from "@mintplayer/ng-bootstrap/spinner";
10
+ import { SparkAuthTranslationService } from "@mintplayer/ng-spark/auth/core";
11
+ var SparkAcceptOrganizationInvitationComponent = class SparkAcceptOrganizationInvitationComponent {
12
+ http = inject(HttpClient);
13
+ route = inject(ActivatedRoute);
14
+ translations = inject(SparkAuthTranslationService);
15
+ colors = Color;
16
+ token = this.route.snapshot.paramMap.get("token") ?? "";
17
+ invitationId = this.route.snapshot.queryParamMap.get("id") ?? "";
18
+ valid = !!this.token && !!this.invitationId;
19
+ busy = signal(false, ...ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []);
20
+ failed = signal(false, ...ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []);
21
+ reason = signal("invalid", ...ngDevMode ? [{ debugName: "reason" }] : /* istanbul ignore next */ []);
22
+ joined = signal(null, ...ngDevMode ? [{ debugName: "joined" }] : /* istanbul ignore next */ []);
23
+ t(key) {
24
+ return this.translations.t("organizations.spa." + key);
25
+ }
26
+ async accept() {
27
+ if (!this.valid) return;
28
+ this.busy.set(true);
29
+ try {
30
+ const result = await firstValueFrom(this.http.post("/spark/organizations/accept", {
31
+ invitationId: this.invitationId,
32
+ token: this.token
33
+ }));
34
+ if (result.accepted) this.joined.set(result);
35
+ else {
36
+ this.reason.set(result.reason === "otherAddress" ? "otherAddress" : "invalid");
37
+ this.failed.set(true);
38
+ }
39
+ } catch {
40
+ this.failed.set(true);
41
+ } finally {
42
+ this.busy.set(false);
43
+ }
44
+ }
45
+ static ɵfac = i0.ɵɵngDeclareFactory({
46
+ minVersion: "12.0.0",
47
+ version: "22.2.2",
48
+ ngImport: i0,
49
+ type: SparkAcceptOrganizationInvitationComponent,
50
+ deps: [],
51
+ target: i0.ɵɵFactoryTarget.Component
52
+ });
53
+ static ɵcmp = i0.ɵɵngDeclareComponent({
54
+ minVersion: "17.0.0",
55
+ version: "22.2.2",
56
+ type: SparkAcceptOrganizationInvitationComponent,
57
+ isStandalone: true,
58
+ selector: "spark-accept-organization-invitation",
59
+ ngImport: i0,
60
+ template: `
61
+ <div class="d-flex justify-content-center">
62
+ <bs-card style="width: 100%; max-width: 640px;">
63
+ <bs-card-header><h3 class="mb-0">{{ t('title') }}</h3></bs-card-header>
64
+ <div class="p-4">
65
+ @if (joined(); as joined) {
66
+ <bs-alert [type]="colors.success" class="mb-3 d-block spark-organization-accepted">{{ t('accepted').replace('{0}', joined.organization ?? '') }}</bs-alert>
67
+ <a class="btn btn-outline-primary spark-organization-open" [routerLink]="['/po', 'organization', joined.organizationId]">{{ t('open') }}</a>
68
+ } @else if (!valid || failed()) {
69
+ <bs-alert [type]="colors.danger" class="mb-3 d-block spark-organization-invalid">{{ t(reason()) }}</bs-alert>
70
+ } @else {
71
+ <p>{{ t('intro') }}</p>
72
+ <button type="button" class="btn btn-primary spark-organization-accept" [disabled]="busy()" (click)="accept()">
73
+ @if (busy()) { <bs-spinner class="me-1" /> }
74
+ {{ t('accept') }}
75
+ </button>
76
+ }
77
+ </div>
78
+ </bs-card>
79
+ </div>
80
+ `,
81
+ isInline: true,
82
+ dependencies: [
83
+ {
84
+ kind: "directive",
85
+ type: RouterLink,
86
+ selector: "[routerLink]",
87
+ inputs: [
88
+ "target",
89
+ "queryParams",
90
+ "fragment",
91
+ "queryParamsHandling",
92
+ "state",
93
+ "info",
94
+ "relativeTo",
95
+ "preserveFragment",
96
+ "skipLocationChange",
97
+ "replaceUrl",
98
+ "browserUrl",
99
+ "routerLink"
100
+ ]
101
+ },
102
+ {
103
+ kind: "component",
104
+ type: BsAlertComponent,
105
+ selector: "bs-alert",
106
+ inputs: [
107
+ "type",
108
+ "announce",
109
+ "isVisible"
110
+ ],
111
+ outputs: ["isVisibleChange", "afterOpenedOrClosed"]
112
+ },
113
+ {
114
+ kind: "component",
115
+ type: BsCardComponent,
116
+ selector: "bs-card",
117
+ inputs: ["color", "outline"]
118
+ },
119
+ {
120
+ kind: "component",
121
+ type: BsCardHeaderComponent,
122
+ selector: "bs-card-header",
123
+ inputs: ["color", "navStyle"]
124
+ },
125
+ {
126
+ kind: "component",
127
+ type: BsSpinnerComponent,
128
+ selector: "bs-spinner",
129
+ inputs: ["type", "color"]
130
+ }
131
+ ],
132
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
133
+ });
134
+ };
135
+ i0.ɵɵngDeclareClassMetadata({
136
+ minVersion: "12.0.0",
137
+ version: "22.2.2",
138
+ ngImport: i0,
139
+ type: SparkAcceptOrganizationInvitationComponent,
140
+ decorators: [{
141
+ type: Component,
142
+ args: [{
143
+ selector: "spark-accept-organization-invitation",
144
+ standalone: true,
145
+ changeDetection: ChangeDetectionStrategy.OnPush,
146
+ imports: [
147
+ RouterLink,
148
+ BsAlertComponent,
149
+ BsCardComponent,
150
+ BsCardHeaderComponent,
151
+ BsSpinnerComponent
152
+ ],
153
+ template: `
154
+ <div class="d-flex justify-content-center">
155
+ <bs-card style="width: 100%; max-width: 640px;">
156
+ <bs-card-header><h3 class="mb-0">{{ t('title') }}</h3></bs-card-header>
157
+ <div class="p-4">
158
+ @if (joined(); as joined) {
159
+ <bs-alert [type]="colors.success" class="mb-3 d-block spark-organization-accepted">{{ t('accepted').replace('{0}', joined.organization ?? '') }}</bs-alert>
160
+ <a class="btn btn-outline-primary spark-organization-open" [routerLink]="['/po', 'organization', joined.organizationId]">{{ t('open') }}</a>
161
+ } @else if (!valid || failed()) {
162
+ <bs-alert [type]="colors.danger" class="mb-3 d-block spark-organization-invalid">{{ t(reason()) }}</bs-alert>
163
+ } @else {
164
+ <p>{{ t('intro') }}</p>
165
+ <button type="button" class="btn btn-primary spark-organization-accept" [disabled]="busy()" (click)="accept()">
166
+ @if (busy()) { <bs-spinner class="me-1" /> }
167
+ {{ t('accept') }}
168
+ </button>
169
+ }
170
+ </div>
171
+ </bs-card>
172
+ </div>
173
+ `
174
+ }]
175
+ }]
176
+ });
177
+ export { SparkAcceptOrganizationInvitationComponent };
178
+
179
+ //# sourceMappingURL=mintplayer-ng-spark-organizations-accept.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mintplayer-ng-spark-organizations-accept.mjs","names":[],"sources":["../../organizations/accept/src/spark-accept-organization-invitation.component.ts"],"sourcesContent":["import { HttpClient } from '@angular/common/http';\nimport { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';\nimport { ActivatedRoute, RouterLink } from '@angular/router';\nimport { firstValueFrom } from 'rxjs';\nimport { Color } from '@mintplayer/ng-bootstrap';\nimport { BsAlertComponent } from '@mintplayer/ng-bootstrap/alert';\nimport { BsCardComponent, BsCardHeaderComponent } from '@mintplayer/ng-bootstrap/card';\nimport { BsSpinnerComponent } from '@mintplayer/ng-bootstrap/spinner';\nimport { SparkAuthTranslationService } from '@mintplayer/ng-spark/auth/core';\n\n/** What `POST /spark/organizations/accept` answers. */\nexport interface SparkOrganizationAcceptance {\n accepted: boolean;\n organizationId: string | null;\n organization: string | null;\n problem: string | null;\n /** Which refusal: `invalid` (any unknown, used or expired link) or `otherAddress` (D43). */\n reason?: 'invalid' | 'otherAddress' | null;\n}\n\n/**\n * The target of a mailed organization invitation or join request\n * (`/organizations/accept/:token?id=<invitationId>`, `docs/seo_analyzer_PRD.md` D18). Accepting is an\n * explicit click, never a side effect of opening the link (mail scanners open links). An invitation\n * makes the signed-in account a member; a join request lets its requester in and needs an Owner or\n * Admin. Every failure reads the same, except a valid invitation opened by an account that does not own\n * the invited address (D43): the page says so, so the user can sign in with the right account.\n */\n@Component({\n selector: 'spark-accept-organization-invitation',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [RouterLink, BsAlertComponent, BsCardComponent, BsCardHeaderComponent, BsSpinnerComponent],\n template: `\n <div class=\"d-flex justify-content-center\">\n <bs-card style=\"width: 100%; max-width: 640px;\">\n <bs-card-header><h3 class=\"mb-0\">{{ t('title') }}</h3></bs-card-header>\n <div class=\"p-4\">\n @if (joined(); as joined) {\n <bs-alert [type]=\"colors.success\" class=\"mb-3 d-block spark-organization-accepted\">{{ t('accepted').replace('{0}', joined.organization ?? '') }}</bs-alert>\n <a class=\"btn btn-outline-primary spark-organization-open\" [routerLink]=\"['/po', 'organization', joined.organizationId]\">{{ t('open') }}</a>\n } @else if (!valid || failed()) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3 d-block spark-organization-invalid\">{{ t(reason()) }}</bs-alert>\n } @else {\n <p>{{ t('intro') }}</p>\n <button type=\"button\" class=\"btn btn-primary spark-organization-accept\" [disabled]=\"busy()\" (click)=\"accept()\">\n @if (busy()) { <bs-spinner class=\"me-1\" /> }\n {{ t('accept') }}\n </button>\n }\n </div>\n </bs-card>\n </div>\n `,\n})\nexport class SparkAcceptOrganizationInvitationComponent {\n private readonly http = inject(HttpClient);\n private readonly route = inject(ActivatedRoute);\n private readonly translations = inject(SparkAuthTranslationService);\n protected readonly colors = Color;\n\n private readonly token = this.route.snapshot.paramMap.get('token') ?? '';\n private readonly invitationId = this.route.snapshot.queryParamMap.get('id') ?? '';\n /** A link without both values cannot be an invitation. */\n protected readonly valid = !!this.token && !!this.invitationId;\n\n readonly busy = signal(false);\n readonly failed = signal(false);\n /** The refusal to show: a translation key under `organizations.spa`. */\n readonly reason = signal<'invalid' | 'otherAddress'>('invalid');\n /** The organization joined, once accepted. */\n readonly joined = signal<SparkOrganizationAcceptance | null>(null);\n\n /** The page's texts: `organizations.spa.*` in the organizations library's `translations.json`. */\n protected t(key: string): string {\n return this.translations.t('organizations.spa.' + key);\n }\n\n async accept(): Promise<void> {\n if (!this.valid) return;\n this.busy.set(true);\n try {\n const result = await firstValueFrom(this.http.post<SparkOrganizationAcceptance>('/spark/organizations/accept', {\n invitationId: this.invitationId,\n token: this.token,\n }));\n if (result.accepted) this.joined.set(result);\n else {\n this.reason.set(result.reason === 'otherAddress' ? 'otherAddress' : 'invalid');\n this.failed.set(true);\n }\n } catch {\n this.failed.set(true);\n } finally {\n this.busy.set(false);\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAuDA,IAAa,6CAAb,MAAa,2CAA0C;CACpC,OAAO,OAAO,UAAU;CACxB,QAAQ,OAAO,cAAc;CAC7B,eAAe,OAAO,2BAA2B;CAC/C,SAAS;CAEX,QAAQ,KAAK,MAAM,SAAS,SAAS,IAAI,OAAO,KAAK;CACrD,eAAe,KAAK,MAAM,SAAS,cAAc,IAAI,IAAI,KAAK;CAE5D,QAAQ,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC,KAAK;CAEzC,OAAO,OAAO,OAAM,GAAA,YAAA,CAAA,EAAA,WAAA,OAAA,CAAA,+BAAA,CAAA,CAAA;CACpB,SAAS,OAAO,OAAM,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAEtB,SAAS,OAAmC,WAAU,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAEtD,SAAS,OAA2C,MAAK,GAAA,YAAA,CAAA,EAAA,WAAA,SAAA,CAAA,+BAAA,CAAA,CAAA;CAGxD,EAAE,KAAW;EACrB,OAAO,KAAK,aAAa,EAAE,uBAAuB,GAAG;CACvD;CAEA,MAAM,SAAM;EACV,IAAI,CAAC,KAAK,OAAO;EACjB,KAAK,KAAK,IAAI,IAAI;EAClB,IAAI;GACF,MAAM,SAAS,MAAM,eAAe,KAAK,KAAK,KAAkC,+BAA+B;IAC7G,cAAc,KAAK;IACnB,OAAO,KAAK;GACb,CAAA,CAAC;GACF,IAAI,OAAO,UAAU,KAAK,OAAO,IAAI,MAAM;QACtC;IACH,KAAK,OAAO,IAAI,OAAO,WAAW,iBAAiB,iBAAiB,SAAS;IAC7E,KAAK,OAAO,IAAI,IAAI;GACtB;EACF,QAAQ;GACN,KAAK,OAAO,IAAI,IAAI;EACtB,UAAU;GACR,KAAK,KAAK,IAAI,KAAK;EACrB;CACF;CAzCW,OAAA,OAAA,GAAA,mBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,UAAA;EAAA,MAAA;EAA0C,MAAA,CAAA;EAAA,QAAA,GAAA,gBAAA;CAAA,CAAA;CAA1C,OAAA,OAAA,GAAA,qBAAA;EAAA,YAAA;EAAA,SAAA;EAAA,MAAA;EAA0C,cAAA;EAAA,UAAA;EAAA,UAAA;EAAA,UAtB3C;;;;;;;;;;;;;;;;;;;;;EAoBT,UAAA;EAAA,cAAA;GAAA;IAAA,MAAA;IAAA,MArBS;IAAU,UAAA;IAAA,QAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;KAAA;IAAA;GAAA;GAAA;IAAA,MAAA;IAAA,MAAE;IAAgB,UAAA;IAAA,QAAA;KAAA;KAAA;KAAA;IAAA;IAAA,SAAA,CAAA,mBAAA,qBAAA;GAAA;GAAA;IAAA,MAAA;IAAA,MAAE;IAAe,UAAA;IAAA,QAAA,CAAA,SAAA,SAAA;GAAA;GAAA;IAAA,MAAA;IAAA,MAAE;IAAqB,UAAA;IAAA,QAAA,CAAA,SAAA,UAAA;GAAA;GAAA;IAAA,MAAA;IAAA,MAAE;IAAkB,UAAA;IAAA,QAAA,CAAA,QAAA,OAAA;GAAA;EAAA;EAAA,iBAAA,GAAA,wBAAA;CAAA,CAAA;;AAuBvF,GAAA,yBAAA;CAAA,YAAA;CAAA,SAAA;CAAA,UAAA;CAAA,MAAA;CAA0C,YAAA,CAAA;EA3BtD,MAAA;EAAU,MAAA,CAAA;GACT,UAAU;GACV,YAAY;GACZ,iBAAiB,wBAAwB;GACzC,SAAS;IAAC;IAAY;IAAkB;IAAiB;IAAuB;GAAkB;GAClG,UAAU;;;;;;;;;;;;;;;;;;;;;EAqBX,CAAA"}
@@ -0,0 +1,202 @@
1
+ import { sparkAuthGuard } from "@mintplayer/ng-spark/auth/guards";
2
+ import { HttpClient } from "@angular/common/http";
3
+ import { DOCUMENT } from "@angular/common";
4
+ import * as i0 from "@angular/core";
5
+ import { ChangeDetectionStrategy, Component, inject, input, signal } from "@angular/core";
6
+ import * as i1 from "@angular/forms";
7
+ import { FormsModule } from "@angular/forms";
8
+ import { firstValueFrom } from "rxjs";
9
+ import { BsSelectComponent, BsSelectOption } from "@mintplayer/ng-bootstrap/select";
10
+ import { SparkLanguageService } from "@mintplayer/ng-spark/services";
11
+ function withOrganizationRoutes(options) {
12
+ const guard = options?.canActivate ?? [sparkAuthGuard];
13
+ const entry = options?.accept;
14
+ const path = entry === void 0 ? "organizations/accept/:token" : typeof entry === "string" ? entry : entry.path;
15
+ const component = typeof entry === "object" && entry.component ? entry.component : void 0;
16
+ return {
17
+ paths: {},
18
+ children: [{
19
+ path,
20
+ loadComponent: component ? () => Promise.resolve(component) : () => import("@mintplayer/ng-spark/organizations/accept").then((m) => m.SparkAcceptOrganizationInvitationComponent),
21
+ ...guard.length ? { canActivate: guard } : {}
22
+ }]
23
+ };
24
+ }
25
+ const SPARK_ORGANIZATION_SWITCHER_TEXTS = {
26
+ switcher: "Organization",
27
+ allOrganizations: "All organizations",
28
+ personal: "personal"
29
+ };
30
+ var SparkOrganizationSwitcherComponent = class SparkOrganizationSwitcherComponent {
31
+ http = inject(HttpClient);
32
+ language = inject(SparkLanguageService);
33
+ document = inject(DOCUMENT);
34
+ reloadOnChange = input(true, ...ngDevMode ? [{ debugName: "reloadOnChange" }] : /* istanbul ignore next */ []);
35
+ state = signal(null, ...ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []);
36
+ busy = signal(false, ...ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []);
37
+ async ngOnInit() {
38
+ try {
39
+ this.state.set(await firstValueFrom(this.http.get("/spark/organizations/current")));
40
+ } catch {
41
+ this.state.set(null);
42
+ }
43
+ }
44
+ t(key) {
45
+ return this.language.t("organizations.spa." + key, SPARK_ORGANIZATION_SWITCHER_TEXTS[key]);
46
+ }
47
+ label(organization) {
48
+ return organization.isPersonal ? `${organization.name} (${this.t("personal")})` : organization.name;
49
+ }
50
+ reload() {
51
+ this.document.defaultView?.location.reload();
52
+ }
53
+ async choose(organizationId) {
54
+ if (organizationId === (this.state()?.current ?? null)) return;
55
+ this.busy.set(true);
56
+ try {
57
+ this.state.set(await firstValueFrom(this.http.post("/spark/organizations/current", { organizationId })));
58
+ if (this.reloadOnChange()) this.reload();
59
+ } catch {
60
+ await this.ngOnInit();
61
+ } finally {
62
+ this.busy.set(false);
63
+ }
64
+ }
65
+ static ɵfac = i0.ɵɵngDeclareFactory({
66
+ minVersion: "12.0.0",
67
+ version: "22.2.2",
68
+ ngImport: i0,
69
+ type: SparkOrganizationSwitcherComponent,
70
+ deps: [],
71
+ target: i0.ɵɵFactoryTarget.Component
72
+ });
73
+ static ɵcmp = i0.ɵɵngDeclareComponent({
74
+ minVersion: "17.0.0",
75
+ version: "22.2.2",
76
+ type: SparkOrganizationSwitcherComponent,
77
+ isStandalone: true,
78
+ selector: "spark-organization-switcher",
79
+ inputs: { reloadOnChange: {
80
+ classPropertyName: "reloadOnChange",
81
+ publicName: "reloadOnChange",
82
+ isSignal: true,
83
+ isRequired: false,
84
+ transformFunction: null
85
+ } },
86
+ ngImport: i0,
87
+ template: `
88
+ @if (state(); as state) {
89
+ @if (state.organizations.length > 1) {
90
+ <bs-select class="spark-organization-switcher" [title]="t('switcher')" [attr.aria-label]="t('switcher')"
91
+ [ngModel]="state.current" (ngModelChange)="choose($event)" [disabled]="busy()">
92
+ <option [ngValue]="null">{{ t('allOrganizations') }}</option>
93
+ @for (organization of state.organizations; track organization.id) {
94
+ <option [ngValue]="organization.id">{{ label(organization) }}</option>
95
+ }
96
+ </bs-select>
97
+ }
98
+ }
99
+ `,
100
+ isInline: true,
101
+ styles: [":host{display:inline-block;max-width:14rem;min-width:0}@media(max-width:575.98px){:host{max-width:6rem;min-width:4.25rem}}\n"],
102
+ dependencies: [
103
+ {
104
+ kind: "ngmodule",
105
+ type: FormsModule
106
+ },
107
+ {
108
+ kind: "directive",
109
+ type: i1.NgSelectOption,
110
+ selector: "option",
111
+ inputs: ["ngValue", "value"]
112
+ },
113
+ {
114
+ kind: "directive",
115
+ type: i1.ɵNgSelectMultipleOption,
116
+ selector: "option",
117
+ inputs: ["ngValue", "value"]
118
+ },
119
+ {
120
+ kind: "directive",
121
+ type: i1.NgControlStatus,
122
+ selector: "[formControlName],[ngModel],[formControl]"
123
+ },
124
+ {
125
+ kind: "directive",
126
+ type: i1.NgModel,
127
+ selector: "[ngModel]:not([formControlName]):not([formControl])",
128
+ inputs: [
129
+ "name",
130
+ "disabled",
131
+ "ngModel",
132
+ "ngModelOptions"
133
+ ],
134
+ outputs: ["ngModelChange"],
135
+ exportAs: ["ngModel"]
136
+ },
137
+ {
138
+ kind: "component",
139
+ type: BsSelectComponent,
140
+ selector: "bs-select",
141
+ inputs: [
142
+ "identifier",
143
+ "size",
144
+ "multiple",
145
+ "numberVisible",
146
+ "disabled"
147
+ ]
148
+ },
149
+ {
150
+ kind: "directive",
151
+ type: BsSelectOption,
152
+ selector: "option",
153
+ inputs: ["ngValue", "value"]
154
+ }
155
+ ],
156
+ changeDetection: i0.ChangeDetectionStrategy.OnPush
157
+ });
158
+ };
159
+ i0.ɵɵngDeclareClassMetadata({
160
+ minVersion: "12.0.0",
161
+ version: "22.2.2",
162
+ ngImport: i0,
163
+ type: SparkOrganizationSwitcherComponent,
164
+ decorators: [{
165
+ type: Component,
166
+ args: [{
167
+ selector: "spark-organization-switcher",
168
+ standalone: true,
169
+ changeDetection: ChangeDetectionStrategy.OnPush,
170
+ imports: [
171
+ FormsModule,
172
+ BsSelectComponent,
173
+ BsSelectOption
174
+ ],
175
+ template: `
176
+ @if (state(); as state) {
177
+ @if (state.organizations.length > 1) {
178
+ <bs-select class="spark-organization-switcher" [title]="t('switcher')" [attr.aria-label]="t('switcher')"
179
+ [ngModel]="state.current" (ngModelChange)="choose($event)" [disabled]="busy()">
180
+ <option [ngValue]="null">{{ t('allOrganizations') }}</option>
181
+ @for (organization of state.organizations; track organization.id) {
182
+ <option [ngValue]="organization.id">{{ label(organization) }}</option>
183
+ }
184
+ </bs-select>
185
+ }
186
+ }
187
+ `,
188
+ styles: [":host{display:inline-block;max-width:14rem;min-width:0}@media(max-width:575.98px){:host{max-width:6rem;min-width:4.25rem}}\n"]
189
+ }]
190
+ }],
191
+ propDecorators: { reloadOnChange: [{
192
+ type: i0.Input,
193
+ args: [{
194
+ isSignal: true,
195
+ alias: "reloadOnChange",
196
+ required: false
197
+ }]
198
+ }] }
199
+ });
200
+ export { SPARK_ORGANIZATION_SWITCHER_TEXTS, SparkOrganizationSwitcherComponent, withOrganizationRoutes };
201
+
202
+ //# sourceMappingURL=mintplayer-ng-spark-organizations.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mintplayer-ng-spark-organizations.mjs","names":[],"sources":["../../organizations/src/organization-routes.ts","../../organizations/src/spark-organization-switcher.component.ts"],"sourcesContent":["import type { CanActivateFn } from '@angular/router';\nimport { sparkAuthGuard } from '@mintplayer/ng-spark/auth/guards';\nimport type { SparkAuthRouteEntry } from '@mintplayer/ng-spark/auth/models';\nimport type { SparkAuthRoutesFeature } from '@mintplayer/ng-spark/auth/routes';\n\n/**\n * The organizations package's one SPA page (`docs/seo_analyzer_PRD.md` X11): the target of a mailed\n * invitation or join request, `organizations/accept/:token?id=<invitationId>`, as a `sparkAuthRoutes()`\n * feature:\n *\n * ```ts\n * sparkAuthRoutes(withExternalLogin(), withAccount(), withOrganizationRoutes())\n * ```\n *\n * The page needs a signed-in user: the guard (default `[sparkAuthGuard]`) sends everyone else to the\n * sign-in page and back. Organizations, members and invitations themselves are the generic `po/:type`\n * and `query/:id` screens. Moving the page means setting `Spark:Organizations:AcceptPath` to match, so\n * the mailed links follow.\n */\nexport function withOrganizationRoutes(\n options?: { canActivate?: CanActivateFn[]; accept?: SparkAuthRouteEntry },\n): SparkAuthRoutesFeature {\n const guard = options?.canActivate ?? [sparkAuthGuard];\n const entry = options?.accept;\n const path = entry === undefined ? 'organizations/accept/:token' : typeof entry === 'string' ? entry : entry.path;\n const component = typeof entry === 'object' && entry.component ? entry.component : undefined;\n return {\n paths: {},\n children: [\n {\n path,\n loadComponent: component\n ? () => Promise.resolve(component)\n : () => import('@mintplayer/ng-spark/organizations/accept').then(m => m.SparkAcceptOrganizationInvitationComponent),\n ...(guard.length ? { canActivate: guard } : {}),\n },\n ],\n };\n}\n","import { HttpClient } from '@angular/common/http';\nimport { DOCUMENT } from '@angular/common';\nimport { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { firstValueFrom } from 'rxjs';\nimport { BsSelectComponent, BsSelectOption } from '@mintplayer/ng-bootstrap/select';\nimport { SparkLanguageService } from '@mintplayer/ng-spark/services';\n\n/** One organization the switcher offers (`GET /spark/organizations/current`). */\nexport interface SparkOrganizationChoice {\n id: string;\n name: string;\n isPersonal: boolean;\n}\n\n/** What `/spark/organizations/current` answers: the chosen organization (`null`: all of them) and the caller's organizations. */\nexport interface SparkCurrentOrganization {\n current: string | null;\n organizations: SparkOrganizationChoice[];\n}\n\n/** English fallbacks of the switcher's texts (`organizations.spa.*` in the organizations package's translations). */\nexport const SPARK_ORGANIZATION_SWITCHER_TEXTS: Record<string, string> = {\n switcher: 'Organization',\n allOrganizations: 'All organizations',\n personal: 'personal',\n};\n\n/**\n * The organization switcher for an app's top bar (`docs/seo_analyzer_PRD.md` Q-M11a): \"All organizations\" or one\n * of the caller's organizations. The server keeps the choice (a cookie it checks against the caller's memberships\n * on every request), lists of organization-owned rows show only the chosen organization's, and a new row whose\n * form names no organization lands in it. A detail page, a sub-query and the organization list are not narrowed.\n *\n * ```html\n * @if (auth.isAuthenticated()) { <spark-organization-switcher /> }\n * ```\n *\n * Renders nothing for a user in one organization (or none), so it can sit in the top bar unconditionally. A\n * choice reloads the page (`reloadOnChange`), so every grid, live list and cached page reads the new scope.\n */\n@Component({\n selector: 'spark-organization-switcher',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n imports: [FormsModule, BsSelectComponent, BsSelectOption],\n template: `\n @if (state(); as state) {\n @if (state.organizations.length > 1) {\n <bs-select class=\"spark-organization-switcher\" [title]=\"t('switcher')\" [attr.aria-label]=\"t('switcher')\"\n [ngModel]=\"state.current\" (ngModelChange)=\"choose($event)\" [disabled]=\"busy()\">\n <option [ngValue]=\"null\">{{ t('allOrganizations') }}</option>\n @for (organization of state.organizations; track organization.id) {\n <option [ngValue]=\"organization.id\">{{ label(organization) }}</option>\n }\n </bs-select>\n }\n }\n `,\n styles: [`\n :host { display: inline-block; max-width: 14rem; min-width: 0; }\n /* A phone's topbar holds the language, this, the bell and the account buttons in 375 px. */\n @media (max-width: 575.98px) { :host { max-width: 6rem; min-width: 4.25rem; } }\n `],\n})\nexport class SparkOrganizationSwitcherComponent implements OnInit {\n private readonly http = inject(HttpClient);\n private readonly language = inject(SparkLanguageService);\n private readonly document = inject(DOCUMENT);\n\n /** Reload the page after a choice, so every list reads the new scope. Default true. */\n readonly reloadOnChange = input(true);\n\n readonly state = signal<SparkCurrentOrganization | null>(null);\n readonly busy = signal(false);\n\n async ngOnInit(): Promise<void> {\n try {\n this.state.set(await firstValueFrom(this.http.get<SparkCurrentOrganization>('/spark/organizations/current')));\n } catch {\n this.state.set(null); // signed out, or the app has no organizations: no switcher\n }\n }\n\n protected t(key: string): string {\n return this.language.t('organizations.spa.' + key, SPARK_ORGANIZATION_SWITCHER_TEXTS[key]);\n }\n\n protected label(organization: SparkOrganizationChoice): string {\n return organization.isPersonal ? `${organization.name} (${this.t('personal')})` : organization.name;\n }\n\n /** Reloads the page; a seam for tests. */\n protected reload(): void {\n this.document.defaultView?.location.reload();\n }\n\n async choose(organizationId: string | null): Promise<void> {\n if (organizationId === (this.state()?.current ?? null)) return;\n this.busy.set(true);\n try {\n this.state.set(await firstValueFrom(this.http.post<SparkCurrentOrganization>('/spark/organizations/current', { organizationId })));\n if (this.reloadOnChange()) this.reload();\n } catch {\n // The organization is gone or the caller left it: show what the server holds now.\n await this.ngOnInit();\n } finally {\n this.busy.set(false);\n }\n }\n}\n"],"mappings":"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"}