@smartsoft001-mobilems/public-collections-shell-angular 2.118.0 → 2.120.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.
@@ -1,12 +1,17 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, Directive, effect } from '@angular/core';
3
- import { FILE_SERVICE_CONFIG } from '@smartsoft001/angular';
2
+ import { Injectable, inject, contentChild, ChangeDetectionStrategy, Component, NgModule, signal, Directive, effect, PLATFORM_ID, viewChild, computed, LOCALE_ID, afterNextRender } from '@angular/core';
3
+ import { FILE_SERVICE_CONFIG, PaginationMode, ListMode, StorageService } from '@smartsoft001/angular';
4
4
  import { CrudFacade, initialState as initialState$1, getReducer as getReducer$1, CrudService, CrudConfig, CrudFullConfig, CrudFullModule, CrudModule, CrudListPageBaseComponent, CrudItemPageBaseComponent } from '@smartsoft001/crud-shell-angular';
5
- import { CrudBaseService, FiltersBaseComponent, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService } from '@smartsoft001-mobilems/angular';
6
- import { NgTemplateOutlet, CommonModule } from '@angular/common';
7
- import { toSignal } from '@angular/core/rxjs-interop';
5
+ import { CrudBaseService, FiltersBaseComponent, provideInheritedSharedConfig, SharedModule, SeoService, TranslationService, MuseumSeoService, createSafeTimeouts, dictionaryFilterItems, createCycleTagSearch, createCatalog, mergeSelectedTags, createCatalogLoadMore, toggleDictionaryFilterItem, normalizeMetaText, FileUrlService, MasonryGridComponent, pageToOffset } from '@smartsoft001-mobilems/angular';
6
+ import { NgTemplateOutlet, CommonModule, isPlatformBrowser, DOCUMENT, formatDate } from '@angular/common';
7
+ import { HttpErrorResponse } from '@angular/common/http';
8
+ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
9
+ import { switchMap, map, catchError, of, finalize } from 'rxjs';
8
10
  import { ActivatedRoute } from '@angular/router';
9
11
  import { createFeatureSelector } from '@ngrx/store';
12
+ import { TranslateService } from '@ngx-translate/core';
13
+ import { capitalize } from '@smartsoft001/utils';
14
+ import { PublicCollectionItemType } from '@smartsoft001-mobilems/public-collections-domain';
10
15
 
11
16
  class PublicCollectionsService extends CrudBaseService {
12
17
  getUrlNameForDetails() {
@@ -95,8 +100,87 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
95
100
  }]
96
101
  }] });
97
102
 
103
+ /** How many related articles a collection page shows. */
104
+ const PUBLIC_COLLECTION_RELATED_ARTICLES_LIMIT = 20;
105
+ class PublicCollectionItemsService extends PublicCollectionsService {
106
+ constructor() {
107
+ super(...arguments);
108
+ /**
109
+ * Status of the last failed page load. `getItems` yields `null` both for no
110
+ * data and for an error, so without it a page cannot tell a 404 from a load
111
+ * in progress.
112
+ */
113
+ this.loadErrorStatus = signal(null, /* @ts-ignore */
114
+ ...(ngDevMode ? [{ debugName: "loadErrorStatus" }] : /* istanbul ignore next */ []));
115
+ /** A collection page is loading (paging, sorting and "load more" go through `getItems`). */
116
+ this.loading = signal(false, /* @ts-ignore */
117
+ ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
118
+ }
119
+ /**
120
+ * Fetches a page of the collection items. Call in an injection context.
121
+ * The URL (parameter order included) is the transfer cache key: keep it as is.
122
+ */
123
+ getItems(collectionId, filter) {
124
+ const data$ = toObservable(filter).pipe(switchMap((value) => {
125
+ this.loadErrorStatus.set(null);
126
+ this.loading.set(true);
127
+ const url = this.collectionItemsBuildUrl(collectionId, value);
128
+ return this.http.get(url).pipe(map((response) => {
129
+ this.loadErrorStatus.set(null);
130
+ return response?.data ?? null;
131
+ }), catchError((error) => {
132
+ this.loadErrorStatus.set(error instanceof HttpErrorResponse ? error.status : 0);
133
+ return of(null);
134
+ }), finalize(() => this.loading.set(false)));
135
+ }));
136
+ return toSignal(data$, {
137
+ initialValue: null,
138
+ });
139
+ }
140
+ /**
141
+ * Articles related to the collection. The API answers 404
142
+ * (`NO_RESULTS_FOR_GIVEN_QUERY`) when there are none; that, like any error,
143
+ * is an empty list.
144
+ */
145
+ getRelatedArticles(collectionId) {
146
+ return this.http
147
+ .get(`${this.config.apiUrl}search/Article/page/1?maxPerPage=${PUBLIC_COLLECTION_RELATED_ARTICLES_LIMIT}&filter[relatedEntryCollections]=${encodeURIComponent(collectionId)}`)
148
+ .pipe(map((response) => response?.data?.items ?? []), catchError(() => of([])));
149
+ }
150
+ collectionItemsBuildUrl(collectionId, filter) {
151
+ let page = 1;
152
+ if (filter?.offset && filter?.limit) {
153
+ page = filter.offset / filter.limit;
154
+ }
155
+ let url = `${this.config.apiUrl}public-collections/${collectionId}?currentPage=${page}&maxPerPage=${filter?.limit ?? 20}`;
156
+ if (filter?.searchText) {
157
+ url += `&filter[phrase]=${filter.searchText}`;
158
+ }
159
+ if (filter?.sortBy) {
160
+ url += `&sort=${filter.sortBy}-${filter.sortDesc ? 'desc' : 'asc'}`;
161
+ }
162
+ filter?.query?.forEach((q) => {
163
+ if (Array.isArray(q.value)) {
164
+ q.value.forEach((value) => {
165
+ url += `&filter[${q.key}]${this.getQueryType(q.type, true)}=${value.id}`;
166
+ });
167
+ }
168
+ else {
169
+ url += `&filter[${q.key}]${this.getQueryType(q.type, false)}=${q.value}`;
170
+ }
171
+ });
172
+ return url;
173
+ }
174
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: PublicCollectionItemsService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
175
+ /** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: PublicCollectionItemsService }); }
176
+ }
177
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: PublicCollectionItemsService, decorators: [{
178
+ type: Injectable
179
+ }] });
180
+
98
181
  const SERVICES = [
99
182
  PublicCollectionsService,
183
+ PublicCollectionItemsService,
100
184
  {
101
185
  provide: CrudService,
102
186
  useClass: PublicCollectionsService,
@@ -211,9 +295,616 @@ const getPublicCollectionsState = createFeatureSelector('publicCollections');
211
295
  // (state) => state.test
212
296
  //);
213
297
 
298
+ /**
299
+ * Filter defaults of the collections list (Muzeum Podlasia): a 3x4 grid, 12
300
+ * collections per page. `sortBy`/`sortDesc` match the toolbar's default label
301
+ * ('Name A to Z'); without them `CrudBaseService` sends `sort=random`.
302
+ */
303
+ const COLLECTIONS_CATALOG_DEFAULTS = {
304
+ paginationMode: PaginationMode.singlePage,
305
+ offset: 0,
306
+ limit: 12,
307
+ sortBy: 'name',
308
+ sortDesc: false,
309
+ };
310
+ /**
311
+ * Query key of the theme tags — cycles (`filter[cycles][]=<id>`; checked on
312
+ * the API: the forms with and without `[]` filter the same, an empty result
313
+ * is a 404).
314
+ */
315
+ const COLLECTIONS_CYCLES_FILTER_KEY = 'cycles';
316
+ /** Former tag key (the `Keyword` dictionary) — dropped from restored filters. */
317
+ const COLLECTIONS_CATALOG_LEGACY_KEYWORDS_KEY = 'keywords';
318
+ /**
319
+ * Cleans a persisted collections filter: the collections API ignores tags of
320
+ * the former `Keyword` dictionary, so they are not restored as an invisible
321
+ * filter.
322
+ */
323
+ function dropLegacyCollectionKeywords(saved) {
324
+ return {
325
+ ...saved,
326
+ query: (saved.query ?? []).filter((q) => q.key !== COLLECTIONS_CATALOG_LEGACY_KEYWORDS_KEY),
327
+ };
328
+ }
329
+ const COLLECTIONS_CATALOG_SEO = {
330
+ titleKey: 'PAGE_TITLES.collections',
331
+ descriptionKey: 'HOME.collectionsDescription',
332
+ siteNameKey: 'MODEL.mainTitle',
333
+ url: '/kolekcje',
334
+ };
335
+ const COLLECTIONS_CATALOG_ITEM_SELECTOR = '[data-testid="collection-grid-item"], [data-testid="collection-list-item"]';
336
+ const COLLECTIONS_CATALOG_LOADED_MORE_KEY = 'COMMON.loadedMore';
337
+ /**
338
+ * Base of a collections list page on the shared catalog engine
339
+ * (`createCatalog`): first read (also in SSR when the route carries the
340
+ * filter), persistence, sort/size/page, the read guard, cycle tags,
341
+ * „więcej ↓” and the list SEO. The page keeps its template.
342
+ *
343
+ * ```ts
344
+ * export class ListComponent extends CollectionsCatalogBaseComponent {
345
+ * constructor() {
346
+ * super({ cycleTags: { debounceMs: 300 } });
347
+ * }
348
+ * }
349
+ * ```
350
+ *
351
+ * A `#listContainer` element in the template is scrolled to after a page
352
+ * change from the bottom pagination and holds the cards focused by load more.
353
+ */
354
+ class CollectionsCatalogBaseComponent extends PublicCollectionsListComponent {
355
+ constructor(options = {}) {
356
+ super();
357
+ this.ListMode = ListMode;
358
+ /** Screen reader message after cards were appended. */
359
+ this.loadMoreStatus = signal('', /* @ts-ignore */
360
+ ...(ngDevMode ? [{ debugName: "loadMoreStatus" }] : /* istanbul ignore next */ []));
361
+ this.collectionsCatalogTranslate = inject(TranslateService);
362
+ this.collectionsCatalogSeoService = inject(MuseumSeoService);
363
+ this.collectionsCatalogBrowser = isPlatformBrowser(inject(PLATFORM_ID));
364
+ this.collectionsCatalogTimeouts = createSafeTimeouts();
365
+ this.collectionsCatalogListContainer = viewChild('listContainer', /* @ts-ignore */
366
+ ...(ngDevMode ? [{ debugName: "collectionsCatalogListContainer" }] : /* istanbul ignore next */ []));
367
+ this.collectionsCatalogScrollAfterLoad = false;
368
+ /**
369
+ * Selected cycles — the `cycles` item of the current filter, numeric ids
370
+ * without repeats (`"7"` from a deep link = `7` from the dictionary,
371
+ * review MOB-1815).
372
+ */
373
+ this.collectionsCatalogSelectedTags = computed(() => dictionaryFilterItems((this.facade.filter()?.query ?? []).find((q) => q.key === COLLECTIONS_CYCLES_FILTER_KEY)?.value), /* @ts-ignore */
374
+ ...(ngDevMode ? [{ debugName: "collectionsCatalogSelectedTags" }] : /* istanbul ignore next */ []));
375
+ const { seo = COLLECTIONS_CATALOG_SEO, cycleTags, loadMore = {}, ...catalogOptions } = options;
376
+ const defaults = catalogOptions.defaults ?? COLLECTIONS_CATALOG_DEFAULTS;
377
+ // Cycle tags first: Podlasie requests the dictionary before the list.
378
+ if (cycleTags) {
379
+ const search = createCycleTagSearch(cycleTags);
380
+ this.tagQuery = search.query;
381
+ this.cycleTags = search.tags;
382
+ }
383
+ else {
384
+ this.tagQuery = signal('', /* @ts-ignore */
385
+ ...(ngDevMode ? [{ debugName: "tagQuery" }] : /* istanbul ignore next */ []));
386
+ this.cycleTags = signal([]).asReadonly();
387
+ }
388
+ this.catalog = createCatalog({
389
+ persistKey: 'MUSEUM_COLLECTIONS_FILTER',
390
+ listPath: '/kolekcje',
391
+ searchTab: 'kolekcje',
392
+ listModeKey: 'MUSEUM_COLLECTIONS_LIST_MODE',
393
+ restoreSaved: dropLegacyCollectionKeywords,
394
+ ssrRead: 'route',
395
+ ...catalogOptions,
396
+ defaults,
397
+ facade: this.facade,
398
+ });
399
+ this.isBrowser = this.catalog.isBrowser;
400
+ this.routeList = this.catalog.routeList;
401
+ this.listMode = this.catalog.listMode;
402
+ this.searchEnabled = this.catalog.searchEnabled;
403
+ this.barPhrase = this.catalog.barPhrase;
404
+ this.isLoading = this.catalog.isLoading;
405
+ this.hasError = this.catalog.hasError;
406
+ this.totalCount = this.catalog.totalCount;
407
+ this.sortKey = this.catalog.sortKey;
408
+ this.selectedPage = this.catalog.selectedPage;
409
+ this.selectedTagIds = computed(() => this.collectionsCatalogSelectedTags().map((item) => item.id), /* @ts-ignore */
410
+ ...(ngDevMode ? [{ debugName: "selectedTagIds" }] : /* istanbul ignore next */ []));
411
+ this.tagChips = computed(() => mergeSelectedTags(this.collectionsCatalogSelectedTags(), this.cycleTags()), /* @ts-ignore */
412
+ ...(ngDevMode ? [{ debugName: "tagChips" }] : /* istanbul ignore next */ []));
413
+ // The focus moves to the first appended card.
414
+ this.loadMore = createCatalogLoadMore({
415
+ list: this.list,
416
+ filter: this.facade.filter,
417
+ totalCount: this.totalCount,
418
+ loading: this.isLoading,
419
+ read: (filter) => this.facade.read(filter),
420
+ defaultLimit: defaults.limit || 12,
421
+ onAppended: (added, firstIndex) => {
422
+ this.loadMoreStatus.set(this.collectionsCatalogTranslate.instant(loadMore.loadedMoreKey ?? COLLECTIONS_CATALOG_LOADED_MORE_KEY, { count: added }));
423
+ if (!added || !this.collectionsCatalogBrowser)
424
+ return;
425
+ this.collectionsCatalogTimeouts.later(() => this.listContainerElement
426
+ ?.querySelectorAll(loadMore.itemSelector ?? COLLECTIONS_CATALOG_ITEM_SELECTOR)[firstIndex]?.focus({ preventScroll: true }), 0);
427
+ },
428
+ });
429
+ // After a page change from the bottom pagination scroll to the list top
430
+ // once the data has loaded.
431
+ effect(() => {
432
+ const loading = this.isLoading();
433
+ if (!loading &&
434
+ this.collectionsCatalogScrollAfterLoad &&
435
+ this.collectionsCatalogBrowser) {
436
+ this.collectionsCatalogScrollAfterLoad = false;
437
+ this.collectionsCatalogTimeouts.later(() => this.listContainerElement?.scrollIntoView({
438
+ behavior: 'smooth',
439
+ block: 'start',
440
+ }), 0);
441
+ }
442
+ });
443
+ if (seo)
444
+ this.applyListSeo(seo);
445
+ }
446
+ /** The list root element (`#listContainer`). */
447
+ get listContainerElement() {
448
+ return this.collectionsCatalogListContainer()?.nativeElement;
449
+ }
450
+ retryLoad() {
451
+ this.catalog.retry();
452
+ }
453
+ pageChange(page) {
454
+ this.catalog.setPage(page);
455
+ }
456
+ /** Page change from the bottom pagination — scrolls to the list once loaded. */
457
+ bottomPageChange(page) {
458
+ this.collectionsCatalogScrollAfterLoad = true;
459
+ this.pageChange(page);
460
+ }
461
+ onSortChange(sort) {
462
+ this.catalog.setSort(sort);
463
+ }
464
+ /** Sort by key (`name-desc`, `random`). */
465
+ sortChange(key) {
466
+ this.catalog.setSortKey(key);
467
+ }
468
+ onLimitChange(limit) {
469
+ this.catalog.setLimit(limit);
470
+ }
471
+ /**
472
+ * Toggles one cycle in the `cycles` item (filter bar and tile pills);
473
+ * unselecting the last one removes the whole query item. Compared by the
474
+ * normalised id: a click on `7` also removes `"7"` from a deep link
475
+ * instead of adding a second filter (review MOB-1815); an added cycle is
476
+ * stored with its numeric id.
477
+ */
478
+ onTagToggle(entry) {
479
+ const current = this.facade.filter()?.query ?? [];
480
+ const next = toggleDictionaryFilterItem(current.find((q) => q.key === COLLECTIONS_CYCLES_FILTER_KEY)?.value, entry);
481
+ if (!next)
482
+ return;
483
+ const query = current.filter((q) => q.key !== COLLECTIONS_CYCLES_FILTER_KEY);
484
+ if (next.length) {
485
+ query.push({
486
+ key: COLLECTIONS_CYCLES_FILTER_KEY,
487
+ value: next,
488
+ type: '=',
489
+ hidden: true,
490
+ });
491
+ }
492
+ this.catalog.update({ query });
493
+ }
494
+ /**
495
+ * Search from the filter bar. In the catalog it patches the filter; in a
496
+ * search tab the phrase lives in the address (MOB-1818) — a new one opens
497
+ * `/wyszukaj/:text/kolekcje`, a blank one `/kolekcje` with the other filters.
498
+ */
499
+ onFilterBarSearch(phrase) {
500
+ this.catalog.search(phrase);
501
+ }
502
+ onListModeChange(mode) {
503
+ this.catalog.setListMode(mode);
504
+ }
505
+ clearAllFilters() {
506
+ this.catalog.clearAll();
507
+ }
508
+ /**
509
+ * Full list meta (title, description, canonical, og), not in a search tab —
510
+ * there the search page sets them, and the canonical of the list must not
511
+ * point at a phrase url. The description goes out as translated.
512
+ */
513
+ applyListSeo(seo) {
514
+ if (this.catalog.searchEnabled())
515
+ return;
516
+ const t = (key) => this.collectionsCatalogTranslate.instant(key);
517
+ this.collectionsCatalogSeoService.apply({
518
+ title: `${capitalize(t(seo.titleKey))} - ${capitalize(t(seo.siteNameKey ?? 'MODEL.mainTitle'))}`,
519
+ description: t(seo.descriptionKey),
520
+ type: 'website',
521
+ url: seo.url,
522
+ });
523
+ }
524
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionsCatalogBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
525
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: CollectionsCatalogBaseComponent, isStandalone: true, viewQueries: [{ propertyName: "collectionsCatalogListContainer", first: true, predicate: ["listContainer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0 }); }
526
+ }
527
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionsCatalogBaseComponent, decorators: [{
528
+ type: Directive
529
+ }], ctorParameters: () => [{ type: undefined }], propDecorators: { collectionsCatalogListContainer: [{ type: i0.ViewChild, args: ['listContainer', { isSignal: true }] }] } });
530
+
531
+ /** Local filter of a collection page (Muzeum Podlasia): 20 objects per page, unsorted. */
532
+ const COLLECTION_DETAILS_DEFAULTS = {
533
+ limit: 20,
534
+ offset: 0,
535
+ sortBy: '',
536
+ sortDesc: false,
537
+ searchText: '',
538
+ query: [],
539
+ };
540
+ const COLLECTION_DETAILS_LIST_MODE_KEY = 'MUSEUM_OBJECTS_LIST_MODE';
541
+ const COLLECTION_DETAILS_SITE_NAME_KEY = 'MODEL.mainTitle';
542
+ const COLLECTION_DETAILS_ITEM_SELECTOR = '[data-testid="objects-grid-item-link"], [data-testid="objects-list-item"]';
543
+ const COLLECTION_DETAILS_LOADED_MORE_KEY = 'COMMON.loadedMore';
544
+ /**
545
+ * Plain text of a CMS article title: tags become spaces, entities are decoded
546
+ * without `DOMParser` (the same result in SSR), whitespace collapsed.
547
+ */
548
+ function collectionDetailsArticleTitle(title) {
549
+ return normalizeMetaText((title ?? '').replace(/<[^>]*>/g, ' '));
550
+ }
551
+ function collectionDetailsStripHtml(html) {
552
+ if (!html)
553
+ return '';
554
+ return html.replace(/<\/?[^>]+(>|$)/g, '');
555
+ }
556
+ /**
557
+ * Base of a collection page: the collection with a locally paged list of its
558
+ * items (`PublicCollectionItemsService.getItems`), sort/size/page, 404 vs
559
+ * other load errors, SEO (also in SSR), hero image and meta, opt-in related
560
+ * articles, „więcej ↓” and the grid/list mode. The page keeps its template.
561
+ *
562
+ * ```ts
563
+ * // Muzeum Podlasia
564
+ * export class ItemComponent extends CollectionDetailsBaseComponent {
565
+ * constructor() {
566
+ * super({ relatedArticles: true });
567
+ * }
568
+ * }
569
+ * ```
570
+ *
571
+ * A `#listContainer` element in the template is scrolled to after a page
572
+ * change from the bottom pagination and holds the cards focused by load more.
573
+ */
574
+ class CollectionDetailsBaseComponent extends PublicCollectionsItemComponent {
575
+ constructor(options = {}) {
576
+ super();
577
+ this.collectionDetailsService = inject(PublicCollectionItemsService);
578
+ this.collectionDetailsSeo = inject(MuseumSeoService);
579
+ this.collectionDetailsFileUrl = inject(FileUrlService);
580
+ this.collectionDetailsStorage = inject(StorageService);
581
+ this.collectionDetailsTranslate = inject(TranslateService);
582
+ this.collectionDetailsLocale = inject(LOCALE_ID);
583
+ this.collectionDetailsDocument = inject(DOCUMENT);
584
+ this.collectionDetailsBrowser = isPlatformBrowser(inject(PLATFORM_ID));
585
+ this.collectionDetailsTimeouts = createSafeTimeouts();
586
+ this.collectionDetailsListContainer = viewChild('listContainer', /* @ts-ignore */
587
+ ...(ngDevMode ? [{ debugName: "collectionDetailsListContainer" }] : /* istanbul ignore next */ []));
588
+ this.collectionDetailsMasonry = viewChild(MasonryGridComponent, /* @ts-ignore */
589
+ ...(ngDevMode ? [{ debugName: "collectionDetailsMasonry" }] : /* istanbul ignore next */ []));
590
+ this.collectionDetailsScrollAfterLoad = false;
591
+ /** The collection read by the facade or a page of objects by the service. */
592
+ this.isLoading = computed(() => this.facade.loading() || this.collectionDetailsService.loading(), /* @ts-ignore */
593
+ ...(ngDevMode ? [{ debugName: "isLoading" }] : /* istanbul ignore next */ []));
594
+ /** Mode of the objects list — tiles (masonry) or a vertical list. */
595
+ this.listMode = signal(ListMode.masonryGrid, /* @ts-ignore */
596
+ ...(ngDevMode ? [{ debugName: "listMode" }] : /* istanbul ignore next */ []));
597
+ /** The current filter as a toolbar option key (e.g. `name-desc`). */
598
+ this.toolbarSortKey = computed(() => {
599
+ const filter = this.localFilter();
600
+ return !filter.sortBy
601
+ ? 'random'
602
+ : `${filter.sortBy}-${filter.sortDesc ? 'desc' : 'asc'}`;
603
+ }, /* @ts-ignore */
604
+ ...(ngDevMode ? [{ debugName: "toolbarSortKey" }] : /* istanbul ignore next */ []));
605
+ /**
606
+ * The hero straight from the real file (`noCache` = `filePath` without the
607
+ * extension swap). `FileUrlService.get()` in `cache` mode forces `.jpg` for
608
+ * paths outside `media/dictionaries`/`media/articles`, and the CDN has no
609
+ * preset for `entry_collections` — the `.jpg` ended in 404 /
610
+ * ERR_BLOCKED_BY_ORB (MOB-1564), and the real `.png` came only after
611
+ * hydration through `(error)` (Lighthouse: collection LCP 20 s, MOB-1850).
612
+ */
613
+ this.heroImageSrc = computed(() => {
614
+ const collection = this.data();
615
+ if (!collection?.image) {
616
+ return '';
617
+ }
618
+ const src = this.collectionDetailsFileUrl.get(collection.image, this.collectionDetailsOptions.heroImageMode ?? 'noCache');
619
+ return src.includes('assets/no-image.svg') ? '/gfx/no-image.png' : src;
620
+ }, /* @ts-ignore */
621
+ ...(ngDevMode ? [{ debugName: "heroImageSrc" }] : /* istanbul ignore next */ []));
622
+ this.description = computed(() => this.data()?.description || '', /* @ts-ignore */
623
+ ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
624
+ /**
625
+ * „author | date” row under the hero title. `undefined` when there is
626
+ * nothing to show — the hero then renders no paragraph at all.
627
+ */
628
+ this.heroMeta = computed(() => {
629
+ const collection = this.data();
630
+ const parts = [
631
+ collection?.author,
632
+ this.collectionDetailsFormatDate(collection?.publicationDate),
633
+ ].filter((part) => !!part);
634
+ return parts.length ? parts.join(' | ') : undefined;
635
+ }, /* @ts-ignore */
636
+ ...(ngDevMode ? [{ debugName: "heroMeta" }] : /* istanbul ignore next */ []));
637
+ /** The API answered 404 — the page shows „not found” instead of nothing. */
638
+ this.notFound = computed(() => this.collectionDetailsService.loadErrorStatus() === 404, /* @ts-ignore */
639
+ ...(ngDevMode ? [{ debugName: "notFound" }] : /* istanbul ignore next */ []));
640
+ /** Other load errors — a message with a retry. */
641
+ this.hasLoadError = computed(() => {
642
+ const status = this.collectionDetailsService.loadErrorStatus();
643
+ return status !== null && status !== 404;
644
+ }, /* @ts-ignore */
645
+ ...(ngDevMode ? [{ debugName: "hasLoadError" }] : /* istanbul ignore next */ []));
646
+ this.selectedPage = computed(() => {
647
+ const filter = this.localFilter();
648
+ return filter?.offset ? filter.offset / (filter.limit || 20) : 1;
649
+ }, /* @ts-ignore */
650
+ ...(ngDevMode ? [{ debugName: "selectedPage" }] : /* istanbul ignore next */ []));
651
+ this.totalCount = computed(() => this.data()?.paginatorDetails?.totalItemsCount ?? 0, /* @ts-ignore */
652
+ ...(ngDevMode ? [{ debugName: "totalCount" }] : /* istanbul ignore next */ []));
653
+ this.list = computed(() => (this.data()?.items ?? []), /* @ts-ignore */
654
+ ...(ngDevMode ? [{ debugName: "list" }] : /* istanbul ignore next */ []));
655
+ /** The collection's objects — the grid under „Objects in the collection”. */
656
+ this.objectItems = computed(() => this.list().filter((item) => this.isObject(item.type)), /* @ts-ignore */
657
+ ...(ngDevMode ? [{ debugName: "objectItems" }] : /* istanbul ignore next */ []));
658
+ /** Screen reader message after cards were appended („więcej ↓”). */
659
+ this.loadMoreStatus = signal('', /* @ts-ignore */
660
+ ...(ngDevMode ? [{ debugName: "loadMoreStatus" }] : /* istanbul ignore next */ []));
661
+ this.collectionDetailsOptions = options;
662
+ const defaults = options.defaults ?? COLLECTION_DETAILS_DEFAULTS;
663
+ const collectionId = this.route.snapshot.params['id'];
664
+ this.localFilter = signal({ ...defaults }, /* @ts-ignore */
665
+ ...(ngDevMode ? [{ debugName: "localFilter" }] : /* istanbul ignore next */ []));
666
+ // Reacts to filter changes (paging).
667
+ this.data = this.collectionDetailsService.getItems(collectionId, this.localFilter);
668
+ this.relatedArticles = options.relatedArticles
669
+ ? toSignal(this.collectionDetailsService.getRelatedArticles(collectionId).pipe(map((articles) => articles
670
+ .map((article) => ({
671
+ id: article.id,
672
+ title: collectionDetailsArticleTitle(article.title),
673
+ }))
674
+ // A title made of markup only would give a link without an accessible name.
675
+ .filter((article) => !!article.title))), { initialValue: [] })
676
+ : signal([]).asReadonly();
677
+ const loadMore = options.loadMore ?? {};
678
+ this.loadMore = createCatalogLoadMore({
679
+ list: this.objectItems,
680
+ filter: this.localFilter,
681
+ totalCount: this.totalCount,
682
+ loading: this.collectionDetailsService.loading,
683
+ read: (filter) => this.localFilter.set(filter),
684
+ defaultLimit: defaults.limit ?? 20,
685
+ onAppended: (added, firstIndex) => {
686
+ this.loadMoreStatus.set(this.collectionDetailsTranslate.instant(loadMore.loadedMoreKey ?? COLLECTION_DETAILS_LOADED_MORE_KEY, { count: added }));
687
+ if (!added || !this.collectionDetailsBrowser) {
688
+ return;
689
+ }
690
+ this.collectionDetailsTimeouts.later(() => this.collectionDetailsListContainer()
691
+ ?.nativeElement.querySelectorAll(loadMore.itemSelector ?? COLLECTION_DETAILS_ITEM_SELECTOR)[firstIndex]?.focus({ preventScroll: true }), 0);
692
+ },
693
+ });
694
+ this.collectionDetailsSeo.applyForSsr(() => this.collectionDetailsFetchSsrSeo());
695
+ effect(() => {
696
+ const collection = this.data();
697
+ if (collection) {
698
+ this.collectionDetailsSeo.apply(this.buildSeoMetadata(collection));
699
+ if (this.collectionDetailsBrowser) {
700
+ this.collectionDetailsDocument.defaultView?.scrollTo({
701
+ top: 0,
702
+ behavior: 'instant',
703
+ });
704
+ }
705
+ }
706
+ });
707
+ // On a 404 the document title must not stay the plain museum name.
708
+ effect(() => {
709
+ if (!this.notFound())
710
+ return;
711
+ this.collectionDetailsSeo.apply({
712
+ title: `${this.translationService.get('NOT_FOUND.title')} - ${this.translationService.get(this.collectionDetailsSiteNameKey)}`,
713
+ });
714
+ });
715
+ // After a page change from the bottom pagination scroll to the list top
716
+ // once the data has loaded.
717
+ effect(() => {
718
+ const loading = this.isLoading();
719
+ if (!loading &&
720
+ this.collectionDetailsScrollAfterLoad &&
721
+ this.collectionDetailsBrowser) {
722
+ this.collectionDetailsScrollAfterLoad = false;
723
+ this.collectionDetailsTimeouts.later(() => this.collectionDetailsListContainer()?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }), 0);
724
+ }
725
+ });
726
+ // Restores the view mode from the shared key (mobile forces its mode).
727
+ afterNextRender(() => this.collectionDetailsInitListMode());
728
+ }
729
+ onListMode(value) {
730
+ // On mobile the mode is forced — the toggle is hidden and inactive.
731
+ if (this.collectionDetailsIsMobile()) {
732
+ return;
733
+ }
734
+ const newMode = value === ListMode.masonryGrid ? ListMode.masonryGrid : ListMode.desktop;
735
+ this.listMode.set(newMode);
736
+ this.collectionDetailsStorage.setItem(this.collectionDetailsOptions.listModeKey ??
737
+ COLLECTION_DETAILS_LIST_MODE_KEY, value);
738
+ if (newMode !== ListMode.masonryGrid) {
739
+ this.collectionDetailsMasonry()?.destroyMasonry();
740
+ }
741
+ else {
742
+ this.collectionDetailsTimeouts.later(() => {
743
+ if (this.listMode() !== ListMode.masonryGrid)
744
+ return;
745
+ this.collectionDetailsMasonry()?.initMasonry();
746
+ }, 100);
747
+ }
748
+ }
749
+ onToolbarSortChange({ by, desc }) {
750
+ // „random” = no sort (consistent with /obiekty).
751
+ const sortBy = by === 'random' ? '' : by;
752
+ this.localFilter.update((filter) => ({
753
+ ...filter,
754
+ sortBy,
755
+ sortDesc: desc,
756
+ offset: 0,
757
+ }));
758
+ }
759
+ /** Sort by a dropdown key (`name-desc`, `random`). */
760
+ sortChange(key) {
761
+ const sortDesc = key.endsWith('-desc');
762
+ let sortBy = key.replace(/-asc$/, '').replace(/-desc$/, '');
763
+ // „random” = no sort (consistent with /obiekty).
764
+ if (sortBy === 'random') {
765
+ sortBy = '';
766
+ }
767
+ this.localFilter.update((filter) => ({
768
+ ...filter,
769
+ sortBy,
770
+ sortDesc,
771
+ offset: 0,
772
+ }));
773
+ }
774
+ /** Repeats the last request — a new filter reference forces another call. */
775
+ retryLoad() {
776
+ this.localFilter.update((filter) => ({ ...filter }));
777
+ }
778
+ limitChange(value) {
779
+ this.localFilter.update((filter) => ({
780
+ ...filter,
781
+ limit: Number(value),
782
+ offset: 0,
783
+ }));
784
+ }
785
+ pageChange(page) {
786
+ const currentLimit = this.localFilter().limit ?? 20;
787
+ this.localFilter.update((filter) => ({
788
+ ...filter,
789
+ offset: pageToOffset(page, currentLimit),
790
+ }));
791
+ }
792
+ /** Page change from the bottom pagination — scrolls to the list once loaded. */
793
+ bottomPageChange(page) {
794
+ this.collectionDetailsScrollAfterLoad = true;
795
+ this.pageChange(page);
796
+ }
797
+ isObject(type) {
798
+ return type === PublicCollectionItemType.object;
799
+ }
800
+ $MsObject(item) {
801
+ return item;
802
+ }
803
+ /** SEO of the collection: `<title> - <site>`, description, image alt. */
804
+ buildSeoMetadata(item) {
805
+ const strippedTitle = collectionDetailsStripHtml(item.title);
806
+ const siteName = this.translationService.get(this.collectionDetailsSiteNameKey);
807
+ return {
808
+ title: `${strippedTitle} - ${siteName}`,
809
+ description: item.description
810
+ ? collectionDetailsStripHtml(item.description)
811
+ : undefined,
812
+ imageAlt: strippedTitle,
813
+ type: 'article',
814
+ };
815
+ }
816
+ get collectionDetailsSiteNameKey() {
817
+ return (this.collectionDetailsOptions.siteNameKey ??
818
+ COLLECTION_DETAILS_SITE_NAME_KEY);
819
+ }
820
+ collectionDetailsIsMobile() {
821
+ const width = this.collectionDetailsDocument.defaultView?.innerWidth;
822
+ return (width !== undefined &&
823
+ width < (this.collectionDetailsOptions.mobileBreakpoint ?? 768));
824
+ }
825
+ collectionDetailsInitListMode() {
826
+ if (!this.collectionDetailsBrowser) {
827
+ return;
828
+ }
829
+ // The mobile mock-up (2290:10471) has only the two-column grid — the
830
+ // view toggle is hidden below md, as on /obiekty.
831
+ if (this.collectionDetailsIsMobile()) {
832
+ this.listMode.set(this.collectionDetailsOptions.mobileListMode ?? ListMode.masonryGrid);
833
+ }
834
+ else {
835
+ this.listMode.set(this.collectionDetailsStorage.getItem(this.collectionDetailsOptions.listModeKey ??
836
+ COLLECTION_DETAILS_LIST_MODE_KEY) === ListMode.desktop
837
+ ? ListMode.desktop
838
+ : ListMode.masonryGrid);
839
+ }
840
+ }
841
+ async collectionDetailsFetchSsrSeo() {
842
+ const id = this.route.snapshot.paramMap.get('id');
843
+ if (!id)
844
+ return null;
845
+ try {
846
+ const item = await this.collectionDetailsService.getById(id);
847
+ return item ? this.buildSeoMetadata(item) : null;
848
+ }
849
+ catch {
850
+ return null;
851
+ }
852
+ }
853
+ collectionDetailsFormatDate(value) {
854
+ if (!value)
855
+ return undefined;
856
+ return formatDate(value, 'dd.MM.yyyy', this.collectionDetailsLocale);
857
+ }
858
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionDetailsBaseComponent, deps: "invalid", target: i0.ɵɵFactoryTarget.Directive }); }
859
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.2.0", version: "22.1.3", type: CollectionDetailsBaseComponent, isStandalone: true, viewQueries: [{ propertyName: "collectionDetailsListContainer", first: true, predicate: ["listContainer"], descendants: true, isSignal: true }, { propertyName: "collectionDetailsMasonry", first: true, predicate: MasonryGridComponent, descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0 }); }
860
+ }
861
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionDetailsBaseComponent, decorators: [{
862
+ type: Directive
863
+ }], ctorParameters: () => [{ type: undefined }], propDecorators: { collectionDetailsListContainer: [{ type: i0.ViewChild, args: ['listContainer', { isSignal: true }] }], collectionDetailsMasonry: [{ type: i0.ViewChild, args: [i0.forwardRef(() => MasonryGridComponent), { isSignal: true }] }] } });
864
+
865
+ /** How long (ms) the "link copied" message stays after a click. */
866
+ const COLLECTION_SHARE_COPIED_TIMEOUT = 3000;
867
+ /**
868
+ * Logic of the "Share collection" button: the native share sheet, or a
869
+ * clipboard copy with a "link copied" flag when the browser has no share sheet.
870
+ * The template stays in the app.
871
+ */
872
+ class CollectionShareButtonBaseComponent {
873
+ constructor() {
874
+ this.collectionShareButtonPlatformId = inject(PLATFORM_ID);
875
+ this.collectionShareButtonDocument = inject(DOCUMENT);
876
+ this.collectionShareButtonTimeouts = createSafeTimeouts();
877
+ this.copied = signal(false, /* @ts-ignore */
878
+ ...(ngDevMode ? [{ debugName: "copied" }] : /* istanbul ignore next */ []));
879
+ /** How long (ms) "copied" stays on; overridable, read inside `share`. */
880
+ this.collectionShareButtonCopiedTimeout = COLLECTION_SHARE_COPIED_TIMEOUT;
881
+ }
882
+ async share() {
883
+ if (!isPlatformBrowser(this.collectionShareButtonPlatformId)) {
884
+ return;
885
+ }
886
+ const doc = this.collectionShareButtonDocument;
887
+ const url = doc.defaultView?.location.href ?? '';
888
+ const title = doc.title;
889
+ if (navigator.share) {
890
+ // Cancelling the share sheet rejects with AbortError — not an error.
891
+ await navigator.share({ title, url }).catch(() => undefined);
892
+ return;
893
+ }
894
+ await navigator.clipboard.writeText(url);
895
+ this.copied.set(true);
896
+ this.collectionShareButtonTimeouts.later(() => this.copied.set(false), this.collectionShareButtonCopiedTimeout);
897
+ }
898
+ /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionShareButtonBaseComponent, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
899
+ /** @nocollapse */ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: CollectionShareButtonBaseComponent, isStandalone: true, ngImport: i0 }); }
900
+ }
901
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: CollectionShareButtonBaseComponent, decorators: [{
902
+ type: Directive
903
+ }] });
904
+
214
905
  /**
215
906
  * Generated bundle index. Do not edit.
216
907
  */
217
908
 
218
- export { ComponentsModule, FiltersComponent, ListComponent, PublicCollectionsFacade, PublicCollectionsItemComponent, PublicCollectionsListComponent, PublicCollectionsModule, PublicCollectionsService, PublicCollectionsSharedModule, SERVICES, getPublicCollectionsState, getReducer, initialState };
909
+ export { COLLECTIONS_CATALOG_DEFAULTS, COLLECTIONS_CATALOG_SEO, COLLECTIONS_CYCLES_FILTER_KEY, COLLECTION_DETAILS_DEFAULTS, COLLECTION_SHARE_COPIED_TIMEOUT, CollectionDetailsBaseComponent, CollectionShareButtonBaseComponent, CollectionsCatalogBaseComponent, ComponentsModule, FiltersComponent, ListComponent, PUBLIC_COLLECTION_RELATED_ARTICLES_LIMIT, PublicCollectionItemsService, PublicCollectionsFacade, PublicCollectionsItemComponent, PublicCollectionsListComponent, PublicCollectionsModule, PublicCollectionsService, PublicCollectionsSharedModule, SERVICES, dropLegacyCollectionKeywords, getPublicCollectionsState, getReducer, initialState };
219
910
  //# sourceMappingURL=smartsoft001-mobilems-public-collections-shell-angular.mjs.map